에디터 블록 패널 상태 분리
This commit is contained in:
@@ -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')
|
||||
|
||||
332
composables/useAdminMarkdownBlockPanel.js
Normal file
332
composables/useAdminMarkdownBlockPanel.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 적용 단계를 분리한다.
|
||||
|
||||
@@ -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 닫기·키보드 이벤트 연결 관리 |
|
||||
|
||||
@@ -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.115
|
||||
|
||||
- 관리자 Markdown 에디터의 블록 설정 패널 표시 상태·포커스 유지·IME 조합 중 직전 컨텍스트 유지·인라인 색상 패널 우선순위를 `useAdminMarkdownBlockPanel` composable로 분리.
|
||||
- 에디터 컴포넌트는 패널 상태를 직접 계산하지 않고 활성 줄·본문·인라인 색상 선택 계산 함수만 주입하도록 정리.
|
||||
- 블록 패널 표시, 인라인 색상 우선순위, IME 컨텍스트 유지, 라이브 줄 포커스, 패널 닫기 단위 테스트 추가.
|
||||
|
||||
## v1.11.114
|
||||
|
||||
- 관리자 Markdown 에디터의 textarea 높이 동기화·거터 줄 높이 측정·선택 기억·줄 포커스·스크롤 실행을 `useAdminMarkdownTextareaFocus` composable로 분리.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.114",
|
||||
"version": "1.11.115",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
154
tests/useAdminMarkdownBlockPanel.test.js
Normal file
154
tests/useAdminMarkdownBlockPanel.test.js
Normal file
@@ -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 ?? '')
|
||||
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<void>}
|
||||
*/
|
||||
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()
|
||||
})
|
||||
Reference in New Issue
Block a user