에디터 블록 옵션 계산 분리

This commit is contained in:
2026-07-28 15:59:01 +09:00
parent 126f608808
commit 737d064b06
10 changed files with 668 additions and 182 deletions

View File

@@ -2,18 +2,15 @@
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { resolveActiveBlockContext } from '../../lib/markdown-block-context.js'
import {
getImageDefaultAltLabel,
isImageUrl,
parseImageMarkdownLine,
serializeImageMarkdown
} from '../../lib/markdown-image.js'
import {
convertHtmlToMarkdown,
formatInlineColorMarkdown,
readEditableTextFromElement
} from '../../lib/markdown-inline.js'
import {
getMarkdownLineBodyPrefixLength,
getSelectionOffsetInElement
} from '../../lib/markdown-live-selection.js'
import {
@@ -42,6 +39,21 @@ import {
resolveImageUploadInsertTarget,
resolveUploadFetchErrorMessage
} from '../../lib/markdown-editor-media.js'
import {
createCalloutOptionsReplacementPlan,
createCodeOptionsReplacementPlan,
createEmbedUrlReplacementPlan,
createGalleryImageMovePlan,
createGalleryImagesAppendPlan,
createLiveInlineColorValue,
createMediaImageRemovePlan,
createMediaImagesReplacementPlan,
createMediaImageUpdatePlan,
createMediaUseAltPatch,
createQuoteBackgroundValue,
createSourceInlineColorValue,
createToggleOptionsReplacementPlan
} from '../../lib/markdown-editor-block-options.js'
import {
createSlashCommandApplyPlan,
createSlashCommandEditorEffectPlan,
@@ -61,9 +73,6 @@ import {
DEFAULT_SLASH_MENU_WIDTH
} from '../../lib/markdown-slash-menu-position.js'
import { calculateSlashMenuScrollTop } from '../../lib/markdown-slash-menu-scroll.js'
import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js'
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
import { useAdminMarkdownSlashMenu } from '../../composables/useAdminMarkdownSlashMenu.js'
import { useAdminMarkdownHistory } from '../../composables/useAdminMarkdownHistory.js'
@@ -1213,6 +1222,21 @@ const replaceLineRange = (startLine, endLine, replacementLines, focusEditor = tr
}
}
/**
* 줄 범위 교체 계획을 현재 마크다운 본문에 적용한다.
* @param {{ startLine: number, endLine: number, replacementLines: string[] }|null} plan - 교체 계획
* @param {boolean} focusEditor - 교체 후 textarea에 포커스를 돌릴지 여부
* @returns {boolean} 적용 여부
*/
const applyLineReplacementPlan = (plan, focusEditor = false) => {
if (!plan) {
return false
}
replaceLineRange(plan.startLine, plan.endLine, plan.replacementLines, focusEditor)
return true
}
/**
* 현재 문서 기준 fenced 블록의 닫는 줄을 찾는다.
* @param {number} startLine - 시작 줄
@@ -1242,32 +1266,10 @@ const resolveCurrentFencedBlockEndLine = (startLine, fallbackEndLine) => {
* @returns {void}
*/
const replaceActiveMediaImages = (images) => {
const block = activeMediaBlock.value
if (!block) {
return
}
if (block.kind === 'image') {
if (!images[0]) {
replaceLineRange(block.startLine, block.endLine, [], false)
return
}
replaceLineRange(block.startLine, block.endLine, [createImageMarkdown(images[0])], false)
return
}
if (!images.length) {
replaceLineRange(block.startLine, block.endLine, [], false)
return
}
replaceLineRange(block.startLine, block.endLine, [
':::gallery',
...images.map(createImageMarkdown),
':::'
], false)
applyLineReplacementPlan(
createMediaImagesReplacementPlan(activeMediaBlock.value, images),
false
)
}
/**
@@ -1277,15 +1279,14 @@ const replaceActiveMediaImages = (images) => {
* @returns {void}
*/
const updateActiveMediaImage = (imageIndex, patch) => {
const block = activeMediaBlock.value
const plan = createMediaImageUpdatePlan(activeMediaBlock.value, imageIndex, patch)
if (!block) {
if (!plan) {
return
}
ensureBlockPanelEngaged()
const images = block.images.map((image, index) => index === imageIndex ? { ...image, ...patch } : image)
replaceActiveMediaImages(images)
applyLineReplacementPlan(plan, false)
}
/**
@@ -1296,24 +1297,12 @@ const updateActiveMediaImage = (imageIndex, patch) => {
*/
const setActiveMediaUseAlt = (imageIndex, enabled) => {
const image = activeMediaBlock.value?.images[imageIndex]
const patch = createMediaUseAltPatch(image, enabled)
if (!image) {
if (!patch) {
return
}
const filename = getImageDefaultAltLabel(image.url)
const patch = { useAlt: enabled }
if (enabled) {
if (!String(image.caption || '').trim()) {
const legacy = String(image.legacyBracketLabel || '').trim()
patch.caption = legacy && legacy !== filename ? legacy : filename
}
} else if (String(image.caption || '').trim() === filename) {
patch.caption = ''
}
updateActiveMediaImage(imageIndex, patch)
}
@@ -2078,22 +2067,13 @@ const onPreviewMergeWithPreviousLine = ({ lineIndex, mergedLine }) => {
* @returns {void}
*/
const moveActiveGalleryImage = (imageIndex, direction) => {
const block = activeMediaBlock.value
const plan = createGalleryImageMovePlan(activeMediaBlock.value, imageIndex, direction)
if (!block || block.kind !== 'gallery') {
if (!plan) {
return
}
const nextIndex = imageIndex + direction
if (nextIndex < 0 || nextIndex >= block.images.length) {
return
}
const images = [...block.images]
const [target] = images.splice(imageIndex, 1)
images.splice(nextIndex, 0, target)
replaceActiveMediaImages(images)
applyLineReplacementPlan(plan, false)
}
/**
@@ -2102,13 +2082,10 @@ const moveActiveGalleryImage = (imageIndex, direction) => {
* @returns {void}
*/
const removeActiveMediaImage = (imageIndex) => {
const block = activeMediaBlock.value
if (!block) {
return
}
replaceActiveMediaImages(block.images.filter((_, index) => index !== imageIndex))
applyLineReplacementPlan(
createMediaImageRemovePlan(activeMediaBlock.value, imageIndex),
false
)
}
/**
@@ -2117,14 +2094,14 @@ const removeActiveMediaImage = (imageIndex) => {
* @returns {void}
*/
const appendImagesToActiveGallery = (images) => {
const block = activeMediaBlock.value
const plan = createGalleryImagesAppendPlan(activeMediaBlock.value, images)
if (!block || block.kind !== 'gallery') {
if (!plan) {
insertGallery(images)
return
}
replaceActiveMediaImages([...block.images, ...images])
applyLineReplacementPlan(plan, false)
}
/**
@@ -2133,21 +2110,14 @@ const appendImagesToActiveGallery = (images) => {
* @returns {void}
*/
const updateActiveEmbedUrl = (url) => {
const block = activeBlockContext.value
const plan = createEmbedUrlReplacementPlan(activeBlockContext.value, url)
if (!block || !['link', 'iframe', 'html'].includes(block.kind)) {
if (!plan) {
return
}
ensureBlockPanelEngaged()
const trimmed = String(url || '').trim()
if (!trimmed) {
replaceLineRange(block.startLine, block.endLine, [], false)
return
}
replaceLineRange(block.startLine, block.endLine, [`:::${block.kind}`, trimmed, ':::'], false)
applyLineReplacementPlan(plan, false)
}
/**
@@ -2156,30 +2126,18 @@ const updateActiveEmbedUrl = (url) => {
* @returns {void}
*/
const updateActiveQuoteBackground = (background) => {
const block = activeBlockContext.value
const nextValue = createQuoteBackgroundValue({
value: markdownValue.value,
block: activeBlockContext.value,
background
})
if (!block || block.kind !== 'quote') {
if (nextValue === null) {
return
}
ensureBlockPanelEngaged()
const value = String(background || '').trim()
if (!QUOTE_BACKGROUND_OPTIONS.includes(value)) {
return
}
const optionLine = `> [!bg=${value}]`
const lines = (markdownValue.value || '').split('\n')
const nextLines = [...lines]
if (block.hasQuoteOptions) {
nextLines.splice(block.startLine, 1, optionLine)
} else {
nextLines.splice(block.startLine, 0, optionLine)
}
markdownValue.value = nextLines.join('\n')
markdownValue.value = nextValue
}
/**
@@ -2188,27 +2146,14 @@ const updateActiveQuoteBackground = (background) => {
* @returns {void}
*/
const updateActiveCalloutOptions = (patch = {}) => {
const block = activeBlockContext.value
const plan = createCalloutOptionsReplacementPlan(activeBlockContext.value, patch)
if (!block || block.kind !== 'callout') {
if (!plan) {
return
}
ensureBlockPanelEngaged()
const nextBackground = CALLOUT_BACKGROUND_OPTIONS.includes(patch.calloutBackground)
? patch.calloutBackground
: block.calloutBackground
const nextLine = buildCalloutOpenerLine({
calloutEmojiEnabled: patch.calloutEmojiEnabled ?? block.calloutEmojiEnabled,
calloutEmoji: patch.calloutEmoji ?? block.calloutEmoji,
calloutBackground: nextBackground,
title: patch.title ?? block.title
})
const lines = (markdownValue.value || '').split('\n')
const nextLines = [...lines]
nextLines.splice(block.startLine, 1, nextLine)
markdownValue.value = nextLines.join('\n')
applyLineReplacementPlan(plan, false)
}
/**
@@ -2217,22 +2162,14 @@ const updateActiveCalloutOptions = (patch = {}) => {
* @returns {void}
*/
const updateActiveCodeOptions = (patch = {}) => {
const block = activeBlockContext.value
const plan = createCodeOptionsReplacementPlan(activeBlockContext.value, patch)
if (!block || block.kind !== 'code') {
if (!plan) {
return
}
ensureBlockPanelEngaged()
const nextLine = buildCodeFenceOpener({
language: patch.language ?? block.language,
showLineNumbers: patch.showLineNumbers ?? block.showLineNumbers
})
const lines = (markdownValue.value || '').split('\n')
const nextLines = [...lines]
nextLines.splice(block.startLine, 1, nextLine)
markdownValue.value = nextLines.join('\n')
applyLineReplacementPlan(plan, false)
}
/**
@@ -2241,22 +2178,14 @@ const updateActiveCodeOptions = (patch = {}) => {
* @returns {void}
*/
const updateActiveToggleOptions = (patch = {}) => {
const block = activeBlockContext.value
const plan = createToggleOptionsReplacementPlan(activeBlockContext.value, patch)
if (!block || block.kind !== 'toggle') {
if (!plan) {
return
}
ensureBlockPanelEngaged()
const nextLine = buildToggleOpenerLine({
title: block.title,
defaultOpen: patch.defaultOpen ?? block.defaultOpen
})
const lines = (markdownValue.value || '').split('\n')
const nextLines = [...lines]
nextLines.splice(block.startLine, 1, nextLine)
markdownValue.value = nextLines.join('\n')
applyLineReplacementPlan(plan, false)
}
/**
@@ -2273,51 +2202,44 @@ const updateActiveInlineColor = (color) => {
ensureBlockPanelEngaged()
if (context.mode === 'source') {
const value = markdownValue.value || ''
const start = Math.max(0, Math.min(context.start ?? 0, value.length))
const end = Math.max(start, Math.min(context.end ?? start, value.length))
const selectedText = value.slice(start, end)
const replacement = formatInlineColorMarkdown(color, selectedText)
const sourceResult = createSourceInlineColorValue({
value: markdownValue.value,
context,
color
})
markdownValue.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`
if (sourceResult) {
markdownValue.value = sourceResult.value
inlineColorSelectionContext.value = null
setTextareaSelection(start + replacement.length, start + replacement.length, { preserveScroll: true })
setTextareaSelection(sourceResult.selectionStart, sourceResult.selectionEnd, { preserveScroll: true })
return
}
if (context.mode === 'live' && Number.isInteger(context.sourceLine)) {
const lines = (markdownValue.value || '').split('\n')
const line = lines[context.sourceLine]
const liveResult = createLiveInlineColorValue({
value: markdownValue.value,
context,
color
})
if (typeof line !== 'string') {
return
}
const prefixLength = getMarkdownLineBodyPrefixLength(line, context.bodyText || '')
const absoluteStart = prefixLength + Math.max(0, context.startOffset ?? 0)
const absoluteEnd = prefixLength + Math.max(context.startOffset ?? 0, context.endOffset ?? 0)
const selectedText = line.slice(absoluteStart, absoluteEnd)
const replacement = formatInlineColorMarkdown(color, selectedText)
const nextVisibleOffset = (context.startOffset ?? 0) + selectedText.length
lines.splice(context.sourceLine, 1, `${line.slice(0, absoluteStart)}${replacement}${line.slice(absoluteEnd)}`)
markdownValue.value = lines.join('\n')
inlineColorSelectionContext.value = null
requestPreviewFocus({
line: context.sourceLine,
offset: nextVisibleOffset
})
nextTick(() => {
focusPreviewEditableAtLine(
context.sourceLine,
0,
'auto',
nextVisibleOffset,
'nearest'
)
})
if (!liveResult) {
return
}
markdownValue.value = liveResult.value
inlineColorSelectionContext.value = null
requestPreviewFocus({
line: liveResult.focusLine,
offset: liveResult.focusOffset
})
nextTick(() => {
focusPreviewEditableAtLine(
liveResult.focusLine,
0,
'auto',
liveResult.focusOffset,
'nearest'
)
})
}
/**