공개 본문 라이브 선택 상태 분리
This commit is contained in:
@@ -9,16 +9,6 @@ import {
|
||||
getContentLightboxImageUrl,
|
||||
getGalleryImageSizes
|
||||
} from '../../lib/content-image-urls.js'
|
||||
import { readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js'
|
||||
import {
|
||||
applyLiveSelectionDelete,
|
||||
collapseLiveSelection,
|
||||
extendSelectionAcrossBlocks,
|
||||
getSelectableEditableElements,
|
||||
isLiveSelectionDeleteKey,
|
||||
LIVE_SELECTION_BRIDGE_KEY,
|
||||
selectAllEditableElements
|
||||
} from '../../lib/markdown-live-selection.js'
|
||||
import {
|
||||
appendTextToMarkdownLine,
|
||||
getAppendTextForMerge,
|
||||
@@ -44,6 +34,7 @@ import { getGalleryRows } from '../../lib/content-gallery-images.js'
|
||||
import { useContentMarkdownLightbox } from '../../composables/useContentMarkdownLightbox.js'
|
||||
import { useContentMarkdownBlocks } from '../../composables/useContentMarkdownBlocks.js'
|
||||
import { useContentMarkdownImageDrag } from '../../composables/useContentMarkdownImageDrag.js'
|
||||
import { useContentMarkdownLiveSelection } from '../../composables/useContentMarkdownLiveSelection.js'
|
||||
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||
import ProseHtml from './ProseHtml.vue'
|
||||
@@ -100,15 +91,6 @@ const emit = defineEmits([
|
||||
const CONTENT_LIST_MARKER_COLOR = '#2eb6ea'
|
||||
/** @type {import('vue').Ref<Record<string, number>>} 갤러리 이미지 자연 비율 */
|
||||
const galleryImageAspectRatios = ref({})
|
||||
/** @type {import('vue').Ref<number|null>} */
|
||||
const pendingFocusLine = ref(null)
|
||||
/** @type {import('vue').Ref<'start'|'end'|'auto'>} 포커스 후 커서 위치 */
|
||||
const pendingFocusPosition = ref('auto')
|
||||
/** @type {import('vue').Ref<number|null>} 포커스 후 텍스트 오프셋 */
|
||||
const pendingFocusOffset = ref(null)
|
||||
const rendererRootRef = ref(null)
|
||||
/** @type {import('vue').Ref<Set<number>>} 원문(raw) 편집 중인 목록 줄 */
|
||||
const rawEditingSourceLines = ref(new Set())
|
||||
/** @type {number} 문단 분리 연속 호출 방지 */
|
||||
let lastParagraphSplitAt = 0
|
||||
|
||||
@@ -164,6 +146,28 @@ const {
|
||||
onGalleryReorder: (payload) => emit('gallery-reorder', payload)
|
||||
})
|
||||
|
||||
const {
|
||||
pendingFocusLine,
|
||||
pendingFocusPosition,
|
||||
pendingFocusOffset,
|
||||
rendererRootRef,
|
||||
rawEditingSourceLines,
|
||||
focusEditableAtLine,
|
||||
selectAllLiveDocument,
|
||||
onRendererSelectionKeydown,
|
||||
emitLiveLineFocus,
|
||||
emitLiveLineBlur,
|
||||
onInlineRawMode,
|
||||
onEditorFocusLine,
|
||||
emitEditorFocusedLine
|
||||
} = useContentMarkdownLiveSelection({
|
||||
content: computed(() => props.content),
|
||||
interactive: computed(() => props.interactive),
|
||||
onLineFocus: (line) => emit('line-focus', line),
|
||||
onLineBlur: () => emit('line-blur'),
|
||||
onContentReplace: (payload) => emit('content-replace', payload)
|
||||
})
|
||||
|
||||
/**
|
||||
* 빈 줄 공백 블록 높이를 반환한다.
|
||||
* @param {Object} block - 렌더링 블록
|
||||
@@ -250,299 +254,11 @@ const getGalleryItemStyle = (block, image, imageIndex) => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.content, () => {
|
||||
if (pendingFocusLine.value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const line = pendingFocusLine.value
|
||||
const position = pendingFocusPosition.value
|
||||
const offset = pendingFocusOffset.value
|
||||
pendingFocusLine.value = null
|
||||
pendingFocusPosition.value = 'auto'
|
||||
pendingFocusOffset.value = null
|
||||
|
||||
nextTick(() => {
|
||||
nextTick(() => {
|
||||
focusEditableAtLine(line, 0, position, offset)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 지정한 원본 줄의 편집 영역에 포커스를 둔다.
|
||||
* @param {number} lineIndex - 줄 번호(0-based)
|
||||
* @param {number} [attempt=0] - DOM 탐색 재시도 횟수
|
||||
* @param {'start'|'end'|'auto'} [cursorPosition='auto'] - 커서 위치
|
||||
* @param {number|null} [caretOffset=null] - 텍스트 오프셋
|
||||
* @param {'nearest'|'center'} [scrollBlock='nearest'] - 스크롤 정렬 위치
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', caretOffset = null, scrollBlock = 'nearest') => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const matches = rendererRootRef.value
|
||||
? [...rendererRootRef.value.querySelectorAll(`[data-source-line="${lineIndex}"]`)]
|
||||
: []
|
||||
const rangedMatches = rendererRootRef.value
|
||||
? [...rendererRootRef.value.querySelectorAll('[data-source-line][data-source-line-end]')]
|
||||
.filter((node) => {
|
||||
const start = Number(node.getAttribute('data-source-line'))
|
||||
const end = Number(node.getAttribute('data-source-line-end'))
|
||||
|
||||
return node.getAttribute('contenteditable') === 'true'
|
||||
&& Number.isInteger(start)
|
||||
&& Number.isInteger(end)
|
||||
&& start <= lineIndex
|
||||
&& lineIndex <= end
|
||||
})
|
||||
: []
|
||||
|
||||
const element = matches.find((node) => node.getAttribute('contenteditable') === 'true')
|
||||
|| matches[0]
|
||||
|| rangedMatches[0]
|
||||
|| null
|
||||
|
||||
if (!element) {
|
||||
if (attempt < 8) {
|
||||
requestAnimationFrame(() => {
|
||||
focusEditableAtLine(lineIndex, attempt + 1, cursorPosition, caretOffset, scrollBlock)
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const line = getMarkdownLine(lineIndex)
|
||||
const isBlankMarker = /^>\s*$/.test(line) || /^[-*+]\s*$/.test(line) || /^\d+\.\s*$/.test(line.trim())
|
||||
const elementSourceLine = Number(element.getAttribute('data-source-line'))
|
||||
const elementSourceLineEnd = Number(element.getAttribute('data-source-line-end'))
|
||||
const hasSourceLineRange = Number.isInteger(elementSourceLine) && Number.isInteger(elementSourceLineEnd)
|
||||
const spansMultipleLines = hasSourceLineRange && elementSourceLineEnd > elementSourceLine
|
||||
const isWithinSourceLineRange = hasSourceLineRange
|
||||
&& lineIndex >= elementSourceLine
|
||||
&& lineIndex <= elementSourceLineEnd
|
||||
const useMultilineCaret = spansMultipleLines && isWithinSourceLineRange
|
||||
|
||||
/**
|
||||
* 멀티라인 편집 영역에서 원본 줄에 해당하는 텍스트 오프셋을 반환한다.
|
||||
* @returns {number} 루트 기준 오프셋
|
||||
*/
|
||||
const getCaretOffsetForSourceLine = () => {
|
||||
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
|
||||
const textLines = text.length ? text.split('\n') : ['']
|
||||
const targetLineIndex = Math.max(0, Math.min(lineIndex - elementSourceLine, textLines.length - 1))
|
||||
|
||||
return textLines
|
||||
.slice(0, targetLineIndex)
|
||||
.reduce((sum, textLine) => sum + textLine.length + 1, 0)
|
||||
}
|
||||
|
||||
if (!useMultilineCaret && (isBlankMarker || !line.trim())) {
|
||||
if (element.getAttribute('contenteditable') === 'true') {
|
||||
element.textContent = ''
|
||||
element.innerHTML = ''
|
||||
}
|
||||
}
|
||||
|
||||
element.focus({ preventScroll: true })
|
||||
|
||||
if (element.getAttribute('contenteditable') !== 'true') {
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof caretOffset === 'number' && caretOffset >= 0) {
|
||||
if (useMultilineCaret) {
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), getCaretOffsetForSourceLine() + caretOffset)
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
return
|
||||
}
|
||||
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), caretOffset)
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
return
|
||||
}
|
||||
|
||||
if (useMultilineCaret) {
|
||||
const lineOffset = getCaretOffsetForSourceLine()
|
||||
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
|
||||
const textLines = text.length ? text.split('\n') : ['']
|
||||
const targetLineIndex = Math.max(0, Math.min(lineIndex - elementSourceLine, textLines.length - 1))
|
||||
const lineText = textLines[targetLineIndex] ?? ''
|
||||
const nextOffset = cursorPosition === 'end'
|
||||
? lineOffset + lineText.length
|
||||
: lineOffset
|
||||
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), nextOffset)
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
return
|
||||
}
|
||||
|
||||
if (cursorPosition === 'start' || (cursorPosition === 'auto' && (isBlankMarker || !line.trim()))) {
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
return
|
||||
}
|
||||
|
||||
if (cursorPosition === 'end') {
|
||||
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), text.length)
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
return
|
||||
}
|
||||
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 확장 대상 컨테이너를 반환한다.
|
||||
* @param {{ navigationScope?: string, sourceLine?: number }} payload - 확장 요청
|
||||
* @returns {HTMLElement|null} 컨테이너
|
||||
*/
|
||||
const resolveSelectionContainer = (payload) => {
|
||||
if (!rendererRootRef.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (payload.navigationScope === 'parent' && typeof payload.sourceLine === 'number') {
|
||||
const current = rendererRootRef.value.querySelector(`[data-source-line="${payload.sourceLine}"]`)
|
||||
return current?.closest('[data-editable-scope]') || rendererRootRef.value
|
||||
}
|
||||
|
||||
return rendererRootRef.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift 선택을 인접 편집 블록으로 확장한다.
|
||||
* @param {{ sourceLine: number, direction: number, column?: number, navigationScope?: string }} payload - 확장 요청
|
||||
* @returns {void}
|
||||
*/
|
||||
const onExtendLiveSelection = (payload) => {
|
||||
if (!props.interactive || typeof payload?.sourceLine !== 'number' || !payload.direction) {
|
||||
return
|
||||
}
|
||||
|
||||
const container = resolveSelectionContainer(payload)
|
||||
|
||||
extendSelectionAcrossBlocks({
|
||||
container,
|
||||
sourceLine: payload.sourceLine,
|
||||
direction: payload.direction,
|
||||
column: payload.column
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 본문의 모든 편집 가능 텍스트를 선택한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectAllLiveDocument = () => {
|
||||
if (!props.interactive || !rendererRootRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const elements = getSelectableEditableElements(rendererRootRef.value)
|
||||
selectAllEditableElements(elements)
|
||||
}
|
||||
|
||||
/**
|
||||
* 교차 블록·전체 선택 삭제를 마크다운에 반영한다.
|
||||
* @returns {boolean} 처리 여부
|
||||
*/
|
||||
const deleteLiveSelection = () => {
|
||||
if (!props.interactive || !rendererRootRef.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nextMarkdown = applyLiveSelectionDelete(props.content, rendererRootRef.value)
|
||||
|
||||
if (nextMarkdown === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
emit('content-replace', { value: nextMarkdown })
|
||||
collapseLiveSelection()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 선택 삭제 단축키를 처리한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onRendererSelectionKeydown = (event) => {
|
||||
if (!props.interactive || !isLiveSelectionDeleteKey(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
const isCut = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'x'
|
||||
|
||||
if (isCut && import.meta.client) {
|
||||
const selectedText = window.getSelection()?.toString() ?? ''
|
||||
|
||||
if (selectedText) {
|
||||
navigator.clipboard?.writeText(selectedText).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleteLiveSelection()) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
provide(LIVE_SELECTION_BRIDGE_KEY, {
|
||||
extendSelection: (payload) => {
|
||||
if (props.interactive) {
|
||||
onExtendLiveSelection(payload)
|
||||
}
|
||||
},
|
||||
selectDocument: () => {
|
||||
if (props.interactive) {
|
||||
selectAllLiveDocument()
|
||||
}
|
||||
},
|
||||
deleteSelection: () => {
|
||||
if (props.interactive) {
|
||||
return deleteLiveSelection()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
focusEditableAtLine,
|
||||
selectAllLiveDocument
|
||||
})
|
||||
|
||||
/**
|
||||
* 인라인 편집 원문 모드 표시 상태를 갱신한다.
|
||||
* @param {{ sourceLine: number, active: boolean }} payload - 줄 번호·활성 여부
|
||||
* @returns {void}
|
||||
*/
|
||||
const onInlineRawMode = ({ sourceLine, active }) => {
|
||||
if (typeof sourceLine !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
const next = new Set(rawEditingSourceLines.value)
|
||||
|
||||
if (active) {
|
||||
next.add(sourceLine)
|
||||
} else {
|
||||
next.delete(sourceLine)
|
||||
}
|
||||
|
||||
rawEditingSourceLines.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 레벨별 편집 영역 클래스
|
||||
* @param {number} level - 제목 레벨
|
||||
@@ -751,57 +467,6 @@ const commitInlineBlockLines = (block, replacementLines) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 편집 포커스/클릭 위치의 원본 줄을 상위에 알린다.
|
||||
* @param {Event} event - 포커스 또는 포인터 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const emitLiveLineFocus = (event) => {
|
||||
if (!props.interactive) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target
|
||||
|
||||
if (!(target instanceof Element)) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceElement = target.closest('[data-source-line]')
|
||||
const sourceLine = Number(sourceElement?.getAttribute('data-source-line'))
|
||||
|
||||
if (!Number.isInteger(sourceLine) || sourceLine < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('line-focus', sourceLine)
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 편집 영역을 벗어난 포커스를 상위에 알린다.
|
||||
* @param {FocusEvent} event - 포커스 이탈 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const emitLiveLineBlur = (event) => {
|
||||
if (!props.interactive || !rendererRootRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextTarget = event.relatedTarget
|
||||
|
||||
if (nextTarget instanceof Node && rendererRootRef.value.contains(nextTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('line-blur')
|
||||
}
|
||||
|
||||
/**
|
||||
* 문단 인라인 편집 반영
|
||||
* @param {Object} block - 블록
|
||||
* @param {string} text - 편집된 텍스트
|
||||
* @returns {void}
|
||||
*/
|
||||
/**
|
||||
* 코드 블록 편집 반영
|
||||
* @param {Object} block - 코드 블록
|
||||
@@ -876,34 +541,6 @@ const onCalloutBlockInsertBelow = (block, payload) => {
|
||||
onInsertBelowBlock(block, { lines: [''] })
|
||||
}
|
||||
|
||||
/**
|
||||
* 자식 편집기가 요청한 원본 줄에 포커스를 둔다.
|
||||
* @param {{ line?: number, position?: 'start'|'end'|'auto', offset?: number|null }} payload - 포커스 요청
|
||||
* @returns {void}
|
||||
*/
|
||||
const onEditorFocusLine = (payload) => {
|
||||
if (typeof payload?.line !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
pendingFocusLine.value = payload.line
|
||||
pendingFocusPosition.value = payload.position || 'auto'
|
||||
pendingFocusOffset.value = typeof payload.offset === 'number' ? payload.offset : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 자식 편집기의 현재 원본 줄을 상위 에디터에 알린다.
|
||||
* @param {number} line - 원본 줄 번호
|
||||
* @returns {void}
|
||||
*/
|
||||
const emitEditorFocusedLine = (line) => {
|
||||
if (!Number.isInteger(line) || line < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('line-focus', line)
|
||||
}
|
||||
|
||||
/**
|
||||
* 토글 편집 반영
|
||||
* @param {Object} block - 토글 블록
|
||||
|
||||
Reference in New Issue
Block a user