/** * 라이브/마크다운 에디터 슬래시 명령 정의 * @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: '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=💡 bg=blue)', keywords: ['callout', 'notice', 'info', '콜아웃'], action: 'lines', lines: [':::callout emoji=💡 bg=blue', '', ':::'] }, { id: 'toggle', label: '토글', description: '접기/펼치기 블록', keywords: ['toggle', 'details', '토글'], action: 'lines', lines: [':::toggle 제목', '', ':::'] }, { id: 'embed', label: '임베드', description: 'YouTube·X 등 외부 URL 한 줄', keywords: ['embed', 'youtube', 'link', '임베드', '유튜브'], action: 'lines', lines: ['https://'] }, { 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 {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 } }