글쓰기 입력 흐름과 저장 피드백 보정

This commit is contained in:
2026-05-02 10:45:44 +09:00
parent 722e027f18
commit 792460b27a
10 changed files with 194 additions and 7 deletions

View File

@@ -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)
})
</script>
<template>