429 lines
14 KiB
JavaScript
429 lines
14 KiB
JavaScript
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>|string} options.content - Markdown 본문
|
|
* @param {import('vue').Ref<boolean>|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<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)
|
|
/** @type {import('vue').Ref<HTMLElement|null>} */
|
|
const rendererRootRef = ref(null)
|
|
/** @type {import('vue').Ref<Set<number>>} 원문(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
|
|
}
|
|
}
|