블록 에디터 줄바꿈과 핸들 표시 보정

This commit is contained in:
2026-05-07 15:14:32 +09:00
parent 5bda4d5472
commit 38f8abb1ff
7 changed files with 140 additions and 21 deletions

View File

@@ -439,9 +439,9 @@ const setBlockRef = (element, index) => {
if (
!isComposingText.value
&& isTextBlock(editorBlocks.value[index])
&& element.innerText !== editorBlocks.value[index]?.text
&& element.textContent !== editorBlocks.value[index]?.text
) {
element.innerText = editorBlocks.value[index]?.text || ''
element.textContent = editorBlocks.value[index]?.text || ''
}
}
}
@@ -516,7 +516,49 @@ const updateSlashMenuDirection = (index) => {
const getTextBlockDomText = (index) => {
const element = blockRefs.value[index]
return element?.innerText.replace(/\n$/, '') || ''
return element?.textContent || ''
}
/**
* 현재 선택 영역에 문단 내부 줄바꿈을 삽입
* @param {number} index - 블록 인덱스
* @returns {void}
*/
const insertSoftLineBreak = (index) => {
const block = editorBlocks.value[index]
const element = blockRefs.value[index]
if (!block || !element) {
return
}
element.focus()
const selection = window.getSelection()
if (!selection) {
return
}
const range = selection?.rangeCount ? selection.getRangeAt(0) : document.createRange()
if (!selection.rangeCount || !element.contains(range.commonAncestorContainer)) {
range.selectNodeContents(element)
range.collapse(false)
}
range.deleteContents()
const lineBreak = document.createTextNode('\n')
range.insertNode(lineBreak)
range.setStartAfter(lineBreak)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
block.text = getTextBlockDomText(index)
activeBlockId.value = block.id
slashQuery.value = ''
updateSlashMenuDirection(index)
emitContent()
}
/**
@@ -685,7 +727,7 @@ const applyMarkdownShortcut = (block, index) => {
const element = blockRefs.value[index]
if (element) {
element.innerText = block.text
element.textContent = block.text
focusBlock(index)
}
})
@@ -750,7 +792,7 @@ const applyCommand = (command) => {
const element = blockRefs.value[index]
if (element) {
element.innerText = ''
element.textContent = ''
}
slashQuery.value = ''
@@ -1018,7 +1060,13 @@ const handleEnter = (event, index) => {
return
}
if (event.shiftKey && isTextBlock(currentBlock)) {
if (currentBlock.type === 'code') {
return
}
if (event.shiftKey && isTextBlock(currentBlock) && currentBlock.type !== 'code') {
event.preventDefault()
insertSoftLineBreak(index)
return
}
@@ -1033,10 +1081,6 @@ const handleEnter = (event, index) => {
return
}
if (currentBlock.type === 'code' && !event.shiftKey) {
return
}
event.preventDefault()
if (['divider', 'image', 'gallery', 'toggle', 'embed'].includes(currentBlock.type)) {
@@ -1254,9 +1298,9 @@ defineExpose({
<div
v-for="(block, index) in editorBlocks"
:key="block.id"
class="admin-block-editor__row group/block relative rounded transition-colors"
class="admin-block-editor__row group/block relative isolate rounded"
:class="{
'admin-block-editor__row--selected bg-[#eff1f2] ring-1 ring-[#d8dde1]': selectedBlockId === block.id,
'admin-block-editor__row--selected': selectedBlockId === block.id,
'admin-block-editor__row--dragging opacity-50': draggingBlockId === block.id,
'admin-block-editor__row--text': isTextBlock(block),
'admin-block-editor__row--structure': !isTextBlock(block)
@@ -1266,7 +1310,7 @@ defineExpose({
@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"
class="admin-block-editor__handle absolute -left-9 bottom-0 top-0 z-10 flex w-5 cursor-grab items-stretch justify-center rounded opacity-0 outline-none transition-opacity duration-150 group-hover/block:opacity-100 focus:opacity-100 active:cursor-grabbing"
type="button"
draggable="true"
aria-label="블록 이동 선택"
@@ -1276,7 +1320,9 @@ defineExpose({
@dragstart="startBlockDrag($event, block)"
@dragend="finishBlockDrag"
>
<span aria-hidden="true"></span>
<span class="admin-block-editor__handle-container" aria-hidden="true">
<span class="admin-block-editor__handle-grabber"></span>
</span>
</button>
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider border-line">
@@ -1513,6 +1559,62 @@ defineExpose({
color: #1f2328;
}
.admin-block-editor__row::before {
position: absolute;
inset: -4px -10px;
z-index: -1;
border-radius: 8px;
background: transparent;
content: "";
opacity: 0;
transform: scaleX(0.995);
transition:
background-color 160ms ease,
opacity 160ms ease,
transform 160ms ease;
}
.admin-block-editor__row:hover::before,
.admin-block-editor__row--selected::before {
background: #eff1f2;
opacity: 1;
transform: scaleX(1);
}
.admin-block-editor__handle {
min-height: 32px;
}
.admin-block-editor__handle-container {
display: flex;
width: 16px;
height: 100%;
align-items: center;
justify-content: center;
padding: 6px 0;
}
.admin-block-editor__handle-grabber {
display: block;
width: 4px;
height: 20px;
border-radius: 999px;
background: #8d969f;
opacity: 0.72;
transition:
height 160ms ease,
background-color 160ms ease,
opacity 160ms ease;
}
.admin-block-editor__row:hover .admin-block-editor__handle-grabber,
.admin-block-editor__row--selected .admin-block-editor__handle-grabber,
.admin-block-editor__handle:focus .admin-block-editor__handle-grabber {
height: 100%;
background: #5d6670;
opacity: 1;
}
.admin-block-editor__row + .admin-block-editor__row--text {
margin-top: 32px;
}

View File

@@ -1,5 +1,13 @@
# 의사결정 이력
## 2026-05-07 v0.0.39
### 블록 에디터 줄바꿈과 핸들 표시 보정 결정
관리자 블록 에디터의 Shift+Enter는 브라우저 기본 동작에 맡기지 않고 에디터가 줄바꿈 문자를 직접 삽입한 뒤 커서를 줄바꿈 뒤로 복구한다. `contenteditable`의 기본 줄바꿈은 브라우저별로 `<div>`, `<br>`, 텍스트 노드 처리가 달라 Vue 상태 동기화와 충돌할 수 있고, 특히 문단 끝에서 커서가 문단 앞으로 이동하는 현상을 만들 수 있기 때문이다.
블록 핸들은 문자 아이콘 대신 AFFiNE 참고 스타일의 세로 막대로 표시한다. 작성 중에는 시각 소음을 줄이고, hover 또는 선택 상태에서는 막대가 블록 높이만큼 늘어나 사용자가 선택, 삭제, 드래그할 범위를 바로 인식하게 하기 위해서다.
## 2026-05-07 v0.0.38
### 에디터 문단 모델과 설정 패널 액션 배치 결정

View File

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

View File

@@ -285,10 +285,11 @@ components/content/
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르거나 ` ``` `을 입력하면 현재 블록 타입을 즉시 변환한다.
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
- 한글 등 조합형 입력 직후 확정된 텍스트로 슬래시 메뉴 필터와 Enter 블록 이동을 반영한다.
- Shift+Enter는 같은 텍스트 블록 안의 줄바꿈으로 처리하고, Enter는 다음 문단 블록을 생성한다.
- Shift+Enter는 같은 텍스트 블록 안에 줄바꿈 문자를 직접 삽입하고 커서를 줄바꿈 뒤로 유지하며, Enter는 다음 문단 블록을 생성한다.
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
- 문단 간 기본 간격은 다음 블록의 `margin-top: 32px` 기준으로 조정한다.
- 블록 왼쪽 핸들은 hover/focus 상태에서 표시되며, 클릭하면 블록을 선택하고 Delete 또는 Backspace로 해당 블록을 삭제할 수 있다.
- 블록 왼쪽 핸들은 hover/focus 상태에서 AFFiNE 참고 스타일의 세로 막대로 표시되며, hover 시 해당 블록 높이만큼 확장해 선택 범위를 드러낸다.
- 블록 왼쪽 핸들을 클릭하면 블록을 선택하고 Delete 또는 Backspace로 해당 블록을 삭제할 수 있다.
- 블록 왼쪽 핸들을 드래그하면 블록 순서를 이동할 수 있다.
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
@@ -447,6 +448,6 @@ APP_PORT=43118
## 버전 관리
- 현재 버전: v0.0.38
- 현재 버전: v0.0.39
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정

View File

@@ -1,5 +1,13 @@
# 업데이트 이력
## v0.0.39
- 관리자 블록 에디터 Shift+Enter 줄바꿈이 문단 첫 위치로 커서를 이동시키던 문제 수정.
- 관리자 블록 에디터 텍스트 DOM 동기화를 `textContent` 기준으로 보정.
- 관리자 블록 에디터 왼쪽 블록 핸들을 AFFiNE 참고 스타일의 세로 막대형 hover 표시로 수정.
- 기술 명세 현재 버전을 v0.0.39로 갱신.
- 패키지 버전을 0.0.39로 갱신.
## v0.0.38
- 관리자 블록 에디터 슬래시 메뉴 필터가 입력 즉시 동기화되도록 보정.

4
package-lock.json generated
View File

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

View File

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