에디터 블록 패널 상태 분리

This commit is contained in:
2026-07-28 15:14:35 +09:00
parent db7cf62c92
commit 126f608808
9 changed files with 537 additions and 254 deletions

View File

@@ -0,0 +1,332 @@
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<string>} options.value - Markdown 본문
* @param {import('vue').Ref<string|null>} options.writeModeContextValue - 작성 모드 최신 본문
* @param {import('vue').Ref<number>} options.activeLogicalLineIndex - 현재 논리 줄
* @param {import('vue').Ref<boolean>} 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<void>}
*/
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
}
}