Files
sori.studio/lib/markdown-slash-commands.js

492 lines
14 KiB
JavaScript

/**
* 라이브/마크다운 에디터 슬래시 명령 정의
* @typedef {Object} MarkdownSlashCommand
* @property {string} id - 명령 ID
* @property {string} label - 표시 이름
* @property {string} description - 설명
* @property {string[]} keywords - 검색 키워드
* @property {'media-image'|'media-gallery'|'media-video'|'media-audio'|'media-file'|'lines'} action - 실행 유형
* @property {string[]} [lines] - 삽입할 마크다운 줄(action이 lines일 때)
* @property {boolean} [showInDefaultMenu=true] - `/`만 입력했을 때 메뉴에 표시할지
*/
/** @type {MarkdownSlashCommand[]} */
export const MARKDOWN_SLASH_COMMANDS = [
{
id: 'image',
label: '이미지',
description: '단일 이미지 삽입',
keywords: ['image', 'img', 'photo', '사진', '이미지'],
action: 'media-image'
},
{
id: 'gallery',
label: '갤러리',
description: '여러 이미지 갤러리',
keywords: ['gallery', 'images', '갤러리'],
action: 'media-gallery'
},
{
id: 'h1',
label: '제목 1',
description: '큰 제목(게시물 제목 외에는 비권장)',
keywords: ['h1', 'heading1'],
action: 'lines',
lines: ['# '],
showInDefaultMenu: false
},
{
id: 'h2',
label: '제목 2',
description: '섹션 제목',
keywords: ['h2', 'heading', 'subtitle', '제목'],
action: 'lines',
lines: ['## ']
},
{
id: 'h3',
label: '제목 3',
description: '작은 섹션 제목',
keywords: ['h3', 'heading', '제목'],
action: 'lines',
lines: ['### ']
},
{
id: 'h4',
label: '제목 4',
description: '소제목',
keywords: ['h4', 'heading', '제목'],
action: 'lines',
lines: ['#### ']
},
{
id: 'quote',
label: '인용',
description: '인용문 블록',
keywords: ['quote', 'blockquote', '인용'],
action: 'lines',
lines: ['> ']
},
{
id: 'list',
label: '목록',
description: '불릿 목록',
keywords: ['list', 'bullet', 'ul', '목록'],
action: 'lines',
lines: ['- ']
},
{
id: 'table',
label: '표',
description: '기본 3열 표',
keywords: ['table', '표', '테이블'],
action: 'lines',
lines: ['| 제목 | 제목 | 제목 |', '| --- | --- | --- |', '| 내용 | 내용 | 내용 |']
},
{
id: 'code',
label: '코드',
description: '코드 블록(줄번호 숨김)',
keywords: ['code', 'pre', '코드'],
action: 'lines',
lines: ['```', '', '```']
},
{
id: 'divider',
label: '구분선',
description: '가로 구분선',
keywords: ['divider', 'hr', 'line', '구분선'],
action: 'lines',
lines: ['---']
},
{
id: 'callout',
label: '콜아웃',
description: '강조 안내(첫 줄: :::callout emoji=none bg=blue)',
keywords: ['callout', 'notice', 'info', '콜아웃'],
action: 'lines',
lines: [':::callout emoji=none bg=blue', '', ':::']
},
{
id: 'toggle',
label: '토글',
description: '접기/펼치기 블록',
keywords: ['toggle', 'details', '토글'],
action: 'lines',
lines: [':::toggle 제목', '', ':::']
},
{
id: 'html',
label: 'HTML',
description: '원하는 HTML 코드 삽입',
keywords: ['html', 'markup', 'tablehtml', 'html표', '코드', '마크업'],
action: 'lines',
lines: [':::html', '', ':::']
},
{
id: 'link',
label: '링크',
description: 'URL 링크/임베드 카드',
keywords: ['link', 'url', 'embed', 'youtube', '링크', '주소', '임베드', '유튜브'],
action: 'lines',
lines: [':::link', 'https://', ':::']
},
{
id: 'iframe',
label: 'iframe',
description: '외부 iframe 코드 삽입',
keywords: ['iframe', 'frame', '아이프레임', 'html'],
action: 'lines',
lines: [':::iframe', '<iframe src="https://"></iframe>', ':::']
},
{
id: 'video',
label: '비디오',
description: '업로드 비디오 카드 선택',
keywords: ['video', 'movie', 'mp4', '비디오', '영상'],
action: 'media-video'
},
{
id: 'audio',
label: '오디오',
description: '오디오 플레이어 카드 선택',
keywords: ['audio', 'music', 'mp3', '오디오', '음악'],
action: 'media-audio'
},
{
id: 'file',
label: '파일',
description: '다운로드 파일 카드 선택',
keywords: ['file', 'download', 'pdf', '파일', '다운로드'],
action: 'media-file'
}
]
/**
* 슬래시 입력 문자열을 파싱한다.
* @param {string} value - 편집 값
* @returns {{ query: string, raw: string }|null} 파싱 결과
*/
export const parseSlashInput = (value) => {
const raw = String(value ?? '')
if (!raw.startsWith('/') || raw.includes('\n')) {
return null
}
return {
query: raw.slice(1).trim().toLowerCase(),
raw
}
}
/**
* 검색어에 맞는 슬래시 명령을 필터링한다.
* @param {string} query - 검색어(/ 제외)
* @returns {MarkdownSlashCommand[]} 명령 목록
*/
export const filterSlashCommands = (query) => {
const normalized = String(query ?? '').trim().toLowerCase()
if (!normalized) {
return MARKDOWN_SLASH_COMMANDS.filter((command) => command.showInDefaultMenu !== false)
}
return MARKDOWN_SLASH_COMMANDS.filter((command) => [
command.id,
command.label,
command.description,
...command.keywords
].some((keyword) => String(keyword).toLowerCase().includes(normalized)))
}
/**
* 검색어와 일치하는 최우선 명령을 반환한다.
* @param {string} query - 검색어
* @returns {MarkdownSlashCommand|null} 명령
*/
export const resolveSlashCommand = (query) => {
const normalized = String(query ?? '').trim().toLowerCase()
const matches = filterSlashCommands(normalized)
if (!matches.length) {
return null
}
const exact = matches.find((command) => command.id === normalized
|| command.keywords.some((keyword) => keyword.toLowerCase() === normalized))
return exact ?? matches[0]
}
/**
* 편집 값에서 적용 가능한 슬래시 명령을 해석한다.
* @param {string} value - 편집 값
* @returns {MarkdownSlashCommand|null} 명령
*/
export const resolveSlashCommandFromInputValue = (value) => {
const parsed = parseSlashInput(value)
if (!parsed) {
return null
}
return resolveSlashCommand(parsed.query)
}
/**
* 미디어 선택 모달을 여는 슬래시 명령인지 확인한다.
* @param {string} action - 명령 action
* @returns {boolean} 미디어 명령 여부
*/
export const isMediaSlashAction = (action) => ['media-image', 'media-gallery', 'media-video', 'media-audio', 'media-file']
.includes(String(action || ''))
/**
* 미디어 슬래시 action에서 미디어 선택 모달 종류를 계산한다.
* @param {string} action - 명령 action
* @returns {'image'|'gallery'|'video'|'audio'|'file'|''} 모달 종류
*/
export const getMediaPickerKindFromSlashAction = (action) => {
const normalized = String(action || '')
if (!isMediaSlashAction(normalized)) {
return ''
}
return normalized.slice('media-'.length)
}
/**
* 슬래시 명령 적용 계획을 계산한다.
* @param {Object} options - 계산 옵션
* @param {MarkdownSlashCommand|null|undefined} options.command - 적용할 명령
* @param {number|null} options.sourceLine - 명령이 입력된 줄 번호
* @returns {{ kind: 'none' }|{ kind: 'media', line: number, mediaKind: 'image'|'gallery'|'video'|'audio'|'file' }|{ kind: 'lines', line: number, lines: string[] }} 적용 계획
*/
export const createSlashCommandApplyPlan = ({ command, sourceLine }) => {
if (!command || !Number.isFinite(sourceLine)) {
return { kind: 'none' }
}
const line = Math.max(0, Math.floor(sourceLine))
if (isMediaSlashAction(command.action)) {
const mediaKind = getMediaPickerKindFromSlashAction(command.action)
if (!mediaKind) {
return { kind: 'none' }
}
return {
kind: 'media',
line,
mediaKind
}
}
if (command.action === 'lines' && Array.isArray(command.lines)) {
return {
kind: 'lines',
line,
lines: [...command.lines]
}
}
return { kind: 'none' }
}
/**
* 슬래시 메뉴를 다시 열지 않을 줄 목록에 줄을 추가한다.
* @param {Set<number>} suppressedLines - 기존 제외 줄 Set
* @param {number} line - 추가할 줄 번호
* @returns {Set<number>} 갱신된 Set
*/
export const addSlashSuppressedLine = (suppressedLines, line) => {
const next = new Set(suppressedLines instanceof Set ? suppressedLines : [])
if (Number.isFinite(line)) {
next.add(line)
}
return next
}
/**
* 슬래시 메뉴 제외 줄 목록에서 줄을 제거한다.
* @param {Set<number>} suppressedLines - 기존 제외 줄 Set
* @param {number} line - 제거할 줄 번호
* @returns {Set<number>} 갱신된 Set
*/
export const removeSlashSuppressedLine = (suppressedLines, line) => {
const next = new Set(suppressedLines instanceof Set ? suppressedLines : [])
if (Number.isFinite(line)) {
next.delete(line)
}
return next
}
/**
* 슬래시 메뉴 하이라이트 인덱스를 명령 목록 범위 안으로 보정한다.
* @param {number} currentIndex - 현재 하이라이트 인덱스
* @param {number} commandCount - 표시 가능한 명령 개수
* @returns {number} 보정된 인덱스
*/
export const clampSlashHighlightedIndex = (currentIndex, commandCount) => {
const count = Math.max(0, Number.isFinite(commandCount) ? Math.floor(commandCount) : 0)
const index = Math.max(0, Number.isFinite(currentIndex) ? Math.floor(currentIndex) : 0)
if (count <= 0) {
return 0
}
return Math.min(index, count - 1)
}
/**
* 슬래시 메뉴 방향키 탐색 후 하이라이트 인덱스를 계산한다.
* @param {Object} options - 계산 옵션
* @param {number} options.currentIndex - 현재 하이라이트 인덱스
* @param {number} options.commandCount - 표시 가능한 명령 개수
* @param {string} options.key - 누른 키
* @returns {number} 다음 하이라이트 인덱스
*/
export const getNextSlashHighlightedIndex = ({ currentIndex, commandCount, key }) => {
const count = Math.max(0, Number.isFinite(commandCount) ? Math.floor(commandCount) : 0)
if (count <= 0) {
return 0
}
const index = clampSlashHighlightedIndex(currentIndex, count)
if (key === 'ArrowDown') {
return (index + 1) % count
}
if (key === 'ArrowUp') {
return (index - 1 + count) % count
}
return index
}
/**
* 슬래시 메뉴에서 키보드 입력이 수행할 동작을 계산한다.
* @param {string} key - 누른 키
* @returns {'navigate'|'apply'|'cancel'|''} 메뉴 동작
*/
export const getSlashMenuKeyboardAction = (key) => {
if (key === 'ArrowDown' || key === 'ArrowUp') {
return 'navigate'
}
if (key === 'Enter') {
return 'apply'
}
if (key === 'Escape') {
return 'cancel'
}
return ''
}
/**
* 슬래시 메뉴가 키보드 이벤트를 직접 가로채야 하는지 확인한다.
* @param {Object} options - 판별 옵션
* @param {'navigate'|'apply'|'cancel'|''} options.action - 키보드 동작
* @param {number} options.commandCount - 현재 표시된 명령 개수
* @returns {boolean} 가로채기 여부
*/
export const shouldCaptureSlashMenuKeyboardAction = ({ action, commandCount }) => {
if (action === 'cancel') {
return true
}
const count = Number.isFinite(commandCount) ? Math.max(0, Math.floor(commandCount)) : 0
if (action === 'navigate' || action === 'apply') {
return count > 0
}
return false
}
/**
* 라이브 모드 미디어 슬래시 명령 처리 중 지연 commit을 무시할지 확인한다.
* @param {Object} options - 판별 옵션
* @param {number|null} options.pendingInsertLine - 미디어 삽입 대기 줄
* @param {number} options.startLine - commit 시작 줄
* @param {string[]} options.replacementLines - commit 대체 줄
* @returns {boolean} 무시 여부
*/
export const shouldIgnorePendingMediaSlashCommit = ({ pendingInsertLine, startLine, replacementLines }) => pendingInsertLine === startLine
&& Array.isArray(replacementLines)
&& replacementLines.length === 1
&& Boolean(parseSlashInput(replacementLines[0]))
/**
* 지정 줄에 마크다운 줄을 삽입하되, 해당 줄이 슬래시 명령이면 교체한다.
* @param {string} markdown - 원본 마크다운
* @param {number} lineIndex - 삽입 위치
* @param {string[]} insertLines - 삽입 줄
* @returns {string} 갱신된 마크다운
*/
export const insertMarkdownLinesAtSlashTarget = (markdown, lineIndex, insertLines) => {
const sourceLines = String(markdown ?? '').split('\n')
const safeLine = Math.max(0, Math.min(Number.isFinite(lineIndex) ? lineIndex : 0, sourceLines.length))
const lines = Array.isArray(insertLines) ? insertLines : []
const replaceCount = parseSlashInput(sourceLines[safeLine] ?? '') ? 1 : 0
return [
...sourceLines.slice(0, safeLine),
...lines,
...sourceLines.slice(safeLine + replaceCount)
].join('\n')
}
/**
* 슬래시 명령 적용 후 포커스할 줄·커서 오프셋을 계산한다.
* @param {number} startLine - 교체 시작 줄(0-based)
* @param {string[]} lines - 삽입 줄
* @returns {{ line: number, offset: number }} 포커스 대상
*/
export const getSlashCommandFocusTarget = (startLine, lines) => {
const safeStart = Number.isFinite(startLine) ? startLine : 0
if (!Array.isArray(lines) || !lines.length) {
return { line: safeStart, offset: 0 }
}
if (lines.length === 1) {
return { line: safeStart, offset: String(lines[0] ?? '').length }
}
return { line: safeStart + 1, offset: 0 }
}
/**
* 슬래시 명령 적용 후 textarea 선택 위치를 계산한다.
* @param {Object} options - 계산 옵션
* @param {string} options.markdown - 현재 마크다운
* @param {number} options.startLine - 교체 시작 줄(0-based)
* @param {string[]} options.lines - 삽입 줄
* @returns {{ start: number, end: number }} 선택 범위
*/
export const getSlashCommandTextareaSelection = ({ markdown, startLine, lines }) => {
const sourceLines = String(markdown ?? '').split('\n')
const safeStart = Math.max(0, Math.min(Number.isFinite(startLine) ? startLine : 0, sourceLines.length))
const { line, offset } = getSlashCommandFocusTarget(safeStart, lines)
const safeLine = Math.max(0, Math.min(line, sourceLines.length))
const lineStart = sourceLines.slice(0, safeLine).join('\n').length + (safeLine > 0 ? 1 : 0)
const selection = lineStart + offset
return {
start: selection,
end: selection
}
}