import { computed, nextTick, ref, watch } from 'vue' /** * 지정 줄이 블록 컨텍스트 범위 안에 있는지 확인한다. * @param {Object|null} context - 블록 컨텍스트 * @param {number} lineIndex - 현재 논리 줄 * @returns {boolean} */ const isLineInsideBlockContext = (context, lineIndex) => { if (!context) { return false } const startLine = Number(context.startLine) const endLine = Number(context.endLine) return Number.isInteger(startLine) && Number.isInteger(endLine) && lineIndex >= startLine && lineIndex <= endLine } /** * 관리자 Markdown 에디터의 블록 설정 패널 표시 상태를 만든다. * @param {Object} options - 블록 패널 옵션 * @param {import('vue').Ref<'write'|'preview'|string>} options.activeMode - 현재 에디터 모드 * @param {import('vue').Ref} options.value - Markdown 본문 * @param {import('vue').Ref} options.writeModeContextValue - 작성 모드 최신 본문 * @param {import('vue').Ref} options.activeLogicalLineIndex - 현재 논리 줄 * @param {import('vue').Ref} options.isMediaPickerOpen - 미디어 선택 모달 표시 여부 * @param {(value: string, lineIndex: number) => Object|null} options.resolveBlockContext - 블록 컨텍스트 계산 함수 * @param {() => Object|null} options.getInlineColorSelectionContext - 인라인 색상 선택 컨텍스트 계산 함수 * @param {(payload: { open: boolean, panel: Object|null }) => void} options.emitBlockPanel - 패널 상태 전달 함수 * @returns {Object} 블록 패널 상태와 이벤트 */ export const useAdminMarkdownBlockPanel = (options) => { /** textarea 포커스 여부 */ const isTextareaFocused = ref(false) /** 블록 패널에 포커스가 있거나 패널 편집을 유지해야 하는지 여부 */ const isBlockPanelEngaged = ref(false) /** 한글 등 IME 조합 입력 중 여부 */ const isTextComposing = ref(false) /** 조합 입력 중 패널 닫힘을 방지하기 위한 마지막 블록 컨텍스트 */ const lastStableBlockContext = ref(null) /** @type {import('vue').Ref<{ kind: 'inline-color', mode: 'source'|'live', start?: number, end?: number, sourceLine?: number, startOffset?: number, endOffset?: number, bodyText?: string, text: string }|null>} 선택 텍스트 색상 패널 컨텍스트 */ const inlineColorSelectionContext = ref(null) let blockPanelFocusTimer = null /** * 현재 포커스가 블록 설정 패널 안에 있는지 확인한다. * @returns {boolean} */ const isFocusInBlockPanel = () => Boolean( typeof document !== 'undefined' && document.activeElement?.closest?.('.admin-editor-block-panel') ) /** * 현재 포커스가 미디어 선택 모달 안에 있는지 확인한다. * @returns {boolean} */ const isFocusInMediaPicker = () => Boolean( typeof document !== 'undefined' && document.activeElement?.closest?.('.admin-markdown-editor__media-modal') ) /** * 커서 줄 기준 활성 블록(이미지·갤러리·임베드·인용) * @returns {Object|null} */ const activeBlockContext = computed(() => options.resolveBlockContext( options.writeModeContextValue.value ?? options.value.value, options.activeLogicalLineIndex.value )) /** @deprecated 내부 호환 alias */ const activeMediaBlock = activeBlockContext /** * IME 조합 중에는 일시적으로 블록 판별이 비어도 직전 패널을 유지한다. * @returns {Object|null} 패널 표시용 블록 컨텍스트 */ const visibleBlockContext = computed(() => { if (inlineColorSelectionContext.value) { return null } if (activeBlockContext.value) { return activeBlockContext.value } if (isTextComposing.value && isLineInsideBlockContext(lastStableBlockContext.value, options.activeLogicalLineIndex.value)) { return lastStableBlockContext.value } return null }) /** * 오른쪽 설정 패널에 표시할 현재 컨텍스트를 반환한다. * @returns {Object|null} 패널 컨텍스트 */ const activePanelContext = computed(() => inlineColorSelectionContext.value || visibleBlockContext.value) /** * 블록 설정 패널 표시 여부 * @returns {boolean} */ const isBlockPanelVisible = computed(() => (options.activeMode.value === 'write' || isBlockPanelEngaged.value) && Boolean(activePanelContext.value) && (isTextareaFocused.value || isBlockPanelEngaged.value)) /** * 부모(글 설정 사이드바 오버레이)에 패널 상태를 전달한다. * @returns {void} */ const syncBlockPanelState = () => { options.emitBlockPanel({ open: isBlockPanelVisible.value, panel: activePanelContext.value }) } /** * 블록 패널 편집 중 상태를 유지한다. * @returns {void} */ const ensureBlockPanelEngaged = () => { window.clearTimeout(blockPanelFocusTimer) isBlockPanelEngaged.value = true syncBlockPanelState() } /** * 블록 설정 패널을 닫는다. * @returns {void} */ const closeBlockPanel = () => { window.clearTimeout(blockPanelFocusTimer) isBlockPanelEngaged.value = false isTextareaFocused.value = false inlineColorSelectionContext.value = null syncBlockPanelState() } /** * 현재 선택 범위를 기준으로 텍스트 색상 패널 컨텍스트를 갱신한다. * @returns {void} */ const syncInlineColorSelectionContext = () => { if (isFocusInBlockPanel() || options.isMediaPickerOpen.value) { return } const nextContext = options.getInlineColorSelectionContext() inlineColorSelectionContext.value = nextContext if (nextContext) { isBlockPanelEngaged.value = true } } /** * textarea 포커스 진입 상태를 기록한다. * @returns {void} */ const handleTextareaFocusIn = () => { window.clearTimeout(blockPanelFocusTimer) isTextareaFocused.value = true } /** * textarea 포커스 이탈 상태를 지연 반영한다. * @returns {void} */ const handleTextareaFocusOut = () => { blockPanelFocusTimer = window.setTimeout(() => { if (options.isMediaPickerOpen.value || isBlockPanelEngaged.value || isFocusInBlockPanel() || isFocusInMediaPicker()) { return } isTextareaFocused.value = false syncBlockPanelState() }, 0) } /** * 블록 패널 포커스 진입(AdminPostForm 오버레이에서 호출) * @returns {void} */ const handleBlockPanelFocusIn = () => { window.clearTimeout(blockPanelFocusTimer) isBlockPanelEngaged.value = true syncBlockPanelState() } /** * 블록 패널 포커스 이탈 * @returns {void} */ const handleBlockPanelFocusOut = () => { blockPanelFocusTimer = window.setTimeout(() => { if (options.isMediaPickerOpen.value || isFocusInBlockPanel() || isFocusInMediaPicker()) { return } isBlockPanelEngaged.value = false if (!isTextareaFocused.value) { syncBlockPanelState() } }, 50) } /** * IME 조합 입력 시작 시 패널 컨텍스트를 유지한다. * @returns {void} */ const handleTextareaCompositionStart = () => { isTextComposing.value = true if (activeBlockContext.value) { lastStableBlockContext.value = activeBlockContext.value } } /** * IME 조합 입력 종료 상태를 기록한다. * @returns {void} */ const handleTextareaCompositionEnd = () => { isTextComposing.value = false } /** * 라이브 모드 포커스 줄을 기준으로 블록 패널을 동기화한다. * @param {number} sourceLine - 원본 마크다운 줄(0-based) * @returns {Promise} */ const handleLiveLineFocus = async (sourceLine) => { const nextLine = Number(sourceLine) if (!Number.isInteger(nextLine) || nextLine < 0) { return } options.activeLogicalLineIndex.value = nextLine isTextareaFocused.value = false isTextComposing.value = false await nextTick() if (activeBlockContext.value) { lastStableBlockContext.value = activeBlockContext.value isBlockPanelEngaged.value = true } else { isBlockPanelEngaged.value = false } syncBlockPanelState() } /** * 라이브 모드 포커스가 블록·패널 밖으로 나가면 패널을 닫는다. * @returns {void} */ const handleLiveLineBlur = () => { blockPanelFocusTimer = window.setTimeout(() => { if (options.isMediaPickerOpen.value || isBlockPanelEngaged.value || isFocusInBlockPanel() || isFocusInMediaPicker()) { return } isBlockPanelEngaged.value = false syncBlockPanelState() }, 0) } /** * 블록 패널 컨텍스트를 초기화한다. * @returns {void} */ const resetBlockPanelContext = () => { isBlockPanelEngaged.value = false lastStableBlockContext.value = null syncBlockPanelState() } /** * 블록 패널 타이머를 정리한다. * @returns {void} */ const clearBlockPanelFocusTimer = () => { window.clearTimeout(blockPanelFocusTimer) } watch(activeBlockContext, (context) => { if (context) { lastStableBlockContext.value = context } }, { deep: true, immediate: true }) watch([isBlockPanelVisible, activePanelContext], syncBlockPanelState, { deep: true }) return { isTextareaFocused, isBlockPanelEngaged, isTextComposing, lastStableBlockContext, inlineColorSelectionContext, activeBlockContext, activeMediaBlock, activePanelContext, isBlockPanelVisible, isFocusInBlockPanel, isFocusInMediaPicker, ensureBlockPanelEngaged, closeBlockPanel, syncBlockPanelState, syncInlineColorSelectionContext, handleTextareaFocusIn, handleTextareaFocusOut, handleBlockPanelFocusIn, handleBlockPanelFocusOut, handleTextareaCompositionStart, handleTextareaCompositionEnd, handleLiveLineFocus, handleLiveLineBlur, resetBlockPanelContext, clearBlockPanelFocusTimer } }