From 88a0860078f05792a45d69bcfde1fd9559d907be Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 14 May 2026 14:53:55 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=EC=97=90=EB=94=94=ED=84=B0=EB=A5=BC=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20v1.0.5=20=EC=8B=9C=EC=A0=90=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90(v1.0.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.0.6 이후 붙여넣기 분할·Cmd+A MD 복사·블록 범위 선택 등 제거. 명세·맵·이력·업데이트 동기화. --- components/admin/AdminBlockEditor.vue | 592 +------------------------- docs/history.md | 24 +- docs/map.md | 2 +- docs/spec.md | 5 +- docs/update.md | 26 +- package.json | 2 +- 6 files changed, 13 insertions(+), 638 deletions(-) diff --git a/components/admin/AdminBlockEditor.vue b/components/admin/AdminBlockEditor.vue index 801e00c..7d4f6db 100644 --- a/components/admin/AdminBlockEditor.vue +++ b/components/admin/AdminBlockEditor.vue @@ -35,12 +35,7 @@ const isKeyboardPriorityMode = ref(false) const calloutEmojiPickerBlockId = ref('') const calloutColorPopoverBlockId = ref('') const calloutEmojiComposingBlockId = ref('') -const editorFlashMessage = ref('') -const blockRangeSelection = ref(null) -const isBlockRangeDragging = ref(false) -const blockEditorRootRef = ref(null) let blockIdSeed = 0 -let editorFlashTimer = null const imageWidthOptions = [ { value: 'regular', label: '기본' }, @@ -373,12 +368,11 @@ const serializeImage = (image) => { } /** - * 주어진 블록 배열을 저장용 마크다운으로 변환한다. - * @param {Array} blocks - 직렬화할 블록 목록 + * 에디터 블록 목록을 저장용 마크다운으로 변환 * @returns {string} 마크다운 문자열 */ -const serializeBlockArray = (blocks) => { - const lines = blocks +const serializeBlocks = () => { + const lines = editorBlocks.value .map((block, index) => { const rawText = block.text || '' const text = rawText.trim() @@ -432,7 +426,7 @@ const serializeBlockArray = (blocks) => { } if (!text && block.type === 'paragraph') { - if (index === blocks.length - 1) { + if (index === editorBlocks.value.length - 1) { return null } @@ -477,29 +471,6 @@ const serializeBlockArray = (blocks) => { }, '') } -/** - * 에디터 블록 인덱스 구간을 마크다운으로 직렬화한다. - * @param {number} startIdx - 시작 인덱스 - * @param {number} endIdx - 끝 인덱스(포함) - * @returns {string} 마크다운 문자열 - */ -const serializeBlockIndexSlice = (startIdx, endIdx) => { - const lo = Math.max(0, Math.min(startIdx, endIdx)) - const hi = Math.min(editorBlocks.value.length - 1, Math.max(startIdx, endIdx)) - - if (lo > hi) { - return '' - } - - return serializeBlockArray(editorBlocks.value.slice(lo, hi + 1)) -} - -/** - * 에디터 블록 목록을 저장용 마크다운으로 변환 - * @returns {string} 마크다운 문자열 - */ -const serializeBlocks = () => serializeBlockArray(editorBlocks.value) - /** * 부모 폼으로 콘텐츠 변경 전달 * @returns {void} @@ -761,230 +732,6 @@ const syncTextBlockFromDom = (index) => { return block } -/** - * 에디터 상단에 잠깐 안내 문구를 표시한다. - * @param {string} message - 표시할 문구 - * @returns {void} - */ -const flashEditorMessage = (message) => { - editorFlashMessage.value = message - - if (import.meta.client && editorFlashTimer) { - window.clearTimeout(editorFlashTimer) - } - - if (import.meta.client) { - editorFlashTimer = window.setTimeout(() => { - editorFlashMessage.value = '' - editorFlashTimer = null - }, 2600) - } -} - -/** - * 클립보드 텍스트를 여러 블록으로 나누어 붙일지 판별한다. - * @param {string} text - 순수 텍스트 - * @param {FileList|undefined} files - 첨부 파일 - * @returns {boolean} 구조화 붙여넣기 여부 - */ -const shouldParseClipboardAsBlocks = (text, files) => { - if (files && files.length > 0) { - return false - } - - if (!text || !String(text).trim()) { - return false - } - - if (text.includes('\n')) { - return true - } - - const head = text.trimStart().slice(0, 120) - - if (/^#{1,3}\s/m.test(head) || /^>\s/m.test(head) || /^- /m.test(head) || /^```/m.test(head) || /^:::+/m.test(head)) { - return true - } - - if (/\[[^\]]+\]\([^)]+\)/.test(head)) { - return true - } - - return false -} - -/** - * contenteditable 요소 안에서 선택 구간의 순수 텍스트 기준 오프셋을 계산한다. - * @param {HTMLElement} element - 편집 루트 요소 - * @returns {{ start: number, end: number }} 시작·끝 오프셋 - */ -const getPlainTextOffsetsWithinElement = (element) => { - const selection = window.getSelection() - - if (!selection?.rangeCount || !element.contains(selection.anchorNode)) { - const len = element.textContent?.length || 0 - - return { start: len, end: len } - } - - const range = selection.getRangeAt(0) - const startRange = document.createRange() - - startRange.selectNodeContents(element) - startRange.setEnd(range.startContainer, range.startOffset) - const start = startRange.toString().length - const endRange = document.createRange() - - endRange.selectNodeContents(element) - endRange.setEnd(range.endContainer, range.endOffset) - - return { - start, - end: endRange.toString().length - } -} - -/** - * 붙여넣기용 블록 목록에 새 id를 부여한다. - * @param {Array} blocks - 파싱된 블록 - * @returns {Array} id가 갱신된 블록 - */ -const cloneBlocksWithNewIds = (blocks) => blocks.map((block) => ({ - ...block, - id: `editor-block-${(blockIdSeed += 1)}` -})) - -/** - * 텍스트를 앞뒤 잘라낸 뒤 붙인 블록으로 현재 블록을 대체한다. - * @param {number} index - 대상 블록 인덱스 - * @param {string} clipboardText - 클립보드 순수 텍스트 - * @param {string} before - 커서 앞에 남길 텍스트 - * @param {string} after - 커서 뒤에 남길 텍스트 - * @returns {void} - */ -const applyStructuredPaste = (index, clipboardText, before, after) => { - let inserted = parseMarkdownToBlocks(clipboardText) - - inserted = cloneBlocksWithNewIds(inserted) - - if (!inserted.length) { - return - } - - const mergeableTypes = ['paragraph', 'heading', 'quote', 'list', 'code', 'callout'] - const first = inserted[0] - - if (before) { - if (mergeableTypes.includes(first.type)) { - first.text = `${before}${first.text || ''}` - } else { - inserted = [createEditorBlock('paragraph', before), ...inserted] - } - } - - const last = inserted[inserted.length - 1] - - if (after) { - if (mergeableTypes.includes(last.type)) { - last.text = `${last.text || ''}${after}` - } else { - inserted = [...inserted, createEditorBlock('paragraph', after)] - } - } - - clearBlockRangeSelection() - editorBlocks.value.splice(index, 1, ...inserted) - normalizeTrailingTextBlock() - emitContent() - nextTick(() => focusBlock(index + inserted.length - 1, 'end')) -} - -/** - * 여러 줄·마크다운 붙여넣기를 블록 단위로 반영한다. - * @param {ClipboardEvent} event - 붙여넣기 이벤트 - * @param {number} index - 블록 인덱스 - * @returns {void} - */ -const handleTextBlockPaste = (event, index) => { - const block = editorBlocks.value[index] - - if (!block || !isTextBlock(block) || isComposingText.value) { - return - } - - const data = event.clipboardData - - if (!data || data.files?.length) { - return - } - - const clip = data.getData('text/plain') || '' - - if (!shouldParseClipboardAsBlocks(clip, data.files)) { - return - } - - event.preventDefault() - const element = blockRefs.value[index] - - if (!element) { - return - } - - const { start, end } = getPlainTextOffsetsWithinElement(element) - const domText = getTextBlockDomText(index) - const before = domText.slice(0, start) - const after = domText.slice(end) - - applyStructuredPaste(index, clip, before, after) -} - -/** - * 블록마다 분리된 contenteditable에서는 문서 전체 선택이 불가하므로, Cmd/Ctrl+A 시 마크다운을 클립보드에 담는다. 블록 범위 선택 중이면 해당 구간만 복사한다. - * @param {KeyboardEvent} event - 키보드 이벤트 - * @param {number} index - 포커스 블록 인덱스 - * @returns {Promise} - */ -const handleTextBlockSelectAll = async (event, index) => { - if (!(event.metaKey || event.ctrlKey) || event.shiftKey || String(event.key).toLowerCase() !== 'a') { - return - } - - event.preventDefault() - const rangeSel = blockRangeSelection.value - const md = rangeSel - ? serializeBlockIndexSlice(rangeSel.anchor, rangeSel.focus) - : serializeBlocks() - - try { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(md) - - if (rangeSel) { - flashEditorMessage('선택한 블록 구간(마크다운)을 클립보드에 복사했습니다.') - } else { - flashEditorMessage('전체 본문(마크다운)을 클립보드에 복사했습니다. 다른 편집기에 Cmd+V로 붙여넣을 수 있습니다.') - } - } else { - flashEditorMessage('이 브라우저에서는 클립보드 API를 사용할 수 없습니다.') - } - } catch { - flashEditorMessage('클립보드 복사에 실패했습니다. 주소가 https인지·사이트 권한을 확인해 주세요.') - } - - nextTick(() => focusBlock(index, 'end')) -} - -/** - * 텍스트 블록 공통 키다운(전체 복사 단축키 등) - * @param {KeyboardEvent} event - 키보드 이벤트 - * @param {number} index - 블록 인덱스 - * @returns {Promise} - */ -const handleTextBlockKeydownRoot = async (event, index) => { - await handleTextBlockSelectAll(event, index) -} - /** * 블록 타입에 맞는 태그명 반환 * @param {Object} block - 에디터 블록 @@ -1240,7 +987,6 @@ const activeCalloutBlock = computed(() => { * @returns {void} */ const applyCommand = (command) => { - clearBlockRangeSelection() const index = activeBlockIndex.value if (index < 0) { @@ -1665,252 +1411,6 @@ const scrollHighlightedCommandIntoView = () => { }) } -/** - * 블록 단위 범위 선택을 해제한다. - * @returns {void} - */ -const clearBlockRangeSelection = () => { - blockRangeSelection.value = null - isBlockRangeDragging.value = false -} - -/** - * 블록 인덱스가 범위 선택에 포함되는지 여부 - * @param {number} index - 블록 인덱스 - * @returns {boolean} 포함 여부 - */ -const isBlockRangeRowSelected = (index) => { - const sel = blockRangeSelection.value - - if (!sel) { - return false - } - - const lo = Math.min(sel.anchor, sel.focus) - const hi = Math.max(sel.anchor, sel.focus) - - return index >= lo && index <= hi -} - -/** - * 포인터 좌표에 해당하는 행 블록 인덱스를 찾는다. - * elementFromPoint가 행 밖 여백(블록 간 margin 등)을 가리키면 세로 거리로 가장 가까운 행을 고른다. - * @param {number} clientX - 뷰포트 X - * @param {number} clientY - 뷰포트 Y - * @returns {number} 인덱스, 없으면 -1 - */ -const resolveBlockIndexFromPointer = (clientX, clientY) => { - const root = blockEditorRootRef.value - - if (!root || typeof document === 'undefined') { - return -1 - } - - const topEl = document.elementFromPoint(clientX, clientY) - const directRow = topEl?.closest?.('[data-editor-block-id]') - - if (directRow && root.contains(directRow)) { - const id = directRow.getAttribute('data-editor-block-id') - const idx = editorBlocks.value.findIndex((b) => b.id === id) - - if (idx >= 0) { - return idx - } - } - - const idToIndex = new Map(editorBlocks.value.map((b, i) => [b.id, i])) - let bestIdx = -1 - let bestDelta = Infinity - - root.querySelectorAll('[data-editor-block-id]').forEach((row) => { - const id = row.getAttribute('data-editor-block-id') - const i = id == null ? -1 : idToIndex.get(id) - - if (i === undefined || i < 0) { - return - } - - const r = row.getBoundingClientRect() - let delta = 0 - - if (clientY < r.top) { - delta = r.top - clientY - } else if (clientY > r.bottom) { - delta = clientY - r.bottom - } - - if (delta < bestDelta) { - bestDelta = delta - bestIdx = i - } - }) - - const snapPx = 72 - - if (bestIdx >= 0 && bestDelta <= snapPx) { - return bestIdx - } - - return -1 -} - -/** - * 범위 선택 레인에서 포인터로 블록 범위 드래그를 시작한다. - * @param {PointerEvent} event - 포인터 이벤트 - * @param {number} index - 블록 인덱스 - * @returns {void} - */ -const onBlockRangeLanePointerDown = (event, index) => { - if (event.button !== 0) { - return - } - - event.preventDefault() - event.stopPropagation() - - if (event.shiftKey) { - if (blockRangeSelection.value) { - blockRangeSelection.value = { - anchor: blockRangeSelection.value.anchor, - focus: index - } - } else { - blockRangeSelection.value = { anchor: index, focus: index } - } - - return - } - - const anchorIndex = index - - isBlockRangeDragging.value = true - blockRangeSelection.value = { anchor: anchorIndex, focus: anchorIndex } - - /** - * 드래그 중 포인터 위치의 블록으로 범위 끝을 갱신한다. - * @param {PointerEvent} ev - 포인터 이벤트 - * @returns {void} - */ - const onMove = (ev) => { - if (!isBlockRangeDragging.value) { - return - } - - const idx = resolveBlockIndexFromPointer(ev.clientX, ev.clientY) - - if (idx < 0) { - return - } - - blockRangeSelection.value = { - anchor: anchorIndex, - focus: idx - } - } - - /** - * 드래그 종료 시 문서 리스너를 제거한다. - * @returns {void} - */ - const onUp = () => { - isBlockRangeDragging.value = false - document.removeEventListener('pointermove', onMove) - document.removeEventListener('pointerup', onUp) - document.removeEventListener('pointercancel', onUp) - } - - document.addEventListener('pointermove', onMove, { passive: true }) - document.addEventListener('pointerup', onUp, { passive: true }) - document.addEventListener('pointercancel', onUp, { passive: true }) -} - -/** - * 블록 범위 선택이 있어도 브라우저 기본 복사를 우선해야 하는지 판별한다. - * contenteditable 안의 비접힘 선택 또는 textarea/input의 선택 구간이 있으면 true. - * @returns {boolean} 기본 복사를 그대로 두면 true - */ -const shouldDeferBlockRangeCopyToNative = () => { - if (typeof document === 'undefined') { - return false - } - - const activeEl = document.activeElement - - if (activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLInputElement) { - const { selectionStart, selectionEnd } = activeEl - - if (selectionStart != null && selectionEnd != null && selectionStart !== selectionEnd) { - return true - } - } - - if (typeof window === 'undefined') { - return false - } - - const sel = window.getSelection() - - if (!sel || sel.isCollapsed || !sel.rangeCount) { - return false - } - - const range = sel.getRangeAt(0) - let ancestor = range.commonAncestorContainer - - if (ancestor.nodeType === Node.TEXT_NODE) { - ancestor = ancestor.parentElement - } - - if (!ancestor || typeof ancestor.closest !== 'function') { - return false - } - - const host = ancestor.closest('[contenteditable="true"]') - - if (!host) { - return false - } - - return blockRefs.value.some((el) => el === host) -} - -/** - * 에디터 루트에서 범위 선택이 있을 때 복사 시 마크다운만 클립보드에 넣는다. - * @param {ClipboardEvent} event - 복사 이벤트 - * @returns {void} - */ -const handleEditorRootCopy = (event) => { - if (!blockRangeSelection.value) { - return - } - - if (shouldDeferBlockRangeCopyToNative()) { - return - } - - const { anchor, focus } = blockRangeSelection.value - const md = serializeBlockIndexSlice(anchor, focus) - - if (!md) { - return - } - - event.preventDefault() - event.clipboardData?.setData('text/plain', md) -} - -/** - * 에디터 루트 키다운(Escape로 범위 해제 등) - * @param {KeyboardEvent} event - 키보드 이벤트 - * @returns {void} - */ -const handleEditorRootKeydown = (event) => { - if (event.key === 'Escape' && blockRangeSelection.value) { - event.preventDefault() - clearBlockRangeSelection() - } -} - /** * 일반 본문 블록 방향키 이동 처리 * @param {KeyboardEvent} event - 키보드 이벤트 @@ -1926,22 +1426,10 @@ const navigateAcrossBlocks = (event, index, direction) => { } const currentElement = blockRefs.value[index] - if (!currentElement) { return } - if (event.shiftKey && blockRangeSelection.value) { - event.preventDefault() - const { anchor, focus } = blockRangeSelection.value - const nextFocus = direction === 'down' - ? Math.min(editorBlocks.value.length - 1, focus + 1) - : Math.max(0, focus - 1) - - blockRangeSelection.value = { anchor, focus: nextFocus } - return - } - const isBoundary = direction === 'up' ? isCaretOnBoundary(currentElement, 'start') : isCaretOnBoundary(currentElement, 'end') @@ -1951,21 +1439,12 @@ const navigateAcrossBlocks = (event, index, direction) => { } const nextIndex = direction === 'up' ? index - 1 : index + 1 - if (nextIndex < 0 || nextIndex >= editorBlocks.value.length) { return } - if (event.shiftKey) { - event.preventDefault() - blockRangeSelection.value = { anchor: index, focus: nextIndex } - return - } - - clearBlockRangeSelection() event.preventDefault() const targetBlock = editorBlocks.value[nextIndex] - if (isTextBlock(targetBlock)) { focusBlock(nextIndex, direction === 'up' ? 'end' : 'start') return @@ -1982,7 +1461,6 @@ const navigateAcrossBlocks = (event, index, direction) => { */ const handleEnter = (event, index) => { enableKeyboardPriorityMode() - clearBlockRangeSelection() const currentBlock = syncTextBlockFromDom(index) @@ -2073,7 +1551,6 @@ const handleBackspace = (event, index) => { } event.preventDefault() - clearBlockRangeSelection() editorBlocks.value.splice(index, 1) normalizeTrailingTextBlock() emitContent() @@ -2127,7 +1604,6 @@ const cleanupUnusedStructuredBlockOnActivate = (nextBlockId) => { return } - clearBlockRangeSelection() editorBlocks.value.splice(previousBlockIndex, 1) normalizeTrailingTextBlock() emitContent() @@ -2139,7 +1615,6 @@ const cleanupUnusedStructuredBlockOnActivate = (nextBlockId) => { * @returns {void} */ const selectBlock = (block) => { - clearBlockRangeSelection() selectedBlockId.value = block.id activeBlockId.value = block.id slashQuery.value = '' @@ -2159,7 +1634,6 @@ const deleteBlock = (index) => { editorBlocks.value.splice(0, 1, createEditorBlock()) selectedBlockId.value = '' activeBlockId.value = editorBlocks.value[0].id - clearBlockRangeSelection() emitContent() focusBlock(0) return @@ -2167,7 +1641,6 @@ const deleteBlock = (index) => { editorBlocks.value.splice(index, 1) selectedBlockId.value = '' - clearBlockRangeSelection() normalizeTrailingTextBlock() emitContent() focusBlock(Math.min(index, editorBlocks.value.length - 1)) @@ -2191,7 +1664,6 @@ const deleteSelectedBlock = (event, index) => { * @returns {void} */ const startBlockDrag = (event, block) => { - clearBlockRangeSelection() draggingBlockId.value = block.id selectedBlockId.value = block.id dragTargetIndex.value = -1 @@ -2249,7 +1721,6 @@ const moveDraggedBlock = (draggedId, targetIndex, targetPosition) => { return false } - clearBlockRangeSelection() const [draggedBlock] = editorBlocks.value.splice(sourceIndex, 1) editorBlocks.value.splice(nextTargetIndex, 0, draggedBlock) selectedBlockId.value = draggedBlock.id @@ -2297,7 +1768,6 @@ const activateBlock = (block) => { const index = editorBlocks.value.findIndex((item) => item.id === block.id) activeBlockId.value = block.id selectedBlockId.value = '' - clearBlockRangeSelection() updateSlashQuery(block) updateSlashMenuDirection(index) } @@ -2476,13 +1946,6 @@ watch(activeCalloutBlock, (nextBlock) => { } }) -onBeforeUnmount(() => { - if (import.meta.client && editorFlashTimer) { - window.clearTimeout(editorFlashTimer) - editorFlashTimer = null - } -}) - defineExpose({ focusFirstBlock: () => focusBlock(0) }) @@ -2490,20 +1953,10 @@ defineExpose({