diff --git a/components/admin/AdminBlockEditor.vue b/components/admin/AdminBlockEditor.vue index 8a34e9d..d9fb9fa 100644 --- a/components/admin/AdminBlockEditor.vue +++ b/components/admin/AdminBlockEditor.vue @@ -112,6 +112,8 @@ const blockCommands = [ } ] +const BLANK_PARAGRAPH_MARKER = '' + /** * 에디터 블록 생성 * @param {string} type - 블록 타입 @@ -187,6 +189,12 @@ const parseMarkdownToBlocks = (markdown) => { const line = lines[index] const trimmedLine = line.trim() + if (trimmedLine === BLANK_PARAGRAPH_MARKER) { + blocks.push(createEditorBlock('paragraph', '', null, `editor-block-${blocks.length}`)) + index += 1 + continue + } + if (!trimmedLine) { index += 1 continue @@ -304,8 +312,9 @@ const serializeImage = (image) => { */ const serializeBlocks = () => { const lines = editorBlocks.value - .map((block) => { - const text = block.text.trim() + .map((block, index) => { + const rawText = block.text || '' + const text = rawText.trim() if (block.type === 'divider') { return { type: block.type, value: '---' } @@ -349,6 +358,14 @@ const serializeBlocks = () => { : null } + if (!text && block.type === 'paragraph') { + if (index === editorBlocks.value.length - 1) { + return null + } + + return { type: block.type, value: BLANK_PARAGRAPH_MARKER } + } + if (!text) { return null } @@ -454,7 +471,7 @@ const setBlockRef = (element, index) => { * @param {number} index - 블록 인덱스 * @returns {void} */ -const focusBlock = (index) => { +const focusBlock = (index, position = 'end') => { nextTick(() => { const element = blockRefs.value[index] @@ -466,12 +483,41 @@ const focusBlock = (index) => { const selection = window.getSelection() const range = document.createRange() range.selectNodeContents(element) - range.collapse(false) + range.collapse(position === 'start') selection.removeAllRanges() selection.addRange(range) }) } +/** + * 현재 커서가 블록 시작/끝 경계에 있는지 확인 + * @param {Element} element - 블록 요소 + * @param {'start'|'end'} boundary - 경계 방향 + * @returns {boolean} 경계 위치 여부 + */ +const isCaretOnBoundary = (element, boundary) => { + const selection = window.getSelection() + if (!selection?.rangeCount) { + return false + } + + const range = selection.getRangeAt(0) + if (!range.collapsed || !element.contains(range.commonAncestorContainer)) { + return false + } + + const probeRange = range.cloneRange() + probeRange.selectNodeContents(element) + + if (boundary === 'start') { + probeRange.setEnd(range.startContainer, range.startOffset) + return probeRange.toString().length === 0 + } + + probeRange.setStart(range.startContainer, range.startOffset) + return probeRange.toString().length === 0 +} + /** * 구조형 블록의 첫 입력 필드로 커서 이동 * @param {number} index - 블록 인덱스 @@ -751,11 +797,25 @@ const applyMarkdownShortcut = (block, index) => { * @returns {void} */ const updateSlashQuery = (block) => { - slashQuery.value = block.text.startsWith('/') + const nextSlashQuery = block.text.startsWith('/') ? block.text.slice(1).trim().toLowerCase() : '' + const hasQueryChanged = slashQuery.value !== nextSlashQuery + slashQuery.value = nextSlashQuery - highlightedCommandIndex.value = 0 + if (hasQueryChanged) { + highlightedCommandIndex.value = 0 + return + } + + if (!visibleCommands.value.length) { + highlightedCommandIndex.value = 0 + return + } + + if (highlightedCommandIndex.value >= visibleCommands.value.length) { + highlightedCommandIndex.value = visibleCommands.value.length - 1 + } } const activeBlockIndex = computed(() => editorBlocks.value.findIndex((block) => block.id === activeBlockId.value)) @@ -1030,6 +1090,12 @@ const removeGalleryImage = (block, imageIndex) => { * @returns {void} */ const highlightNextCommand = (event) => { + const block = editorBlocks.value[activeBlockIndex.value] + + if (!block?.text?.startsWith('/')) { + return + } + syncTextBlockFromDom(activeBlockIndex.value) if (!visibleCommands.value.length) { @@ -1046,6 +1112,12 @@ const highlightNextCommand = (event) => { * @returns {void} */ const highlightPreviousCommand = (event) => { + const block = editorBlocks.value[activeBlockIndex.value] + + if (!block?.text?.startsWith('/')) { + return + } + syncTextBlockFromDom(activeBlockIndex.value) if (!visibleCommands.value.length) { @@ -1058,6 +1130,72 @@ const highlightPreviousCommand = (event) => { : highlightedCommandIndex.value - 1 } +/** + * 현재 하이라이트된 슬래시 메뉴 항목을 스크롤 영역에 맞춘다. + * @returns {void} + */ +const scrollHighlightedCommandIntoView = () => { + nextTick(() => { + if (!activeBlockId.value || !visibleCommands.value.length) { + return + } + + const row = document.querySelector(`[data-editor-block-id="${activeBlockId.value}"]`) + const menu = row?.querySelector('.admin-block-editor__slash-menu') + const highlightedItem = row?.querySelector('.admin-block-editor__slash-item--active') + + if (!menu || !highlightedItem) { + return + } + + highlightedItem.scrollIntoView({ + block: 'nearest' + }) + }) +} + +/** + * 일반 본문 블록 방향키 이동 처리 + * @param {KeyboardEvent} event - 키보드 이벤트 + * @param {number} index - 현재 블록 인덱스 + * @param {'up'|'down'} direction - 이동 방향 + * @returns {void} + */ +const navigateAcrossBlocks = (event, index, direction) => { + const currentBlock = editorBlocks.value[index] + + if (!currentBlock || currentBlock.text.startsWith('/')) { + return + } + + const currentElement = blockRefs.value[index] + if (!currentElement) { + return + } + + const isBoundary = direction === 'up' + ? isCaretOnBoundary(currentElement, 'start') + : isCaretOnBoundary(currentElement, 'end') + + if (!isBoundary) { + return + } + + const nextIndex = direction === 'up' ? index - 1 : index + 1 + if (nextIndex < 0 || nextIndex >= editorBlocks.value.length) { + return + } + + event.preventDefault() + const targetBlock = editorBlocks.value[nextIndex] + if (isTextBlock(targetBlock)) { + focusBlock(nextIndex, direction === 'up' ? 'end' : 'start') + return + } + + focusStructuredBlock(nextIndex) +} + /** * 엔터 키로 다음 블록 생성 * @param {KeyboardEvent} event - 키보드 이벤트 @@ -1359,6 +1497,13 @@ watch(editorBlocks, () => { }) }, { deep: true }) +watch( + [highlightedCommandIndex, () => visibleCommands.value.length, activeBlockId], + () => { + scrollHighlightedCommandIntoView() + } +) + defineExpose({ focusFirstBlock: () => focusBlock(0) }) @@ -1547,8 +1692,8 @@ defineExpose({ @compositionstart="startTextComposition" @compositionend="finishTextComposition($event, index)" @keydown.enter="handleEnter($event, index)" - @keydown.down="highlightNextCommand" - @keydown.up="highlightPreviousCommand" + @keydown.down="block.text.startsWith('/') ? highlightNextCommand($event) : navigateAcrossBlocks($event, index, 'down')" + @keydown.up="block.text.startsWith('/') ? highlightPreviousCommand($event) : navigateAcrossBlocks($event, index, 'up')" @keydown.backspace="handleBackspace($event, index)" /> @@ -1565,14 +1710,14 @@ defineExpose({