슬래시 메뉴 위치 관리 분리

This commit is contained in:
2026-07-29 11:24:00 +09:00
parent d8cfa77080
commit 393d173af5
10 changed files with 514 additions and 220 deletions

View File

@@ -50,14 +50,8 @@ import {
resolveSlashCommand,
shouldCaptureSlashMenuKeyboardAction
} from '../../lib/markdown-slash-commands.js'
import {
calculateSlashMenuPosition,
DEFAULT_SLASH_MENU_HEIGHT,
DEFAULT_SLASH_MENU_WIDTH
} from '../../lib/markdown-slash-menu-position.js'
import { calculateSlashMenuScrollTop } from '../../lib/markdown-slash-menu-scroll.js'
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
import { useAdminMarkdownSlashMenu } from '../../composables/useAdminMarkdownSlashMenu.js'
import { useAdminMarkdownSlashMenuPosition } from '../../composables/useAdminMarkdownSlashMenuPosition.js'
import { useAdminMarkdownHistory } from '../../composables/useAdminMarkdownHistory.js'
import { useAdminMarkdownMediaPicker } from '../../composables/useAdminMarkdownMediaPicker.js'
import { useAdminMarkdownFocusBridge } from '../../composables/useAdminMarkdownFocusBridge.js'
@@ -184,10 +178,6 @@ const {
uploadMaxFileCount,
uploadMaxTotalSize
})
/** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 미리보기 스크롤 영역 */
let liveSlashPreviewScrollElement = null
/** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 작성 textarea */
let liveSlashTextareaScrollElement = null
/**
* 작성 표면의 textarea DOM을 반환한다.
@@ -211,6 +201,25 @@ const markdownValue = computed({
set: (value) => emit('update:modelValue', value)
})
const {
updateLiveSlashMenuPosition
} = useAdminMarkdownSlashMenuPosition({
activeMode,
markdownValue,
liveSlashSourceLine,
liveSlashQuery,
liveSlashHighlightedIndex,
liveSlashMenuPosition,
liveSlashMenuRef,
liveSlashVisible,
visibleLiveSlashCommands,
resetLiveSlashHighlightedIndex,
clampLiveSlashHighlightedIndex,
getPreviewSourceLineElement,
getPreviewSurfaceElement,
getWriteTextareaElement
})
watch(markdownValue, () => {
const textarea = getWriteTextareaElement()
@@ -802,7 +811,6 @@ onMounted(() => {
onBeforeUnmount(() => {
clearBlockPanelFocusTimer()
stopLiveSlashKeyboardNavigation()
removeLiveSlashRepositionListeners()
document.removeEventListener('selectionchange', onSelectionChange)
document.removeEventListener('keydown', onDocumentKeydown, true)
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
@@ -1224,180 +1232,6 @@ const handlePreviewKeydownCapture = (event) => {
onPreviewDeleteLine(lineIndex)
}
/**
* 슬래시 메뉴 컴포넌트가 노출한 실제 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}
*/
const updatePreviewSlashMenuPosition = () => {
const element = getPreviewSourceLineElement(liveSlashSourceLine.value)
if (!element) {
return
}
const rect = element.getBoundingClientRect()
const menuEl = getLiveSlashMenuElement()
const menuHeight = menuEl?.offsetHeight || DEFAULT_SLASH_MENU_HEIGHT
const menuWidth = menuEl?.offsetWidth || DEFAULT_SLASH_MENU_WIDTH
const gap = 4
liveSlashMenuPosition.value = calculateSlashMenuPosition({
top: rect.bottom + gap,
left: rect.left,
menuHeight,
menuWidth,
anchorTop: rect.top,
viewportHeight: window.innerHeight,
viewportWidth: window.innerWidth
})
}
/**
* 소스 모드 textarea 슬래시 메뉴 위치를 갱신한다.
* @returns {void}
*/
const updateWriteSlashMenuPosition = () => {
const textarea = getWriteTextareaElement()
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 = getLiveSlashMenuElement()
const menuHeight = menuEl?.offsetHeight || DEFAULT_SLASH_MENU_HEIGHT
const menuWidth = menuEl?.offsetWidth || DEFAULT_SLASH_MENU_WIDTH
const gap = 4
const anchorTop = rect.top + coords.top - textarea.scrollTop
liveSlashMenuPosition.value = calculateSlashMenuPosition({
top: anchorTop + coords.height + gap,
left: rect.left + coords.left - textarea.scrollLeft,
menuHeight,
menuWidth,
anchorTop,
viewportHeight: window.innerHeight,
viewportWidth: window.innerWidth
})
}
/**
* 슬래시 메뉴 위치를 갱신한다(뷰포트 아래로 넘치면 위쪽·좌우 클램프).
* @returns {void}
*/
const updateLiveSlashMenuPosition = () => {
if (!import.meta.client || liveSlashSourceLine.value === null) {
return
}
nextTick(() => {
nextTick(() => {
if (activeMode.value === 'write') {
updateWriteSlashMenuPosition()
return
}
updatePreviewSlashMenuPosition()
})
})
}
/**
* 슬래시 메뉴 위치 재계산 이벤트를 실행한다.
* @returns {void}
*/
const repositionLiveSlashMenu = () => {
updateLiveSlashMenuPosition()
}
/**
* 슬래시 메뉴 위치 재계산 이벤트 리스너를 제거한다.
* @returns {void}
*/
const removeLiveSlashRepositionListeners = () => {
if (!import.meta.client) {
return
}
window.removeEventListener('scroll', repositionLiveSlashMenu, true)
window.removeEventListener('resize', repositionLiveSlashMenu)
liveSlashPreviewScrollElement?.removeEventListener('scroll', repositionLiveSlashMenu)
liveSlashTextareaScrollElement?.removeEventListener('scroll', repositionLiveSlashMenu)
liveSlashPreviewScrollElement = null
liveSlashTextareaScrollElement = null
}
/**
* 슬래시 메뉴 위치 재계산 이벤트 리스너를 등록한다.
* @returns {void}
*/
const addLiveSlashRepositionListeners = () => {
if (!import.meta.client) {
return
}
removeLiveSlashRepositionListeners()
liveSlashPreviewScrollElement = getPreviewSurfaceElement()
liveSlashTextareaScrollElement = getWriteTextareaElement()
window.addEventListener('scroll', repositionLiveSlashMenu, true)
window.addEventListener('resize', repositionLiveSlashMenu)
liveSlashPreviewScrollElement?.addEventListener('scroll', repositionLiveSlashMenu)
liveSlashTextareaScrollElement?.addEventListener('scroll', repositionLiveSlashMenu)
}
/**
* 키보드로 선택한 슬래시 메뉴 항목이 보이도록 스크롤한다.
* @returns {void}
*/
const scrollLiveSlashHighlightedIntoView = () => {
if (!import.meta.client || !liveSlashVisible.value) {
return
}
nextTick(() => {
const menu = getLiveSlashMenuElement()
if (!menu) {
return
}
const highlightedItem = menu.querySelector('.admin-markdown-editor__slash-item--active')
if (!highlightedItem) {
return
}
menu.scrollTop = calculateSlashMenuScrollTop({
itemTop: highlightedItem.offsetTop,
itemHeight: highlightedItem.offsetHeight,
viewTop: menu.scrollTop,
viewHeight: menu.clientHeight
})
})
}
/**
* 슬래시 메뉴 항목 mouseenter 시 하이라이트(키보드 탐색 중에는 무시).
* @param {number} commandIndex - 명령 인덱스
@@ -1443,35 +1277,6 @@ const onLiveSlashUpdate = (payload) => {
nextTick(updateLiveSlashMenuPosition)
}
watch(liveSlashQuery, () => {
resetLiveSlashHighlightedIndex()
})
watch(visibleLiveSlashCommands, (commands) => {
clampLiveSlashHighlightedIndex(commands.length)
updateLiveSlashMenuPosition()
})
watch(liveSlashHighlightedIndex, () => {
scrollLiveSlashHighlightedIntoView()
})
watch(liveSlashVisible, (visible) => {
if (!import.meta.client) {
return
}
if (visible) {
addLiveSlashRepositionListeners()
updateLiveSlashMenuPosition()
scrollLiveSlashHighlightedIntoView()
return
}
removeLiveSlashRepositionListeners()
})
/**
* 슬래시 명령 입력을 종료한다.
* @returns {void}