관리자 블록 에디터를 태그 v1.0.5 시점으로 복원(v1.0.10)

v1.0.6 이후 붙여넣기 분할·Cmd+A MD 복사·블록 범위 선택 등 제거.
명세·맵·이력·업데이트 동기화.
This commit is contained in:
2026-05-14 14:53:55 +09:00
parent 35c378c8f5
commit 88a0860078
6 changed files with 13 additions and 638 deletions

View File

@@ -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<Object>} 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<Object>} blocks - 파싱된 블록
* @returns {Array<Object>} 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<void>}
*/
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<void>}
*/
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({
<template>
<div
ref="blockEditorRootRef"
class="admin-block-editor bg-transparent py-4 text-ink"
:class="{ 'admin-block-editor--keyboard-priority': isKeyboardPriorityMode }"
@mousemove="handleEditorMouseMove"
@keydown="handleEditorRootKeydown"
@copy.capture="handleEditorRootCopy"
>
<p
v-if="editorFlashMessage"
class="admin-block-editor__flash-message mb-3 rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-900"
role="status"
>
{{ editorFlashMessage }}
</p>
<div class="admin-block-editor__surface post-prose">
<div
v-for="(block, index) in editorBlocks"
@@ -2511,7 +1964,6 @@ defineExpose({
class="admin-block-editor__row group/block relative isolate rounded"
:class="{
'admin-block-editor__row--selected': selectedBlockId === block.id,
'admin-block-editor__row--range-selected': isBlockRangeRowSelected(index),
'admin-block-editor__row--dragging opacity-50': draggingBlockId === block.id,
'admin-block-editor__row--drop-before': dragTargetIndex === index && dragTargetPosition === 'before',
'admin-block-editor__row--drop-after': dragTargetIndex === index && dragTargetPosition === 'after',
@@ -2521,7 +1973,6 @@ defineExpose({
'admin-block-editor__row--structure': !isTextBlock(block)
}"
:data-editor-block-id="block.id"
:data-block-index="index"
@dragover="updateBlockDropTarget($event, index)"
@drop="dropBlock($event, index)"
>
@@ -2541,16 +1992,6 @@ defineExpose({
</span>
</button>
<span
v-if="block.type !== 'divider'"
class="admin-block-editor__range-lane absolute bottom-0 left-[-1.375rem] top-0 z-[8] w-[18px] touch-none select-none"
role="button"
tabindex="-1"
aria-label="블록 범위 선택: 드래그 또는 Shift+클릭"
title="블록 범위 선택: 드래그 또는 Shift+클릭"
@pointerdown="onBlockRangeLanePointerDown($event, index)"
/>
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider border-line">
<figure
@@ -2686,8 +2127,6 @@ defineExpose({
data-placeholder="콜아웃 텍스트 입력은 이렇게"
:data-show-placeholder="!block.text"
@focus="activateBlock(block)"
@paste="handleTextBlockPaste($event, index)"
@keydown="handleTextBlockKeydownRoot($event, index)"
@input="updateBlockText($event, index)"
@compositionstart="startTextComposition"
@compositionend="finishTextComposition($event, index)"
@@ -2845,8 +2284,6 @@ defineExpose({
:data-placeholder="index === 0 ? '본문을 입력하세요...' : '/ 눌러 블록 선택'"
:data-show-placeholder="shouldShowPlaceholder(block, index)"
@focus="activateBlock(block)"
@paste="handleTextBlockPaste($event, index)"
@keydown="handleTextBlockKeydownRoot($event, index)"
@input="updateBlockText($event, index)"
@compositionstart="startTextComposition"
@compositionend="finishTextComposition($event, index)"
@@ -3007,27 +2444,6 @@ defineExpose({
opacity: 0;
}
.admin-block-editor__row--callout.admin-block-editor__row--range-selected::before {
opacity: 1;
}
.admin-block-editor__row--range-selected::before {
z-index: -1;
border-radius: 8px;
background: rgba(46, 182, 234, 0.14);
opacity: 1;
transform: scaleX(1);
}
.admin-block-editor__range-lane {
cursor: crosshair;
}
.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover .admin-block-editor__range-lane {
border-radius: 4px;
background: rgba(46, 182, 234, 0.08);
}
.admin-block-editor__row--drop-before::after {
top: -18px;
opacity: 1;

View File

@@ -1,28 +1,10 @@
# 의사결정 이력
## 2026-05-13 v1.0.9
## 2026-05-13 v1.0.10
### 블록 범위 드래그와 행 간 여백
### 관리자 블록 에디터를 v1.0.5 파일 기준으로 복원
범위 드래그 중 `pointermove`마다 `elementFromPoint``[data-editor-block-id]` 조상을 찾았다. 블록 행 사이에는 `margin-top`으로 생기는 **보더 박스 밖 여백**이 있어, 포인터가 그 구간에 있으면 최상단 요소가 행이 아니라 상위 래퍼가 되고 `closest`가 실패한다. 에디터 루트 ref 안의 모든 행 박스와 `clientY`의 거리를 비교하는 보조 경로를 두어 동일 제스처로 다중 블록까지 이어지게 했다.
## 2026-05-13 v1.0.8
### 블록 범위 복사와 부분 텍스트 선택
블록 인덱스 범위가 잡힌 상태에서 사용자가 한 블록 안에서 드래그로 일부 문자만 선택해 복사하는 경우가 있다. 루트 `copy` 캡처로 항상 구간 마크다운을 넣으면 기대와 어긋나므로, 비접힘 DOM 선택이 해당 행의 `contenteditable` 호스트에 있거나 `textarea`/`input`에 선택 구간이 있으면 `preventDefault`를 하지 않고 네이티브 클립보드 동작을 유지한다.
## 2026-05-14 v1.0.7
### 블록 단위 범위 선택과 마크다운 전용 복사
다중 `contenteditable` 구조에서는 브라우저가 블록 경계를 넘는 선택을 제공하지 않는다. 붙여넣기 분할과 전체 Cmd+A 클립보드 복사만으로는 구간 복사 흐름이 부족하므로, 핸들 옆 좁은 레인에서 포인터 드래그와 Shift 조합으로 블록 인덱스 구간을 잡고, 복사 파이프는 `text/plain` 마크다운으로만 통일했다. 문자 단위 범위는 다음 단계로 남긴다. 범위는 인덱스 쌍이므로 블록 삭제·이동·분할 붙여넣기 등 배열이 바뀌는 경로마다 해제해 stale 상태를 막는다.
## 2026-05-14 v1.0.6
### 블록 에디터 붙여넣기·전체 선택 UX
블록마다 `contenteditable`을 두면 브라우저가 편집 호스트 경계를 넘는 선택을 허용하지 않아 Cmd+A가 한 블록에만 먹고, 여러 줄 마크다운을 붙여넣으면 한 블록 안에 줄바꿈 문자만 들어가 저장 구조와 어긋난다. 완전한 단일 편집면(ProseMirror 등)으로 바꾸지 않는 한, 붙여넣기 경로에서 `parseMarkdownToBlocks`로 분할 삽입하고, Cmd/Ctrl+A는 전체 마크다운을 클립보드에 복사하는 보완으로 실사용 복사·이동 요구를 맞춘다.
v1.0.6부터 적용했던 다중 줄 붙여넣기 분할, Cmd/Ctrl+A로 전체 마크다운 복사, 블록 단위 범위 선택·레인 드래그·복사 가로채기 등이 실제 사용에서 어색하다는 피드백이 있어, `AdminBlockEditor.vue`는 Git 태그 `v1.0.5` 시점 내용으로 되돌렸다. Docker·부트스트랩 등 v1.0.5 이후 서버/배포 변경은 유지하고 에디터 파일만 이전 동작으로 맞춘다.
## 2026-05-14 v1.0.5

View File

@@ -64,7 +64,7 @@
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시, 여러 줄·마크다운 붙여넣기 시 블록 분할, 블록 단위 범위 선택(레인 드래그·행 간 margin에서도 세로 스냅, Shift+클릭·Shift+↑↓·Escape, 레인 aria-label) 및 선택 구간 마크다운 복사(단 contenteditable 비접힘 선택·textarea/input 선택 시 복사는 네이티브)·범위 있을 때 Cmd/Ctrl+A는 구간만 복사, 블록 삭제·이동·분할 붙여넣기 등 배열 변경 시 범위 자동 해제, 범위 없을 때 Cmd/Ctrl+A는 전체 MD 복사 안내 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |

View File

@@ -440,10 +440,7 @@ components/content/
### 관리자 글 편집
- 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다. 텍스트 블록마다 별도 `contenteditable`을 쓰므로, 브라우저는 **편집 호스트 경계를 넘는 드래그 선택**을 허용하지 않는다(한 블록 안에서만 연속 선택).
- **Cmd/Ctrl+A**(Mac은 Cmd, Windows/Linux는 Ctrl)는 현재 블록만 전체 선택되는 대신, **저장 형식인 전체 본문 마크다운**을 클립보드에 복사하고 짧은 안내 문구를 표시한다. 다른 편집기·파일로 옮길 때 사용한다.
- **여러 줄**이거나 제목·인용·목록·펜스 코드·콜아웃/갤러리 등 **마크다운으로 인식되는 한 줄**을 텍스트 블록에 붙여넣으면, 기본 한 블록 삽입 대신 `parseMarkdownToBlocks`로 나눈 **여러 블록**을 현재 커서 위치에 끼워 넣는다. 클립보드에 파일이 있으면 기본 붙여넣기(이미지 등)를 유지한다.
- **블록 단위 범위 선택**: 각 행 왼쪽(핸들 오른쪽) **좁은 레인**에서 포인터 드래그로 시작·끝 블록을 지정한다(드래그 중 포인터가 블록 **사이 margin**에 있어도 세로 위치로 가장 가까운 행에 스냅). **Shift+클릭**으로 끝 블록을 지정한다. 텍스트 블록에서 **Shift+↑/↓**는 경계에 있을 때 범위를 시작하거나, 이미 범위가 있으면 **포커스 쪽 끝 블록 인덱스**를 한 칸씩 늘리거나 줄인다. **Escape**로 범위를 해제한다. 범위가 있을 때 **Cmd/Ctrl+C** 또는 복사(`copy`)는 `text/plain`**선택 구간만** 마크다운으로 넣는다. 다만 **한 블록의 contenteditable 안에서 비접힘 텍스트 선택**이 있거나 **textarea/input에 선택 구간**이 있으면 복사는 브라우저 기본 동작(선택된 문자열 등)을 따른다. 범위가 있을 때 **Cmd/Ctrl+A**는 전체가 아니라 **선택 구간** 마크다운을 클립보드에 복사한다. 블록 삭제·드래그 순서 변경·마크다운 분할 붙여넣기 등으로 `editorBlocks` 순서가 바뀌면 범위 선택은 자동으로 해제된다.
- 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다.
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 유지한다.
- `/` 입력 시 블록 선택 메뉴를 표시한다.
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.

View File

@@ -1,29 +1,9 @@
# 업데이트 이력
## v1.0.9
## v1.0.10
- 관리자 블록 에디터 범위 레인 드래그 시 `elementFromPoint`만 쓰면 블록 간 margin 구간에서 행을 못 찾아 범위가 늘지 않던 문제를, 에디터 루트 기준 행 `getBoundingClientRect`로 세로 거리 보완해 해결.
- 범위 레인 히트 영역을 약간 넓힘.
- 패키지 버전 `1.0.9`로 갱신.
## v1.0.8
- 관리자 블록 에디터에서 블록 범위 선택이 있어도, contenteditable 안 비접힘 텍스트 선택 또는 textarea/input 선택 구간이 있으면 복사는 브라우저 기본 동작으로 두고 마크다운 가로채기를 하지 않음.
- 패키지 버전 `1.0.8`으로 갱신.
## v1.0.7
- 관리자 블록 에디터에 블록 단위 범위 선택 추가(핸들 오른쪽 레인 드래그·Shift+클릭, Shift+↑↓ 확장, Escape 해제).
- 범위 선택 시 Cmd/Ctrl+C 및 복사 동작은 `text/plain` 마크다운으로만 반영. 범위가 있을 때 Cmd/Ctrl+A는 선택 구간만 클립보드에 복사.
- 블록 삭제·드래그 이동·마크다운 붙여넣기 분할·활성 전환 시 미사용 구조형 블록 제거 등으로 배열이 바뀔 때 범위 선택을 해제해 인덱스 불일치를 방지.
- 범위 선택 레인에 `aria-label`·`role="button"` 추가.
- 패키지 버전 `1.0.7`으로 갱신.
## v1.0.6
- 관리자 블록 에디터에서 여러 줄·마크다운 붙여넣기 시 한 블록에 몰리지 않고 파싱된 여러 블록으로 삽입되도록 처리.
- 블록별 contenteditable 한계로 문서 전체 드래그 선택이 불가한 점을 보완하기 위해 Cmd/Ctrl+A로 전체 본문 마크다운을 클립보드에 복사하고 안내 문구를 표시하도록 추가.
- 패키지 버전 `1.0.6`으로 갱신.
- 관리자 `AdminBlockEditor.vue`를 저장소 태그 `v1.0.5` 시점과 동일한 내용으로 복원(다중 줄·마크다운 붙여넣기 분할, Cmd/Ctrl+A 전체 MD 복사 안내, 블록 단위 범위 선택 등 v1.0.6 이후 에디터 UX 변경 제거). 동작 불만에 따른 되돌림.
- 패키지 버전 `1.0.10`으로 갱신.
## v1.0.5

View File

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