Files
sori.studio/composables/useAdminMarkdownSlashCommandActions.js
2026-07-29 11:43:39 +09:00

173 lines
6.2 KiB
JavaScript

import { nextTick, unref } from 'vue'
import {
createSlashCommandApplyPlan,
createSlashCommandEditorEffectPlan,
getSlashCommandFocusTarget,
getSlashCommandTextareaSelection,
parseSlashInput,
resolveSlashCommand
} from '../lib/markdown-slash-commands.js'
/**
* 관리자 Markdown 에디터의 슬래시 명령 적용 실행을 관리한다.
* @param {Object} options - 슬래시 명령 실행 옵션
* @param {import('vue').Ref<string>} options.activeMode - 현재 에디터 모드
* @param {import('vue').Ref<string>} options.markdownValue - 마크다운 본문
* @param {import('vue').Ref<number|null>} options.liveSlashSourceLine - 슬래시 입력 줄
* @param {import('vue').Ref<string>} options.liveSlashQuery - 슬래시 검색어
* @param {import('vue').Ref<number|null>} options.liveSlashInsertLine - 미디어 삽입 대기 줄
* @param {() => void} options.clearLiveSlashState - 슬래시 상태 초기화 함수
* @param {(line: number) => void} options.releaseLiveSlashLine - 슬래시 억제 해제 함수
* @param {(line: number, lineValue?: string) => void} options.suppressLiveSlashLine - 슬래시 억제 함수
* @param {(startLine: number, endLine: number, replacementLines: string[], focusEditor?: boolean) => void} options.replaceLineRange - 줄 교체 함수
* @param {(mediaKind: 'image'|'gallery'|'video'|'audio'|'file') => void} options.openMediaPicker - 미디어 모달 열기 함수
* @param {(start: number, end: number) => void} options.setTextareaSelection - textarea 선택 복원 함수
* @param {(line: number, column?: number, position?: string, offset?: number) => void} options.focusPreviewEditableAtLine - 미리보기 줄 포커스 함수
* @param {boolean} [options.isClient] - 클라이언트 실행 여부
* @returns {{
* applyLiveSlashCommand: (command: import('../lib/markdown-slash-commands.js').MarkdownSlashCommand) => void,
* cancelLiveSlashCommand: () => void,
* focusPreviewAfterSlashCommand: (line: number, replacementLines: string[]) => void,
* focusTextareaAfterSlashCommand: (line: number, replacementLines: string[]) => void,
* onLiveSlashApply: (payload?: { sourceLine?: number, value?: string }) => void,
* onLiveSlashEnd: (payload?: { sourceLine?: number }) => void
* }} 슬래시 명령 실행 액션
*/
export const useAdminMarkdownSlashCommandActions = (options) => {
const isClient = options.isClient ?? import.meta.client
/**
* 슬래시 명령 입력을 종료한다.
* @param {{ sourceLine?: number }} [payload] - 종료 대상 줄
* @returns {void}
*/
const onLiveSlashEnd = (payload) => {
if (typeof payload?.sourceLine === 'number') {
options.releaseLiveSlashLine(payload.sourceLine)
}
options.clearLiveSlashState()
}
/**
* 슬래시 명령 입력을 취소한다(줄 내용은 유지, 메뉴만 닫음).
* @returns {void}
*/
const cancelLiveSlashCommand = () => {
const line = options.liveSlashSourceLine.value
if (line !== null) {
const lines = String(unref(options.markdownValue) ?? '').split('\n')
options.suppressLiveSlashLine(line, lines[line] ?? '')
}
options.clearLiveSlashState()
}
/**
* 슬래시 명령 적용 후 미리보기 인라인 편집에 포커스를 복원한다.
* @param {number} line - 줄 번호(0-based)
* @param {string[]} replacementLines - 삽입된 줄
* @returns {void}
*/
const focusPreviewAfterSlashCommand = (line, replacementLines) => {
if (!isClient) {
return
}
const { line: focusLine, offset } = getSlashCommandFocusTarget(line, replacementLines)
const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => globalThis.setTimeout(callback, 0))
nextTick(() => {
nextTick(() => {
requestFrame(() => {
requestFrame(() => {
options.focusPreviewEditableAtLine(focusLine, 0, 'end', offset)
})
})
})
})
}
/**
* 소스 모드 textarea에 슬래시 명령 적용 후 커서를 복원한다.
* @param {number} line - 줄 번호(0-based)
* @param {string[]} replacementLines - 삽입된 줄
* @returns {void}
*/
const focusTextareaAfterSlashCommand = (line, replacementLines) => {
const selection = getSlashCommandTextareaSelection({
markdown: unref(options.markdownValue) ?? '',
startLine: line,
lines: replacementLines
})
options.setTextareaSelection(selection.start, selection.end)
}
/**
* 슬래시 명령을 적용한다.
* @param {import('../lib/markdown-slash-commands.js').MarkdownSlashCommand} command - 명령
* @returns {void}
*/
const applyLiveSlashCommand = (command) => {
const plan = createSlashCommandApplyPlan({
command,
sourceLine: options.liveSlashSourceLine.value
})
const effectPlan = createSlashCommandEditorEffectPlan({
plan,
activeMode: options.activeMode.value
})
if (effectPlan.kind === 'none') {
return
}
if (effectPlan.kind === 'media') {
options.liveSlashInsertLine.value = effectPlan.line
options.replaceLineRange(effectPlan.line, effectPlan.line, [], false)
onLiveSlashEnd()
options.openMediaPicker(effectPlan.mediaKind)
return
}
if (effectPlan.kind === 'lines') {
options.releaseLiveSlashLine(effectPlan.line)
options.replaceLineRange(effectPlan.line, effectPlan.line, effectPlan.lines, false)
options.clearLiveSlashState()
if (effectPlan.focusTarget === 'textarea') {
focusTextareaAfterSlashCommand(effectPlan.line, effectPlan.lines)
} else {
focusPreviewAfterSlashCommand(effectPlan.line, effectPlan.lines)
}
}
}
/**
* 슬래시 명령 Enter 적용.
* @param {{ sourceLine?: number, value?: string }} [payload] - 줄·입력 값
* @returns {void}
*/
const onLiveSlashApply = (payload) => {
const parsed = parseSlashInput(payload?.value)
const command = resolveSlashCommand(parsed?.query ?? options.liveSlashQuery.value)
if (!command) {
onLiveSlashEnd({ sourceLine: payload?.sourceLine })
return
}
applyLiveSlashCommand(command)
}
return {
applyLiveSlashCommand,
cancelLiveSlashCommand,
focusPreviewAfterSlashCommand,
focusTextareaAfterSlashCommand,
onLiveSlashApply,
onLiveSlashEnd
}
}