공개 본문 라이브 선택 상태 분리
This commit is contained in:
@@ -9,16 +9,6 @@ import {
|
|||||||
getContentLightboxImageUrl,
|
getContentLightboxImageUrl,
|
||||||
getGalleryImageSizes
|
getGalleryImageSizes
|
||||||
} from '../../lib/content-image-urls.js'
|
} 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 {
|
import {
|
||||||
appendTextToMarkdownLine,
|
appendTextToMarkdownLine,
|
||||||
getAppendTextForMerge,
|
getAppendTextForMerge,
|
||||||
@@ -44,6 +34,7 @@ import { getGalleryRows } from '../../lib/content-gallery-images.js'
|
|||||||
import { useContentMarkdownLightbox } from '../../composables/useContentMarkdownLightbox.js'
|
import { useContentMarkdownLightbox } from '../../composables/useContentMarkdownLightbox.js'
|
||||||
import { useContentMarkdownBlocks } from '../../composables/useContentMarkdownBlocks.js'
|
import { useContentMarkdownBlocks } from '../../composables/useContentMarkdownBlocks.js'
|
||||||
import { useContentMarkdownImageDrag } from '../../composables/useContentMarkdownImageDrag.js'
|
import { useContentMarkdownImageDrag } from '../../composables/useContentMarkdownImageDrag.js'
|
||||||
|
import { useContentMarkdownLiveSelection } from '../../composables/useContentMarkdownLiveSelection.js'
|
||||||
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
||||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||||
import ProseHtml from './ProseHtml.vue'
|
import ProseHtml from './ProseHtml.vue'
|
||||||
@@ -100,15 +91,6 @@ const emit = defineEmits([
|
|||||||
const CONTENT_LIST_MARKER_COLOR = '#2eb6ea'
|
const CONTENT_LIST_MARKER_COLOR = '#2eb6ea'
|
||||||
/** @type {import('vue').Ref<Record<string, number>>} 갤러리 이미지 자연 비율 */
|
/** @type {import('vue').Ref<Record<string, number>>} 갤러리 이미지 자연 비율 */
|
||||||
const galleryImageAspectRatios = ref({})
|
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} 문단 분리 연속 호출 방지 */
|
/** @type {number} 문단 분리 연속 호출 방지 */
|
||||||
let lastParagraphSplitAt = 0
|
let lastParagraphSplitAt = 0
|
||||||
|
|
||||||
@@ -164,6 +146,28 @@ const {
|
|||||||
onGalleryReorder: (payload) => emit('gallery-reorder', payload)
|
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 - 렌더링 블록
|
* @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({
|
defineExpose({
|
||||||
focusEditableAtLine,
|
focusEditableAtLine,
|
||||||
selectAllLiveDocument
|
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 - 제목 레벨
|
* @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 - 코드 블록
|
* @param {Object} block - 코드 블록
|
||||||
@@ -876,34 +541,6 @@ const onCalloutBlockInsertBelow = (block, payload) => {
|
|||||||
onInsertBelowBlock(block, { lines: [''] })
|
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 - 토글 블록
|
* @param {Object} block - 토글 블록
|
||||||
|
|||||||
428
composables/useContentMarkdownLiveSelection.js
Normal file
428
composables/useContentMarkdownLiveSelection.js
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
## 2026-07-27 v1.11.98 — 공개 Markdown 라이브 선택과 포커스 상태를 분리한다
|
||||||
|
|
||||||
|
라이브 편집 모드의 포커스 복원, Shift 범위 선택, 전체 선택, 선택 삭제, line focus/blur 이벤트는 본문 블록 렌더링보다 편집 세션 상태에 가깝다. 이를 `ContentMarkdownRenderer` 안에 두면 블록 렌더링 이벤트와 DOM 선택 중개가 섞여 파일을 줄여도 todo의 실제 범위가 보이지 않는다. `useContentMarkdownLiveSelection`으로 옮겨 렌더러는 블록별 편집 이벤트 연결에 집중하고, 남은 todo는 실제 블록 편집 도메인 분리로 좁힌다.
|
||||||
|
|
||||||
## 2026-07-27 v1.11.97 — 공개 Markdown 이미지 드래그 상태를 렌더러 밖으로 옮긴다
|
## 2026-07-27 v1.11.97 — 공개 Markdown 이미지 드래그 상태를 렌더러 밖으로 옮긴다
|
||||||
|
|
||||||
단일 이미지와 갤러리의 라이브 드래그는 드래그 payload, drop target 표시, 병합·삽입·분리·정렬 이벤트를 함께 다루지만, 실제 본문 렌더링 UI와는 별도 상태다. `ContentMarkdownRenderer` 안에 계속 두면 이미지 UI를 수정할 때 렌더링·편집·드래그 책임이 한 파일에서 얽히므로, `useContentMarkdownImageDrag`로 분리해 렌더러는 템플릿 연결과 emit 위임만 담당하게 한다.
|
단일 이미지와 갤러리의 라이브 드래그는 드래그 payload, drop target 표시, 병합·삽입·분리·정렬 이벤트를 함께 다루지만, 실제 본문 렌더링 UI와는 별도 상태다. `ContentMarkdownRenderer` 안에 계속 두면 이미지 UI를 수정할 때 렌더링·편집·드래그 책임이 한 파일에서 얽히므로, `useContentMarkdownImageDrag`로 분리해 렌더러는 템플릿 연결과 emit 위임만 담당하게 한다.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
| composables/useContentMarkdownBlocks.js | 공개 Markdown 본문 파싱·제목 ID·인아티클 광고 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산 |
|
| composables/useContentMarkdownBlocks.js | 공개 Markdown 본문 파싱·제목 ID·인아티클 광고 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산 |
|
||||||
| composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 |
|
| composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 |
|
||||||
| composables/useContentMarkdownImageDrag.js | 관리자 라이브 Markdown 본문의 단일 이미지·갤러리 드래그 상태, drop target 표시, 병합·삽입·분리·정렬 이벤트 중개 |
|
| composables/useContentMarkdownImageDrag.js | 관리자 라이브 Markdown 본문의 단일 이미지·갤러리 드래그 상태, drop target 표시, 병합·삽입·분리·정렬 이벤트 중개 |
|
||||||
|
| composables/useContentMarkdownLiveSelection.js | 관리자 라이브 Markdown 본문의 Selection Bridge, 포커스 복원, line focus/blur, 원문 편집 표시 상태 관리 |
|
||||||
| composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 |
|
| composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 |
|
||||||
| composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 |
|
| composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 |
|
||||||
|
|
||||||
@@ -135,6 +136,7 @@
|
|||||||
| tests/useContentMarkdownBlocks.test.js | 공개 Markdown 블록 계산 composable의 파싱·제목 ID·광고 삽입·하단 삽입 줄 단위 테스트 |
|
| tests/useContentMarkdownBlocks.test.js | 공개 Markdown 블록 계산 composable의 파싱·제목 ID·광고 삽입·하단 삽입 줄 단위 테스트 |
|
||||||
| tests/useContentMarkdownLightbox.test.js | 공개 Markdown 라이트박스 composable의 열기·좌우 순환·ESC 닫기·키보드 이벤트 해제 단위 테스트 |
|
| tests/useContentMarkdownLightbox.test.js | 공개 Markdown 라이트박스 composable의 열기·좌우 순환·ESC 닫기·키보드 이벤트 해제 단위 테스트 |
|
||||||
| tests/useContentMarkdownImageDrag.test.js | 공개 Markdown 라이브 이미지·갤러리 드래그 composable의 병합·삽입·분리·정렬 단위 테스트 |
|
| tests/useContentMarkdownImageDrag.test.js | 공개 Markdown 라이브 이미지·갤러리 드래그 composable의 병합·삽입·분리·정렬 단위 테스트 |
|
||||||
|
| tests/useContentMarkdownLiveSelection.test.js | 공개 Markdown 라이브 선택 composable의 포커스 요청·예약 포커스 초기화·line focus/blur 단위 테스트 |
|
||||||
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |
|
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |
|
||||||
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |
|
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |
|
||||||
| tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 |
|
| tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 |
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
|
|
||||||
- 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한다. 업로드만으로는 카드 썸네일을 만들지 않고, 해당 이미지가 게시물 대표 이미지로 저장될 때 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp`에 생성한다.
|
- 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한다. 업로드만으로는 카드 썸네일을 만들지 않고, 해당 이미지가 게시물 대표 이미지로 저장될 때 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp`에 생성한다.
|
||||||
- 카드 썸네일은 640×360 기준 `cover` 리사이즈와 WebP 품질 82를 사용한다. GIF·동영상·문서 파일은 썸네일을 자동 생성하지 않는다.
|
- 카드 썸네일은 640×360 기준 `cover` 리사이즈와 WebP 품질 82를 사용한다. GIF·동영상·문서 파일은 썸네일을 자동 생성하지 않는다.
|
||||||
- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다. 공개 본문 라이트박스 대상 목록은 `useContentMarkdownBlocks`에서 `lib/content-gallery-images.js` 기준으로 계산하고, 갤러리 행 분할은 `lib/content-gallery-images.js`에서 계산한다. 관리자 라이브 모드의 단일 이미지·갤러리 드래그 상태와 병합·삽입·분리·정렬 이벤트 중개는 `useContentMarkdownImageDrag`에서 관리한다.
|
- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다. 공개 본문 라이트박스 대상 목록은 `useContentMarkdownBlocks`에서 `lib/content-gallery-images.js` 기준으로 계산하고, 갤러리 행 분할은 `lib/content-gallery-images.js`에서 계산한다. 관리자 라이브 모드의 단일 이미지·갤러리 드래그 상태와 병합·삽입·분리·정렬 이벤트 중개는 `useContentMarkdownImageDrag`에서 관리한다. 라이브 모드의 선택 확장·전체 선택·선택 삭제·포커스 복원·line focus/blur 중개는 `useContentMarkdownLiveSelection`에서 관리한다.
|
||||||
- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다.
|
- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다.
|
||||||
- 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다.
|
- 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다.
|
||||||
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.
|
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- 거대 컴포넌트 책임 분리 진행.
|
- 거대 컴포넌트 책임 분리 진행.
|
||||||
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
||||||
- `components/content/ContentMarkdownRenderer.vue`: 블록별 렌더링 UI와 텍스트 라이브 편집 중개 책임을 추가 분리.
|
- `components/content/ContentMarkdownRenderer.vue`: 남은 블록별 편집 이벤트(`onParagraph*`, `onList*`, `onQuote*`, `onCallout*`, `onToggle*`, `onCode*`)를 도메인별 helper/composable로 추가 분리.
|
||||||
- 미디어 카탈로그 전환 코드 수명 정리.
|
- 미디어 카탈로그 전환 코드 수명 정리.
|
||||||
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
||||||
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.
|
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.11.98
|
||||||
|
|
||||||
|
- 공개 Markdown 렌더러의 라이브 선택·포커스 복원·line focus/blur·원문 편집 표시 상태를 `useContentMarkdownLiveSelection` composable로 분리.
|
||||||
|
- 라이브 선택 composable의 포커스 요청, 예약 포커스 초기화, line focus/blur 전달 단위 테스트 추가.
|
||||||
|
- v1.11 내부 개선 todo에서 완료된 공개 렌더러 상태 분리 범위를 제외하고 남은 작업을 더 좁게 정리.
|
||||||
|
|
||||||
## v1.11.97
|
## v1.11.97
|
||||||
|
|
||||||
- 공개 Markdown 렌더러의 라이브 단일 이미지·갤러리 드래그 상태와 이벤트 중개를 `useContentMarkdownImageDrag` composable로 분리.
|
- 공개 Markdown 렌더러의 라이브 단일 이미지·갤러리 드래그 상태와 이벤트 중개를 `useContentMarkdownImageDrag` composable로 분리.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.97",
|
"version": "1.11.98",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.97",
|
"version": "1.11.98",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.97",
|
"version": "1.11.98",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
115
tests/useContentMarkdownLiveSelection.test.js
Normal file
115
tests/useContentMarkdownLiveSelection.test.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { nextTick, ref } from 'vue'
|
||||||
|
import { useContentMarkdownLiveSelection } from '../composables/useContentMarkdownLiveSelection.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트용 라이브 선택 상태를 만든다.
|
||||||
|
* @param {Object} calls - 콜백 기록 객체
|
||||||
|
* @returns {{ content: import('vue').Ref<string>, state: Object }} 상태
|
||||||
|
*/
|
||||||
|
const createLiveSelectionState = (calls = {}) => {
|
||||||
|
const content = ref('첫 줄\n둘째 줄')
|
||||||
|
const state = useContentMarkdownLiveSelection({
|
||||||
|
content,
|
||||||
|
interactive: ref(true),
|
||||||
|
onLineFocus: (line) => {
|
||||||
|
calls.focus = line
|
||||||
|
},
|
||||||
|
onLineBlur: () => {
|
||||||
|
calls.blur = true
|
||||||
|
},
|
||||||
|
onContentReplace: (payload) => {
|
||||||
|
calls.replace = payload
|
||||||
|
},
|
||||||
|
provideBridge: false
|
||||||
|
})
|
||||||
|
|
||||||
|
return { content, state }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('라이브 선택 상태는 자식 편집기 포커스 요청과 원문 모드 표시를 관리한다', () => {
|
||||||
|
const calls = {}
|
||||||
|
const { state } = createLiveSelectionState(calls)
|
||||||
|
|
||||||
|
state.onEditorFocusLine({ line: 3, position: 'end', offset: 2 })
|
||||||
|
|
||||||
|
assert.equal(state.pendingFocusLine.value, 3)
|
||||||
|
assert.equal(state.pendingFocusPosition.value, 'end')
|
||||||
|
assert.equal(state.pendingFocusOffset.value, 2)
|
||||||
|
|
||||||
|
state.onInlineRawMode({ sourceLine: 7, active: true })
|
||||||
|
assert.equal(state.rawEditingSourceLines.value.has(7), true)
|
||||||
|
|
||||||
|
state.onInlineRawMode({ sourceLine: 7, active: false })
|
||||||
|
assert.equal(state.rawEditingSourceLines.value.has(7), false)
|
||||||
|
|
||||||
|
state.emitEditorFocusedLine(5)
|
||||||
|
assert.equal(calls.focus, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('라이브 선택 상태는 본문 변경 후 예약된 포커스 값을 초기화한다', async () => {
|
||||||
|
const { content, state } = createLiveSelectionState()
|
||||||
|
|
||||||
|
state.onEditorFocusLine({ line: 1, position: 'start' })
|
||||||
|
content.value = '바뀐 줄'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
assert.equal(state.pendingFocusLine.value, null)
|
||||||
|
assert.equal(state.pendingFocusPosition.value, 'auto')
|
||||||
|
assert.equal(state.pendingFocusOffset.value, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('라이브 줄 포커스와 블러 이벤트를 상위 콜백으로 전달한다', () => {
|
||||||
|
class FakeElement {
|
||||||
|
/**
|
||||||
|
* @param {number} line - 원본 줄 번호
|
||||||
|
*/
|
||||||
|
constructor(line) {
|
||||||
|
this.line = line
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가장 가까운 데이터 줄 요소를 반환한다.
|
||||||
|
* @returns {FakeElement} 자기 자신
|
||||||
|
*/
|
||||||
|
closest() {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 속성 값을 반환한다.
|
||||||
|
* @param {string} name - 속성명
|
||||||
|
* @returns {string|null} 속성 값
|
||||||
|
*/
|
||||||
|
getAttribute(name) {
|
||||||
|
return name === 'data-source-line' ? String(this.line) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousElement = globalThis.Element
|
||||||
|
const previousNode = globalThis.Node
|
||||||
|
globalThis.Element = FakeElement
|
||||||
|
globalThis.Node = FakeElement
|
||||||
|
|
||||||
|
try {
|
||||||
|
const calls = {}
|
||||||
|
const { state } = createLiveSelectionState(calls)
|
||||||
|
const target = new FakeElement(4)
|
||||||
|
|
||||||
|
state.emitLiveLineFocus({ target })
|
||||||
|
assert.equal(calls.focus, 4)
|
||||||
|
|
||||||
|
state.rendererRootRef.value = {
|
||||||
|
contains: (node) => node === target
|
||||||
|
}
|
||||||
|
state.emitLiveLineBlur({ relatedTarget: target })
|
||||||
|
assert.equal(calls.blur, undefined)
|
||||||
|
|
||||||
|
state.emitLiveLineBlur({ relatedTarget: new FakeElement(9) })
|
||||||
|
assert.equal(calls.blur, true)
|
||||||
|
} finally {
|
||||||
|
globalThis.Element = previousElement
|
||||||
|
globalThis.Node = previousNode
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user