From 792460b27ab9c370577b188ea5b76ac8607e0f21 Mon Sep 17 00:00:00 2001
From: zenn
Date: Sat, 2 May 2026 10:45:44 +0900
Subject: [PATCH] =?UTF-8?q?=EA=B8=80=EC=93=B0=EA=B8=B0=20=EC=9E=85?=
=?UTF-8?q?=EB=A0=A5=20=ED=9D=90=EB=A6=84=EA=B3=BC=20=EC=A0=80=EC=9E=A5=20?=
=?UTF-8?q?=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B3=B4=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
components/admin/AdminBlockEditor.vue | 70 ++++++++++++++++++++++++++-
docs/history.md | 10 ++++
docs/map.md | 6 +--
docs/spec.md | 3 ++
docs/todo.md | 1 +
docs/update.md | 9 ++++
package-lock.json | 4 +-
package.json | 2 +-
pages/admin/posts/[id].vue | 58 ++++++++++++++++++++++
pages/admin/posts/new.vue | 38 +++++++++++++++
10 files changed, 194 insertions(+), 7 deletions(-)
diff --git a/components/admin/AdminBlockEditor.vue b/components/admin/AdminBlockEditor.vue
index f7b76eb..bce6ae7 100644
--- a/components/admin/AdminBlockEditor.vue
+++ b/components/admin/AdminBlockEditor.vue
@@ -21,6 +21,9 @@ 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 = [
@@ -396,6 +399,41 @@ const emitContent = () => {
*/
const isTextBlock = (block) => !['divider', 'image', 'gallery', 'toggle', 'embed'].includes(block.type)
+/**
+ * 비어 있는 문단 블록 여부 반환
+ * @param {Object|undefined} block - 에디터 블록
+ * @returns {boolean} 비어 있는 문단 블록 여부
+ */
+const isBlankParagraphBlock = (block) => block?.type === 'paragraph' && !block.text
+
+/**
+ * 마지막 클릭 가능 문단 블록 유지
+ * @returns {void}
+ */
+const normalizeTrailingTextBlock = () => {
+ if (isNormalizingTrailingBlock.value) {
+ return
+ }
+
+ isNormalizingTrailingBlock.value = true
+
+ while (
+ editorBlocks.value.length > 1
+ && isBlankParagraphBlock(editorBlocks.value.at(-1))
+ && isBlankParagraphBlock(editorBlocks.value.at(-2))
+ ) {
+ editorBlocks.value.pop()
+ }
+
+ if (!isBlankParagraphBlock(editorBlocks.value.at(-1))) {
+ editorBlocks.value.push(createEditorBlock('paragraph'))
+ }
+
+ nextTick(() => {
+ isNormalizingTrailingBlock.value = false
+ })
+}
+
/**
* 블록 DOM 요소를 저장
* @param {Element|null} element - 블록 DOM 요소
@@ -564,7 +602,9 @@ const updateBlockText = (event, index) => {
* @returns {void}
*/
const startTextComposition = () => {
+ window.clearTimeout(compositionEnterGuardTimer)
isComposingText.value = true
+ isCompositionEnterGuardActive.value = true
}
/**
@@ -576,6 +616,10 @@ const startTextComposition = () => {
const finishTextComposition = (event, index) => {
isComposingText.value = false
updateBlockText(event, index)
+ window.clearTimeout(compositionEnterGuardTimer)
+ compositionEnterGuardTimer = window.setTimeout(() => {
+ isCompositionEnterGuardActive.value = false
+ }, 120)
}
/**
@@ -681,11 +725,13 @@ const applyCommand = (command) => {
if (command.type === 'divider') {
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
+ normalizeTrailingTextBlock()
emitContent()
focusBlock(index + 1)
return
}
+ normalizeTrailingTextBlock()
emitContent()
if (isTextBlock(block)) {
@@ -800,6 +846,7 @@ const selectMediaItem = (mediaItem) => {
block.alt = ''
}
+ normalizeTrailingTextBlock()
emitContent()
closeMediaPicker()
}
@@ -825,6 +872,7 @@ const handleImageUpload = async (event, block) => {
if (file) {
block.url = file.url
block.alt = ''
+ normalizeTrailingTextBlock()
emitContent()
}
} finally {
@@ -858,6 +906,7 @@ const handleGalleryUpload = async (event, block) => {
width: 'regular'
}))
]
+ normalizeTrailingTextBlock()
emitContent()
} finally {
event.target.value = ''
@@ -873,6 +922,7 @@ const handleGalleryUpload = async (event, block) => {
*/
const updateImageWidth = (block, width) => {
block.width = width
+ normalizeTrailingTextBlock()
emitContent()
}
@@ -884,6 +934,7 @@ const updateImageWidth = (block, width) => {
*/
const removeGalleryImage = (block, imageIndex) => {
block.images.splice(imageIndex, 1)
+ normalizeTrailingTextBlock()
emitContent()
}
@@ -926,6 +977,11 @@ const highlightPreviousCommand = (event) => {
const handleEnter = (event, index) => {
const currentBlock = editorBlocks.value[index]
+ if (isComposingText.value || isCompositionEnterGuardActive.value || event.isComposing || event.keyCode === 229) {
+ event.preventDefault()
+ return
+ }
+
if (visibleCommands.value.length && currentBlock.text.startsWith('/')) {
event.preventDefault()
applyCommand(highlightedCommand.value || visibleCommands.value[0])
@@ -940,6 +996,7 @@ const handleEnter = (event, index) => {
if (['divider', 'image', 'gallery', 'toggle', 'embed'].includes(currentBlock.type)) {
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
+ normalizeTrailingTextBlock()
emitContent()
focusBlock(index + 1)
return
@@ -948,6 +1005,7 @@ const handleEnter = (event, index) => {
if (!currentBlock.text.trim() && currentBlock.type !== 'paragraph') {
currentBlock.type = 'paragraph'
currentBlock.level = null
+ normalizeTrailingTextBlock()
emitContent()
focusBlock(index)
return
@@ -955,6 +1013,7 @@ const handleEnter = (event, index) => {
const nextType = currentBlock.type === 'list' ? 'list' : 'paragraph'
editorBlocks.value.splice(index + 1, 0, createEditorBlock(nextType))
+ normalizeTrailingTextBlock()
emitContent()
focusBlock(index + 1)
}
@@ -978,6 +1037,7 @@ const handleBackspace = (event, index) => {
event.preventDefault()
editorBlocks.value.splice(index, 1)
+ normalizeTrailingTextBlock()
emitContent()
focusBlock(Math.max(index - 1, 0))
}
@@ -1002,7 +1062,8 @@ const activateBlock = (block) => {
*/
const shouldShowPlaceholder = (block, index) => !block.text && (
activeBlockId.value === block.id ||
- (index === 0 && editorBlocks.value.length === 1)
+ (index === 0 && editorBlocks.value.length === 1) ||
+ index === editorBlocks.value.length - 1
)
/**
@@ -1010,6 +1071,7 @@ const shouldShowPlaceholder = (block, index) => !block.text && (
* @returns {void}
*/
const updateStructuredBlock = () => {
+ normalizeTrailingTextBlock()
emitContent()
}
@@ -1025,9 +1087,11 @@ watch(() => props.modelValue, (value) => {
}
editorBlocks.value = parseMarkdownToBlocks(value)
+ normalizeTrailingTextBlock()
}, { immediate: true })
watch(editorBlocks, () => {
+ normalizeTrailingTextBlock()
isApplyingExternalValue.value = true
nextTick(() => {
isApplyingExternalValue.value = false
@@ -1037,6 +1101,10 @@ watch(editorBlocks, () => {
defineExpose({
focusFirstBlock: () => focusBlock(0)
})
+
+onBeforeUnmount(() => {
+ window.clearTimeout(compositionEnterGuardTimer)
+})
diff --git a/docs/history.md b/docs/history.md
index 9eb74d7..0126436 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -1,5 +1,15 @@
# 의사결정 이력
+## 2026-05-02 v0.0.22
+
+### 글쓰기 하단 빈 블록과 저장 피드백 보정
+
+이미지, 갤러리, 임베드 같은 비텍스트 블록이 글의 마지막에 오더라도 작성자가 이어서 글을 쓸 수 있도록 에디터 마지막에는 항상 빈 문단 블록을 유지한다. 이 빈 문단은 작성 편의를 위한 입력 지점이므로 내용이 없으면 저장 마크다운에는 포함하지 않는다.
+
+한글 조합 입력 직후 Enter는 IME 확정 동작으로 들어오는 경우가 있으므로 즉시 새 블록 생성으로 처리하지 않는다. 조합 확정 Enter와 문단 이동 Enter를 분리해 마지막 글자가 다음 블록에 중복 입력되는 문제를 줄이기 위해서다.
+
+저장 버튼을 눌렀을 때 동작 여부가 보이지 않으면 작성자가 같은 동작을 반복할 수 있으므로, 저장/수정/삭제 진행과 결과는 우측 상단 토스트로 표시한다. 새 글 저장 후 수정 화면으로 이동하는 경우에도 성공 토스트를 이어서 표시한다.
+
## 2026-05-02 v0.0.21
### 글 작성 중 자동 저장 범위 결정
diff --git a/docs/map.md b/docs/map.md
index 360d908..7539a59 100644
--- a/docs/map.md
+++ b/docs/map.md
@@ -27,7 +27,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택, 로컬 자동 저장 |
-| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리 |
+| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 하단 빈 입력 블록 유지 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
## 콘텐츠 컴포넌트
@@ -57,8 +57,8 @@
| pages/admin/index.vue | 대시보드 |
| pages/admin/login.vue | 관리자 로그인 |
| pages/admin/posts/index.vue | 글 목록 |
-| pages/admin/posts/new.vue | 글 작성 |
-| pages/admin/posts/[id].vue | 글 수정 |
+| pages/admin/posts/new.vue | 글 작성, 저장 토스트 |
+| pages/admin/posts/[id].vue | 글 수정, 저장/삭제 토스트 |
| pages/admin/pages/index.vue | 페이지 목록 |
| pages/admin/media/index.vue | 미디어 관리 |
| pages/admin/tags/index.vue | 태그 관리 |
diff --git a/docs/spec.md b/docs/spec.md
index 604d8b4..9778f2d 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -222,8 +222,10 @@ components/content/
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르면 현재 블록 타입을 즉시 변환한다.
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
+- 한글 등 조합형 입력 직후 Enter는 새 블록 생성으로 처리하지 않는다.
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
+- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
@@ -231,6 +233,7 @@ components/content/
- 자동 저장 키는 `SORI_ADMIN_POST_AUTOSAVE:new` 또는 `SORI_ADMIN_POST_AUTOSAVE:{postId}` 형식을 사용한다.
- 자동 저장본이 있으면 작성 화면에서 복원 또는 삭제를 선택할 수 있다.
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
+- 글 저장/수정/삭제 진행과 성공/실패 상태는 화면 우측 상단 토스트로 표시한다.
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 새 이미지 업로드로 설정한다.
diff --git a/docs/todo.md b/docs/todo.md
index d9102c7..931a2ee 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -11,6 +11,7 @@
- [ ] 미디어 라이브러리 상세 모달 수동 QA: 검색, 사용 현황, 파일명 변경, 삭제 잠금 확인
- [ ] 콜아웃, 토글, 임베드 블록 브라우저 수동 QA: `/` 메뉴 선택, 저장/수정 왕복, 공개 렌더링 확인
- [ ] 글 작성 중 자동 저장 브라우저 수동 QA: 새 글/수정 글 복원, 저장 성공 후 삭제, 빈 글 자동 저장 삭제 확인
+- [ ] 저장 토스트 브라우저 수동 QA: 새 글 저장 후 이동, 수정 저장, 저장 실패, 삭제 실패 상태 확인
## 2차 관리자 개발
diff --git a/docs/update.md b/docs/update.md
index 08b81b0..d4771a1 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,14 @@
# 업데이트 이력
+## v0.0.22
+
+- 관리자 블록 에디터 마지막에 클릭 가능한 빈 문단 블록을 항상 유지하도록 수정.
+- 빈 마지막 문단 블록은 저장 콘텐츠에 포함하지 않도록 유지.
+- 한글 조합 입력 직후 Enter가 새 블록 입력으로 중복 처리되는 문제 보정.
+- 관리자 글 저장/수정/삭제 진행 상태를 토스트로 표시하도록 추가.
+- 새 글 저장 후 수정 화면으로 이동해도 저장 성공 토스트를 표시하도록 추가.
+- 패키지 버전을 0.0.22로 갱신.
+
## v0.0.21
- 관리자 글 작성/수정 폼에 로컬 자동 저장 기능 추가.
diff --git a/package-lock.json b/package-lock.json
index e2c6b0d..0f8666b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sori.studio",
- "version": "0.0.21",
+ "version": "0.0.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
- "version": "0.0.21",
+ "version": "0.0.22",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
diff --git a/package.json b/package.json
index 82ddd91..075b8ad 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sori.studio",
- "version": "0.0.21",
+ "version": "0.0.22",
"private": true,
"type": "module",
"scripts": {
diff --git a/pages/admin/posts/[id].vue b/pages/admin/posts/[id].vue
index c84f654..590d58e 100644
--- a/pages/admin/posts/[id].vue
+++ b/pages/admin/posts/[id].vue
@@ -9,6 +9,8 @@ const saving = ref(false)
const deleting = ref(false)
const errorMessage = ref('')
const postForm = ref(null)
+const toast = ref(null)
+let toastTimer = null
const { data: post } = await useFetch(() => `/admin/api/posts/${id.value}`)
@@ -19,6 +21,39 @@ if (!post.value) {
})
}
+/**
+ * 저장 상태 토스트 표시
+ * @param {'success'|'error'|'info'} type - 토스트 타입
+ * @param {string} message - 표시 메시지
+ * @returns {void}
+ */
+const showToast = (type, message) => {
+ window.clearTimeout(toastTimer)
+ toast.value = { type, message }
+ toastTimer = window.setTimeout(() => {
+ toast.value = null
+ }, 3200)
+}
+
+/**
+ * 이전 화면에서 전달한 토스트 표시
+ * @returns {void}
+ */
+const showStoredToast = () => {
+ const storedToast = sessionStorage.getItem('SORI_ADMIN_POST_TOAST')
+
+ if (!storedToast) {
+ return
+ }
+
+ try {
+ const parsedToast = JSON.parse(storedToast)
+ showToast(parsedToast.type || 'success', parsedToast.message || '저장되었습니다.')
+ } finally {
+ sessionStorage.removeItem('SORI_ADMIN_POST_TOAST')
+ }
+}
+
/**
* 게시물 수정 저장
* @param {Object} payload - 게시물 입력값
@@ -27,6 +62,7 @@ if (!post.value) {
const savePost = async (payload) => {
saving.value = true
errorMessage.value = ''
+ showToast('info', '변경 내용을 저장하는 중입니다.')
try {
const updatedPost = await $fetch(`/admin/api/posts/${id.value}`, {
@@ -36,8 +72,10 @@ const savePost = async (payload) => {
post.value = updatedPost
postForm.value?.clearAutosave()
+ showToast('success', '변경 내용이 저장되었습니다.')
} catch (error) {
errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.'
+ showToast('error', errorMessage.value)
} finally {
saving.value = false
}
@@ -54,6 +92,7 @@ const deletePost = async () => {
deleting.value = true
errorMessage.value = ''
+ showToast('info', '글을 삭제하는 중입니다.')
try {
await $fetch(`/admin/api/posts/${id.value}`, {
@@ -62,10 +101,17 @@ const deletePost = async () => {
await navigateTo('/admin/posts')
} catch (error) {
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
+ showToast('error', errorMessage.value)
} finally {
deleting.value = false
}
}
+
+onMounted(showStoredToast)
+
+onBeforeUnmount(() => {
+ window.clearTimeout(toastTimer)
+})
@@ -102,5 +148,17 @@ const deletePost = async () => {
{{ errorMessage }}
+
+ {{ toast.message }}
+
diff --git a/pages/admin/posts/new.vue b/pages/admin/posts/new.vue
index 1ff74bd..ce69474 100644
--- a/pages/admin/posts/new.vue
+++ b/pages/admin/posts/new.vue
@@ -6,6 +6,22 @@ definePageMeta({
const saving = ref(false)
const errorMessage = ref('')
const postForm = ref(null)
+const toast = ref(null)
+let toastTimer = null
+
+/**
+ * 저장 상태 토스트 표시
+ * @param {'success'|'error'|'info'} type - 토스트 타입
+ * @param {string} message - 표시 메시지
+ * @returns {void}
+ */
+const showToast = (type, message) => {
+ window.clearTimeout(toastTimer)
+ toast.value = { type, message }
+ toastTimer = window.setTimeout(() => {
+ toast.value = null
+ }, 3200)
+}
/**
* 새 게시물 저장
@@ -15,6 +31,7 @@ const postForm = ref(null)
const savePost = async (payload) => {
saving.value = true
errorMessage.value = ''
+ showToast('info', '글을 저장하는 중입니다.')
try {
const post = await $fetch('/admin/api/posts', {
@@ -23,13 +40,22 @@ const savePost = async (payload) => {
})
postForm.value?.clearAutosave()
+ sessionStorage.setItem('SORI_ADMIN_POST_TOAST', JSON.stringify({
+ type: 'success',
+ message: '글이 저장되었습니다.'
+ }))
await navigateTo(`/admin/posts/${post.id}`)
} catch (error) {
errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.'
+ showToast('error', errorMessage.value)
} finally {
saving.value = false
}
}
+
+onBeforeUnmount(() => {
+ window.clearTimeout(toastTimer)
+})
@@ -46,5 +72,17 @@ const savePost = async (payload) => {
{{ errorMessage }}
+
+ {{ toast.message }}
+