Files
sori.studio/lib/markdown-slash-commands.js
zenn 095a8fa5f0 v1.4.1: 관리자 미디어 업로드 한도·라이브 에디터 UX 개선
종류별 업로드 크기 한도와 413 안내를 추가하고, 임베드·미디어 라이브 프리뷰·제목 Enter 포커스·스크롤 동작을 보정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:33:23 +09:00

217 lines
5.7 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: '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 }
}