import { getEditableCaretOffset, getEditableDomPointAtOffset, readEditableTextFromElement } from './markdown-inline.js' /** @type {import('vue').InjectionKey<{ extendSelection: Function, selectDocument: Function, deleteSelection: Function }>} */ export const LIVE_SELECTION_BRIDGE_KEY = Symbol('liveSelectionBridge') /** * 컨테이너 안의 선택 가능한 contenteditable 요소 목록을 반환한다. * @param {HTMLElement|null} container - 탐색 루트 * @returns {HTMLElement[]} 편집 요소 목록 */ export const getSelectableEditableElements = (container) => { if (!container || !import.meta.client) { return [] } return [...container.querySelectorAll('[data-source-line][contenteditable="true"]')] .filter((element) => element.getAttribute('contenteditable') === 'true') .sort((left, right) => Number(left.getAttribute('data-source-line')) - Number(right.getAttribute('data-source-line'))) } /** * 노드가 속한 편집 요소를 반환한다. * @param {Node|null} node - 기준 노드 * @param {HTMLElement[]} elements - 편집 요소 목록 * @returns {HTMLElement|null} */ export const getEditableElementFromNode = (node, elements) => { if (!node) { return null } return elements.find((element) => element.contains(node)) ?? null } /** * 원본 줄 번호에 해당하는 편집 요소 인덱스를 찾는다. * @param {HTMLElement[]} elements - 편집 요소 목록 * @param {number} sourceLine - 원본 줄 번호(0-based) * @returns {number} 인덱스(없으면 -1) */ export const findEditableIndexBySourceLine = (elements, sourceLine) => { return elements.findIndex((element) => { const start = Number(element.getAttribute('data-source-line')) const end = Number(element.getAttribute('data-source-line-end')) if (!Number.isInteger(start)) { return false } if (Number.isInteger(end) && end > start) { return sourceLine >= start && sourceLine <= end } return start === sourceLine }) } /** * 편집 요소의 원본 줄 범위를 반환한다. * @param {HTMLElement} element - contenteditable 요소 * @returns {{ startLine: number, endLine: number }} */ export const getEditableSourceLineRange = (element) => { const startLine = Number(element.getAttribute('data-source-line')) const endLine = Number(element.getAttribute('data-source-line-end')) if (!Number.isInteger(startLine)) { return { startLine: -1, endLine: -1 } } return { startLine, endLine: Number.isInteger(endLine) ? endLine : startLine } } /** * 선택 범위가 편집 요소와 겹치는지 확인한다. * @param {Range} range - 선택 범위 * @param {HTMLElement} element - contenteditable 요소 * @returns {boolean} */ export const rangeIntersectsElement = (range, element) => { const elementRange = document.createRange() elementRange.selectNodeContents(element) return range.compareBoundaryPoints(Range.START_TO_END, elementRange) < 0 && range.compareBoundaryPoints(Range.END_TO_START, elementRange) > 0 } /** * 편집 요소 안에서 선택 경계의 텍스트 오프셋을 계산한다. * @param {HTMLElement} element - contenteditable 요소 * @param {Range} range - 선택 범위 * @param {'start'|'end'} edge - 경계 * @returns {number} */ export const getSelectionOffsetInElement = (element, range, edge) => { const partial = document.createRange() partial.selectNodeContents(element) if (edge === 'start') { partial.setEnd(range.startContainer, range.startOffset) } else { partial.setStart(range.endContainer, range.endOffset) } return getEditableCaretOffset(element, partial) } /** * 마크다운 줄에서 편집 본문 앞 접두사 길이를 반환한다. * @param {string} markdownLine - 마크다운 한 줄 * @param {string} bodyText - 편집 본문 * @returns {number} */ export const getMarkdownLineBodyPrefixLength = (markdownLine, bodyText) => { const line = String(markdownLine ?? '') const body = String(bodyText ?? '') if (line === body) { return 0 } const prefixPatterns = [ /^(#{1,6}\s+)/, /^(>\s*)/, /^([-*+]\s+)/, /^(\d+\.\s+)/ ] for (const pattern of prefixPatterns) { const match = line.match(pattern) if (match && line.slice(match[0].length) === body) { return match[0].length } } if (line.endsWith(body)) { return line.length - body.length } return 0 } /** * 마크다운 한 줄에 편집 본문 치환 결과를 반영한다. * @param {string} markdownLine - 마크다운 한 줄 * @param {string} bodyText - 편집 본문 * @param {string} nextBody - 치환 후 본문 * @returns {string} */ export const rebuildMarkdownLineBody = (markdownLine, bodyText, nextBody) => { const prefixLength = getMarkdownLineBodyPrefixLength(markdownLine, bodyText) return `${String(markdownLine ?? '').slice(0, prefixLength)}${nextBody}` } /** * 마크다운 한 줄에서 본문 일부를 삭제한다. * @param {string} markdownLine - 마크다운 한 줄 * @param {string} bodyText - 편집 본문 * @param {number} startOffset - 시작 오프셋 * @param {number} endOffset - 끝 오프셋 * @returns {string} */ export const applyBodyEditToMarkdownLine = (markdownLine, bodyText, startOffset, endOffset) => { const prefixLength = getMarkdownLineBodyPrefixLength(markdownLine, bodyText) const line = String(markdownLine ?? '') const absoluteStart = prefixLength + startOffset const absoluteEnd = prefixLength + endOffset return `${line.slice(0, absoluteStart)}${line.slice(absoluteEnd)}` } /** * 인접 블록 선택 확장 대상 오프셋을 계산한다. * @param {string} text - 대상 편집 요소 텍스트 * @param {number} direction - 이동 방향 * @param {number} column - 유지할 열 * @returns {number} */ export const getTargetSelectionOffset = (text, direction, column) => { const source = String(text ?? '') const lines = source.length ? source.split('\n') : [''] const safeColumn = Math.max(0, column) if (direction > 0) { const lineText = lines[0] ?? '' return Math.min(safeColumn, lineText.length) } const lineText = lines[lines.length - 1] ?? '' const previousLength = lines.slice(0, -1).reduce((sum, line) => sum + line.length + 1, 0) return previousLength + Math.min(safeColumn, lineText.length) } /** * 편집 요소 본문 전체를 선택한다. * @param {HTMLElement} element - contenteditable 요소 * @returns {void} */ export const selectEditableElementContents = (element) => { if (!element || !import.meta.client) { return } const selection = window.getSelection() if (!selection) { return } const range = document.createRange() range.selectNodeContents(element) selection.removeAllRanges() selection.addRange(range) } /** * 편집 요소 목록 전체를 하나의 선택 범위로 만든다. * @param {HTMLElement[]} elements - 편집 요소 목록 * @returns {void} */ export const selectAllEditableElements = (elements) => { if (!import.meta.client || !elements.length) { return } const selection = window.getSelection() if (!selection) { return } const first = elements[0] const last = elements[elements.length - 1] const startPoint = getEditableDomPointAtOffset(first, 0) const endText = readEditableTextFromElement(last) const endPoint = getEditableDomPointAtOffset(last, endText.length) if (!startPoint || !endPoint) { return } selection.setBaseAndExtent( startPoint.node, startPoint.offset, endPoint.node, endPoint.offset ) } /** * 인접 편집 블록으로 선택 범위를 확장한다. * @param {{ container: HTMLElement|null, sourceLine: number, direction: number, column?: number }} options - 확장 옵션 * @returns {boolean} 확장 여부 */ export const extendSelectionAcrossBlocks = ({ container, sourceLine, direction, column = 0 }) => { if (!import.meta.client || !container || !direction) { return false } const elements = getSelectableEditableElements(container) const currentIndex = findEditableIndexBySourceLine(elements, sourceLine) if (currentIndex < 0) { return false } const selection = window.getSelection() if (!selection || selection.rangeCount === 0) { return false } const focusedElement = getEditableElementFromNode(selection.focusNode, elements) const focusedIndex = focusedElement ? elements.indexOf(focusedElement) : -1 const resolvedCurrentIndex = focusedIndex >= 0 ? focusedIndex : currentIndex const targetIndex = resolvedCurrentIndex + direction if (targetIndex < 0 || targetIndex >= elements.length) { return false } const target = elements[targetIndex] const targetText = readEditableTextFromElement(target) const anchorPoint = { node: selection.anchorNode, offset: selection.anchorOffset } const focusPoint = getEditableDomPointAtOffset( target, getTargetSelectionOffset(targetText, direction, column) ) if (!anchorPoint?.node || !focusPoint) { return false } selection.setBaseAndExtent( anchorPoint.node, anchorPoint.offset, focusPoint.node, focusPoint.offset ) return true } /** * 편집 요소가 현재 전체 선택 상태인지 확인한다. * @param {HTMLElement} element - contenteditable 요소 * @returns {boolean} */ export const isEditableElementFullySelected = (element) => { if (!element || !import.meta.client) { return false } const selection = window.getSelection() if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { return false } const range = selection.getRangeAt(0) const fullText = readEditableTextFromElement(element) if (!fullText.length) { return false } const startOffset = getSelectionOffsetInElement(element, range, 'start') const endOffset = getSelectionOffsetInElement(element, range, 'end') return startOffset === 0 && endOffset >= fullText.length } /** * 선택 삭제·잘라내기 단축키인지 확인한다. * @param {KeyboardEvent} event - 키보드 이벤트 * @returns {boolean} */ export const isLiveSelectionDeleteKey = (event) => { if (event.key === 'Backspace' || event.key === 'Delete') { return true } return (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'x' } /** * 라이브 선택을 마크다운에 반영해 삭제한다. * @param {string} markdown - 현재 마크다운 * @param {HTMLElement|null} container - 렌더러 루트 * @returns {string|null} 갱신된 마크다운(처리하지 않으면 null) */ export const applyLiveSelectionDelete = (markdown, container) => { if (!import.meta.client || !container) { return null } const selection = window.getSelection() if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { return null } const range = selection.getRangeAt(0) const elements = getSelectableEditableElements(container) const affected = elements.filter((element) => rangeIntersectsElement(range, element)) if (!affected.length) { return null } const lines = String(markdown ?? '').split('\n') if (affected.length === 1) { const element = affected[0] const startsIn = element.contains(range.startContainer) const endsIn = element.contains(range.endContainer) if (!startsIn || !endsIn) { return null } const bodyText = readEditableTextFromElement(element, { trimEnd: false }) const startOffset = getSelectionOffsetInElement(element, range, 'start') const endOffset = getSelectionOffsetInElement(element, range, 'end') if (startOffset === 0 && endOffset >= bodyText.length) { const { startLine, endLine } = getEditableSourceLineRange(element) if (startLine < 0) { return null } const replacementLines = element.hasAttribute('data-empty-markdown-line') ? [element.getAttribute('data-empty-markdown-line') ?? ''] : [] return [ ...lines.slice(0, startLine), ...replacementLines, ...lines.slice(endLine + 1) ].join('\n') } if (startOffset < endOffset) { const { startLine } = getEditableSourceLineRange(element) const nextLine = applyBodyEditToMarkdownLine(lines[startLine] ?? '', bodyText, startOffset, endOffset) return [ ...lines.slice(0, startLine), nextLine, ...lines.slice(startLine + 1) ].join('\n') } return null } const first = affected[0] const last = affected[affected.length - 1] const { startLine: firstLine, endLine: firstEndLine } = getEditableSourceLineRange(first) const { startLine: lastLine, endLine: lastEndLine } = getEditableSourceLineRange(last) if (firstLine < 0 || lastLine < 0) { return null } const firstBody = readEditableTextFromElement(first, { trimEnd: false }) const lastBody = readEditableTextFromElement(last, { trimEnd: false }) const startOffset = getSelectionOffsetInElement(first, range, 'start') const endOffset = getSelectionOffsetInElement(last, range, 'end') const mergedBody = `${firstBody.slice(0, startOffset)}${lastBody.slice(endOffset)}` let replacementLines = [] if (mergedBody.length) { const bodySegments = mergedBody.split('\n') replacementLines = bodySegments.map((segment, index) => { if (index === 0) { return rebuildMarkdownLineBody(lines[firstLine] ?? '', firstBody, segment) } if (index === bodySegments.length - 1 && firstLine !== lastLine) { return rebuildMarkdownLineBody(lines[lastLine] ?? '', lastBody, segment) } return segment }) } return [ ...lines.slice(0, firstLine), ...replacementLines, ...lines.slice(lastEndLine + 1) ].join('\n') } /** * 현재 문서 선택을 접는다. * @returns {void} */ export const collapseLiveSelection = () => { if (!import.meta.client) { return } window.getSelection()?.removeAllRanges() }