import { nextTick, provide, ref, unref, watch } from 'vue' 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' /** * 공개 Markdown 렌더러의 라이브 선택·포커스 상태를 만든다. * @param {Object} options - 라이브 선택 옵션 * @param {import('vue').Ref|string} options.content - Markdown 본문 * @param {import('vue').Ref|boolean} options.interactive - 라이브 편집 여부 * @param {(line: number) => void} options.onLineFocus - 줄 포커스 콜백 * @param {() => void} options.onLineBlur - 줄 포커스 이탈 콜백 * @param {(payload: { value: string }) => void} options.onContentReplace - 본문 교체 콜백 * @param {boolean} [options.provideBridge=true] - Selection Bridge 제공 여부 * @returns {Object} 라이브 선택·포커스 상태와 이벤트 핸들러 */ export const useContentMarkdownLiveSelection = (options) => { /** @type {import('vue').Ref} */ const pendingFocusLine = ref(null) /** @type {import('vue').Ref<'start'|'end'|'auto'>} 포커스 후 커서 위치 */ const pendingFocusPosition = ref('auto') /** @type {import('vue').Ref} 포커스 후 텍스트 오프셋 */ const pendingFocusOffset = ref(null) /** @type {import('vue').Ref} */ const rendererRootRef = ref(null) /** @type {import('vue').Ref>} 원문(raw) 편집 중인 목록 줄 */ const rawEditingSourceLines = ref(new Set()) /** * 라이브 편집 가능 여부를 반환한다. * @returns {boolean} 편집 가능 여부 */ const isInteractive = () => Boolean(unref(options.interactive)) /** * 원본 마크다운 줄을 반환한다. * @param {number} lineIndex - 줄 번호 * @returns {string} 줄 텍스트 */ const getMarkdownLine = (lineIndex) => String(unref(options.content) || '').split('\n')[lineIndex] ?? '' /** * 지정한 원본 줄의 편집 영역에 포커스를 둔다. * @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' }) } watch(() => unref(options.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 {{ 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 (!isInteractive() || 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 (!isInteractive() || !rendererRootRef.value) { return } const elements = getSelectableEditableElements(rendererRootRef.value) selectAllEditableElements(elements) } /** * 교차 블록·전체 선택 삭제를 마크다운에 반영한다. * @returns {boolean} 처리 여부 */ const deleteLiveSelection = () => { if (!isInteractive() || !rendererRootRef.value) { return false } const nextMarkdown = applyLiveSelectionDelete(String(unref(options.content) || ''), rendererRootRef.value) if (nextMarkdown === null) { return false } options.onContentReplace({ value: nextMarkdown }) collapseLiveSelection() return true } /** * 라이브 선택 삭제 단축키를 처리한다. * @param {KeyboardEvent} event - 키보드 이벤트 * @returns {void} */ const onRendererSelectionKeydown = (event) => { if (!isInteractive() || !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() } /** * 라이브 편집 포커스/클릭 위치의 원본 줄을 상위에 알린다. * @param {Event} event - 포커스 또는 포인터 이벤트 * @returns {void} */ const emitLiveLineFocus = (event) => { if (!isInteractive()) { return } const target = event.target if (typeof Element === 'undefined' || !(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 } options.onLineFocus(sourceLine) } /** * 라이브 편집 영역을 벗어난 포커스를 상위에 알린다. * @param {FocusEvent} event - 포커스 이탈 이벤트 * @returns {void} */ const emitLiveLineBlur = (event) => { if (!isInteractive() || !rendererRootRef.value) { return } const nextTarget = event.relatedTarget if (typeof Node !== 'undefined' && nextTarget instanceof Node && rendererRootRef.value.contains(nextTarget)) { return } options.onLineBlur() } /** * 인라인 편집 원문 모드 표시 상태를 갱신한다. * @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 {{ 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 } options.onLineFocus(line) } if (options.provideBridge !== false) { provide(LIVE_SELECTION_BRIDGE_KEY, { extendSelection: (payload) => { if (isInteractive()) { onExtendLiveSelection(payload) } }, selectDocument: () => { if (isInteractive()) { selectAllLiveDocument() } }, deleteSelection: () => { if (isInteractive()) { return deleteLiveSelection() } return false } }) } return { pendingFocusLine, pendingFocusPosition, pendingFocusOffset, rendererRootRef, rawEditingSourceLines, focusEditableAtLine, selectAllLiveDocument, deleteLiveSelection, onRendererSelectionKeydown, emitLiveLineFocus, emitLiveLineBlur, onInlineRawMode, onEditorFocusLine, emitEditorFocusedLine } }