글쓰기 스크롤과 블록 드롭 피드백 보정

This commit is contained in:
2026-05-07 15:22:50 +09:00
parent 38f8abb1ff
commit 4e5ccb2726
8 changed files with 116 additions and 12 deletions

View File

@@ -13,6 +13,8 @@ const blockRefs = ref([])
const activeBlockId = ref('') const activeBlockId = ref('')
const selectedBlockId = ref('') const selectedBlockId = ref('')
const draggingBlockId = ref('') const draggingBlockId = ref('')
const dragTargetIndex = ref(-1)
const dragTargetPosition = ref('')
const slashQuery = ref('') const slashQuery = ref('')
const slashMenuDirection = ref('down') const slashMenuDirection = ref('down')
const highlightedCommandIndex = ref(0) const highlightedCommandIndex = ref(0)
@@ -24,6 +26,7 @@ const isMediaPickerOpen = ref(false)
const isLoadingMedia = ref(false) const isLoadingMedia = ref(false)
const isComposingText = ref(false) const isComposingText = ref(false)
const isNormalizingTrailingBlock = ref(false) const isNormalizingTrailingBlock = ref(false)
const pendingSoftLineBreakIndex = ref(-1)
let blockIdSeed = 0 let blockIdSeed = 0
const imageWidthOptions = [ const imageWidthOptions = [
@@ -684,10 +687,19 @@ const finishTextComposition = (event, index) => {
const block = syncTextBlockFromDom(index) const block = syncTextBlockFromDom(index)
if (!block) { if (!block) {
pendingSoftLineBreakIndex.value = -1
return return
} }
applyMarkdownShortcut(block, index) applyMarkdownShortcut(block, index)
if (pendingSoftLineBreakIndex.value === index && isTextBlock(block) && block.type !== 'code') {
pendingSoftLineBreakIndex.value = -1
insertSoftLineBreak(index)
return
}
pendingSoftLineBreakIndex.value = -1
emitContent() emitContent()
}, 0) }, 0)
}) })
@@ -1057,6 +1069,11 @@ const handleEnter = (event, index) => {
if (isComposingText.value || event.isComposing || event.keyCode === 229) { if (isComposingText.value || event.isComposing || event.keyCode === 229) {
event.preventDefault() event.preventDefault()
if (event.shiftKey && isTextBlock(currentBlock) && currentBlock.type !== 'code') {
pendingSoftLineBreakIndex.value = index
}
return return
} }
@@ -1195,10 +1212,30 @@ const deleteSelectedBlock = (event, index) => {
const startBlockDrag = (event, block) => { const startBlockDrag = (event, block) => {
draggingBlockId.value = block.id draggingBlockId.value = block.id
selectedBlockId.value = block.id selectedBlockId.value = block.id
dragTargetIndex.value = -1
dragTargetPosition.value = ''
event.dataTransfer.effectAllowed = 'move' event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', block.id) event.dataTransfer.setData('text/plain', block.id)
} }
/**
* 블록 드래그 중 드롭 위치 표시 갱신
* @param {DragEvent} event - 드래그 이벤트
* @param {number} targetIndex - 드래그 중인 대상 인덱스
* @returns {void}
*/
const updateBlockDropTarget = (event, targetIndex) => {
if (!draggingBlockId.value) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
const rect = event.currentTarget.getBoundingClientRect()
dragTargetIndex.value = targetIndex
dragTargetPosition.value = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
/** /**
* 블록 드롭 이동 처리 * 블록 드롭 이동 처리
* @param {DragEvent} event - 드롭 이벤트 * @param {DragEvent} event - 드롭 이벤트
@@ -1206,18 +1243,25 @@ const startBlockDrag = (event, block) => {
* @returns {void} * @returns {void}
*/ */
const dropBlock = (event, targetIndex) => { const dropBlock = (event, targetIndex) => {
event.preventDefault()
const draggedId = event.dataTransfer.getData('text/plain') || draggingBlockId.value const draggedId = event.dataTransfer.getData('text/plain') || draggingBlockId.value
const sourceIndex = getBlockIndex(draggedId) const sourceIndex = getBlockIndex(draggedId)
const targetPosition = dragTargetIndex.value === targetIndex ? dragTargetPosition.value : 'after'
const insertionIndex = targetPosition === 'after' ? targetIndex + 1 : targetIndex
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) { if (sourceIndex < 0 || targetIndex < 0) {
draggingBlockId.value = '' draggingBlockId.value = ''
dragTargetIndex.value = -1
dragTargetPosition.value = ''
return return
} }
const [draggedBlock] = editorBlocks.value.splice(sourceIndex, 1) const [draggedBlock] = editorBlocks.value.splice(sourceIndex, 1)
const nextTargetIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex const nextTargetIndex = sourceIndex < insertionIndex ? insertionIndex - 1 : insertionIndex
editorBlocks.value.splice(nextTargetIndex, 0, draggedBlock) editorBlocks.value.splice(nextTargetIndex, 0, draggedBlock)
draggingBlockId.value = '' draggingBlockId.value = ''
dragTargetIndex.value = -1
dragTargetPosition.value = ''
selectedBlockId.value = draggedBlock.id selectedBlockId.value = draggedBlock.id
normalizeTrailingTextBlock() normalizeTrailingTextBlock()
emitContent() emitContent()
@@ -1229,6 +1273,8 @@ const dropBlock = (event, targetIndex) => {
*/ */
const finishBlockDrag = () => { const finishBlockDrag = () => {
draggingBlockId.value = '' draggingBlockId.value = ''
dragTargetIndex.value = -1
dragTargetPosition.value = ''
} }
/** /**
@@ -1302,11 +1348,13 @@ defineExpose({
:class="{ :class="{
'admin-block-editor__row--selected': 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--dragging opacity-50': draggingBlockId === block.id,
'admin-block-editor__row--drop-before': dragTargetIndex === index && dragTargetPosition === 'before',
'admin-block-editor__row--drop-after': dragTargetIndex === index && dragTargetPosition === 'after',
'admin-block-editor__row--text': isTextBlock(block), 'admin-block-editor__row--text': isTextBlock(block),
'admin-block-editor__row--structure': !isTextBlock(block) 'admin-block-editor__row--structure': !isTextBlock(block)
}" }"
:data-editor-block-id="block.id" :data-editor-block-id="block.id"
@dragover.prevent @dragover="updateBlockDropTarget($event, index)"
@drop="dropBlock($event, index)" @drop="dropBlock($event, index)"
> >
<button <button
@@ -1574,6 +1622,24 @@ defineExpose({
transform 160ms ease; transform 160ms ease;
} }
.admin-block-editor__row::after {
position: absolute;
left: 0;
right: 0;
z-index: 20;
height: 3px;
border-radius: 999px;
background: #2eb6ea;
box-shadow: 0 0 0 1px rgba(46, 182, 234, 0.18);
content: "";
opacity: 0;
pointer-events: none;
transform: scaleX(0.98);
transition:
opacity 120ms ease,
transform 120ms ease;
}
.admin-block-editor__row:hover::before, .admin-block-editor__row:hover::before,
.admin-block-editor__row--selected::before { .admin-block-editor__row--selected::before {
background: #eff1f2; background: #eff1f2;
@@ -1581,6 +1647,18 @@ defineExpose({
transform: scaleX(1); transform: scaleX(1);
} }
.admin-block-editor__row--drop-before::after {
top: -18px;
opacity: 1;
transform: scaleX(1);
}
.admin-block-editor__row--drop-after::after {
bottom: -18px;
opacity: 1;
transform: scaleX(1);
}
.admin-block-editor__handle { .admin-block-editor__handle {
min-height: 32px; min-height: 32px;
} }

View File

@@ -1,5 +1,13 @@
# 의사결정 이력 # 의사결정 이력
## 2026-05-07 v0.0.40
### 글쓰기 스크롤과 드래그 드롭 피드백 결정
관리자 글 작성/수정 화면은 글쓰기 라우트에서 관리자 레이아웃 자체를 화면 높이로 고정하고, 실제 세로 스크롤은 에디터 작업 영역 내부에서만 처리한다. 작성 화면 아래로 문서 전체가 밀리면 전역 다크 배경이 노출될 수 있고, Ghost 스타일 전체 화면 편집 모드의 집중감도 깨지기 때문이다.
블록 드래그 이동은 대상 블록 위/아래 절반을 기준으로 삽입 위치를 계산하고 같은 위치에 삽입선을 표시한다. 사용자가 놓는 순간의 결과를 드롭 전에 알 수 있어야 블록형 에디터의 이동 조작이 안전하게 느껴지기 때문이다.
## 2026-05-07 v0.0.39 ## 2026-05-07 v0.0.39
### 블록 에디터 줄바꿈과 핸들 표시 보정 결정 ### 블록 에디터 줄바꿈과 핸들 표시 보정 결정

View File

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

View File

@@ -285,17 +285,20 @@ components/content/
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르거나 ` ``` `을 입력하면 현재 블록 타입을 즉시 변환한다. - `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르거나 ` ``` `을 입력하면 현재 블록 타입을 즉시 변환한다.
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다. - 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
- 한글 등 조합형 입력 직후 확정된 텍스트로 슬래시 메뉴 필터와 Enter 블록 이동을 반영한다. - 한글 등 조합형 입력 직후 확정된 텍스트로 슬래시 메뉴 필터와 Enter 블록 이동을 반영한다.
- 한글 등 조합형 입력 중 Shift+Enter가 들어오면 조합 완료 직후 줄바꿈을 예약 적용한다.
- Shift+Enter는 같은 텍스트 블록 안에 줄바꿈 문자를 직접 삽입하고 커서를 줄바꿈 뒤로 유지하며, Enter는 다음 문단 블록을 생성한다. - Shift+Enter는 같은 텍스트 블록 안에 줄바꿈 문자를 직접 삽입하고 커서를 줄바꿈 뒤로 유지하며, Enter는 다음 문단 블록을 생성한다.
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다. - 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
- 문단 간 기본 간격은 다음 블록의 `margin-top: 32px` 기준으로 조정한다. - 문단 간 기본 간격은 다음 블록의 `margin-top: 32px` 기준으로 조정한다.
- 블록 왼쪽 핸들은 hover/focus 상태에서 AFFiNE 참고 스타일의 세로 막대로 표시되며, hover 시 해당 블록 높이만큼 확장해 선택 범위를 드러낸다. - 블록 왼쪽 핸들은 hover/focus 상태에서 AFFiNE 참고 스타일의 세로 막대로 표시되며, hover 시 해당 블록 높이만큼 확장해 선택 범위를 드러낸다.
- 블록 왼쪽 핸들을 클릭하면 블록을 선택하고 Delete 또는 Backspace로 해당 블록을 삭제할 수 있다. - 블록 왼쪽 핸들을 클릭하면 블록을 선택하고 Delete 또는 Backspace로 해당 블록을 삭제할 수 있다.
- 블록 왼쪽 핸들을 드래그하면 블록 순서를 이동할 수 있다. - 블록 왼쪽 핸들을 드래그하면 블록 순서를 이동할 수 있다.
- 블록 드래그 중에는 현재 포인터 위치 기준으로 대상 블록 위 또는 아래에 삽입선을 표시하고, 드롭 시 표시 위치와 같은 곳으로 이동한다.
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다. - 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다. - 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다. - 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다. - 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
- 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다. - 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
- 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
- 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다. - 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다.
- 본문 에디터는 데스크톱에서 좌우 32px 패딩을 포함한 804px 중앙 컬럼으로 배치해 실제 입력 영역 최대 폭을 740px로 유지한다. - 본문 에디터는 데스크톱에서 좌우 32px 패딩을 포함한 804px 중앙 컬럼으로 배치해 실제 입력 영역 최대 폭을 740px로 유지한다.
- 게시물 설정은 데스크톱에서 420px 우측 패널로 표시하며, 도구막대 버튼으로 열고 닫을 수 있고 너비 전환 애니메이션을 적용한다. - 게시물 설정은 데스크톱에서 420px 우측 패널로 표시하며, 도구막대 버튼으로 열고 닫을 수 있고 너비 전환 애니메이션을 적용한다.
@@ -448,6 +451,6 @@ APP_PORT=43118
## 버전 관리 ## 버전 관리
- 현재 버전: v0.0.39 - 현재 버전: v0.0.40
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가 - 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정 - 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정

View File

@@ -1,5 +1,14 @@
# 업데이트 이력 # 업데이트 이력
## v0.0.40
- 관리자 글쓰기 화면에서 바깥 문서가 함께 스크롤되어 하단 배경이 노출되던 문제 수정.
- 관리자 블록 에디터 드래그 중 드롭 위치를 위/아래 삽입선으로 표시하도록 추가.
- 관리자 블록 에디터 드롭 이동 위치가 표시된 삽입선과 일치하도록 수정.
- 관리자 블록 에디터 한글 조합 중 Shift+Enter 입력 시 조합 완료 후 줄바꿈이 바로 적용되도록 보정.
- 기술 명세 현재 버전을 v0.0.40으로 갱신.
- 패키지 버전을 0.0.40으로 갱신.
## v0.0.39 ## v0.0.39
- 관리자 블록 에디터 Shift+Enter 줄바꿈이 문단 첫 위치로 커서를 이동시키던 문제 수정. - 관리자 블록 에디터 Shift+Enter 줄바꿈이 문단 첫 위치로 커서를 이동시키던 문제 수정.

View File

@@ -17,7 +17,10 @@ const logoutAdmin = async () => {
</script> </script>
<template> <template>
<div class="admin-layout min-h-screen bg-[#f5f5f2] text-ink"> <div
class="admin-layout bg-[#f5f5f2] text-ink"
:class="isPostEditorRoute ? 'h-screen overflow-hidden bg-white' : 'min-h-screen'"
>
<aside <aside
v-if="!isPostEditorRoute" v-if="!isPostEditorRoute"
class="admin-layout__sidebar fixed inset-y-0 left-0 hidden w-64 border-r border-line bg-[#15171a] p-5 text-white lg:block" class="admin-layout__sidebar fixed inset-y-0 left-0 hidden w-64 border-r border-line bg-[#15171a] p-5 text-white lg:block"
@@ -50,8 +53,11 @@ const logoutAdmin = async () => {
</nav> </nav>
</aside> </aside>
<main <main
class="admin-layout__main min-h-screen" class="admin-layout__main"
:class="{ 'lg:ml-64': !isPostEditorRoute }" :class="[
isPostEditorRoute ? 'h-screen overflow-hidden' : 'min-h-screen',
{ 'lg:ml-64': !isPostEditorRoute }
]"
> >
<slot /> <slot />
</main> </main>

4
package-lock.json generated
View File

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

View File

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