블록 에디터 한글 입력과 코드 블록 보정

This commit is contained in:
2026-05-07 11:06:36 +09:00
parent 60c5c3d5c9
commit 398877fd92
8 changed files with 171 additions and 23 deletions

View File

@@ -11,6 +11,8 @@ const emit = defineEmits(['update:modelValue'])
const editorBlocks = ref([])
const blockRefs = ref([])
const activeBlockId = ref('')
const selectedBlockId = ref('')
const draggingBlockId = ref('')
const slashQuery = ref('')
const slashMenuDirection = ref('down')
const highlightedCommandIndex = ref(0)
@@ -21,9 +23,7 @@ const mediaPickerTarget = ref(null)
const isMediaPickerOpen = ref(false)
const isLoadingMedia = ref(false)
const isComposingText = ref(false)
const isCompositionEnterGuardActive = ref(false)
const isNormalizingTrailingBlock = ref(false)
let compositionEnterGuardTimer = null
let blockIdSeed = 0
const imageWidthOptions = [
@@ -594,9 +594,7 @@ const updateBlockText = (event, index) => {
* @returns {void}
*/
const startTextComposition = () => {
window.clearTimeout(compositionEnterGuardTimer)
isComposingText.value = true
isCompositionEnterGuardActive.value = true
}
/**
@@ -607,11 +605,12 @@ const startTextComposition = () => {
*/
const finishTextComposition = (event, index) => {
isComposingText.value = false
updateBlockText(event, index)
window.clearTimeout(compositionEnterGuardTimer)
compositionEnterGuardTimer = window.setTimeout(() => {
isCompositionEnterGuardActive.value = false
}, 120)
nextTick(() => {
window.setTimeout(() => {
updateBlockText(event, index)
}, 0)
})
}
/**
@@ -627,10 +626,13 @@ const applyMarkdownShortcut = (block, index) => {
{ marker: '### ', type: 'heading', level: 3 },
{ marker: '> ', type: 'quote' },
{ marker: '- ', type: 'list' },
{ marker: '```', type: 'code', exact: true },
{ marker: '``` ', type: 'code' }
].sort((a, b) => b.marker.length - a.marker.length)
const shortcut = shortcutMap.find((item) => block.text.startsWith(item.marker))
const shortcut = shortcutMap.find((item) => item.exact
? block.text === item.marker
: block.text.startsWith(item.marker))
if (!shortcut) {
return
@@ -969,7 +971,7 @@ const highlightPreviousCommand = (event) => {
const handleEnter = (event, index) => {
const currentBlock = editorBlocks.value[index]
if (isComposingText.value || isCompositionEnterGuardActive.value || event.isComposing || event.keyCode === 229) {
if (isComposingText.value || event.isComposing || event.keyCode === 229) {
event.preventDefault()
return
}
@@ -1034,6 +1036,106 @@ const handleBackspace = (event, index) => {
focusBlock(Math.max(index - 1, 0))
}
/**
* 블록 인덱스 반환
* @param {string} blockId - 블록 ID
* @returns {number} 블록 인덱스
*/
const getBlockIndex = (blockId) => editorBlocks.value.findIndex((block) => block.id === blockId)
/**
* 블록 선택 상태 적용
* @param {Object} block - 선택할 블록
* @returns {void}
*/
const selectBlock = (block) => {
selectedBlockId.value = block.id
activeBlockId.value = block.id
slashQuery.value = ''
}
/**
* 지정 블록 삭제
* @param {number} index - 삭제할 블록 인덱스
* @returns {void}
*/
const deleteBlock = (index) => {
if (index < 0) {
return
}
if (editorBlocks.value.length <= 1) {
editorBlocks.value.splice(0, 1, createEditorBlock())
selectedBlockId.value = ''
activeBlockId.value = editorBlocks.value[0].id
emitContent()
focusBlock(0)
return
}
editorBlocks.value.splice(index, 1)
selectedBlockId.value = ''
normalizeTrailingTextBlock()
emitContent()
focusBlock(Math.min(index, editorBlocks.value.length - 1))
}
/**
* 선택한 블록 삭제 키 처리
* @param {KeyboardEvent} event - 키보드 이벤트
* @param {number} index - 삭제할 블록 인덱스
* @returns {void}
*/
const deleteSelectedBlock = (event, index) => {
event.preventDefault()
deleteBlock(index)
}
/**
* 블록 드래그 시작 처리
* @param {DragEvent} event - 드래그 이벤트
* @param {Object} block - 드래그할 블록
* @returns {void}
*/
const startBlockDrag = (event, block) => {
draggingBlockId.value = block.id
selectedBlockId.value = block.id
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', block.id)
}
/**
* 블록 드롭 이동 처리
* @param {DragEvent} event - 드롭 이벤트
* @param {number} targetIndex - 이동 대상 인덱스
* @returns {void}
*/
const dropBlock = (event, targetIndex) => {
const draggedId = event.dataTransfer.getData('text/plain') || draggingBlockId.value
const sourceIndex = getBlockIndex(draggedId)
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
draggingBlockId.value = ''
return
}
const [draggedBlock] = editorBlocks.value.splice(sourceIndex, 1)
const nextTargetIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
editorBlocks.value.splice(nextTargetIndex, 0, draggedBlock)
draggingBlockId.value = ''
selectedBlockId.value = draggedBlock.id
normalizeTrailingTextBlock()
emitContent()
}
/**
* 블록 드래그 종료 처리
* @returns {void}
*/
const finishBlockDrag = () => {
draggingBlockId.value = ''
}
/**
* 현재 블록 활성화
* @param {Object} block - 에디터 블록
@@ -1042,6 +1144,7 @@ const handleBackspace = (event, index) => {
const activateBlock = (block) => {
const index = editorBlocks.value.findIndex((item) => item.id === block.id)
activeBlockId.value = block.id
selectedBlockId.value = ''
updateSlashQuery(block)
updateSlashMenuDirection(index)
}
@@ -1092,10 +1195,6 @@ watch(editorBlocks, () => {
defineExpose({
focusFirstBlock: () => focusBlock(0)
})
onBeforeUnmount(() => {
window.clearTimeout(compositionEnterGuardTimer)
})
</script>
<template>
@@ -1104,9 +1203,29 @@ onBeforeUnmount(() => {
<div
v-for="(block, index) in editorBlocks"
:key="block.id"
class="admin-block-editor__row relative"
class="admin-block-editor__row group/block relative rounded transition-colors"
:class="{
'admin-block-editor__row--selected bg-[#eff1f2] ring-1 ring-[#d8dde1]': selectedBlockId === block.id,
'admin-block-editor__row--dragging opacity-50': draggingBlockId === block.id
}"
:data-editor-block-id="block.id"
@dragover.prevent
@drop="dropBlock($event, index)"
>
<button
class="admin-block-editor__handle absolute -left-10 top-1 z-10 grid size-7 cursor-grab place-items-center rounded text-sm font-semibold text-[#8e9cac] opacity-0 transition-colors hover:bg-[#eff1f2] hover:text-[#394047] group-hover/block:opacity-100 focus:opacity-100 active:cursor-grabbing"
type="button"
draggable="true"
aria-label="블록 이동 선택"
@click.stop="selectBlock(block)"
@keydown.delete="deleteSelectedBlock($event, index)"
@keydown.backspace="deleteSelectedBlock($event, index)"
@dragstart="startBlockDrag($event, block)"
@dragend="finishBlockDrag"
>
<span aria-hidden="true"></span>
</button>
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider my-6 border-line">
<figure
@@ -1340,4 +1459,10 @@ onBeforeUnmount(() => {
.admin-block-editor__block {
color: #1f2328;
}
.admin-block-editor__code {
background: #15171a;
color: #f8fafc;
caret-color: #f8fafc;
}
</style>

View File

@@ -1,5 +1,13 @@
# 의사결정 이력
## 2026-05-07 v0.0.37
### 블록 에디터 입력 안정성과 블록 핸들 범위 결정
관리자 블록 에디터의 한글 조합 입력은 compositionend 직후 DOM 텍스트가 완전히 반영된 다음 슬래시 메뉴 필터와 Enter 처리를 갱신한다. 조합 직후 별도 Enter guard로 입력을 막으면 글자가 확정된 뒤에도 사용자가 Enter를 한 번 더 눌러야 하므로, 조합 중 이벤트만 막고 조합 종료 후에는 즉시 일반 입력 흐름으로 돌린다.
블록 핸들은 1차로 선택, Delete/Backspace 삭제, 드래그 이동까지 제공한다. AFFiNE식 잘라내기, 복사, 서식 툴바, 블록 단위 컨텍스트 메뉴는 작성 경험 전반에 영향을 주는 큰 기능이므로 기본 블록 조작이 안정화된 뒤 별도 단계로 확장한다.
## 2026-05-07 v0.0.35
### 관리자 글쓰기 전체 화면 모드 보정 결정

View File

@@ -28,7 +28,7 @@
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, 대표 이미지/OG 이미지 선택, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정, 미리보기 요청 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 코드 블록 단축 변환, 블록 핸들 선택/삭제/드래그 이동, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
## 콘텐츠 컴포넌트

View File

@@ -280,10 +280,12 @@ components/content/
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르면 현재 블록 타입을 즉시 변환한다.
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르거나 ` ``` `을 입력하면 현재 블록 타입을 즉시 변환한다.
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
- 한글 등 조합형 입력 직후 Enter는 새 블록 생성으로 처리하지 않는다.
- 한글 등 조합형 입력 직후 확정된 텍스트로 슬래시 메뉴 필터와 Enter 블록 이동을 반영한다.
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
- 블록 왼쪽 핸들은 hover/focus 상태에서 표시되며, 클릭하면 블록을 선택하고 Delete 또는 Backspace로 해당 블록을 삭제할 수 있다.
- 블록 왼쪽 핸들을 드래그하면 블록 순서를 이동할 수 있다.
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
@@ -439,6 +441,6 @@ APP_PORT=43118
## 버전 관리
- 현재 버전: v0.0.36
- 현재 버전: v0.0.37
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정

View File

@@ -2,6 +2,8 @@
## 1차 관리자 개발
- [ ] 블록 핸들 액션 메뉴 확장: 잘라내기, 복사, 붙여넣기, 블록 타입 변경, 선택 블록 서식 적용
## 2차 관리자 개발
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적

View File

@@ -1,5 +1,16 @@
# 업데이트 이력
## v0.0.37
- 관리자 블록 에디터의 한글 조합 입력 종료 후 Enter 처리 보정.
- 관리자 블록 에디터의 슬래시 메뉴 필터가 한글 조합 완료 뒤 갱신되도록 수정.
- 관리자 블록 에디터에서 ` ``` ` 입력 시 코드 블록으로 즉시 변환되도록 추가.
- 관리자 코드 블록 글자색과 caret 색상 보정.
- 관리자 블록 에디터에 블록 핸들 선택, Delete/Backspace 삭제, 드래그 이동 기능 추가.
- 할 일 문서에 블록 핸들 액션 메뉴 후속 작업 추가.
- 기술 명세 현재 버전을 v0.0.37로 갱신.
- 패키지 버전을 0.0.37로 갱신.
## v0.0.36
- 할 일 문서에서 완료 또는 현재 기준 불필요한 QA 항목 정리.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "0.0.36",
"version": "0.0.37",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "0.0.36",
"version": "0.0.37",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.36",
"version": "0.0.37",
"private": true,
"type": "module",
"scripts": {