공개 본문 라이브 선택 상태 분리

This commit is contained in:
2026-07-27 13:28:04 +09:00
parent 5fb2781d72
commit 7c19447ec8
10 changed files with 583 additions and 391 deletions

View File

@@ -9,16 +9,6 @@ import {
getContentLightboxImageUrl,
getGalleryImageSizes
} 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 {
appendTextToMarkdownLine,
getAppendTextForMerge,
@@ -44,6 +34,7 @@ import { getGalleryRows } from '../../lib/content-gallery-images.js'
import { useContentMarkdownLightbox } from '../../composables/useContentMarkdownLightbox.js'
import { useContentMarkdownBlocks } from '../../composables/useContentMarkdownBlocks.js'
import { useContentMarkdownImageDrag } from '../../composables/useContentMarkdownImageDrag.js'
import { useContentMarkdownLiveSelection } from '../../composables/useContentMarkdownLiveSelection.js'
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
import ProseCodeBlock from './ProseCodeBlock.vue'
import ProseHtml from './ProseHtml.vue'
@@ -100,15 +91,6 @@ const emit = defineEmits([
const CONTENT_LIST_MARKER_COLOR = '#2eb6ea'
/** @type {import('vue').Ref<Record<string, number>>} 갤러리 이미지 자연 비율 */
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} 문단 분리 연속 호출 방지 */
let lastParagraphSplitAt = 0
@@ -164,6 +146,28 @@ const {
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 - 렌더링 블록
@@ -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({
focusEditableAtLine,
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 - 제목 레벨
@@ -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 - 코드 블록
@@ -876,34 +541,6 @@ const onCalloutBlockInsertBelow = (block, payload) => {
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 - 토글 블록

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

View File

@@ -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 이미지 드래그 상태를 렌더러 밖으로 옮긴다
단일 이미지와 갤러리의 라이브 드래그는 드래그 payload, drop target 표시, 병합·삽입·분리·정렬 이벤트를 함께 다루지만, 실제 본문 렌더링 UI와는 별도 상태다. `ContentMarkdownRenderer` 안에 계속 두면 이미지 UI를 수정할 때 렌더링·편집·드래그 책임이 한 파일에서 얽히므로, `useContentMarkdownImageDrag`로 분리해 렌더러는 템플릿 연결과 emit 위임만 담당하게 한다.

View File

@@ -29,6 +29,7 @@
| composables/useContentMarkdownBlocks.js | 공개 Markdown 본문 파싱·제목 ID·인아티클 광고 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산 |
| composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 |
| composables/useContentMarkdownImageDrag.js | 관리자 라이브 Markdown 본문의 단일 이미지·갤러리 드래그 상태, drop target 표시, 병합·삽입·분리·정렬 이벤트 중개 |
| composables/useContentMarkdownLiveSelection.js | 관리자 라이브 Markdown 본문의 Selection Bridge, 포커스 복원, line focus/blur, 원문 편집 표시 상태 관리 |
| composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 |
| composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 |
@@ -135,6 +136,7 @@
| tests/useContentMarkdownBlocks.test.js | 공개 Markdown 블록 계산 composable의 파싱·제목 ID·광고 삽입·하단 삽입 줄 단위 테스트 |
| tests/useContentMarkdownLightbox.test.js | 공개 Markdown 라이트박스 composable의 열기·좌우 순환·ESC 닫기·키보드 이벤트 해제 단위 테스트 |
| tests/useContentMarkdownImageDrag.test.js | 공개 Markdown 라이브 이미지·갤러리 드래그 composable의 병합·삽입·분리·정렬 단위 테스트 |
| tests/useContentMarkdownLiveSelection.test.js | 공개 Markdown 라이브 선택 composable의 포커스 요청·예약 포커스 초기화·line focus/blur 단위 테스트 |
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |
| tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 |

View File

@@ -127,7 +127,7 @@
- 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한다. 업로드만으로는 카드 썸네일을 만들지 않고, 해당 이미지가 게시물 대표 이미지로 저장될 때 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp`에 생성한다.
- 카드 썸네일은 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한다.
- 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다.
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.

View File

@@ -4,7 +4,7 @@
- 거대 컴포넌트 책임 분리 진행.
- `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` 런타임 분기 축소 가능 여부 검토.
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.11.98
- 공개 Markdown 렌더러의 라이브 선택·포커스 복원·line focus/blur·원문 편집 표시 상태를 `useContentMarkdownLiveSelection` composable로 분리.
- 라이브 선택 composable의 포커스 요청, 예약 포커스 초기화, line focus/blur 전달 단위 테스트 추가.
- v1.11 내부 개선 todo에서 완료된 공개 렌더러 상태 분리 범위를 제외하고 남은 작업을 더 좁게 정리.
## v1.11.97
- 공개 Markdown 렌더러의 라이브 단일 이미지·갤러리 드래그 상태와 이벤트 중개를 `useContentMarkdownImageDrag` composable로 분리.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.11.97",
"version": "1.11.98",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.11.97",
"version": "1.11.98",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.11.97",
"version": "1.11.98",
"private": true,
"type": "module",
"imports": {

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