diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index fa37d8a..ac5f869 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -70,6 +70,7 @@ import { useAdminMarkdownHistory } from '../../composables/useAdminMarkdownHisto import { useAdminMarkdownMediaPicker } from '../../composables/useAdminMarkdownMediaPicker.js' import { useAdminMarkdownFocusBridge } from '../../composables/useAdminMarkdownFocusBridge.js' import { useAdminMarkdownTextareaFocus } from '../../composables/useAdminMarkdownTextareaFocus.js' +import { useAdminMarkdownBlockPanel } from '../../composables/useAdminMarkdownBlockPanel.js' import { buildDefaultUploadSizeLimits, DEFAULT_UPLOAD_MAX_FILE_COUNT, @@ -244,57 +245,6 @@ watch(markdownValue, () => { } }) -/** 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 {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() -} - /** * 오프셋이 속한 마크다운 줄 번호를 반환한다. * @param {string} value - 마크다운 본문 @@ -424,152 +374,38 @@ const getLiveInlineColorSelectionContext = () => { } } -/** - * 현재 선택 범위를 기준으로 텍스트 색상 패널 컨텍스트를 갱신한다. - * @returns {void} - */ -const syncInlineColorSelectionContext = () => { - if (isFocusInBlockPanel() || isMediaPickerOpen.value) { - return - } - - const nextContext = getSourceInlineColorSelectionContext() || getLiveInlineColorSelectionContext() - inlineColorSelectionContext.value = nextContext - - if (nextContext) { - isBlockPanelEngaged.value = true - } -} - -/** - * 커서 줄 기준 활성 블록(이미지·갤러리·임베드·인용) - * @returns {Object|null} - */ -const activeBlockContext = computed(() => resolveActiveBlockContext( - writeModeContextValue.value ?? markdownValue.value, - activeLogicalLineIndex.value -)) - -/** @deprecated 내부 호환 alias */ -const activeMediaBlock = activeBlockContext - -/** - * 지정 줄이 블록 컨텍스트 범위 안에 있는지 확인한다. - * @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 -} - -/** - * IME 조합 중에는 일시적으로 블록 판별이 비어도 직전 패널을 유지한다. - * @returns {Object|null} 패널 표시용 블록 컨텍스트 - */ -const visibleBlockContext = computed(() => { - if (inlineColorSelectionContext.value) { - return null - } - - if (activeBlockContext.value) { - return activeBlockContext.value - } - - if (isTextComposing.value - && isLineInsideBlockContext(lastStableBlockContext.value, activeLogicalLineIndex.value)) { - return lastStableBlockContext.value - } - - return null +const { + isBlockPanelEngaged, + inlineColorSelectionContext, + activeBlockContext, + activeMediaBlock, + activePanelContext, + isBlockPanelVisible, + ensureBlockPanelEngaged, + closeBlockPanel, + syncBlockPanelState, + syncInlineColorSelectionContext, + handleTextareaFocusIn, + handleTextareaFocusOut, + handleBlockPanelFocusIn, + handleBlockPanelFocusOut, + handleTextareaCompositionStart, + handleTextareaCompositionEnd: markTextareaCompositionEnd, + handleLiveLineFocus, + handleLiveLineBlur, + resetBlockPanelContext, + clearBlockPanelFocusTimer +} = useAdminMarkdownBlockPanel({ + activeMode, + value: markdownValue, + writeModeContextValue, + activeLogicalLineIndex, + isMediaPickerOpen, + resolveBlockContext: resolveActiveBlockContext, + getInlineColorSelectionContext: () => getSourceInlineColorSelectionContext() || getLiveInlineColorSelectionContext(), + emitBlockPanel: (payload) => emit('block-panel', payload) }) -/** - * 오른쪽 설정 패널에 표시할 현재 컨텍스트를 반환한다. - * @returns {Object|null} 패널 컨텍스트 - */ -const activePanelContext = computed(() => inlineColorSelectionContext.value || visibleBlockContext.value) - -/** - * 블록 설정 패널 표시 여부 - * @returns {boolean} - */ -const isBlockPanelVisible = computed(() => (activeMode.value === 'write' || isBlockPanelEngaged.value) - && Boolean(activePanelContext.value) - && (isTextareaFocused.value || isBlockPanelEngaged.value)) - -/** - * 부모(글 설정 사이드바 오버레이)에 패널 상태를 전달한다. - * @returns {void} - */ -const syncBlockPanelState = () => { - emit('block-panel', { - open: isBlockPanelVisible.value, - panel: activePanelContext.value - }) -} - -/** - * 라이브 모드 포커스 줄을 기준으로 블록 패널을 동기화한다. - * @param {number} sourceLine - 원본 마크다운 줄(0-based) - * @returns {void} - */ -const handleLiveLineFocus = async (sourceLine) => { - const nextLine = Number(sourceLine) - - if (!Number.isInteger(nextLine) || nextLine < 0) { - return - } - - 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 (isMediaPickerOpen.value || isBlockPanelEngaged.value || isFocusInBlockPanel() || isFocusInMediaPicker()) { - return - } - - isBlockPanelEngaged.value = false - syncBlockPanelState() - }, 0) -} - -watch(activeBlockContext, (context) => { - if (context) { - lastStableBlockContext.value = context - } -}, { deep: true, immediate: true }) - -watch([isBlockPanelVisible, activePanelContext], syncBlockPanelState, { deep: true }) - /** * 본문의 논리 줄(`\\n` 기준) 개수. 빈 본문은 1줄로 본다. * @returns {number} @@ -680,8 +516,7 @@ const resolvePreviewFocusPosition = (line, offset) => { * @returns {void} */ const onTextareaFocus = () => { - window.clearTimeout(blockPanelFocusTimer) - isTextareaFocused.value = true + handleTextareaFocusIn() refreshCaretLogicalLine() } @@ -690,54 +525,7 @@ const onTextareaFocus = () => { * @returns {void} */ const onTextareaBlur = () => { - blockPanelFocusTimer = window.setTimeout(() => { - if (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 (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 - } + handleTextareaFocusOut() } /** @@ -745,7 +533,7 @@ const handleTextareaCompositionStart = () => { * @returns {void} */ const handleTextareaCompositionEnd = () => { - isTextComposing.value = false + markTextareaCompositionEnd() refreshCaretLogicalLine() nextTick(syncBlockPanelState) } @@ -1080,7 +868,7 @@ onMounted(() => { window.addEventListener('resize', syncGutterLineHeights) onBeforeUnmount(() => { - window.clearTimeout(blockPanelFocusTimer) + clearBlockPanelFocusTimer() stopLiveSlashKeyboardNavigation() removeLiveSlashRepositionListeners() document.removeEventListener('selectionchange', onSelectionChange) @@ -1802,9 +1590,7 @@ const onPreviewInsertAfterLine = ({ afterLine, lines, focusLine }) => { ].join('\n') activeLogicalLineIndex.value = nextFocusLine - isBlockPanelEngaged.value = false - lastStableBlockContext.value = null - syncBlockPanelState() + resetBlockPanelContext() nextTick(() => { focusPreviewEditableAtLine(nextFocusLine, 0, 'start') diff --git a/composables/useAdminMarkdownBlockPanel.js b/composables/useAdminMarkdownBlockPanel.js new file mode 100644 index 0000000..73bf73f --- /dev/null +++ b/composables/useAdminMarkdownBlockPanel.js @@ -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} 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 + } +} diff --git a/docs/history.md b/docs/history.md index c0f0078..4be207d 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,7 @@ +## 2026-07-28 v1.11.115 — 블록 패널 표시 상태는 편집 본문과 분리한다 + +관리자 Markdown 에디터의 오른쪽 블록 패널은 textarea 포커스, 라이브 편집 줄 포커스, 패널 자체 포커스, IME 조합 중 직전 컨텍스트 유지, 인라인 색상 선택 우선순위가 함께 결정한다. 이 상태 계산을 본문 변경 함수와 같은 파일에 두면 이미지·콜아웃·코드 옵션을 수정할 때 패널 표시 조건까지 같이 흔들릴 수 있다. 따라서 `useAdminMarkdownBlockPanel`이 표시 상태와 포커스 유지 규칙을 맡고, 에디터는 실제 본문 값을 바꾸는 함수와 선택 컨텍스트 계산만 유지한다. + ## 2026-07-28 v1.11.114 — textarea DOM 실행부는 전용 composable이 맡는다 관리자 Markdown 에디터의 textarea 높이 조절, 거터 줄 높이 측정, 현재 커서 줄 계산, 선택 기억과 복원, 지정 줄 스크롤은 모두 DOM 실행 세부사항이다. 이 로직이 모드 전환·블록 패널·슬래시 메뉴 정책과 같은 컴포넌트 안에 섞여 있으면 실제 정책 변경 없이도 에디터 파일이 계속 커진다. 따라서 `useAdminMarkdownTextareaFocus`가 textarea DOM 실행부를 맡고, 컴포넌트는 필요한 상태와 콜백만 주입해 편집 정책과 DOM 적용 단계를 분리한다. diff --git a/docs/map.md b/docs/map.md index fd6419a..e355dea 100644 --- a/docs/map.md +++ b/docs/map.md @@ -29,6 +29,7 @@ | composables/useAdminMarkdownHistory.js | 관리자 Markdown 에디터 undo/redo 스택·기록 그룹화·복원 중 상태 관리 | | composables/useAdminMarkdownFocusBridge.js | 관리자 Markdown 에디터 작성/라이브 모드 전환의 pending 포커스 상태, 위치 기억, 전환 후 textarea·라이브 편집 줄 복원 콜백 실행 순서 관리 | | composables/useAdminMarkdownTextareaFocus.js | 관리자 Markdown 에디터 textarea 높이·거터 줄 높이·선택 기억·커서 줄 갱신·지정 줄 포커스·스크롤 실행 관리 | +| composables/useAdminMarkdownBlockPanel.js | 관리자 Markdown 에디터 블록 설정 패널 표시 상태·포커스 유지·IME 조합 중 직전 컨텍스트 유지·인라인 색상 패널 우선순위 관리 | | composables/useAdminMarkdownMediaPicker.js | 관리자 Markdown 에디터 미디어 선택 모달 열기·닫기·탭·검색어·페이지·선택 상태·표시 계산, 서버 미디어 목록 조회, 업로드 한도 검증·전송, 업로드 후 라이브러리 탭 전환·선택 상태 갱신 관리 | | composables/useContentMarkdownBlocks.js | 공개 Markdown 본문 파싱·제목 ID·인아티클 광고 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산 | | composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 | diff --git a/docs/todo.md b/docs/todo.md index af53129..c9f31c1 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -3,7 +3,7 @@ ## v1.11 내부 개선 - 거대 컴포넌트 책임 분리 진행. - - `components/admin/AdminMarkdownEditor.vue`: 라이브 편집 줄 포커스·블록 패널 표시 계산을 단계적으로 composable 또는 하위 컴포넌트로 분리. + - `components/admin/AdminMarkdownEditor.vue`: 라이브 편집 줄 포커스 실행과 블록별 옵션 수정 함수를 단계적으로 composable 또는 하위 컴포넌트로 분리. - 미디어 카탈로그 전환 코드 수명 정리. - 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토. - fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리. diff --git a/docs/update.md b/docs/update.md index d86112d..f52858f 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.11.115 + +- 관리자 Markdown 에디터의 블록 설정 패널 표시 상태·포커스 유지·IME 조합 중 직전 컨텍스트 유지·인라인 색상 패널 우선순위를 `useAdminMarkdownBlockPanel` composable로 분리. +- 에디터 컴포넌트는 패널 상태를 직접 계산하지 않고 활성 줄·본문·인라인 색상 선택 계산 함수만 주입하도록 정리. +- 블록 패널 표시, 인라인 색상 우선순위, IME 컨텍스트 유지, 라이브 줄 포커스, 패널 닫기 단위 테스트 추가. + ## v1.11.114 - 관리자 Markdown 에디터의 textarea 높이 동기화·거터 줄 높이 측정·선택 기억·줄 포커스·스크롤 실행을 `useAdminMarkdownTextareaFocus` composable로 분리. diff --git a/package-lock.json b/package-lock.json index aafd6b5..007f4d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.114", + "version": "1.11.115", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.114", + "version": "1.11.115", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index b641ac0..e9aeac7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.114", + "version": "1.11.115", "private": true, "type": "module", "imports": { diff --git a/tests/useAdminMarkdownBlockPanel.test.js b/tests/useAdminMarkdownBlockPanel.test.js new file mode 100644 index 0000000..f4d2e97 --- /dev/null +++ b/tests/useAdminMarkdownBlockPanel.test.js @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { effectScope, nextTick, ref } from 'vue' +import { useAdminMarkdownBlockPanel } from '../composables/useAdminMarkdownBlockPanel.js' + +globalThis.window ??= { + setTimeout, + clearTimeout +} + +/** + * 테스트용 블록 패널 상태를 만든다. + * @param {Object} options - 테스트 옵션 + * @returns {Object} 테스트 상태 + */ +const createBlockPanelState = (options = {}) => { + const scope = effectScope() + const activeMode = ref(options.activeMode ?? 'write') + const value = ref(options.value ?? '![이미지](/a.png)') + const writeModeContextValue = ref(null) + const activeLogicalLineIndex = ref(options.activeLogicalLineIndex ?? 0) + const isMediaPickerOpen = ref(false) + const currentBlock = ref(options.block ?? { + kind: 'image', + startLine: 0, + endLine: 0 + }) + const inlineContext = ref(null) + const emitted = [] + let panel + + scope.run(() => { + panel = useAdminMarkdownBlockPanel({ + activeMode, + value, + writeModeContextValue, + activeLogicalLineIndex, + isMediaPickerOpen, + resolveBlockContext: () => currentBlock.value, + getInlineColorSelectionContext: () => inlineContext.value, + emitBlockPanel: (payload) => { + emitted.push(payload) + } + }) + }) + + return { + activeLogicalLineIndex, + currentBlock, + emitted, + inlineContext, + isMediaPickerOpen, + panel, + stop: () => scope.stop() + } +} + +/** + * 패널 watch와 내부 nextTick을 비운다. + * @returns {Promise} + */ +const flushBlockPanelTicks = async () => { + await nextTick() + await nextTick() +} + +test('textarea 포커스가 있으면 활성 블록 패널을 연다', () => { + const state = createBlockPanelState() + + state.panel.handleTextareaFocusIn() + state.panel.syncBlockPanelState() + + assert.equal(state.panel.isBlockPanelVisible.value, true) + assert.deepEqual(state.emitted.at(-1), { + open: true, + panel: { + kind: 'image', + startLine: 0, + endLine: 0 + } + }) + state.stop() +}) + +test('인라인 색상 선택 컨텍스트는 일반 블록보다 우선 표시된다', () => { + const state = createBlockPanelState() + + state.inlineContext.value = { + kind: 'inline-color', + mode: 'source', + start: 0, + end: 2, + text: '색상' + } + state.panel.syncInlineColorSelectionContext() + state.panel.syncBlockPanelState() + + assert.equal(state.panel.isBlockPanelEngaged.value, true) + assert.equal(state.panel.activePanelContext.value.kind, 'inline-color') + assert.deepEqual(state.emitted.at(-1).panel, state.inlineContext.value) + state.stop() +}) + +test('IME 조합 중 활성 블록이 잠시 비어도 직전 블록 패널을 유지한다', async () => { + const state = createBlockPanelState() + + state.panel.handleTextareaFocusIn() + state.panel.handleTextareaCompositionStart() + state.currentBlock.value = null + state.activeLogicalLineIndex.value = 0 + await flushBlockPanelTicks() + + assert.equal(state.panel.activePanelContext.value.kind, 'image') + state.stop() +}) + +test('라이브 줄 포커스는 해당 줄 블록 패널을 편집 중 상태로 연다', async () => { + const state = createBlockPanelState({ + activeMode: 'preview', + block: { + kind: 'callout', + startLine: 2, + endLine: 4 + } + }) + + await state.panel.handleLiveLineFocus(3) + await flushBlockPanelTicks() + + assert.equal(state.activeLogicalLineIndex.value, 3) + assert.equal(state.panel.isBlockPanelEngaged.value, true) + assert.equal(state.panel.activePanelContext.value.kind, 'callout') + assert.equal(state.emitted.at(-1).open, true) + state.stop() +}) + +test('패널 닫기는 편집 상태와 인라인 색상 컨텍스트를 초기화한다', () => { + const state = createBlockPanelState() + + state.inlineContext.value = { + kind: 'inline-color', + mode: 'source', + start: 0, + end: 2, + text: '색상' + } + state.panel.syncInlineColorSelectionContext() + state.panel.closeBlockPanel() + + assert.equal(state.panel.isBlockPanelEngaged.value, false) + assert.equal(state.panel.inlineColorSelectionContext.value, null) + assert.equal(state.emitted.at(-1).open, false) + state.stop() +})