글쓰기 스크롤과 블록 드롭 피드백 보정
This commit is contained in:
@@ -13,6 +13,8 @@ const blockRefs = ref([])
|
||||
const activeBlockId = ref('')
|
||||
const selectedBlockId = ref('')
|
||||
const draggingBlockId = ref('')
|
||||
const dragTargetIndex = ref(-1)
|
||||
const dragTargetPosition = ref('')
|
||||
const slashQuery = ref('')
|
||||
const slashMenuDirection = ref('down')
|
||||
const highlightedCommandIndex = ref(0)
|
||||
@@ -24,6 +26,7 @@ const isMediaPickerOpen = ref(false)
|
||||
const isLoadingMedia = ref(false)
|
||||
const isComposingText = ref(false)
|
||||
const isNormalizingTrailingBlock = ref(false)
|
||||
const pendingSoftLineBreakIndex = ref(-1)
|
||||
let blockIdSeed = 0
|
||||
|
||||
const imageWidthOptions = [
|
||||
@@ -684,10 +687,19 @@ const finishTextComposition = (event, index) => {
|
||||
const block = syncTextBlockFromDom(index)
|
||||
|
||||
if (!block) {
|
||||
pendingSoftLineBreakIndex.value = -1
|
||||
return
|
||||
}
|
||||
|
||||
applyMarkdownShortcut(block, index)
|
||||
|
||||
if (pendingSoftLineBreakIndex.value === index && isTextBlock(block) && block.type !== 'code') {
|
||||
pendingSoftLineBreakIndex.value = -1
|
||||
insertSoftLineBreak(index)
|
||||
return
|
||||
}
|
||||
|
||||
pendingSoftLineBreakIndex.value = -1
|
||||
emitContent()
|
||||
}, 0)
|
||||
})
|
||||
@@ -1057,6 +1069,11 @@ const handleEnter = (event, index) => {
|
||||
|
||||
if (isComposingText.value || event.isComposing || event.keyCode === 229) {
|
||||
event.preventDefault()
|
||||
|
||||
if (event.shiftKey && isTextBlock(currentBlock) && currentBlock.type !== 'code') {
|
||||
pendingSoftLineBreakIndex.value = index
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1195,10 +1212,30 @@ const deleteSelectedBlock = (event, index) => {
|
||||
const startBlockDrag = (event, block) => {
|
||||
draggingBlockId.value = block.id
|
||||
selectedBlockId.value = block.id
|
||||
dragTargetIndex.value = -1
|
||||
dragTargetPosition.value = ''
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
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 - 드롭 이벤트
|
||||
@@ -1206,18 +1243,25 @@ const startBlockDrag = (event, block) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const dropBlock = (event, targetIndex) => {
|
||||
event.preventDefault()
|
||||
const draggedId = event.dataTransfer.getData('text/plain') || draggingBlockId.value
|
||||
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 = ''
|
||||
dragTargetIndex.value = -1
|
||||
dragTargetPosition.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
draggingBlockId.value = ''
|
||||
dragTargetIndex.value = -1
|
||||
dragTargetPosition.value = ''
|
||||
selectedBlockId.value = draggedBlock.id
|
||||
normalizeTrailingTextBlock()
|
||||
emitContent()
|
||||
@@ -1229,6 +1273,8 @@ const dropBlock = (event, targetIndex) => {
|
||||
*/
|
||||
const finishBlockDrag = () => {
|
||||
draggingBlockId.value = ''
|
||||
dragTargetIndex.value = -1
|
||||
dragTargetPosition.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1302,11 +1348,13 @@ defineExpose({
|
||||
:class="{
|
||||
'admin-block-editor__row--selected': selectedBlockId === 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--structure': !isTextBlock(block)
|
||||
}"
|
||||
:data-editor-block-id="block.id"
|
||||
@dragover.prevent
|
||||
@dragover="updateBlockDropTarget($event, index)"
|
||||
@drop="dropBlock($event, index)"
|
||||
>
|
||||
<button
|
||||
@@ -1574,6 +1622,24 @@ defineExpose({
|
||||
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--selected::before {
|
||||
background: #eff1f2;
|
||||
@@ -1581,6 +1647,18 @@ defineExpose({
|
||||
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 {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-07 v0.0.40
|
||||
|
||||
### 글쓰기 스크롤과 드래그 드롭 피드백 결정
|
||||
|
||||
관리자 글 작성/수정 화면은 글쓰기 라우트에서 관리자 레이아웃 자체를 화면 높이로 고정하고, 실제 세로 스크롤은 에디터 작업 영역 내부에서만 처리한다. 작성 화면 아래로 문서 전체가 밀리면 전역 다크 배경이 노출될 수 있고, Ghost 스타일 전체 화면 편집 모드의 집중감도 깨지기 때문이다.
|
||||
|
||||
블록 드래그 이동은 대상 블록 위/아래 절반을 기준으로 삽입 위치를 계산하고 같은 위치에 삽입선을 표시한다. 사용자가 놓는 순간의 결과를 드롭 전에 알 수 있어야 블록형 에디터의 이동 조작이 안전하게 느껴지기 때문이다.
|
||||
|
||||
## 2026-05-07 v0.0.39
|
||||
|
||||
### 블록 에디터 줄바꿈과 핸들 표시 보정 결정
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|------|------|
|
||||
| layouts/default.vue | 메인, 목록, 태그 페이지 |
|
||||
| layouts/post.vue | 개별 게시물 |
|
||||
| layouts/admin.vue | 관리자 전체, 글 작성/수정 화면의 전체 화면 편집 모드 |
|
||||
| layouts/admin.vue | 관리자 전체, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
|
||||
| layouts/page.vue | 고정 페이지 전체 화면 |
|
||||
|
||||
## 사이트 컴포넌트
|
||||
@@ -28,7 +28,7 @@
|
||||
|------|-----------|
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지/OG 이미지 선택, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정, 미리보기 요청 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
||||
|
||||
## 콘텐츠 컴포넌트
|
||||
|
||||
@@ -285,17 +285,20 @@ components/content/
|
||||
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르거나 ` ``` `을 입력하면 현재 블록 타입을 즉시 변환한다.
|
||||
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
|
||||
- 한글 등 조합형 입력 직후 확정된 텍스트로 슬래시 메뉴 필터와 Enter 블록 이동을 반영한다.
|
||||
- 한글 등 조합형 입력 중 Shift+Enter가 들어오면 조합 완료 직후 줄바꿈을 예약 적용한다.
|
||||
- Shift+Enter는 같은 텍스트 블록 안에 줄바꿈 문자를 직접 삽입하고 커서를 줄바꿈 뒤로 유지하며, Enter는 다음 문단 블록을 생성한다.
|
||||
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
|
||||
- 문단 간 기본 간격은 다음 블록의 `margin-top: 32px` 기준으로 조정한다.
|
||||
- 블록 왼쪽 핸들은 hover/focus 상태에서 AFFiNE 참고 스타일의 세로 막대로 표시되며, hover 시 해당 블록 높이만큼 확장해 선택 범위를 드러낸다.
|
||||
- 블록 왼쪽 핸들을 클릭하면 블록을 선택하고 Delete 또는 Backspace로 해당 블록을 삭제할 수 있다.
|
||||
- 블록 왼쪽 핸들을 드래그하면 블록 순서를 이동할 수 있다.
|
||||
- 블록 드래그 중에는 현재 포인터 위치 기준으로 대상 블록 위 또는 아래에 삽입선을 표시하고, 드롭 시 표시 위치와 같은 곳으로 이동한다.
|
||||
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
|
||||
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
|
||||
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
|
||||
- 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
|
||||
- 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
|
||||
- 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다.
|
||||
- 본문 에디터는 데스크톱에서 좌우 32px 패딩을 포함한 804px 중앙 컬럼으로 배치해 실제 입력 영역 최대 폭을 740px로 유지한다.
|
||||
- 게시물 설정은 데스크톱에서 420px 우측 패널로 표시하며, 도구막대 버튼으로 열고 닫을 수 있고 너비 전환 애니메이션을 적용한다.
|
||||
@@ -448,6 +451,6 @@ APP_PORT=43118
|
||||
|
||||
## 버전 관리
|
||||
|
||||
- 현재 버전: v0.0.39
|
||||
- 현재 버전: v0.0.40
|
||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.40
|
||||
|
||||
- 관리자 글쓰기 화면에서 바깥 문서가 함께 스크롤되어 하단 배경이 노출되던 문제 수정.
|
||||
- 관리자 블록 에디터 드래그 중 드롭 위치를 위/아래 삽입선으로 표시하도록 추가.
|
||||
- 관리자 블록 에디터 드롭 이동 위치가 표시된 삽입선과 일치하도록 수정.
|
||||
- 관리자 블록 에디터 한글 조합 중 Shift+Enter 입력 시 조합 완료 후 줄바꿈이 바로 적용되도록 보정.
|
||||
- 기술 명세 현재 버전을 v0.0.40으로 갱신.
|
||||
- 패키지 버전을 0.0.40으로 갱신.
|
||||
|
||||
## v0.0.39
|
||||
|
||||
- 관리자 블록 에디터 Shift+Enter 줄바꿈이 문단 첫 위치로 커서를 이동시키던 문제 수정.
|
||||
|
||||
@@ -17,7 +17,10 @@ const logoutAdmin = async () => {
|
||||
</script>
|
||||
|
||||
<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
|
||||
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"
|
||||
@@ -50,8 +53,11 @@ const logoutAdmin = async () => {
|
||||
</nav>
|
||||
</aside>
|
||||
<main
|
||||
class="admin-layout__main min-h-screen"
|
||||
:class="{ 'lg:ml-64': !isPostEditorRoute }"
|
||||
class="admin-layout__main"
|
||||
:class="[
|
||||
isPostEditorRoute ? 'h-screen overflow-hidden' : 'min-h-screen',
|
||||
{ 'lg:ml-64': !isPostEditorRoute }
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.39",
|
||||
"version": "0.0.40",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.39",
|
||||
"version": "0.0.40",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.39",
|
||||
"version": "0.0.40",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user