에디터 블록 옵션 계산 분리
This commit is contained in:
322
lib/markdown-editor-block-options.js
Normal file
322
lib/markdown-editor-block-options.js
Normal file
@@ -0,0 +1,322 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user