From bd0e2ad12006fa154e2531f1a3074f71c7a9105c Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 14 May 2026 14:42:08 +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=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EB=B3=B4=EC=99=84=20=EB=B0=8F=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EC=8B=9C=20=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0(v1.0.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 블록 범위가 있어도 contenteditable 비접힘 선택·textarea/input 선택 시 copy 가로채기 생략. 문서·버전 v1.0.8 반영. --- components/admin/AdminBlockEditor.vue | 536 +++++++++++++++++++++++++- docs/history.md | 18 + docs/map.md | 2 +- docs/spec.md | 5 +- docs/update.md | 19 + package.json | 2 +- 6 files changed, 575 insertions(+), 7 deletions(-) diff --git a/components/admin/AdminBlockEditor.vue b/components/admin/AdminBlockEditor.vue index 7d4f6db..4b98832 100644 --- a/components/admin/AdminBlockEditor.vue +++ b/components/admin/AdminBlockEditor.vue @@ -35,7 +35,11 @@ const isKeyboardPriorityMode = ref(false) const calloutEmojiPickerBlockId = ref('') const calloutColorPopoverBlockId = ref('') const calloutEmojiComposingBlockId = ref('') +const editorFlashMessage = ref('') +const blockRangeSelection = ref(null) +const isBlockRangeDragging = ref(false) let blockIdSeed = 0 +let editorFlashTimer = null const imageWidthOptions = [ { value: 'regular', label: '기본' }, @@ -368,11 +372,12 @@ const serializeImage = (image) => { } /** - * 에디터 블록 목록을 저장용 마크다운으로 변환 + * 주어진 블록 배열을 저장용 마크다운으로 변환한다. + * @param {Array} blocks - 직렬화할 블록 목록 * @returns {string} 마크다운 문자열 */ -const serializeBlocks = () => { - const lines = editorBlocks.value +const serializeBlockArray = (blocks) => { + const lines = blocks .map((block, index) => { const rawText = block.text || '' const text = rawText.trim() @@ -426,7 +431,7 @@ const serializeBlocks = () => { } if (!text && block.type === 'paragraph') { - if (index === editorBlocks.value.length - 1) { + if (index === blocks.length - 1) { return null } @@ -471,6 +476,29 @@ const serializeBlocks = () => { }, '') } +/** + * 에디터 블록 인덱스 구간을 마크다운으로 직렬화한다. + * @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} @@ -732,6 +760,230 @@ 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 - 에디터 블록 @@ -987,6 +1239,7 @@ const activeCalloutBlock = computed(() => { * @returns {void} */ const applyCommand = (command) => { + clearBlockRangeSelection() const index = activeBlockIndex.value if (index < 0) { @@ -1411,6 +1664,198 @@ 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 +} + +/** + * 범위 선택 레인에서 포인터로 블록 범위 드래그를 시작한다. + * @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 el = document.elementFromPoint(ev.clientX, ev.clientY) + const row = el?.closest?.('[data-editor-block-id]') + + if (!row) { + return + } + + const id = row.getAttribute('data-editor-block-id') + const idx = editorBlocks.value.findIndex((b) => b.id === id) + + 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 - 키보드 이벤트 @@ -1426,10 +1871,22 @@ 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') @@ -1439,12 +1896,21 @@ 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 @@ -1461,6 +1927,7 @@ const navigateAcrossBlocks = (event, index, direction) => { */ const handleEnter = (event, index) => { enableKeyboardPriorityMode() + clearBlockRangeSelection() const currentBlock = syncTextBlockFromDom(index) @@ -1551,6 +2018,7 @@ const handleBackspace = (event, index) => { } event.preventDefault() + clearBlockRangeSelection() editorBlocks.value.splice(index, 1) normalizeTrailingTextBlock() emitContent() @@ -1604,6 +2072,7 @@ const cleanupUnusedStructuredBlockOnActivate = (nextBlockId) => { return } + clearBlockRangeSelection() editorBlocks.value.splice(previousBlockIndex, 1) normalizeTrailingTextBlock() emitContent() @@ -1615,6 +2084,7 @@ const cleanupUnusedStructuredBlockOnActivate = (nextBlockId) => { * @returns {void} */ const selectBlock = (block) => { + clearBlockRangeSelection() selectedBlockId.value = block.id activeBlockId.value = block.id slashQuery.value = '' @@ -1634,6 +2104,7 @@ const deleteBlock = (index) => { editorBlocks.value.splice(0, 1, createEditorBlock()) selectedBlockId.value = '' activeBlockId.value = editorBlocks.value[0].id + clearBlockRangeSelection() emitContent() focusBlock(0) return @@ -1641,6 +2112,7 @@ const deleteBlock = (index) => { editorBlocks.value.splice(index, 1) selectedBlockId.value = '' + clearBlockRangeSelection() normalizeTrailingTextBlock() emitContent() focusBlock(Math.min(index, editorBlocks.value.length - 1)) @@ -1664,6 +2136,7 @@ const deleteSelectedBlock = (event, index) => { * @returns {void} */ const startBlockDrag = (event, block) => { + clearBlockRangeSelection() draggingBlockId.value = block.id selectedBlockId.value = block.id dragTargetIndex.value = -1 @@ -1721,6 +2194,7 @@ 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 @@ -1768,6 +2242,7 @@ const activateBlock = (block) => { const index = editorBlocks.value.findIndex((item) => item.id === block.id) activeBlockId.value = block.id selectedBlockId.value = '' + clearBlockRangeSelection() updateSlashQuery(block) updateSlashMenuDirection(index) } @@ -1946,6 +2421,13 @@ watch(activeCalloutBlock, (nextBlock) => { } }) +onBeforeUnmount(() => { + if (import.meta.client && editorFlashTimer) { + window.clearTimeout(editorFlashTimer) + editorFlashTimer = null + } +}) + defineExpose({ focusFirstBlock: () => focusBlock(0) }) @@ -1956,7 +2438,16 @@ defineExpose({ class="admin-block-editor bg-transparent py-4 text-ink" :class="{ 'admin-block-editor--keyboard-priority': isKeyboardPriorityMode }" @mousemove="handleEditorMouseMove" + @keydown="handleEditorRootKeydown" + @copy.capture="handleEditorRootCopy" > +

+ {{ editorFlashMessage }} +

@@ -1992,6 +2485,16 @@ defineExpose({ + +