글쓰기 입력 흐름과 저장 피드백 보정
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user