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