블록 에디터 빈 단락·슬래시·방향키·검색 폭 등 (v0.0.102)

- 빈 문단 마커 직렬화·공개 렌더 파싱
- 슬래시 메뉴 스크롤·하이라이트·블록 간 이동
- 헤더 검색 버튼 min-width, 네비 관리 안내 문구 정리

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 15:45:48 +09:00
parent 5031b9de22
commit 6e25cdfd60
7 changed files with 178 additions and 15 deletions

View File

@@ -112,6 +112,8 @@ const blockCommands = [
}
]
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
/**
* 에디터 블록 생성
* @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({
<div
v-if="visibleCommands.length && activeBlockId === block.id"
class="admin-block-editor__slash-menu absolute left-0 z-20 w-72 overflow-hidden rounded border border-line bg-white shadow-lg"
class="admin-block-editor__slash-menu absolute left-0 z-20 w-72 max-h-[min(52vh,360px)] overflow-y-auto rounded border border-line bg-white shadow-lg"
:class="slashMenuDirection === 'up' ? 'bottom-full mb-2' : 'top-full mt-2'"
>
<button
v-for="(command, commandIndex) in visibleCommands"
:key="`${command.type}-${command.level || 'default'}`"
class="admin-block-editor__slash-item grid w-full gap-0.5 px-4 py-3 text-left hover:bg-surface"
:class="commandIndex === highlightedCommandIndex ? 'bg-surface' : ''"
:class="commandIndex === highlightedCommandIndex ? 'admin-block-editor__slash-item--active bg-surface' : ''"
type="button"
@mousedown.prevent="applyCommand(command)"
>