diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 733e7ac..c954c05 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -3,17 +3,20 @@ import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer. import { resolveActiveBlockContext } from '../../lib/markdown-block-context.js' import { getImageDefaultAltLabel, serializeImageMarkdown } from '../../lib/markdown-image.js' import { convertHtmlToMarkdown } from '../../lib/markdown-inline.js' +import { + filterSlashCommands, + getSlashCommandFocusTarget, + parseSlashInput, + resolveSlashCommand +} from '../../lib/markdown-slash-commands.js' +import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js' +import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue' const props = defineProps({ modelValue: { type: [String, Array, Object], default: '' }, - /** 헤더 아래 고정 툴바 슬롯(AdminPostForm `#admin-post-form-editor-toolbar-host`) */ - toolbarTeleportTo: { - type: String, - default: '#admin-post-form-editor-toolbar-host' - }, /** 작성/미리보기 토글 버튼 Teleport 대상(AdminPostForm 헤더) */ modeToggleTeleportTo: { type: String, @@ -30,6 +33,7 @@ const activeMode = defineModel('editorMode', { const editorRootRef = ref(null) const textareaRef = ref(null) const previewRef = ref(null) +const previewRendererRef = ref(null) const gutterRef = ref(null) /** 커서가 있는 논리 줄(0-based, `\\n` 기준) */ const activeLogicalLineIndex = ref(0) @@ -45,6 +49,32 @@ const lastSelectionState = ref({ start: 0, end: 0 }) +/** @type {import('vue').Ref} 슬래시 명령이 입력된 줄 */ +const liveSlashSourceLine = ref(null) +/** @type {import('vue').Ref} 슬래시 검색어 */ +const liveSlashQuery = ref('') +/** @type {import('vue').Ref} 슬래시 메뉴 하이라이트 인덱스 */ +const liveSlashHighlightedIndex = ref(0) +/** @type {import('vue').Ref<{ top: number, left: number }>} 슬래시 메뉴 위치 */ +const liveSlashMenuPosition = ref({ top: 0, left: 0 }) +/** @type {import('vue').Ref} 슬래시 메뉴 DOM */ +const liveSlashMenuRef = ref(null) + +/** 슬래시 메뉴 예상 높이(px) — 렌더 전 위치 계산용 */ +const LIVE_SLASH_MENU_ESTIMATED_HEIGHT = 280 +/** @type {import('vue').Ref} 슬래시 후 미디어 삽입 줄 */ +const liveSlashInsertLine = ref(null) +/** @type {import('vue').Ref>} ESC로 슬래시 메뉴만 닫은 줄(0-based) */ +const liveSlashSuppressedLines = ref(new Set()) +/** 키보드로 슬래시 메뉴를 탐색 중일 때 mouseenter 하이라이트 무시 */ +const liveSlashKeyboardNav = ref(false) +let liveSlashKeyboardNavTimer = null + +const liveSlashVisible = computed(() => liveSlashSourceLine.value !== null) + +const liveSlashSuppressedLineList = computed(() => [...liveSlashSuppressedLines.value]) + +const visibleLiveSlashCommands = computed(() => filterSlashCommands(liveSlashQuery.value)) /** 작성 textarea 최소 높이(px) */ const MIN_TEXTAREA_HEIGHT_PX = 620 @@ -215,9 +245,47 @@ const refreshCaretLogicalLine = () => { activeLogicalLineIndex.value = Math.max(0, lineIndex) syncTextareaHeight() syncBlockPanelState() + + if (activeMode.value === 'write' && document.activeElement === textarea) { + syncWriteSlashState() + } }) } +/** + * 소스(작성) 모드 textarea 현재 줄의 슬래시 입력 상태를 갱신한다. + * @returns {void} + */ +const syncWriteSlashState = () => { + if (activeMode.value !== 'write') { + return + } + + const line = activeLogicalLineIndex.value + + if (liveSlashSuppressedLines.value.has(line)) { + if (liveSlashSourceLine.value !== null) { + liveSlashSourceLine.value = null + liveSlashQuery.value = '' + liveSlashHighlightedIndex.value = 0 + } + + return + } + + const lines = (markdownValue.value ?? '').split('\n') + const parsed = parseSlashInput(lines[line] ?? '') + + if (!parsed) { + onLiveSlashEnd({ sourceLine: line }) + return + } + + liveSlashSourceLine.value = line + liveSlashQuery.value = parsed.query + nextTick(updateLiveSlashMenuPosition) +} + /** * textarea의 선택 영역과 스크롤 위치를 기억한다. * @returns {void} @@ -341,12 +409,58 @@ onMounted(() => { * @returns {void} */ const onDocumentKeydown = (event) => { + if (event.key === 'Escape' && isMediaPickerOpen.value) { + event.preventDefault() + closeMediaPicker() + return + } + const root = editorRootRef.value if (!root || !root.contains(document.activeElement)) { return } + if (liveSlashVisible.value) { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + const count = visibleLiveSlashCommands.value.length + + if (count) { + liveSlashKeyboardNav.value = true + window.clearTimeout(liveSlashKeyboardNavTimer) + liveSlashKeyboardNavTimer = window.setTimeout(() => { + liveSlashKeyboardNav.value = false + }, 200) + + if (event.key === 'ArrowDown') { + liveSlashHighlightedIndex.value = (liveSlashHighlightedIndex.value + 1) % count + } else { + liveSlashHighlightedIndex.value = (liveSlashHighlightedIndex.value - 1 + count) % count + } + } + + return + } + + if (event.key === 'Enter') { + event.preventDefault() + const command = visibleLiveSlashCommands.value[liveSlashHighlightedIndex.value] + + if (command) { + applyLiveSlashCommand(command) + } + + return + } + + if (event.key === 'Escape') { + event.preventDefault() + cancelLiveSlashCommand() + return + } + } + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'e') { return } @@ -360,6 +474,7 @@ onMounted(() => { onBeforeUnmount(() => { window.clearTimeout(blockPanelFocusTimer) + window.clearTimeout(liveSlashKeyboardNavTimer) document.removeEventListener('selectionchange', onSelectionChange) document.removeEventListener('keydown', onDocumentKeydown, true) }) @@ -439,25 +554,6 @@ const replaceSelection = (replacement, cursorOffset = replacement.length, select setTextareaSelection(nextStart, nextStart + (selectionLength ?? 0)) } -/** - * Enter 입력을 문단/줄바꿈 규칙에 맞게 처리한다. - * @param {KeyboardEvent} event - 키보드 이벤트 - * @returns {boolean} 직접 처리했는지 여부 - */ -const handleParagraphEnter = (event) => { - if (event.key !== 'Enter' || event.metaKey || event.ctrlKey || event.altKey || event.isComposing) { - return false - } - - if (!event.shiftKey) { - return false - } - - event.preventDefault() - replaceSelection('\\\n') - return true -} - /** * 블록형 마크다운 조각을 커서 위치에 삽입한다. * @param {string} snippet - 삽입할 마크다운 @@ -737,6 +833,392 @@ const onPreviewDeleteLine = (lineIndex) => { markdownValue.value = nextLines.length ? nextLines.join('\n') : '' } +/** + * 라이브 모드에서 현재 줄을 이전 줄 끝에 병합한다. + * @param {{ lineIndex: number, mergedLine: string }} payload - 병합 위치·결과 줄 + * @returns {void} + */ +/** + * 슬래시 메뉴 좌표를 뷰포트 안으로 맞춘다. + * @param {number} top - 후보 top(px) + * @param {number} left - 후보 left(px) + * @param {number} menuHeight - 메뉴 높이 + * @param {number} menuWidth - 메뉴 너비 + * @param {number} anchorTop - 기준 요소 top(위로 넘칠 때) + * @returns {{ top: number, left: number }} + */ +const clampSlashMenuPosition = (top, left, menuHeight, menuWidth, anchorTop) => { + const gap = 4 + const padding = 8 + const viewportBottom = window.innerHeight - padding + const viewportRight = window.innerWidth - padding + let nextTop = top + + if (nextTop + menuHeight > viewportBottom) { + const aboveTop = anchorTop - menuHeight - gap + + if (aboveTop >= padding) { + nextTop = aboveTop + } else { + nextTop = Math.max(padding, viewportBottom - menuHeight) + } + } + + let nextLeft = left + + if (nextLeft + menuWidth > viewportRight) { + nextLeft = viewportRight - menuWidth + } + + return { + top: nextTop, + left: Math.max(padding, nextLeft) + } +} + +/** + * 미리보기 모드 슬래시 메뉴 위치를 갱신한다. + * @returns {void} + */ +const updatePreviewSlashMenuPosition = () => { + const element = previewRef.value?.querySelector(`[data-source-line="${liveSlashSourceLine.value}"]`) + + if (!element) { + return + } + + const rect = element.getBoundingClientRect() + const menuEl = liveSlashMenuRef.value + const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT + const menuWidth = menuEl?.offsetWidth || 288 + const gap = 4 + + liveSlashMenuPosition.value = clampSlashMenuPosition( + rect.bottom + gap, + rect.left, + menuHeight, + menuWidth, + rect.top + ) +} + +/** + * 소스 모드 textarea 슬래시 메뉴 위치를 갱신한다. + * @returns {void} + */ +const updateWriteSlashMenuPosition = () => { + const textarea = textareaRef.value + const line = liveSlashSourceLine.value + + if (!textarea || line === null) { + return + } + + const value = markdownValue.value ?? '' + const lineStart = value.split('\n').slice(0, line).join('\n').length + (line > 0 ? 1 : 0) + const coords = getTextareaCaretCoordinates(textarea, lineStart) + const rect = textarea.getBoundingClientRect() + const menuEl = liveSlashMenuRef.value + const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT + const menuWidth = menuEl?.offsetWidth || 288 + const gap = 4 + const anchorTop = rect.top + coords.top - textarea.scrollTop + + liveSlashMenuPosition.value = clampSlashMenuPosition( + anchorTop + coords.height + gap, + rect.left + coords.left - textarea.scrollLeft, + menuHeight, + menuWidth, + anchorTop + ) +} + +/** + * 슬래시 메뉴 위치를 갱신한다(뷰포트 아래로 넘치면 위쪽·좌우 클램프). + * @returns {void} + */ +const updateLiveSlashMenuPosition = () => { + if (!import.meta.client || liveSlashSourceLine.value === null) { + return + } + + nextTick(() => { + nextTick(() => { + if (activeMode.value === 'write') { + updateWriteSlashMenuPosition() + return + } + + updatePreviewSlashMenuPosition() + }) + }) +} + +/** + * 키보드로 선택한 슬래시 메뉴 항목이 보이도록 스크롤한다. + * @returns {void} + */ +const scrollLiveSlashHighlightedIntoView = () => { + if (!import.meta.client || !liveSlashVisible.value) { + return + } + + nextTick(() => { + const menu = liveSlashMenuRef.value + + if (!menu) { + return + } + + const highlightedItem = menu.querySelector('.admin-markdown-editor__slash-item--active') + + if (!highlightedItem) { + return + } + + const itemTop = highlightedItem.offsetTop + const itemBottom = itemTop + highlightedItem.offsetHeight + const viewTop = menu.scrollTop + const viewBottom = viewTop + menu.clientHeight + + if (itemTop < viewTop) { + menu.scrollTop = itemTop + } else if (itemBottom > viewBottom) { + menu.scrollTop = itemBottom - menu.clientHeight + } + }) +} + +/** + * 슬래시 메뉴 항목 mouseenter 시 하이라이트(키보드 탐색 중에는 무시). + * @param {number} commandIndex - 명령 인덱스 + * @returns {void} + */ +const onSlashMenuItemMouseEnter = (commandIndex) => { + if (liveSlashKeyboardNav.value) { + return + } + + liveSlashHighlightedIndex.value = commandIndex +} + +/** + * 슬래시 명령 입력을 갱신한다. + * @param {{ sourceLine: number, query: string }} payload - 줄·검색어 + * @returns {void} + */ +const onLiveSlashUpdate = (payload) => { + if (typeof payload?.sourceLine !== 'number') { + return + } + + if (liveSlashSuppressedLines.value.has(payload.sourceLine)) { + if (liveSlashSourceLine.value !== null) { + liveSlashSourceLine.value = null + liveSlashQuery.value = '' + liveSlashHighlightedIndex.value = 0 + } + + return + } + + liveSlashSourceLine.value = payload.sourceLine + liveSlashQuery.value = String(payload.query ?? '') + nextTick(updateLiveSlashMenuPosition) +} + +watch(liveSlashQuery, () => { + liveSlashHighlightedIndex.value = 0 +}) + +watch(visibleLiveSlashCommands, (commands) => { + if (liveSlashHighlightedIndex.value >= commands.length) { + liveSlashHighlightedIndex.value = Math.max(0, commands.length - 1) + } + + updateLiveSlashMenuPosition() +}) + +watch(liveSlashHighlightedIndex, () => { + scrollLiveSlashHighlightedIntoView() +}) + +watch(liveSlashVisible, (visible) => { + if (!import.meta.client) { + return + } + + const onReposition = () => updateLiveSlashMenuPosition() + + if (visible) { + window.addEventListener('scroll', onReposition, true) + window.addEventListener('resize', onReposition) + previewRef.value?.addEventListener('scroll', onReposition) + textareaRef.value?.addEventListener('scroll', onReposition) + updateLiveSlashMenuPosition() + scrollLiveSlashHighlightedIntoView() + return + } + + window.removeEventListener('scroll', onReposition, true) + window.removeEventListener('resize', onReposition) + previewRef.value?.removeEventListener('scroll', onReposition) + textareaRef.value?.removeEventListener('scroll', onReposition) +}) + +/** + * 슬래시 명령 입력을 종료한다. + * @returns {void} + */ +const onLiveSlashEnd = (payload) => { + if (typeof payload?.sourceLine === 'number') { + const next = new Set(liveSlashSuppressedLines.value) + next.delete(payload.sourceLine) + liveSlashSuppressedLines.value = next + } + + liveSlashSourceLine.value = null + liveSlashQuery.value = '' + liveSlashHighlightedIndex.value = 0 +} + +/** + * 슬래시 명령 입력을 취소한다(줄 내용은 유지, 메뉴만 닫음). + * @returns {void} + */ +const cancelLiveSlashCommand = () => { + const line = liveSlashSourceLine.value + + if (line !== null) { + liveSlashSuppressedLines.value = new Set([...liveSlashSuppressedLines.value, line]) + } + + liveSlashSourceLine.value = null + liveSlashQuery.value = '' + liveSlashHighlightedIndex.value = 0 +} + +/** + * 슬래시 명령 적용 후 미리보기 인라인 편집에 포커스를 복원한다. + * @param {number} line - 줄 번호(0-based) + * @param {string[]} replacementLines - 삽입된 줄 + * @returns {void} + */ +const focusPreviewAfterSlashCommand = (line, replacementLines) => { + if (!import.meta.client) { + return + } + + const { line: focusLine, offset } = getSlashCommandFocusTarget(line, replacementLines) + + nextTick(() => { + nextTick(() => { + previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'end', offset) + }) + }) +} + +/** + * 소스 모드 textarea에 슬래시 명령 적용 후 커서를 복원한다. + * @param {number} line - 줄 번호(0-based) + * @param {string[]} replacementLines - 삽입된 줄 + * @returns {void} + */ +const focusTextareaAfterSlashCommand = (line, replacementLines) => { + const value = markdownValue.value ?? '' + const lineStart = value.split('\n').slice(0, line).join('\n').length + (line > 0 ? 1 : 0) + const { offset } = getSlashCommandFocusTarget(line, replacementLines) + setTextareaSelection(lineStart + offset, lineStart + offset) +} + +/** + * 슬래시 명령을 적용한다. + * @param {import('../../lib/markdown-slash-commands.js').MarkdownSlashCommand} command - 명령 + * @returns {void} + */ +const applyLiveSlashCommand = (command) => { + if (!command || liveSlashSourceLine.value === null) { + return + } + + const line = liveSlashSourceLine.value + + if (command.action === 'media-image' || command.action === 'media-gallery') { + liveSlashInsertLine.value = line + replaceLineRange(line, line, [], false) + onLiveSlashEnd() + openMediaPicker(command.action === 'media-image' ? 'image' : 'gallery') + return + } + + if (command.action === 'lines' && Array.isArray(command.lines)) { + const replacementLines = command.lines + const nextSuppressed = new Set(liveSlashSuppressedLines.value) + nextSuppressed.delete(line) + liveSlashSuppressedLines.value = nextSuppressed + replaceLineRange(line, line, replacementLines, false) + liveSlashSourceLine.value = null + liveSlashQuery.value = '' + liveSlashHighlightedIndex.value = 0 + + if (activeMode.value === 'write') { + focusTextareaAfterSlashCommand(line, replacementLines) + } else { + focusPreviewAfterSlashCommand(line, replacementLines) + } + } +} + +/** + * 슬래시 명령 Enter 적용 + * @param {{ sourceLine: number, value: string }} payload - 줄·입력 값 + * @returns {void} + */ +const onLiveSlashApply = (payload) => { + const parsed = parseSlashInput(payload?.value) + const command = resolveSlashCommand(parsed?.query ?? liveSlashQuery.value) + + if (!command) { + return + } + + applyLiveSlashCommand(command) +} + +/** + * 지정 줄에 마크다운 줄을 삽입한다. + * @param {number} lineIndex - 삽입 위치(0-based) + * @param {string[]} insertLines - 삽입 줄 + * @returns {void} + */ +const insertMarkdownAtLine = (lineIndex, insertLines) => { + const sourceLines = (markdownValue.value ?? '').split('\n') + + markdownValue.value = [ + ...sourceLines.slice(0, lineIndex), + ...insertLines, + ...sourceLines.slice(lineIndex) + ].join('\n') +} + +const onPreviewMergeWithPreviousLine = ({ lineIndex, mergedLine }) => { + if (typeof lineIndex !== 'number' || lineIndex <= 0) { + return + } + + const sourceLines = (markdownValue.value ?? '').split('\n') + + if (lineIndex >= sourceLines.length) { + return + } + + markdownValue.value = [ + ...sourceLines.slice(0, lineIndex - 1), + String(mergedLine ?? ''), + ...sourceLines.slice(lineIndex + 1) + ].join('\n') +} + /** * 현재 갤러리 이미지 순서를 바꾼다. * @param {number} imageIndex - 이동할 이미지 인덱스 @@ -894,6 +1376,7 @@ const closeMediaPicker = () => { selectedMediaUrls.value = [] activeMediaPickerTab.value = 'library' mediaSearchQuery.value = '' + liveSlashInsertLine.value = null } /** @@ -950,6 +1433,36 @@ const applyMediaSelection = () => { .map((url) => mediaItems.value.find((item) => item.url === url)) .filter(Boolean) + if (liveSlashInsertLine.value !== null) { + const line = liveSlashInsertLine.value + + if (mediaPickerTarget.value === 'image') { + const [item] = selectedItems + + if (item) { + insertMarkdownAtLine(line, [createImageMarkdown({ + url: item.url, + useAlt: false, + caption: '' + })]) + } + } else if (selectedItems.length) { + insertMarkdownAtLine(line, [ + ':::gallery', + ...selectedItems.map((item) => createImageMarkdown({ + url: item.url, + useAlt: false, + caption: '' + })), + ':::' + ]) + } + + liveSlashInsertLine.value = null + closeMediaPicker() + return + } + if (mediaPickerTarget.value === 'image') { const [item] = selectedItems if (item) { @@ -1141,10 +1654,6 @@ const handleDrop = async (event) => { * @returns {void} */ const handleKeydown = (event) => { - if (handleParagraphEnter(event)) { - return - } - if (!(event.metaKey || event.ctrlKey)) { return } @@ -1163,49 +1672,6 @@ const handleKeydown = (event) => {