import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS } from './markdown-callout.js' import { buildCodeFenceOpener } from './markdown-code-block.js' import { buildToggleOpenerLine } from './markdown-toggle.js' import { formatInlineColorMarkdown } from './markdown-inline.js' import { getMarkdownLineBodyPrefixLength } from './markdown-live-selection.js' import { getImageDefaultAltLabel, serializeImageMarkdown } from './markdown-image.js' /** * 블록 줄 범위 교체 계획을 만든다. * @param {Object} block - 대상 블록 * @param {string[]} replacementLines - 대체 줄 목록 * @returns {{ startLine: number, endLine: number, replacementLines: string[] }|null} 교체 계획 */ const createLineReplacementPlan = (block, replacementLines) => { if (!block || !Number.isInteger(block.startLine) || !Number.isInteger(block.endLine)) { return null } return { startLine: block.startLine, endLine: block.endLine, replacementLines } } /** * 이미지 목록 기준 미디어 블록 교체 계획을 만든다. * @param {Object|null} block - 미디어 블록 * @param {Array} images - 이미지 목록 * @returns {{ startLine: number, endLine: number, replacementLines: string[] }|null} 교체 계획 */ export const createMediaImagesReplacementPlan = (block, images) => { if (!block || !['image', 'gallery'].includes(block.kind)) { return null } const safeImages = Array.isArray(images) ? images : [] if (block.kind === 'image') { return createLineReplacementPlan( block, safeImages[0] ? [serializeImageMarkdown(safeImages[0])] : [] ) } if (!safeImages.length) { return createLineReplacementPlan(block, []) } return createLineReplacementPlan(block, [ ':::gallery', ...safeImages.map(serializeImageMarkdown), ':::' ]) } /** * 미디어 이미지 항목 수정 후 교체 계획을 만든다. * @param {Object|null} block - 미디어 블록 * @param {number} imageIndex - 이미지 인덱스 * @param {Object} patch - 이미지 변경값 * @returns {{ startLine: number, endLine: number, replacementLines: string[] }|null} 교체 계획 */ export const createMediaImageUpdatePlan = (block, imageIndex, patch) => { if (!block || !Array.isArray(block.images) || !block.images[imageIndex]) { return null } const images = block.images.map((image, index) => index === imageIndex ? { ...image, ...patch } : image) return createMediaImagesReplacementPlan(block, images) } /** * 파일명 캡션 사용 여부 변경 patch를 만든다. * @param {Object|null} image - 이미지 항목 * @param {boolean} enabled - 파일명 캡션 사용 여부 * @returns {Object|null} 이미지 patch */ export const createMediaUseAltPatch = (image, enabled) => { if (!image) { return null } 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 = '' } return patch } /** * 갤러리 이미지 순서 변경 계획을 만든다. * @param {Object|null} block - 갤러리 블록 * @param {number} imageIndex - 이동할 이미지 인덱스 * @param {-1|1} direction - 이동 방향 * @returns {{ startLine: number, endLine: number, replacementLines: string[] }|null} 교체 계획 */ export const createGalleryImageMovePlan = (block, imageIndex, direction) => { if (!block || block.kind !== 'gallery' || !Array.isArray(block.images)) { return null } const nextIndex = imageIndex + direction if (nextIndex < 0 || nextIndex >= block.images.length) { return null } const images = [...block.images] const [target] = images.splice(imageIndex, 1) images.splice(nextIndex, 0, target) return createMediaImagesReplacementPlan(block, images) } /** * 미디어 이미지 삭제 계획을 만든다. * @param {Object|null} block - 미디어 블록 * @param {number} imageIndex - 삭제할 이미지 인덱스 * @returns {{ startLine: number, endLine: number, replacementLines: string[] }|null} 교체 계획 */ export const createMediaImageRemovePlan = (block, imageIndex) => { if (!block || !Array.isArray(block.images)) { return null } return createMediaImagesReplacementPlan(block, block.images.filter((_, index) => index !== imageIndex)) } /** * 현재 갤러리에 이미지 추가 계획을 만든다. * @param {Object|null} block - 갤러리 블록 * @param {Array} images - 추가 이미지 목록 * @returns {{ startLine: number, endLine: number, replacementLines: string[] }|null} 교체 계획 */ export const createGalleryImagesAppendPlan = (block, images) => { if (!block || block.kind !== 'gallery') { return null } return createMediaImagesReplacementPlan(block, [...block.images, ...images]) } /** * 링크·iframe·HTML 블록 값 교체 계획을 만든다. * @param {Object|null} block - 임베드 블록 * @param {string} url - 새 값 * @returns {{ startLine: number, endLine: number, replacementLines: string[] }|null} 교체 계획 */ export const createEmbedUrlReplacementPlan = (block, url) => { if (!block || !['link', 'iframe', 'html'].includes(block.kind)) { return null } const trimmed = String(url || '').trim() return createLineReplacementPlan(block, trimmed ? [`:::${block.kind}`, trimmed, ':::'] : []) } /** * 인용 배경색 변경 후 전체 Markdown 값을 만든다. * @param {Object} options - 변경 옵션 * @param {string} options.value - Markdown 본문 * @param {Object|null} options.block - 인용 블록 * @param {string} options.background - 배경색 * @returns {string|null} 변경된 Markdown 본문 */ export const createQuoteBackgroundValue = ({ value, block, background }) => { if (!block || block.kind !== 'quote') { return null } const nextBackground = String(background || '').trim() if (!QUOTE_BACKGROUND_OPTIONS.includes(nextBackground)) { return null } const optionLine = `> [!bg=${nextBackground}]` const nextLines = String(value || '').split('\n') if (block.hasQuoteOptions) { nextLines.splice(block.startLine, 1, optionLine) } else { nextLines.splice(block.startLine, 0, optionLine) } return nextLines.join('\n') } /** * 콜아웃 옵션 교체 계획을 만든다. * @param {Object|null} block - 콜아웃 블록 * @param {Object} patch - 변경 옵션 * @returns {{ startLine: number, endLine: number, replacementLines: string[] }|null} 교체 계획 */ export const createCalloutOptionsReplacementPlan = (block, patch = {}) => { if (!block || block.kind !== 'callout') { return null } 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 }) return createLineReplacementPlan(block, [nextLine]) } /** * 코드 옵션 교체 계획을 만든다. * @param {Object|null} block - 코드 블록 * @param {Object} patch - 변경 옵션 * @returns {{ startLine: number, endLine: number, replacementLines: string[] }|null} 교체 계획 */ export const createCodeOptionsReplacementPlan = (block, patch = {}) => { if (!block || block.kind !== 'code') { return null } return createLineReplacementPlan(block, [ buildCodeFenceOpener({ language: patch.language ?? block.language, showLineNumbers: patch.showLineNumbers ?? block.showLineNumbers }) ]) } /** * 토글 옵션 교체 계획을 만든다. * @param {Object|null} block - 토글 블록 * @param {Object} patch - 변경 옵션 * @returns {{ startLine: number, endLine: number, replacementLines: string[] }|null} 교체 계획 */ export const createToggleOptionsReplacementPlan = (block, patch = {}) => { if (!block || block.kind !== 'toggle') { return null } return createLineReplacementPlan(block, [ buildToggleOpenerLine({ title: block.title, defaultOpen: patch.defaultOpen ?? block.defaultOpen }) ]) } /** * 소스 모드 인라인 색상 적용 결과를 만든다. * @param {Object} options - 적용 옵션 * @param {string} options.value - Markdown 본문 * @param {Object|null} options.context - 인라인 색상 컨텍스트 * @param {string} options.color - 색상 이름 * @returns {{ value: string, selectionStart: number, selectionEnd: number }|null} 적용 결과 */ export const createSourceInlineColorValue = ({ value, context, color }) => { if (!context || context.kind !== 'inline-color' || context.mode !== 'source') { return null } const source = String(value || '') const start = Math.max(0, Math.min(context.start ?? 0, source.length)) const end = Math.max(start, Math.min(context.end ?? start, source.length)) const selectedText = source.slice(start, end) const replacement = formatInlineColorMarkdown(color, selectedText) const nextOffset = start + replacement.length return { value: `${source.slice(0, start)}${replacement}${source.slice(end)}`, selectionStart: nextOffset, selectionEnd: nextOffset } } /** * 라이브 모드 인라인 색상 적용 결과를 만든다. * @param {Object} options - 적용 옵션 * @param {string} options.value - Markdown 본문 * @param {Object|null} options.context - 인라인 색상 컨텍스트 * @param {string} options.color - 색상 이름 * @returns {{ value: string, focusLine: number, focusOffset: number }|null} 적용 결과 */ export const createLiveInlineColorValue = ({ value, context, color }) => { if (!context || context.kind !== 'inline-color' || context.mode !== 'live' || !Number.isInteger(context.sourceLine)) { return null } const lines = String(value || '').split('\n') const line = lines[context.sourceLine] if (typeof line !== 'string') { return null } 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 focusOffset = (context.startOffset ?? 0) + selectedText.length lines.splice(context.sourceLine, 1, `${line.slice(0, absoluteStart)}${replacement}${line.slice(absoluteEnd)}`) return { value: lines.join('\n'), focusLine: context.sourceLine, focusOffset } }