슬래시 메뉴 위치 관리 분리
This commit is contained in:
@@ -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}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
## 2026-07-29 v1.11.122 — 슬래시 메뉴 위치 관리는 상태와 분리한다
|
||||
|
||||
관리자 Markdown 에디터의 슬래시 메뉴는 상태·명령 실행과 별개로 미리보기 줄 DOM, textarea caret 좌표, 뷰포트 보정, 스크롤/resize 리스너, 선택 항목 스크롤 후처리를 함께 관리한다. 이 DOM 위치 관리가 컴포넌트에 남으면 슬래시 명령 정책을 볼 때 화면 배치 코드가 먼저 섞인다. 따라서 `useAdminMarkdownSlashMenuPosition`이 메뉴 좌표 갱신과 리스너 수명, 하이라이트 스크롤을 담당하고 에디터는 메뉴 상태와 명령 실행 연결만 유지한다.
|
||||
|
||||
## 2026-07-29 v1.11.121 — 라이브 미리보기 이벤트 적용 실행을 composable로 분리한다
|
||||
|
||||
관리자 Markdown 에디터의 라이브 미리보기 이벤트 계산은 helper로 분리되어 있었지만, 갤러리 순서 변경·이미지 병합·문단 삽입·줄 삭제 같은 이벤트를 실제 본문 ref에 반영하고 포커스를 예약하는 연결 함수는 여전히 에디터에 남아 있었다. 이 실행부가 컴포넌트에 남으면 라이브 편집 표면의 DOM 이벤트와 Markdown 값 변경 흐름이 다시 섞인다. 따라서 `useAdminMarkdownPreviewActions`가 미리보기 이벤트 적용을 맡고, 에디터는 줄 교체·fenced 끝줄 보정·패널 초기화·포커스 실행 콜백만 제공한다.
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
| composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 |
|
||||
| composables/useAdminPostPublishState.js | 관리자 게시물 폼 발행·예약·발행취소 상태와 날짜 입력 상태 |
|
||||
| composables/useAdminMarkdownSlashMenu.js | 관리자 Markdown 에디터 슬래시 메뉴 상태·검색 결과·취소 당시 줄 내용 기준 억제·키보드 탐색 타이머 관리 |
|
||||
| composables/useAdminMarkdownSlashMenuPosition.js | 관리자 Markdown 에디터 슬래시 메뉴 미리보기 줄·textarea caret 기준 위치 계산, scroll·resize 리스너 수명, 선택 항목 스크롤 후처리 관리 |
|
||||
| composables/useAdminMarkdownHistory.js | 관리자 Markdown 에디터 undo/redo 스택·기록 그룹화·복원 중 상태 관리 |
|
||||
| composables/useAdminMarkdownFocusBridge.js | 관리자 Markdown 에디터 작성/라이브 모드 전환의 pending 포커스 상태, 위치 기억, 전환 후 textarea·라이브 편집 줄 복원 콜백 실행 순서 관리 |
|
||||
| composables/useAdminMarkdownPreviewFocus.js | 관리자 Markdown 에디터 라이브 미리보기 표면 DOM 접근, 현재 source line 계산, 상단 스크롤, 전체 선택, 줄 포커스 실행 관리 |
|
||||
@@ -158,6 +159,7 @@
|
||||
| tests/markdown-slash-commands.test.js | 관리자 슬래시 명령 파싱·입력값 기준 명령 해석·미디어 action·모달 종류·적용 계획·제외 줄 Set 갱신·하이라이트 인덱스 보정·방향키 순환·키보드 동작 분류·후보 개수 기준 키보드 가로채기·포커스 대상·textarea 선택 위치·원문 줄 교체 삽입 단위 회귀 테스트 |
|
||||
| tests/markdown-slash-menu-scroll.test.js | 관리자 슬래시 메뉴 선택 항목 scrollTop 보정 단위 테스트 |
|
||||
| tests/useAdminMarkdownSlashMenu.test.js | 관리자 Markdown 에디터 슬래시 메뉴 composable 상태·줄 내용 기준 억제 관리 단위 테스트 |
|
||||
| tests/useAdminMarkdownSlashMenuPosition.test.js | 관리자 Markdown 에디터 슬래시 메뉴 위치 composable의 좌표 갱신·리스너 수명·하이라이트 스크롤 단위 테스트 |
|
||||
| tests/useAdminMarkdownHistory.test.js | 관리자 Markdown 에디터 히스토리 composable의 기록·undo/redo·그룹화·보관 개수 단위 테스트 |
|
||||
| tests/useAdminMarkdownMediaPicker.test.js | 관리자 Markdown 에디터 미디어 선택 모달 composable 상태·서버 목록 조회·업로드 후 선택 상태·업로드 한도 관리 단위 테스트 |
|
||||
| tests/useContentMarkdownBlocks.test.js | 공개 Markdown 블록 계산 composable의 파싱·제목 ID·광고 삽입·하단 삽입 줄 단위 테스트 |
|
||||
@@ -272,7 +274,7 @@
|
||||
| components/admin/AdminMarkdownWriteSurface.vue | 관리자 Markdown 에디터 소스 작성 표면 UI(textarea, 라인 번호 gutter, 업로드 상태 배지, DOM 참조 노출) |
|
||||
| components/admin/AdminPostSettingsSidebar.vue | 관리자 게시물 작성/수정 우측 설정 사이드바(Post URL·상태·발행 시각·요약·태그·대표 이미지 표시·추천 글·검색엔진 노출·본문 통계·삭제 버튼·블록 설정 패널 오버레이) |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 저장 payload 기준 미저장 비교·라우트/브라우저 이탈 확인, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입, HTML 태그 내부 스마트 따옴표 정규화 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함, 후보 없는 슬래시 입력은 일반 줄바꿈·커서 이동 허용)·미디어 모달 선택 결과 본문 삽입(이미지·갤러리·비디오·오디오·파일), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 소스 모드 입력 직후 textarea 최신 본문 기준 커서 논리 줄·블록 컨텍스트 갱신, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 블록 패널 옵션 적용·라이브 미리보기 이벤트 적용 composable 연결, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 현재 블록 범위 안에서만 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함, 후보 없는 슬래시 입력은 일반 줄바꿈·커서 이동 허용)·미디어 모달 선택 결과 본문 삽입(이미지·갤러리·비디오·오디오·파일), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 소스 모드 입력 직후 textarea 최신 본문 기준 커서 논리 줄·블록 컨텍스트 갱신, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 슬래시 메뉴 위치·블록 패널 옵션 적용·라이브 미리보기 이벤트 적용 composable 연결, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 현재 블록 범위 안에서만 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 |
|
||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 단일 이미지 대표 너비 셀렉트(전체·갤러리 2장 1칸·갤러리 3장 1칸·갤러리 3장 2칸·직접 입력)·직접 너비·이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘 ON/OFF 토글·비활성 아이콘 영역·배경색·코드 언어 셀렉트·줄번호 ON/OFF 토글·토글), 갤러리 선택 이미지 강조 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
|
||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
|
||||
|
||||
@@ -778,6 +778,7 @@ components/content/
|
||||
- `ProseImage`는 URL이 비어 있거나 로드에 실패해도 최소 높이 placeholder와 「이미지를 불러올 수 없음」 안내를 표시해 라이브 모드에서 블록 선택·편집이 가능하다.
|
||||
- 인용(`>`) 블록은 첫 인용 줄에 `> [!bg=yellow]` 또는 `> {bg=yellow}` 옵션 줄을 두면 해당 줄은 숨기고 블록 배경을 바꾼다. 지원 배경 프리셋은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이며, 옵션이 없으면 회색 기본 인용 스타일을 쓴다.
|
||||
- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. blur·문단 이동(방향키) 시 편집 영역의 `<strong>`·`<em>` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. 인용·콜아웃 멀티라인 편집 영역도 일반 인라인 렌더러를 사용해 색상·굵게·링크 등 인라인 문법을 표시한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하되, 코드 fenced 블록 본문 안에서는 `/`를 일반 텍스트로 둔다. 슬래시 메뉴에 일치하는 명령 후보가 없으면 Enter·방향키를 메뉴가 가로채지 않고 일반 텍스트 줄바꿈·커서 이동으로 처리한다. 슬래시 메뉴를 ESC로 닫으면 취소 당시 줄 내용이 유지되는 동안만 다시 열리지 않고, `/`를 지웠다가 다시 입력하는 등 줄 내용이 바뀌면 새 입력으로 보고 다시 연다. 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). `Cmd+Shift+K`는 소스 모드와 라이브 모드에서 현재 줄을 삭제하며, 소스 모드에서 여러 줄이 선택되어 있으면 선택 범위가 걸친 줄을 함께 삭제한다. 코드·콜아웃·토글 블록 내부에서는 커서가 있는 본문 줄을 삭제하고, 남은 본문 줄이 1개뿐이면 fenced 블록 전체를 삭제한다. `Cmd/Ctrl+Z`는 에디터 본문 변경을 되돌리고, `Cmd/Ctrl+Shift+Z` 또는 `Ctrl+Y`는 다시 실행한다. 콜아웃 옵션은 첫 줄 `:::callout emoji=none bg=blue title="주의사항"`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple)·`title`로 지정하며, 라이브 모드에서는 블록에 포커스가 들어오면 오른쪽 설정 패널에서 수정한다. 코드 블록은 기본 줄번호 숨김이고, 줄번호 표시를 켜면 선언 줄에 `line-numbers`를 저장한다. 기존 `linenos`, `nolinenos`, `no-line-numbers` 토큰은 읽기 호환으로만 유지한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다.
|
||||
- 관리자 Markdown 슬래시 메뉴 위치는 미리보기 모드에서 현재 source line DOM, 소스 모드에서 textarea caret 좌표를 기준으로 계산한다. 뷰포트 밖으로 넘치는 경우 위쪽·좌우를 보정하고, 메뉴 노출 중 scroll·resize 리스너와 선택 항목 스크롤은 `useAdminMarkdownSlashMenuPosition`에서 관리한다.
|
||||
- 라이브 모드 공통 편집기는 한글 IME 조합 확정 Enter를 문단·빈 줄의 분리, 제목·목록의 아래 줄 삽입, 토글 제목의 본문 이동, 인용·콜아웃·코드·토글 본문의 텍스트 줄바꿈에 동일하게 연결한다. IME가 확정 Enter의 `keydown`을 전달하지 않고 `keyup`만 남기는 경우에도 조합 종료 직후 Enter로 보고 같은 동작을 실행한다. Shift+방향키 범위 선택은 `ContentMarkdownRenderer` Selection Bridge가 인접 편집 블록까지 확장한다. 단일 줄 블록의 Shift+위/아래는 커서 위치와 상관없이 현재 열 기준으로 이전·다음 편집 블록까지 확장하고, 멀티라인 편집 영역은 첫 줄·마지막 줄 경계에서 `data-source-line`~`data-source-line-end` 줄 범위로 탐색한다. `Cmd/Ctrl+A` 1회는 현재 블록 전체, 짧은 시간 안 2회는 라이브 본문 전체를 선택한다. 교차 블록·전체 선택 상태에서 **Backspace**·**Delete**·**Cmd/Ctrl+X**는 선택 범위를 마크다운 줄로 변환해 `content-replace`로 본문을 갱신한다. 콜아웃·인용 내부 전체 선택 삭제는 블록을 제거하지 않고 빈 본문 줄을 남긴다. 블록 이동 단축키는 Shift 조합일 때 실행하지 않으며, 선택 제스처가 우선한다. 콜아웃 본문은 하나의 멀티라인 편집 영역으로 유지해 `Shift+방향키` 선택이 내부 여러 줄을 가로지를 수 있게 한다. 라이브 멀티라인 블록은 브라우저 기본 DOM 줄 생성 대신 텍스트 값 치환 방식으로 줄바꿈을 삽입하며, contenteditable 값을 읽을 때 끝 줄바꿈을 보존해 첫 줄 빈 줄과 후행 빈 줄이 소스·라이브 모드 전환 또는 저장 중 유실되지 않게 한다. 인용 마지막 줄에서 아래 방향키를 누르면 외부 빈 문단을 만들고 커서를 해당 문단으로 이동하지만, 콜아웃 아래 방향키는 본문 줄을 새로 만들지 않는다.
|
||||
- 인라인 마크다운은 Obsidian식 `$...$` 첨자 토큰과 `::color[텍스트]` 색상 토큰을 지원한다. `$H_2O$`는 `H`+아래첨자 `2`+`O`, `$2^8$`은 `2`+위첨자 `8`, `$_B^AR$`는 아래첨자 `B`와 위첨자 `AR`로 렌더링한다. 첨자 본문에 공백·기호가 필요하면 `$_{...}$`, `$^{...}$` 형식도 허용한다. 색상 토큰은 `::gray[덜 중요한 말]`, `::red[**중요한 말**]`처럼 저장하며 `gray`, `blue`, `green`, `yellow`, `red`, `purple`만 허용한다. 색상 토큰 내부의 굵게·기울임·인라인 코드·링크·첨자와 `**[링크](url)**` 같은 굵은 링크는 재귀적으로 렌더링한다.
|
||||
- 라이브 모드 `:::` fenced 블록의 원본 범위는 여는 줄부터 닫는 `:::` 줄까지만 포함한다. 연속된 콜아웃·토글·갤러리 등은 앞 블록 편집 시 다음 블록의 선언 줄을 교체 범위에 포함하지 않는다.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## v1.11 내부 개선
|
||||
|
||||
- 거대 컴포넌트 책임 분리 진행.
|
||||
- `components/admin/AdminMarkdownEditor.vue`: 소스/라이브 공통 포커스 후처리와 슬래시 메뉴 위치·실행 연결부를 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
||||
- `components/admin/AdminMarkdownEditor.vue`: 소스/라이브 공통 포커스 후처리와 슬래시 명령 실행 연결부를 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
||||
- 미디어 카탈로그 전환 코드 수명 정리.
|
||||
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
||||
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.11.122
|
||||
|
||||
- 관리자 Markdown 에디터의 슬래시 메뉴 위치 계산·스크롤 리스너·하이라이트 스크롤 후처리를 `useAdminMarkdownSlashMenuPosition` composable로 분리.
|
||||
- 에디터 컴포넌트는 슬래시 메뉴 상태와 명령 실행 흐름만 유지하고, 미리보기·소스 모드 메뉴 좌표 갱신은 composable에 위임하도록 정리.
|
||||
- 슬래시 메뉴 위치 갱신, 노출 상태별 리스너 등록·해제, 하이라이트 항목 스크롤, 검색어·명령 수 변경 감시 단위 테스트 추가.
|
||||
|
||||
## v1.11.121
|
||||
|
||||
- 관리자 Markdown 에디터의 라이브 미리보기 갤러리·이미지·문단 이벤트 적용 실행을 `useAdminMarkdownPreviewActions` composable로 분리.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.121",
|
||||
"version": "1.11.122",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.121",
|
||||
"version": "1.11.122",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.121",
|
||||
"version": "1.11.122",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
216
tests/useAdminMarkdownSlashMenuPosition.test.js
Normal file
216
tests/useAdminMarkdownSlashMenuPosition.test.js
Normal file
@@ -0,0 +1,216 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { computed, effectScope, nextTick, ref } from 'vue'
|
||||
import { useAdminMarkdownSlashMenuPosition } from '../composables/useAdminMarkdownSlashMenuPosition.js'
|
||||
|
||||
/**
|
||||
* 테스트용 브라우저 window 객체를 설치한다.
|
||||
* @returns {{ added: string[], removed: string[], restore: () => void }} 이벤트 기록과 복구 함수
|
||||
*/
|
||||
const installWindowStub = () => {
|
||||
const previousWindow = globalThis.window
|
||||
const added = []
|
||||
const removed = []
|
||||
|
||||
globalThis.window = {
|
||||
innerHeight: 800,
|
||||
innerWidth: 1200,
|
||||
addEventListener: (eventName) => {
|
||||
added.push(eventName)
|
||||
},
|
||||
removeEventListener: (eventName) => {
|
||||
removed.push(eventName)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
added,
|
||||
removed,
|
||||
restore: () => {
|
||||
if (previousWindow === undefined) {
|
||||
delete globalThis.window
|
||||
return
|
||||
}
|
||||
|
||||
globalThis.window = previousWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 슬래시 메뉴 위치 composable 테스트 상태를 생성한다.
|
||||
* @param {Object} [overrides] - 상태 재정의
|
||||
* @returns {Object} 테스트 상태
|
||||
*/
|
||||
const createSlashMenuPositionHarness = (overrides = {}) => {
|
||||
const activeMode = ref(overrides.activeMode ?? 'preview')
|
||||
const markdownValue = ref(overrides.markdownValue ?? '/image')
|
||||
const liveSlashSourceLine = ref(overrides.sourceLine ?? 0)
|
||||
const liveSlashQuery = ref(overrides.query ?? 'image')
|
||||
const liveSlashHighlightedIndex = ref(overrides.highlightedIndex ?? 0)
|
||||
const liveSlashMenuPosition = ref({ top: 0, left: 0 })
|
||||
const liveSlashMenuRef = ref(overrides.menuRef ?? null)
|
||||
const liveSlashVisible = computed(() => liveSlashSourceLine.value !== null)
|
||||
const visibleLiveSlashCommands = ref(overrides.commands ?? [{ id: 'image' }, { id: 'gallery' }])
|
||||
const resetCalls = []
|
||||
const clampCalls = []
|
||||
const previewSurfaceEvents = []
|
||||
const textareaEvents = []
|
||||
const previewSurface = {
|
||||
addEventListener: (eventName) => previewSurfaceEvents.push(`add:${eventName}`),
|
||||
removeEventListener: (eventName) => previewSurfaceEvents.push(`remove:${eventName}`)
|
||||
}
|
||||
const textarea = {
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
addEventListener: (eventName) => textareaEvents.push(`add:${eventName}`),
|
||||
removeEventListener: (eventName) => textareaEvents.push(`remove:${eventName}`),
|
||||
getBoundingClientRect: () => ({ top: 20, left: 30 })
|
||||
}
|
||||
|
||||
return {
|
||||
options: {
|
||||
activeMode,
|
||||
markdownValue,
|
||||
liveSlashSourceLine,
|
||||
liveSlashQuery,
|
||||
liveSlashHighlightedIndex,
|
||||
liveSlashMenuPosition,
|
||||
liveSlashMenuRef,
|
||||
liveSlashVisible,
|
||||
visibleLiveSlashCommands,
|
||||
resetLiveSlashHighlightedIndex: () => resetCalls.push('reset'),
|
||||
clampLiveSlashHighlightedIndex: (commandCount) => clampCalls.push(commandCount),
|
||||
getPreviewSourceLineElement: () => ({
|
||||
getBoundingClientRect: () => ({ top: 100, bottom: 124, left: 48 })
|
||||
}),
|
||||
getPreviewSurfaceElement: () => previewSurface,
|
||||
getWriteTextareaElement: () => textarea,
|
||||
isClient: true
|
||||
},
|
||||
activeMode,
|
||||
liveSlashSourceLine,
|
||||
liveSlashQuery,
|
||||
liveSlashHighlightedIndex,
|
||||
liveSlashMenuPosition,
|
||||
liveSlashMenuRef,
|
||||
visibleLiveSlashCommands,
|
||||
resetCalls,
|
||||
clampCalls,
|
||||
previewSurfaceEvents,
|
||||
textareaEvents
|
||||
}
|
||||
}
|
||||
|
||||
test('관리자 Markdown 슬래시 메뉴 위치는 미리보기 줄 기준으로 갱신한다', async () => {
|
||||
const windowStub = installWindowStub()
|
||||
const scope = effectScope()
|
||||
const harness = createSlashMenuPositionHarness({
|
||||
menuRef: {
|
||||
offsetHeight: 160,
|
||||
offsetWidth: 240
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
scope.run(() => {
|
||||
const position = useAdminMarkdownSlashMenuPosition(harness.options)
|
||||
position.updateLiveSlashMenuPosition()
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
assert.deepEqual(harness.liveSlashMenuPosition.value, {
|
||||
top: 128,
|
||||
left: 48
|
||||
})
|
||||
} finally {
|
||||
scope.stop()
|
||||
windowStub.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('관리자 Markdown 슬래시 메뉴 위치는 노출 상태에 맞춰 스크롤 리스너를 정리한다', async () => {
|
||||
const windowStub = installWindowStub()
|
||||
const scope = effectScope()
|
||||
const harness = createSlashMenuPositionHarness()
|
||||
|
||||
try {
|
||||
scope.run(() => {
|
||||
useAdminMarkdownSlashMenuPosition(harness.options)
|
||||
})
|
||||
|
||||
harness.liveSlashSourceLine.value = null
|
||||
await nextTick()
|
||||
harness.liveSlashSourceLine.value = 0
|
||||
await nextTick()
|
||||
harness.liveSlashSourceLine.value = null
|
||||
await nextTick()
|
||||
|
||||
assert.deepEqual(windowStub.added, ['scroll', 'resize'])
|
||||
assert.deepEqual(windowStub.removed.slice(-2), ['scroll', 'resize'])
|
||||
assert.deepEqual(harness.previewSurfaceEvents, ['add:scroll', 'remove:scroll'])
|
||||
assert.deepEqual(harness.textareaEvents, ['add:scroll', 'remove:scroll'])
|
||||
} finally {
|
||||
scope.stop()
|
||||
windowStub.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('관리자 Markdown 슬래시 메뉴 하이라이트는 메뉴 안으로 스크롤된다', async () => {
|
||||
const windowStub = installWindowStub()
|
||||
const scope = effectScope()
|
||||
const menu = {
|
||||
offsetHeight: 160,
|
||||
offsetWidth: 240,
|
||||
scrollTop: 0,
|
||||
clientHeight: 80,
|
||||
querySelector: () => ({
|
||||
offsetTop: 120,
|
||||
offsetHeight: 32
|
||||
})
|
||||
}
|
||||
const harness = createSlashMenuPositionHarness({
|
||||
menuRef: {
|
||||
getRootElement: () => menu
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
scope.run(() => {
|
||||
useAdminMarkdownSlashMenuPosition(harness.options)
|
||||
})
|
||||
|
||||
harness.liveSlashHighlightedIndex.value = 1
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
assert.equal(menu.scrollTop, 72)
|
||||
} finally {
|
||||
scope.stop()
|
||||
windowStub.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('관리자 Markdown 슬래시 메뉴 검색어와 명령 수 변경은 하이라이트를 보정한다', async () => {
|
||||
const windowStub = installWindowStub()
|
||||
const scope = effectScope()
|
||||
const harness = createSlashMenuPositionHarness()
|
||||
|
||||
try {
|
||||
scope.run(() => {
|
||||
useAdminMarkdownSlashMenuPosition(harness.options)
|
||||
})
|
||||
|
||||
harness.liveSlashQuery.value = 'callout'
|
||||
harness.visibleLiveSlashCommands.value = [{ id: 'callout' }]
|
||||
await nextTick()
|
||||
|
||||
assert.deepEqual(harness.resetCalls, ['reset'])
|
||||
assert.deepEqual(harness.clampCalls, [1])
|
||||
} finally {
|
||||
scope.stop()
|
||||
windowStub.restore()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user