261 lines
8.6 KiB
JavaScript
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
|
|
}
|
|
}
|