슬래시 메뉴 위치 관리 분리
This commit is contained in:
260
composables/useAdminMarkdownSlashMenuPosition.js
Normal file
260
composables/useAdminMarkdownSlashMenuPosition.js
Normal file
@@ -0,0 +1,260 @@
|
||||
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<string>} options.activeMode - 현재 에디터 모드
|
||||
* @param {import('vue').Ref<string>} options.markdownValue - 마크다운 본문
|
||||
* @param {import('vue').Ref<number|null>} options.liveSlashSourceLine - 슬래시 입력 줄
|
||||
* @param {import('vue').Ref<string>} options.liveSlashQuery - 슬래시 검색어
|
||||
* @param {import('vue').Ref<number>} options.liveSlashHighlightedIndex - 선택된 명령 인덱스
|
||||
* @param {import('vue').Ref<{ top: number, left: number }>} options.liveSlashMenuPosition - 메뉴 좌표
|
||||
* @param {import('vue').Ref<HTMLElement|Object|null>} options.liveSlashMenuRef - 메뉴 컴포넌트 ref
|
||||
* @param {import('vue').ComputedRef<boolean>} options.liveSlashVisible - 메뉴 노출 여부
|
||||
* @param {import('vue').ComputedRef<Array<unknown>>} 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user