에디터 슬래시 메뉴 컴포넌트 분리

This commit is contained in:
2026-07-27 09:54:26 +09:00
parent 84cb3ad9e7
commit f4a2f1c034
6 changed files with 109 additions and 40 deletions

View File

@@ -63,7 +63,7 @@ import {
normalizeUploadMaxFileCount,
normalizeUploadMaxTotalSize
} from '../../lib/upload-size-limit.js'
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'
import AdminMarkdownSlashMenu from './AdminMarkdownSlashMenu.vue'
const { toast, showToast } = useAdminToast()
const runtimeConfig = useRuntimeConfig()
@@ -2325,6 +2325,24 @@ const clampSlashMenuPosition = (top, left, menuHeight, menuWidth, anchorTop) =>
}
}
/**
* 슬래시 메뉴 컴포넌트가 노출한 실제 DOM 엘리먼트를 반환한다.
* @returns {HTMLElement|null} 메뉴 엘리먼트
*/
const getLiveSlashMenuElement = () => {
const menu = liveSlashMenuRef.value
if (!menu) {
return null
}
if (typeof menu.getRootElement === 'function') {
return menu.getRootElement()
}
return menu.rootRef ?? menu
}
/**
* 미리보기 모드 슬래시 메뉴 위치를 갱신한다.
* @returns {void}
@@ -2337,7 +2355,7 @@ const updatePreviewSlashMenuPosition = () => {
}
const rect = element.getBoundingClientRect()
const menuEl = liveSlashMenuRef.value
const menuEl = getLiveSlashMenuElement()
const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT
const menuWidth = menuEl?.offsetWidth || 288
const gap = 4
@@ -2367,7 +2385,7 @@ const updateWriteSlashMenuPosition = () => {
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 menuEl = getLiveSlashMenuElement()
const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT
const menuWidth = menuEl?.offsetWidth || 288
const gap = 4
@@ -2413,7 +2431,7 @@ const scrollLiveSlashHighlightedIntoView = () => {
}
nextTick(() => {
const menu = liveSlashMenuRef.value
const menu = getLiveSlashMenuElement()
if (!menu) {
return
@@ -3589,41 +3607,15 @@ const handleKeydown = (event) => {
</div>
<Teleport to="body">
<div
<AdminMarkdownSlashMenu
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>
:commands="visibleLiveSlashCommands"
:highlighted-index="liveSlashHighlightedIndex"
:position="liveSlashMenuPosition"
@highlight="onSlashMenuItemMouseEnter"
@select="applyLiveSlashCommand"
/>
</Teleport>
<AdminMarkdownMediaPickerModal

View File

@@ -0,0 +1,71 @@
<script setup>
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'
defineProps({
/** @type {import('vue').PropType<Array<{ id: string, label: string }>>} */
commands: {
type: Array,
default: () => []
},
highlightedIndex: {
type: Number,
default: 0
},
/** @type {import('vue').PropType<{ top: number, left: number }>} */
position: {
type: Object,
default: () => ({ top: 0, left: 0 })
}
})
const emit = defineEmits(['highlight', 'select'])
const rootRef = ref(null)
/**
* 부모 컴포넌트의 위치 보정·스크롤 계산에 사용할 메뉴 루트 엘리먼트를 반환한다.
* @returns {HTMLElement|null} 메뉴 루트 엘리먼트
*/
const getRootElement = () => rootRef.value
defineExpose({
getRootElement,
rootRef
})
</script>
<template>
<div
ref="rootRef"
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: `${position.top}px`, left: `${position.left}px` }"
role="listbox"
aria-label="슬래시 명령"
>
<button
v-for="(command, commandIndex) in commands"
: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 === highlightedIndex ? 'admin-markdown-editor__slash-item--active bg-[#f4f5f6]' : ''"
type="button"
role="option"
:aria-selected="commandIndex === highlightedIndex"
@mouseenter="emit('highlight', commandIndex)"
@mousedown.prevent="emit('select', 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 === highlightedIndex ? 'opacity-100' : 'opacity-0'"
>/{{ command.id }}</span>
</button>
<p v-if="!commands.length" class="admin-markdown-editor__slash-empty px-4 py-3 text-sm text-[#6b7280]">
일치하는 명령이 없습니다.
</p>
</div>
</template>