Files
sori.studio/composables/useContentMarkdownLists.js

223 lines
7.1 KiB
JavaScript

import { unref } from 'vue'
import {
hasListMarker,
isEmptyListMarkerLine,
parseOrderedListMarker,
stripListMarker
} from '../lib/markdown-live-edit.js'
/**
* 관리자 라이브 Markdown 목록 편집 이벤트 상태를 만든다.
* @param {Object} options - 목록 편집 옵션
* @param {import('vue').Ref<string>|string} options.content - Markdown 본문
* @param {import('vue').Ref<number|null>} options.pendingFocusLine - 다음 포커스 줄
* @param {import('vue').Ref<'start'|'end'|'auto'>} options.pendingFocusPosition - 다음 포커스 커서 위치
* @param {(block: Object, replacementLines: string[]) => void} options.commitInlineBlockLines - 블록 줄 반영 콜백
* @param {(block: Object, options?: { lines?: string[] }) => void} options.insertBelowBlock - 블록 아래 삽입 콜백
* @returns {Object} 목록 이벤트 핸들러
*/
export const useContentMarkdownLists = (options) => {
/**
* commit 이벤트 페이로드를 정규화한다.
* @param {string|{ value: string, raw?: boolean }} payload - 페이로드
* @returns {{ value: string, raw: boolean }}
*/
const normalizeCommitPayload = (payload) => {
if (typeof payload === 'string') {
return { value: payload, raw: false }
}
return {
value: String(payload?.value ?? ''),
raw: payload?.raw === true
}
}
/**
* insert-below 이벤트 페이로드를 정규화한다.
* @param {string|Object} payload - 페이로드
* @returns {{ value: string, raw: boolean, before: string, after: string, caretAtStart: boolean, caretAtEnd: boolean }}
*/
const normalizeInsertBelowPayload = (payload) => {
if (typeof payload === 'string') {
return {
value: payload,
raw: false,
before: '',
after: '',
caretAtStart: false,
caretAtEnd: true
}
}
return {
value: String(payload?.value ?? ''),
raw: payload?.raw === true,
before: String(payload?.before ?? ''),
after: String(payload?.after ?? ''),
caretAtStart: payload?.caretAtStart === true,
caretAtEnd: payload?.caretAtEnd === true
}
}
/**
* 원본 마크다운 줄을 반환한다.
* @param {number} lineIndex - 줄 번호
* @returns {string} 줄 텍스트
*/
const getMarkdownLine = (lineIndex) => String(unref(options.content) || '').split('\n')[lineIndex] ?? ''
/**
* 블록에 해당하는 원본 마크다운 줄 목록을 반환한다.
* @param {Object} block - 블록
* @returns {string[]} 줄 목록
*/
const getBlockSourceLines = (block) => String(unref(options.content) || '').split('\n').slice(
block.meta.startLine,
(block.meta.endLine ?? block.meta.startLine) + 1
)
/**
* 목록 줄 반영 후 포커스를 준비한다.
* @param {number} line - 줄 번호
* @returns {void}
*/
const prepareListFocus = (line) => {
options.pendingFocusLine.value = line
options.pendingFocusPosition.value = 'start'
}
/**
* 순서 목록 항목에 표시할 번호를 반환한다.
* @param {Object} block - 목록 블록
* @param {number} itemIndex - 항목 인덱스
* @returns {number} 목록 번호
*/
const getListMarkerNumber = (block, itemIndex) => {
if (block.listNumbers?.[itemIndex] != null) {
return block.listNumbers[itemIndex]
}
const line = getMarkdownLine(block.meta.startLine + itemIndex)
const parsed = parseOrderedListMarker(line)
return parsed?.number ?? itemIndex + 1
}
/**
* 목록 항목 마크다운 줄을 만든다.
* @param {string} value - 편집 값
* @param {boolean} raw - 원문 모드 여부
* @param {Object} block - 목록 블록
* @param {number} itemIndex - 항목 인덱스
* @returns {string} 마크다운 줄
*/
const formatListLine = (value, raw, block, itemIndex) => {
if (raw) {
if (!hasListMarker(value, block.ordered)) {
return String(value ?? '').trim()
}
return value
}
const clean = stripListMarker(value, block.ordered)
if (block.ordered) {
const number = getListMarkerNumber(block, itemIndex)
return clean ? `${number}. ${clean}` : `${number}. `
}
return clean ? `- ${clean}` : '- '
}
/**
* 빈 목록 마커 줄을 만든다.
* @param {Object} block - 목록 블록
* @param {number} itemIndex - 항목 인덱스
* @returns {string} 마크다운 줄
*/
const createEmptyListMarkerLine = (block, itemIndex) => {
if (block.ordered) {
return `${getListMarkerNumber(block, itemIndex)}. `
}
return '- '
}
/**
* 목록 항목 인라인 편집 반영
* @param {Object} block - 블록
* @param {number} itemIndex - 항목 인덱스
* @param {string|{ value: string, raw?: boolean }} payload - 편집 내용
* @returns {void}
*/
const onListItemInlineCommit = (block, itemIndex, payload) => {
const { value, raw } = normalizeCommitPayload(payload)
const sourceLines = getBlockSourceLines(block)
const nextLines = sourceLines.map((line, index) => {
if (index !== itemIndex) {
return line
}
return formatListLine(value, raw, block, index)
})
options.commitInlineBlockLines(block, nextLines)
}
/**
* 목록 항목 Enter — 빈 마커 줄이면 문단으로 탈출, 내용이 있으면 아래에 빈 줄만 삽입한다.
* @param {Object} block - 목록 블록
* @param {number} itemIndex - 항목 인덱스
* @param {string|{ value: string, raw?: boolean }} payload - 편집 내용
* @returns {void}
*/
const onListItemInsertBelow = (block, itemIndex, payload) => {
const { value, raw, before, after, caretAtStart, caretAtEnd } = normalizeInsertBelowPayload(payload)
const nextLines = getBlockSourceLines(block)
if (caretAtStart && after.length) {
nextLines[itemIndex] = formatListLine(after, raw, block, itemIndex)
nextLines.splice(itemIndex, 0, createEmptyListMarkerLine(block, itemIndex))
prepareListFocus(block.meta.startLine + itemIndex)
options.commitInlineBlockLines(block, nextLines)
return
}
if (before.length && after.length) {
nextLines[itemIndex] = formatListLine(before, raw, block, itemIndex)
nextLines.splice(itemIndex + 1, 0, formatListLine(after, raw, block, itemIndex + 1))
prepareListFocus(block.meta.startLine + itemIndex + 1)
options.commitInlineBlockLines(block, nextLines)
return
}
if (itemIndex < nextLines.length) {
nextLines[itemIndex] = formatListLine(value, raw, block, itemIndex)
}
const committedLine = nextLines[itemIndex] ?? ''
if (isEmptyListMarkerLine(committedLine, block.ordered)) {
nextLines[itemIndex] = ''
prepareListFocus(block.meta.startLine + itemIndex)
options.commitInlineBlockLines(block, nextLines)
return
}
options.commitInlineBlockLines(block, nextLines)
if (caretAtEnd || !before.length) {
prepareListFocus((block.meta.endLine ?? block.meta.startLine) + 1)
options.insertBelowBlock(block, { lines: [''] })
}
}
return {
getListMarkerNumber,
onListItemInlineCommit,
onListItemInsertBelow
}
}