323 lines
11 KiB
JavaScript
323 lines
11 KiB
JavaScript
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<Object>} 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<Object>} 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
|
|
}
|
|
}
|