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 }}