Files
sori.studio/composables/useAdminMarkdownSlashMenuPosition.js

261 lines
8.6 KiB
JavaScript

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
}
}