블록 에디터 빈 단락·슬래시·방향키·검색 폭 등 (v0.0.102)
- 빈 문단 마커 직렬화·공개 렌더 파싱 - 슬래시 메뉴 스크롤·하이라이트·블록 간 이동 - 헤더 검색 버튼 min-width, 네비 관리 안내 문구 정리 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-12 v0.0.102
|
||||
|
||||
### 관리자 블록 에디터 빈 단락·키보드·슬래시 메뉴
|
||||
|
||||
Markdown 직렬화에서 연속 빈 줄은 사라져 중간 빈 단락을 유지하기 어렵다. 편집용으로만 `<!--sori:blank-paragraph-->` 한 줄을 쓰고 공개 렌더러에서 동일하게 빈 단락으로 복원한다. 슬래시 팔레트는 필터 변경 시 하이라이트를 초기화하고, 긴 목록은 스크롤·`scrollIntoView`로 따라가며, `/`가 아닐 때는 위아래 키로 블록 간 커서를 옮긴다.
|
||||
|
||||
## 2026-05-12 v0.0.101
|
||||
|
||||
### 공개 primary 네비 트리 중복 방지
|
||||
|
||||
@@ -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 중복 제거**(정렬 후 첫 행 유지), **유효 부모 아래에 붙은 항목은 루트에서 제외**해 사이드바에 항목이 두 번 보이던 현상 방지.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.101",
|
||||
"version": "0.0.102",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user