Files
sori.studio/composables/useContentMarkdownLiveSelection.js

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
}
}