/** * 라이브/마크다운 에디터 슬래시 명령 정의 * @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', '', ':::'] }, { 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} suppressedLines - 기존 제외 줄 Set * @param {number} line - 추가할 줄 번호 * @returns {Set} 갱신된 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} suppressedLines - 기존 제외 줄 Set * @param {number} line - 제거할 줄 번호 * @returns {Set} 갱신된 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 } }