v1.2.9: 라이브 에디터·홈 피드·메인 커버 개선

라이브 모드 코드/콜아웃/토글 편집, 슬래시 명령, 홈 Latest List·Compact·Cards 보기,
사이트 설정 메인 화면 커버(720px) 및 HomeHero 반영.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-18 16:57:30 +09:00
parent 666bd304fc
commit 3fb8a40031
34 changed files with 3823 additions and 443 deletions

View File

@@ -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<number|null>} 슬래시 명령이 입력된 줄 */
const liveSlashSourceLine = ref(null)
/** @type {import('vue').Ref<string>} 슬래시 검색어 */
const liveSlashQuery = ref('')
/** @type {import('vue').Ref<number>} 슬래시 메뉴 하이라이트 인덱스 */
const liveSlashHighlightedIndex = ref(0)
/** @type {import('vue').Ref<{ top: number, left: number }>} 슬래시 메뉴 위치 */
const liveSlashMenuPosition = ref({ top: 0, left: 0 })
/** @type {import('vue').Ref<HTMLElement|null>} 슬래시 메뉴 DOM */
const liveSlashMenuRef = ref(null)
/** 슬래시 메뉴 예상 높이(px) — 렌더 전 위치 계산용 */
const LIVE_SLASH_MENU_ESTIMATED_HEIGHT = 280
/** @type {import('vue').Ref<number|null>} 슬래시 후 미디어 삽입 줄 */
const liveSlashInsertLine = ref(null)
/** @type {import('vue').Ref<Set<number>>} 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) => {
<template>
<div ref="editorRootRef" class="admin-markdown-editor grid gap-3">
<Teleport v-if="activeMode === 'write'" :to="toolbarTeleportTo">
<div class="admin-markdown-editor__toolbar flex w-full min-h-[40px] items-center gap-1.5 border-b border-[#e3e6e8] bg-white px-8 py-1.5">
<div class="admin-markdown-editor__toolbar-tools flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(1)">
H1
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(2)">
H2
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(3)">
H3
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-bold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('**', '**', '굵은 글씨')">
B
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm italic text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('*', '*', '기울임')">
I
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 font-mono text-sm text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('`', '`', 'code')">
Code
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyQuote">
인용
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyList">
목록
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="insertCodeBlock">
코드블록
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="insertBlockSnippet('---')">
구분선
</button>
<span class="admin-markdown-editor__divider mx-1 h-6 w-px bg-[#e3e6e8]" aria-hidden="true" />
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('image')">
이미지
</button>
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('gallery')">
갤러리
</button>
</div>
</div>
</Teleport>
<Teleport v-if="modeToggleTeleportTo" :to="modeToggleTeleportTo">
<button
@@ -1299,16 +1765,61 @@ const handleKeydown = (event) => {
tabindex="0"
>
<ContentMarkdownRenderer
ref="previewRendererRef"
:content="markdownValue"
interactive
:slash-menu-active="liveSlashVisible"
:slash-suppressed-lines="liveSlashSuppressedLineList"
@gallery-reorder="onPreviewGalleryReorder"
@block-content-change="onPreviewBlockContentChange"
@append-paragraph="onPreviewAppendParagraph"
@insert-after-line="onPreviewInsertAfterLine"
@delete-line="onPreviewDeleteLine"
@merge-with-previous-line="onPreviewMergeWithPreviousLine"
@slash-update="onLiveSlashUpdate"
@slash-end="onLiveSlashEnd"
@slash-apply="onLiveSlashApply"
/>
</div>
<Teleport to="body">
<div
v-if="liveSlashVisible"
ref="liveSlashMenuRef"
class="admin-markdown-editor__slash-menu fixed z-[60] w-72 max-h-[min(52vh,360px)] overflow-y-auto rounded-lg border border-[#e3e6e8] bg-white p-1 shadow-lg"
:style="{ top: `${liveSlashMenuPosition.top}px`, left: `${liveSlashMenuPosition.left}px` }"
role="listbox"
aria-label="슬래시 명령"
>
<button
v-for="(command, commandIndex) in visibleLiveSlashCommands"
:key="`live-slash-${command.id}`"
class="admin-markdown-editor__slash-item flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-2 text-left transition-colors"
:class="commandIndex === liveSlashHighlightedIndex ? 'admin-markdown-editor__slash-item--active bg-[#f4f5f6]' : ''"
type="button"
role="option"
:aria-selected="commandIndex === liveSlashHighlightedIndex"
@mouseenter="onSlashMenuItemMouseEnter(commandIndex)"
@mousedown.prevent="applyLiveSlashCommand(command)"
>
<span class="admin-markdown-editor__slash-leading flex min-w-0 items-center gap-2.5">
<AdminSlashCommandIcon
:command-id="command.id"
class="admin-markdown-editor__slash-icon shrink-0 text-[#394047]"
/>
<span class="admin-markdown-editor__slash-label truncate text-sm text-[#15171a]">{{ command.label }}</span>
</span>
<span
class="admin-markdown-editor__slash-slug shrink-0 text-xs text-[#8b959e] transition-opacity"
:class="commandIndex === liveSlashHighlightedIndex ? 'opacity-100' : 'opacity-0'"
>/{{ command.id }}</span>
</button>
<p v-if="!visibleLiveSlashCommands.length" class="admin-markdown-editor__slash-empty px-4 py-3 text-sm text-[#6b7280]">
일치하는 명령이 없습니다.
</p>
</div>
</Teleport>
<div v-if="isMediaPickerOpen" class="admin-markdown-editor__media-modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-4 py-6" @click.self="closeMediaPicker">
<div class="admin-markdown-editor__media-panel flex max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded bg-white shadow-2xl">
<header class="admin-markdown-editor__media-header flex items-center justify-between border-b border-[#e3e6e8] px-5 py-4">