블록 에디터 빈 단락·슬래시·방향키·검색 폭 등 (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 - 블록 타입
|
* @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)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 네비 트리 중복 방지
|
||||||
|
|||||||
@@ -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 중복 제거**(정렬 후 첫 행 유지), **유효 부모 아래에 붙은 항목은 루트에서 제외**해 사이드바에 항목이 두 번 보이던 현상 방지.
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user