import { nextTick, onScopeDispose, unref, watch } from 'vue' 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' /** * 관리자 Markdown 에디터의 슬래시 메뉴 위치와 스크롤 후처리를 관리한다. * @param {Object} options - 슬래시 메뉴 위치 옵션 * @param {import('vue').Ref} options.activeMode - 현재 에디터 모드 * @param {import('vue').Ref} options.markdownValue - 마크다운 본문 * @param {import('vue').Ref} options.liveSlashSourceLine - 슬래시 입력 줄 * @param {import('vue').Ref} options.liveSlashQuery - 슬래시 검색어 * @param {import('vue').Ref} options.liveSlashHighlightedIndex - 선택된 명령 인덱스 * @param {import('vue').Ref<{ top: number, left: number }>} options.liveSlashMenuPosition - 메뉴 좌표 * @param {import('vue').Ref} options.liveSlashMenuRef - 메뉴 컴포넌트 ref * @param {import('vue').ComputedRef} options.liveSlashVisible - 메뉴 노출 여부 * @param {import('vue').ComputedRef>} options.visibleLiveSlashCommands - 표시 명령 목록 * @param {() => void} options.resetLiveSlashHighlightedIndex - 하이라이트 초기화 함수 * @param {(commandCount: number) => void} options.clampLiveSlashHighlightedIndex - 하이라이트 보정 함수 * @param {(line: number|null) => HTMLElement|null} options.getPreviewSourceLineElement - 미리보기 줄 요소 조회 * @param {() => HTMLElement|null} options.getPreviewSurfaceElement - 미리보기 스크롤 요소 조회 * @param {() => HTMLTextAreaElement|null} options.getWriteTextareaElement - 작성 textarea 조회 * @param {boolean} [options.isClient] - 클라이언트 실행 여부 * @returns {{ * getLiveSlashMenuElement: () => HTMLElement|null, * updateLiveSlashMenuPosition: () => void, * scrollLiveSlashHighlightedIntoView: () => void, * removeLiveSlashRepositionListeners: () => void * }} 슬래시 메뉴 위치 액션 */ export const useAdminMarkdownSlashMenuPosition = (options) => { const isClient = options.isClient ?? import.meta.client /** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 미리보기 스크롤 영역 */ let previewScrollElement = null /** @type {HTMLTextAreaElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 작성 textarea */ let textareaScrollElement = null /** * 슬래시 메뉴 컴포넌트가 노출한 실제 DOM 엘리먼트를 반환한다. * @returns {HTMLElement|null} 메뉴 엘리먼트 */ const getLiveSlashMenuElement = () => { const menu = options.liveSlashMenuRef.value if (!menu) { return null } if (typeof menu.getRootElement === 'function') { return menu.getRootElement() } return menu.rootRef ?? menu } /** * 미리보기 모드 슬래시 메뉴 위치를 갱신한다. * @returns {void} */ const updatePreviewSlashMenuPosition = () => { if (!isClient || typeof window === 'undefined') { return } const element = options.getPreviewSourceLineElement(options.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 options.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 = () => { if (!isClient || typeof window === 'undefined') { return } const textarea = options.getWriteTextareaElement() const line = options.liveSlashSourceLine.value if (!textarea || line === null) { return } const value = unref(options.markdownValue) ?? '' 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 options.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 (!isClient || options.liveSlashSourceLine.value === null) { return } nextTick(() => { nextTick(() => { if (options.activeMode.value === 'write') { updateWriteSlashMenuPosition() return } updatePreviewSlashMenuPosition() }) }) } /** * 슬래시 메뉴 위치 재계산 이벤트를 실행한다. * @returns {void} */ const repositionLiveSlashMenu = () => { updateLiveSlashMenuPosition() } /** * 슬래시 메뉴 위치 재계산 이벤트 리스너를 제거한다. * @returns {void} */ const removeLiveSlashRepositionListeners = () => { if (!isClient || typeof window === 'undefined') { return } window.removeEventListener('scroll', repositionLiveSlashMenu, true) window.removeEventListener('resize', repositionLiveSlashMenu) previewScrollElement?.removeEventListener('scroll', repositionLiveSlashMenu) textareaScrollElement?.removeEventListener('scroll', repositionLiveSlashMenu) previewScrollElement = null textareaScrollElement = null } /** * 슬래시 메뉴 위치 재계산 이벤트 리스너를 등록한다. * @returns {void} */ const addLiveSlashRepositionListeners = () => { if (!isClient || typeof window === 'undefined') { return } removeLiveSlashRepositionListeners() previewScrollElement = options.getPreviewSurfaceElement() textareaScrollElement = options.getWriteTextareaElement() window.addEventListener('scroll', repositionLiveSlashMenu, true) window.addEventListener('resize', repositionLiveSlashMenu) previewScrollElement?.addEventListener('scroll', repositionLiveSlashMenu) textareaScrollElement?.addEventListener('scroll', repositionLiveSlashMenu) } /** * 키보드로 선택한 슬래시 메뉴 항목이 보이도록 스크롤한다. * @returns {void} */ const scrollLiveSlashHighlightedIntoView = () => { if (!isClient || !options.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 }) }) } watch(options.liveSlashQuery, () => { options.resetLiveSlashHighlightedIndex() }) watch(options.visibleLiveSlashCommands, (commands) => { options.clampLiveSlashHighlightedIndex(commands.length) updateLiveSlashMenuPosition() }) watch(options.liveSlashHighlightedIndex, () => { scrollLiveSlashHighlightedIntoView() }) watch(options.liveSlashVisible, (visible) => { if (!isClient) { return } if (visible) { addLiveSlashRepositionListeners() updateLiveSlashMenuPosition() scrollLiveSlashHighlightedIntoView() return } removeLiveSlashRepositionListeners() }) onScopeDispose(removeLiveSlashRepositionListeners) return { getLiveSlashMenuElement, updateLiveSlashMenuPosition, scrollLiveSlashHighlightedIntoView, removeLiveSlashRepositionListeners } }