524 lines
16 KiB
JavaScript
524 lines
16 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 {Object} options - 계산 옵션
|
|
* @param {{ kind: 'none' }|{ kind: 'media', line: number, mediaKind: 'image'|'gallery'|'video'|'audio'|'file' }|{ kind: 'lines', line: number, lines: string[] }} options.plan - 슬래시 명령 적용 계획
|
|
* @param {string} options.activeMode - 현재 에디터 모드
|
|
* @returns {{ kind: 'none' }|{ kind: 'media', line: number, mediaKind: 'image'|'gallery'|'video'|'audio'|'file' }|{ kind: 'lines', line: number, lines: string[], focusTarget: 'textarea'|'preview' }} 에디터 부수 효과 계획
|
|
*/
|
|
export const createSlashCommandEditorEffectPlan = ({ plan, activeMode }) => {
|
|
if (!plan || plan.kind === 'none') {
|
|
return { kind: 'none' }
|
|
}
|
|
|
|
if (plan.kind === 'media' && Number.isFinite(plan.line) && plan.mediaKind) {
|
|
return {
|
|
kind: 'media',
|
|
line: plan.line,
|
|
mediaKind: plan.mediaKind
|
|
}
|
|
}
|
|
|
|
if (plan.kind === 'lines' && Number.isFinite(plan.line) && Array.isArray(plan.lines)) {
|
|
return {
|
|
kind: 'lines',
|
|
line: plan.line,
|
|
lines: [...plan.lines],
|
|
focusTarget: activeMode === 'write' ? 'textarea' : 'preview'
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|