블록 에디터 빈 단락·슬래시·방향키·검색 폭 등 (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 - 블록 타입 * @param {string} type - 블록 타입
@@ -187,6 +189,12 @@ const parseMarkdownToBlocks = (markdown) => {
const line = lines[index] const line = lines[index]
const trimmedLine = line.trim() const trimmedLine = line.trim()
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
blocks.push(createEditorBlock('paragraph', '', null, `editor-block-${blocks.length}`))
index += 1
continue
}
if (!trimmedLine) { if (!trimmedLine) {
index += 1 index += 1
continue continue
@@ -304,8 +312,9 @@ const serializeImage = (image) => {
*/ */
const serializeBlocks = () => { const serializeBlocks = () => {
const lines = editorBlocks.value const lines = editorBlocks.value
.map((block) => { .map((block, index) => {
const text = block.text.trim() const rawText = block.text || ''
const text = rawText.trim()
if (block.type === 'divider') { if (block.type === 'divider') {
return { type: block.type, value: '---' } return { type: block.type, value: '---' }
@@ -349,6 +358,14 @@ const serializeBlocks = () => {
: null : null
} }
if (!text && block.type === 'paragraph') {
if (index === editorBlocks.value.length - 1) {
return null
}
return { type: block.type, value: BLANK_PARAGRAPH_MARKER }
}
if (!text) { if (!text) {
return null return null
} }
@@ -454,7 +471,7 @@ const setBlockRef = (element, index) => {
* @param {number} index - 블록 인덱스 * @param {number} index - 블록 인덱스
* @returns {void} * @returns {void}
*/ */
const focusBlock = (index) => { const focusBlock = (index, position = 'end') => {
nextTick(() => { nextTick(() => {
const element = blockRefs.value[index] const element = blockRefs.value[index]
@@ -466,12 +483,41 @@ const focusBlock = (index) => {
const selection = window.getSelection() const selection = window.getSelection()
const range = document.createRange() const range = document.createRange()
range.selectNodeContents(element) range.selectNodeContents(element)
range.collapse(false) range.collapse(position === 'start')
selection.removeAllRanges() selection.removeAllRanges()
selection.addRange(range) 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 - 블록 인덱스 * @param {number} index - 블록 인덱스
@@ -751,11 +797,25 @@ const applyMarkdownShortcut = (block, index) => {
* @returns {void} * @returns {void}
*/ */
const updateSlashQuery = (block) => { const updateSlashQuery = (block) => {
slashQuery.value = block.text.startsWith('/') const nextSlashQuery = block.text.startsWith('/')
? block.text.slice(1).trim().toLowerCase() ? 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)) const activeBlockIndex = computed(() => editorBlocks.value.findIndex((block) => block.id === activeBlockId.value))
@@ -1030,6 +1090,12 @@ const removeGalleryImage = (block, imageIndex) => {
* @returns {void} * @returns {void}
*/ */
const highlightNextCommand = (event) => { const highlightNextCommand = (event) => {
const block = editorBlocks.value[activeBlockIndex.value]
if (!block?.text?.startsWith('/')) {
return
}
syncTextBlockFromDom(activeBlockIndex.value) syncTextBlockFromDom(activeBlockIndex.value)
if (!visibleCommands.value.length) { if (!visibleCommands.value.length) {
@@ -1046,6 +1112,12 @@ const highlightNextCommand = (event) => {
* @returns {void} * @returns {void}
*/ */
const highlightPreviousCommand = (event) => { const highlightPreviousCommand = (event) => {
const block = editorBlocks.value[activeBlockIndex.value]
if (!block?.text?.startsWith('/')) {
return
}
syncTextBlockFromDom(activeBlockIndex.value) syncTextBlockFromDom(activeBlockIndex.value)
if (!visibleCommands.value.length) { if (!visibleCommands.value.length) {
@@ -1058,6 +1130,72 @@ const highlightPreviousCommand = (event) => {
: highlightedCommandIndex.value - 1 : 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 - 키보드 이벤트 * @param {KeyboardEvent} event - 키보드 이벤트
@@ -1359,6 +1497,13 @@ watch(editorBlocks, () => {
}) })
}, { deep: true }) }, { deep: true })
watch(
[highlightedCommandIndex, () => visibleCommands.value.length, activeBlockId],
() => {
scrollHighlightedCommandIntoView()
}
)
defineExpose({ defineExpose({
focusFirstBlock: () => focusBlock(0) focusFirstBlock: () => focusBlock(0)
}) })
@@ -1547,8 +1692,8 @@ defineExpose({
@compositionstart="startTextComposition" @compositionstart="startTextComposition"
@compositionend="finishTextComposition($event, index)" @compositionend="finishTextComposition($event, index)"
@keydown.enter="handleEnter($event, index)" @keydown.enter="handleEnter($event, index)"
@keydown.down="highlightNextCommand" @keydown.down="block.text.startsWith('/') ? highlightNextCommand($event) : navigateAcrossBlocks($event, index, 'down')"
@keydown.up="highlightPreviousCommand" @keydown.up="block.text.startsWith('/') ? highlightPreviousCommand($event) : navigateAcrossBlocks($event, index, 'up')"
@keydown.backspace="handleBackspace($event, index)" @keydown.backspace="handleBackspace($event, index)"
/> />
@@ -1565,14 +1710,14 @@ defineExpose({
<div <div
v-if="visibleCommands.length && activeBlockId === block.id" 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'" :class="slashMenuDirection === 'up' ? 'bottom-full mb-2' : 'top-full mt-2'"
> >
<button <button
v-for="(command, commandIndex) in visibleCommands" v-for="(command, commandIndex) in visibleCommands"
:key="`${command.type}-${command.level || 'default'}`" :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="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" type="button"
@mousedown.prevent="applyCommand(command)" @mousedown.prevent="applyCommand(command)"
> >

View File

@@ -6,6 +6,8 @@ const props = defineProps({
} }
}) })
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
const activeLightboxImages = ref([]) const activeLightboxImages = ref([])
const activeLightboxIndex = ref(0) const activeLightboxIndex = ref(0)
@@ -177,6 +179,12 @@ const parseMarkdownBlocks = (markdown) => {
const line = lines[index] const line = lines[index]
const trimmedLine = line.trim() const trimmedLine = line.trim()
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
blocks.push(createBlock('paragraph', '', null, `block-${blocks.length}`))
index += 1
continue
}
if (!trimmedLine) { if (!trimmedLine) {
index += 1 index += 1
continue 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"> <div class="site-header__search-slot flex min-w-0 justify-center justify-self-center px-1 sm:px-2">
<button <button
type="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="검색 열기" aria-label="검색 열기"
@click="openSearchModal" @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 ## 2026-05-12 v0.0.101
### 공개 primary 네비 트리 중복 방지 ### 공개 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 ## v0.0.101
- `server/utils/navigation-tree.js` `buildPublicPrimaryTree`: 평면 `primary`에서 **동일 id 중복 제거**(정렬 후 첫 행 유지), **유효 부모 아래에 붙은 항목은 루트에서 제외**해 사이드바에 항목이 두 번 보이던 현상 방지. - `server/utils/navigation-tree.js` `buildPublicPrimaryTree`: 평면 `primary`에서 **동일 id 중복 제거**(정렬 후 첫 행 유지), **유효 부모 아래에 붙은 항목은 루트에서 제외**해 사이드바에 항목이 두 번 보이던 현상 방지.

View File

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

View File

@@ -381,9 +381,6 @@ const saveNavigation = async () => {
<p class="admin-navigation__lead mt-2 max-w-xl text-sm text-muted"> <p class="admin-navigation__lead mt-2 max-w-xl text-sm text-muted">
상단·하단을 탭으로 나눕니다. 순서는 <span class="font-semibold text-ink">태그 관리의 메인 태그</span> 같이 행을 드래그합니다(번호·여백에서 잡고 이동). 입력란 위에서는 드래그가 시작되지 않습니다. 하위 메뉴가 있으면 공개 사이드바에서 접는 그룹으로 보입니다. 상단·하단을 탭으로 나눕니다. 순서는 <span class="font-semibold text-ink">태그 관리의 메인 태그</span> 같이 행을 드래그합니다(번호·여백에서 잡고 이동). 입력란 위에서는 드래그가 시작되지 않습니다. 하위 메뉴가 있으면 공개 사이드바에서 접는 그룹으로 보입니다.
</p> </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>
<div class="admin-navigation__header-actions flex flex-wrap gap-2"> <div class="admin-navigation__header-actions flex flex-wrap gap-2">
<button <button