Files
sori.studio/lib/markdown-editor-block-options.js

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
}
}