블록 에디터 빈 단락·슬래시·방향키·검색 폭 등 (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)"
>

View File

@@ -6,6 +6,8 @@ const props = defineProps({
}
})
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
const activeLightboxImages = ref([])
const activeLightboxIndex = ref(0)
@@ -177,6 +179,12 @@ const parseMarkdownBlocks = (markdown) => {
const line = lines[index]
const trimmedLine = line.trim()
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
blocks.push(createBlock('paragraph', '', null, `block-${blocks.length}`))
index += 1
continue
}
if (!trimmedLine) {
index += 1
continue

View File

@@ -191,7 +191,7 @@ onBeforeUnmount(() => {
<div class="site-header__search-slot flex min-w-0 justify-center justify-self-center px-1 sm:px-2">
<button
type="button"
class="site-header__search site-header__search--responsive hidden h-9 w-full max-w-[min(470px,100%)] cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex site-input"
class="site-header__search site-header__search--responsive hidden h-9 w-full min-w-[470px] max-w-[min(470px,100%)] cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex site-input"
aria-label="검색 열기"
@click="openSearchModal"
>

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-12 v0.0.102
### 관리자 블록 에디터 빈 단락·키보드·슬래시 메뉴
Markdown 직렬화에서 연속 빈 줄은 사라져 중간 빈 단락을 유지하기 어렵다. 편집용으로만 `<!--sori:blank-paragraph-->` 한 줄을 쓰고 공개 렌더러에서 동일하게 빈 단락으로 복원한다. 슬래시 팔레트는 필터 변경 시 하이라이트를 초기화하고, 긴 목록은 스크롤·`scrollIntoView`로 따라가며, `/`가 아닐 때는 위아래 키로 블록 간 커서를 옮긴다.
## 2026-05-12 v0.0.101
### 공개 primary 네비 트리 중복 방지

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v0.0.102
- `AdminBlockEditor`: 빈 단락은 HTML 주석 마커로 직렬화·복원, 슬래시 메뉴 하이라이트·스크롤·긴 목록 `max-h`·블록 경계에서 위/아래 방향키로 인접 블록 이동.
- `ContentMarkdownRenderer`: 동일 마커 줄을 빈 단락으로 표시.
- `SiteHeader`: 검색 열기 버튼에 `min-w-[470px]`(md 이상)로 최소 폭 고정.
- `pages/admin/navigation`: 상단 마이그레이션 안내 문단 제거.
## v0.0.101
- `server/utils/navigation-tree.js` `buildPublicPrimaryTree`: 평면 `primary`에서 **동일 id 중복 제거**(정렬 후 첫 행 유지), **유효 부모 아래에 붙은 항목은 루트에서 제외**해 사이드바에 항목이 두 번 보이던 현상 방지.

View File

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

View File

@@ -381,9 +381,6 @@ const saveNavigation = async () => {
<p class="admin-navigation__lead mt-2 max-w-xl text-sm text-muted">
상단·하단을 탭으로 나눕니다. 순서는 <span class="font-semibold text-ink">태그 관리의 메인 태그</span> 같이 행을 드래그합니다(번호·여백에서 잡고 이동). 입력란 위에서는 드래그가 시작되지 않습니다. 하위 메뉴가 있으면 공개 사이드바에서 접는 그룹으로 보입니다.
</p>
<p class="admin-navigation__migrate-hint mt-2 max-w-xl rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
저장 <code class="rounded bg-white/80 px-1">parent_id</code> 오류가 나면 DB에 마이그레이션이 아직 없는 것입니다. 로컬에서 <code class="rounded bg-white/80 px-1">npm run db:migrate:dev</code> <code class="rounded bg-white/80 px-1">017_navigation_hierarchy.sql</code> 적용하세요.
</p>
</div>
<div class="admin-navigation__header-actions flex flex-wrap gap-2">
<button