From db7cf62c924ffd9170708193a1c4703d88bb64a7 Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 28 Jul 2026 15:05:04 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=90=EB=94=94=ED=84=B0=20textarea=20?= =?UTF-8?q?=ED=8F=AC=EC=BB=A4=EC=8A=A4=20=EC=8B=A4=ED=96=89=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminMarkdownEditor.vue | 290 ++---------------- composables/useAdminMarkdownTextareaFocus.js | 294 +++++++++++++++++++ docs/history.md | 4 + docs/map.md | 1 + docs/todo.md | 2 +- docs/update.md | 6 + package-lock.json | 4 +- package.json | 2 +- tests/useAdminMarkdownTextareaFocus.test.js | 185 ++++++++++++ 9 files changed, 519 insertions(+), 269 deletions(-) create mode 100644 composables/useAdminMarkdownTextareaFocus.js create mode 100644 tests/useAdminMarkdownTextareaFocus.test.js diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index aa45e37..fa37d8a 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -69,6 +69,7 @@ import { useAdminMarkdownSlashMenu } from '../../composables/useAdminMarkdownSla import { useAdminMarkdownHistory } from '../../composables/useAdminMarkdownHistory.js' import { useAdminMarkdownMediaPicker } from '../../composables/useAdminMarkdownMediaPicker.js' import { useAdminMarkdownFocusBridge } from '../../composables/useAdminMarkdownFocusBridge.js' +import { useAdminMarkdownTextareaFocus } from '../../composables/useAdminMarkdownTextareaFocus.js' import { buildDefaultUploadSizeLimits, DEFAULT_UPLOAD_MAX_FILE_COUNT, @@ -192,9 +193,6 @@ let liveSlashPreviewScrollElement = null /** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 작성 textarea */ let liveSlashTextareaScrollElement = null -/** 작성 textarea 최소 높이(px) */ -const MIN_TEXTAREA_HEIGHT_PX = 620 - /** * 작성 표면의 textarea DOM을 반환한다. * @returns {HTMLTextAreaElement|null} textarea DOM @@ -583,17 +581,6 @@ const gutterLineCount = computed(() => { return Math.max(1, n) }) -/** - * CSS px 값을 숫자로 변환한다. - * @param {string} value - CSS 값 - * @param {number} fallback - 변환 실패 시 기본값 - * @returns {number} px 숫자 - */ -const parseCssPixelValue = (value, fallback) => { - const parsed = Number.parseFloat(value) - return Number.isFinite(parsed) ? parsed : fallback -} - /** * 소스 textarea의 줄 시작 오프셋을 반환한다. * @param {number} lineIndex - 줄 번호(0-based) @@ -612,6 +599,30 @@ const getLineIndexAtOffset = (offset) => { return getMarkdownLineIndexAtOffset(markdownValue.value, offset) } +const { + getGutterLineHeight, + getNearestScrollContainer, + syncGutterLineHeights, + syncTextareaHeight, + refreshCaretLogicalLine, + rememberTextareaSelection, + focusTextareaAtLine, + restoreTextareaFocus +} = useAdminMarkdownTextareaFocus({ + value: markdownValue, + activeMode, + writeModeContextValue, + lastSelectionState, + activeLogicalLineIndex, + gutterLineHeights, + editorRootRef, + minTextareaHeight: 620, + getWriteTextareaElement, + getLineStartOffset, + syncBlockPanelState, + syncWriteSlashState: () => syncWriteSlashState() +}) + /** * 현재 선택 위치에 맞춰 에디터 포커스를 복원한다. * @param {{ start: number, end: number }|null} selection - 복원할 선택 영역 @@ -664,84 +675,6 @@ const resolvePreviewFocusPosition = (line, offset) => { }) } -/** - * textarea와 동일한 폭·폰트로 각 논리 줄의 실제 wrap 높이를 측정한다. - * @returns {void} - */ -const syncGutterLineHeights = () => { - if (!import.meta.client) { - return - } - - nextTick(() => { - const textarea = getWriteTextareaElement() - - if (!textarea) { - gutterLineHeights.value = [] - return - } - - const style = window.getComputedStyle(textarea) - const fallbackLineHeight = parseCssPixelValue(style.lineHeight, 28) - const paddingLeft = parseCssPixelValue(style.paddingLeft, 0) - const paddingRight = parseCssPixelValue(style.paddingRight, 0) - const contentWidth = Math.max(1, textarea.clientWidth - paddingLeft - paddingRight) - const mirror = document.createElement('div') - - mirror.style.position = 'absolute' - mirror.style.left = '-10000px' - mirror.style.top = '0' - mirror.style.visibility = 'hidden' - mirror.style.pointerEvents = 'none' - mirror.style.width = `${contentWidth}px` - mirror.style.boxSizing = 'border-box' - mirror.style.whiteSpace = 'pre-wrap' - mirror.style.overflowWrap = style.overflowWrap || 'break-word' - mirror.style.wordBreak = style.wordBreak || 'normal' - mirror.style.font = style.font - mirror.style.letterSpacing = style.letterSpacing - mirror.style.lineHeight = style.lineHeight - - document.body.appendChild(mirror) - - const nextHeights = (markdownValue.value ?? '').split('\n').map((line) => { - const lineEl = document.createElement('div') - lineEl.textContent = line || ' ' - mirror.appendChild(lineEl) - - return Math.max(fallbackLineHeight, lineEl.getBoundingClientRect().height) - }) - - document.body.removeChild(mirror) - gutterLineHeights.value = nextHeights.length ? nextHeights : [fallbackLineHeight] - }) -} - -/** - * 거터 줄 높이를 반환한다. - * @param {number} lineIndex - 줄 번호(0-based) - * @returns {number} 줄 높이(px) - */ -const getGutterLineHeight = (lineIndex) => gutterLineHeights.value[lineIndex] || 28 - -/** - * textarea 높이를 본문 길이에 맞춘다. 내부 스크롤 없이 부모(`editor-scroll`)만 스크롤한다. - * @returns {void} - */ -const syncTextareaHeight = () => { - nextTick(() => { - const textarea = getWriteTextareaElement() - - if (!textarea) { - return - } - - textarea.style.height = '0px' - textarea.style.height = `${Math.max(MIN_TEXTAREA_HEIGHT_PX, textarea.scrollHeight)}px` - syncGutterLineHeights() - }) -} - /** * textarea 포커스 진입 * @returns {void} @@ -817,36 +750,6 @@ const handleTextareaCompositionEnd = () => { nextTick(syncBlockPanelState) } -/** - * 커서 위치 기준으로 활성 논리 줄 인덱스를 갱신하고 거터 스크롤을 맞춘다. - * @returns {void} - */ -const refreshCaretLogicalLine = () => { - const textarea = getWriteTextareaElement() - - if (!textarea) { - return - } - - const value = textarea.value ?? markdownValue.value ?? '' - const start = Math.min(textarea.selectionStart, value.length) - const end = Math.min(textarea.selectionEnd, value.length) - const lineIndex = value.slice(0, start).split('\n').length - 1 - - writeModeContextValue.value = value - lastSelectionState.value = { - start, - end - } - activeLogicalLineIndex.value = Math.max(0, lineIndex) - syncBlockPanelState() - syncTextareaHeight() - - if (activeMode.value === 'write' && document.activeElement === textarea) { - nextTick(syncWriteSlashState) - } -} - /** * 소스(작성) 모드 textarea 현재 줄의 슬래시 입력 상태를 갱신한다. * @returns {void} @@ -886,149 +789,6 @@ const syncWriteSlashState = () => { nextTick(updateLiveSlashMenuPosition) } -/** - * textarea의 선택 영역과 스크롤 위치를 기억한다. - * @returns {void} - */ -const rememberTextareaSelection = () => { - const textarea = getWriteTextareaElement() - const value = markdownValue.value ?? '' - - if (!textarea) { - lastSelectionState.value = { - start: value.length, - end: value.length - } - return - } - - lastSelectionState.value = { - start: Math.min(textarea.selectionStart, value.length), - end: Math.min(textarea.selectionEnd, value.length) - } -} - -/** - * 가장 가까운 스크롤 컨테이너를 찾는다. - * @param {HTMLElement|null} element - 기준 요소 - * @returns {HTMLElement|null} 스크롤 컨테이너 - */ -const getNearestScrollContainer = (element) => { - let current = element?.parentElement || null - - while (current && current !== document.body && current !== document.documentElement) { - const style = window.getComputedStyle(current) - - if (/(auto|scroll)/.test(`${style.overflowY} ${style.overflow}`) && current.scrollHeight > current.clientHeight) { - return current - } - - current = current.parentElement - } - - return null -} - -/** - * textarea의 지정 줄이 화면 안에 보이도록 스크롤한다. - * @param {number} lineIndex - 줄 번호(0-based) - * @returns {void} - */ -const scrollTextareaLineIntoView = (lineIndex) => { - const textarea = getWriteTextareaElement() - - if (!textarea || !import.meta.client) { - return - } - - const style = window.getComputedStyle(textarea) - const paddingTop = parseCssPixelValue(style.paddingTop, 0) - const lineTop = paddingTop + gutterLineHeights.value - .slice(0, Math.max(0, lineIndex)) - .reduce((sum, height) => sum + height, 0) - const lineHeight = getGutterLineHeight(lineIndex) - const lineBottom = lineTop + lineHeight - const padding = 140 - const scrollContainer = getNearestScrollContainer(editorRootRef.value) - - if (scrollContainer) { - const containerRect = scrollContainer.getBoundingClientRect() - const textareaRect = textarea.getBoundingClientRect() - const absoluteLineTop = textareaRect.top - containerRect.top + scrollContainer.scrollTop + lineTop - const absoluteLineBottom = textareaRect.top - containerRect.top + scrollContainer.scrollTop + lineBottom - const viewTop = scrollContainer.scrollTop - const viewBottom = viewTop + scrollContainer.clientHeight - - if (absoluteLineTop < viewTop + padding) { - scrollContainer.scrollTop = Math.max(0, absoluteLineTop - padding) - } else if (absoluteLineBottom > viewBottom - padding) { - scrollContainer.scrollTop = Math.max(0, absoluteLineBottom - scrollContainer.clientHeight + padding) - } - - return - } - - const textareaRect = textarea.getBoundingClientRect() - const absoluteLineTop = textareaRect.top + window.scrollY + lineTop - const absoluteLineBottom = textareaRect.top + window.scrollY + lineBottom - const viewTop = window.scrollY - const viewBottom = viewTop + window.innerHeight - - if (absoluteLineTop < viewTop + padding) { - window.scrollTo({ top: Math.max(0, absoluteLineTop - padding) }) - } else if (absoluteLineBottom > viewBottom - padding) { - window.scrollTo({ top: Math.max(0, absoluteLineBottom - window.innerHeight + padding) }) - } -} - -/** - * 소스 textarea의 지정 줄로 포커스와 커서를 이동한다. - * @param {number} lineIndex - 줄 번호(0-based) - * @returns {void} - */ -const focusTextareaAtLine = (lineIndex) => { - nextTick(() => { - const textarea = getWriteTextareaElement() - const value = markdownValue.value ?? '' - - if (!textarea) { - return - } - - const offset = Math.min(getLineStartOffset(lineIndex), value.length) - textarea.focus() - textarea.setSelectionRange(offset, offset) - syncTextareaHeight() - nextTick(() => scrollTextareaLineIntoView(lineIndex)) - refreshCaretLogicalLine() - }) -} - -/** - * 기억한 선택 영역과 스크롤 위치로 작성 textarea 포커스를 복원한다. - * @returns {void} - */ -const restoreTextareaFocus = () => { - nextTick(() => { - const textarea = getWriteTextareaElement() - const value = markdownValue.value ?? '' - - if (!textarea) { - return - } - - const start = Math.min(lastSelectionState.value.start, value.length) - const end = Math.min(lastSelectionState.value.end, value.length) - const lineIndex = value.slice(0, start).split('\n').length - 1 - - textarea.focus() - textarea.setSelectionRange(start, end) - syncTextareaHeight() - nextTick(() => scrollTextareaLineIntoView(lineIndex)) - refreshCaretLogicalLine() - }) -} - watch(() => props.modelValue, () => { if (isBlockPanelEngaged.value) { syncBlockPanelState() diff --git a/composables/useAdminMarkdownTextareaFocus.js b/composables/useAdminMarkdownTextareaFocus.js new file mode 100644 index 0000000..e1e2f14 --- /dev/null +++ b/composables/useAdminMarkdownTextareaFocus.js @@ -0,0 +1,294 @@ +import { nextTick } from 'vue' + +/** + * CSS px 값을 숫자로 변환한다. + * @param {string} value - CSS 값 + * @param {number} fallback - 변환 실패 시 기본값 + * @returns {number} px 숫자 + */ +const parseCssPixelValue = (value, fallback) => { + const parsed = Number.parseFloat(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +/** + * 관리자 Markdown 작성 textarea의 포커스·높이·줄 스크롤 실행 함수를 만든다. + * @param {Object} options - textarea 포커스 옵션 + * @param {import('vue').Ref} options.value - Markdown 본문 + * @param {import('vue').Ref<'write'|'preview'|string>} options.activeMode - 현재 에디터 모드 + * @param {import('vue').Ref} options.writeModeContextValue - 작성 모드 최신 본문 + * @param {import('vue').Ref<{ start: number, end: number }>} options.lastSelectionState - 마지막 선택 위치 + * @param {import('vue').Ref} options.activeLogicalLineIndex - 현재 논리 줄 + * @param {import('vue').Ref} options.gutterLineHeights - 거터 줄별 높이 + * @param {import('vue').Ref} options.editorRootRef - 에디터 루트 + * @param {number} options.minTextareaHeight - textarea 최소 높이(px) + * @param {() => HTMLTextAreaElement|null} options.getWriteTextareaElement - 작성 textarea 반환 함수 + * @param {(lineIndex: number) => number} options.getLineStartOffset - 줄 시작 오프셋 계산 함수 + * @param {() => void} options.syncBlockPanelState - 블록 패널 상태 동기화 함수 + * @param {() => void} options.syncWriteSlashState - 소스 슬래시 메뉴 상태 동기화 함수 + * @returns {Object} textarea 포커스 실행 함수 + */ +export const useAdminMarkdownTextareaFocus = (options) => { + /** + * textarea와 동일한 폭·폰트로 각 논리 줄의 실제 wrap 높이를 측정한다. + * @returns {void} + */ + const syncGutterLineHeights = () => { + if (!import.meta.client) { + return + } + + nextTick(() => { + const textarea = options.getWriteTextareaElement() + + if (!textarea) { + options.gutterLineHeights.value = [] + return + } + + const style = window.getComputedStyle(textarea) + const fallbackLineHeight = parseCssPixelValue(style.lineHeight, 28) + const paddingLeft = parseCssPixelValue(style.paddingLeft, 0) + const paddingRight = parseCssPixelValue(style.paddingRight, 0) + const contentWidth = Math.max(1, textarea.clientWidth - paddingLeft - paddingRight) + const mirror = document.createElement('div') + + mirror.style.position = 'absolute' + mirror.style.left = '-10000px' + mirror.style.top = '0' + mirror.style.visibility = 'hidden' + mirror.style.pointerEvents = 'none' + mirror.style.width = `${contentWidth}px` + mirror.style.boxSizing = 'border-box' + mirror.style.whiteSpace = 'pre-wrap' + mirror.style.overflowWrap = style.overflowWrap || 'break-word' + mirror.style.wordBreak = style.wordBreak || 'normal' + mirror.style.font = style.font + mirror.style.letterSpacing = style.letterSpacing + mirror.style.lineHeight = style.lineHeight + + document.body.appendChild(mirror) + + const nextHeights = (options.value.value ?? '').split('\n').map((line) => { + const lineEl = document.createElement('div') + lineEl.textContent = line || ' ' + mirror.appendChild(lineEl) + + return Math.max(fallbackLineHeight, lineEl.getBoundingClientRect().height) + }) + + document.body.removeChild(mirror) + options.gutterLineHeights.value = nextHeights.length ? nextHeights : [fallbackLineHeight] + }) + } + + /** + * 거터 줄 높이를 반환한다. + * @param {number} lineIndex - 줄 번호(0-based) + * @returns {number} 줄 높이(px) + */ + const getGutterLineHeight = (lineIndex) => options.gutterLineHeights.value[lineIndex] || 28 + + /** + * textarea 높이를 본문 길이에 맞춘다. 내부 스크롤 없이 부모(`editor-scroll`)만 스크롤한다. + * @returns {void} + */ + const syncTextareaHeight = () => { + nextTick(() => { + const textarea = options.getWriteTextareaElement() + + if (!textarea) { + return + } + + textarea.style.height = '0px' + textarea.style.height = `${Math.max(options.minTextareaHeight, textarea.scrollHeight)}px` + syncGutterLineHeights() + }) + } + + /** + * 가장 가까운 스크롤 컨테이너를 찾는다. + * @param {HTMLElement|null} element - 기준 요소 + * @returns {HTMLElement|null} 스크롤 컨테이너 + */ + const getNearestScrollContainer = (element) => { + let current = element?.parentElement || null + + while (current && current !== document.body && current !== document.documentElement) { + const style = window.getComputedStyle(current) + + if (/(auto|scroll)/.test(`${style.overflowY} ${style.overflow}`) && current.scrollHeight > current.clientHeight) { + return current + } + + current = current.parentElement + } + + return null + } + + /** + * textarea의 지정 줄이 화면 안에 보이도록 스크롤한다. + * @param {number} lineIndex - 줄 번호(0-based) + * @returns {void} + */ + const scrollTextareaLineIntoView = (lineIndex) => { + const textarea = options.getWriteTextareaElement() + + if (!textarea || !import.meta.client) { + return + } + + const style = window.getComputedStyle(textarea) + const paddingTop = parseCssPixelValue(style.paddingTop, 0) + const lineTop = paddingTop + options.gutterLineHeights.value + .slice(0, Math.max(0, lineIndex)) + .reduce((sum, height) => sum + height, 0) + const lineHeight = getGutterLineHeight(lineIndex) + const lineBottom = lineTop + lineHeight + const padding = 140 + const scrollContainer = getNearestScrollContainer(options.editorRootRef.value) + + if (scrollContainer) { + const containerRect = scrollContainer.getBoundingClientRect() + const textareaRect = textarea.getBoundingClientRect() + const absoluteLineTop = textareaRect.top - containerRect.top + scrollContainer.scrollTop + lineTop + const absoluteLineBottom = textareaRect.top - containerRect.top + scrollContainer.scrollTop + lineBottom + const viewTop = scrollContainer.scrollTop + const viewBottom = viewTop + scrollContainer.clientHeight + + if (absoluteLineTop < viewTop + padding) { + scrollContainer.scrollTop = Math.max(0, absoluteLineTop - padding) + } else if (absoluteLineBottom > viewBottom - padding) { + scrollContainer.scrollTop = Math.max(0, absoluteLineBottom - scrollContainer.clientHeight + padding) + } + + return + } + + const textareaRect = textarea.getBoundingClientRect() + const absoluteLineTop = textareaRect.top + window.scrollY + lineTop + const absoluteLineBottom = textareaRect.top + window.scrollY + lineBottom + const viewTop = window.scrollY + const viewBottom = viewTop + window.innerHeight + + if (absoluteLineTop < viewTop + padding) { + window.scrollTo({ top: Math.max(0, absoluteLineTop - padding) }) + } else if (absoluteLineBottom > viewBottom - padding) { + window.scrollTo({ top: Math.max(0, absoluteLineBottom - window.innerHeight + padding) }) + } + } + + /** + * 커서 위치 기준으로 활성 논리 줄 인덱스를 갱신하고 거터 스크롤을 맞춘다. + * @returns {void} + */ + const refreshCaretLogicalLine = () => { + const textarea = options.getWriteTextareaElement() + + if (!textarea) { + return + } + + const value = textarea.value ?? options.value.value ?? '' + const start = Math.min(textarea.selectionStart, value.length) + const end = Math.min(textarea.selectionEnd, value.length) + const lineIndex = value.slice(0, start).split('\n').length - 1 + + options.writeModeContextValue.value = value + options.lastSelectionState.value = { + start, + end + } + options.activeLogicalLineIndex.value = Math.max(0, lineIndex) + options.syncBlockPanelState() + syncTextareaHeight() + + if (options.activeMode.value === 'write' && import.meta.client && document.activeElement === textarea) { + nextTick(options.syncWriteSlashState) + } + } + + /** + * textarea의 선택 영역과 스크롤 위치를 기억한다. + * @returns {void} + */ + const rememberTextareaSelection = () => { + const textarea = options.getWriteTextareaElement() + const value = options.value.value ?? '' + + if (!textarea) { + options.lastSelectionState.value = { + start: value.length, + end: value.length + } + return + } + + options.lastSelectionState.value = { + start: Math.min(textarea.selectionStart, value.length), + end: Math.min(textarea.selectionEnd, value.length) + } + } + + /** + * 소스 textarea의 지정 줄로 포커스와 커서를 이동한다. + * @param {number} lineIndex - 줄 번호(0-based) + * @returns {void} + */ + const focusTextareaAtLine = (lineIndex) => { + nextTick(() => { + const textarea = options.getWriteTextareaElement() + const value = options.value.value ?? '' + + if (!textarea) { + return + } + + const offset = Math.min(options.getLineStartOffset(lineIndex), value.length) + textarea.focus() + textarea.setSelectionRange(offset, offset) + syncTextareaHeight() + nextTick(() => scrollTextareaLineIntoView(lineIndex)) + refreshCaretLogicalLine() + }) + } + + /** + * 기억한 선택 영역과 스크롤 위치로 작성 textarea 포커스를 복원한다. + * @returns {void} + */ + const restoreTextareaFocus = () => { + nextTick(() => { + const textarea = options.getWriteTextareaElement() + const value = options.value.value ?? '' + + if (!textarea) { + return + } + + const start = Math.min(options.lastSelectionState.value.start, value.length) + const end = Math.min(options.lastSelectionState.value.end, value.length) + const lineIndex = value.slice(0, start).split('\n').length - 1 + + textarea.focus() + textarea.setSelectionRange(start, end) + syncTextareaHeight() + nextTick(() => scrollTextareaLineIntoView(lineIndex)) + refreshCaretLogicalLine() + }) + } + + return { + getGutterLineHeight, + getNearestScrollContainer, + syncGutterLineHeights, + syncTextareaHeight, + refreshCaretLogicalLine, + rememberTextareaSelection, + scrollTextareaLineIntoView, + focusTextareaAtLine, + restoreTextareaFocus + } +} diff --git a/docs/history.md b/docs/history.md index 6e51151..c0f0078 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,7 @@ +## 2026-07-28 v1.11.114 — textarea DOM 실행부는 전용 composable이 맡는다 + +관리자 Markdown 에디터의 textarea 높이 조절, 거터 줄 높이 측정, 현재 커서 줄 계산, 선택 기억과 복원, 지정 줄 스크롤은 모두 DOM 실행 세부사항이다. 이 로직이 모드 전환·블록 패널·슬래시 메뉴 정책과 같은 컴포넌트 안에 섞여 있으면 실제 정책 변경 없이도 에디터 파일이 계속 커진다. 따라서 `useAdminMarkdownTextareaFocus`가 textarea DOM 실행부를 맡고, 컴포넌트는 필요한 상태와 콜백만 주입해 편집 정책과 DOM 적용 단계를 분리한다. + ## 2026-07-28 v1.11.113 — 모드 전환 포커스 상태는 별도 브리지가 맡는다 관리자 Markdown 에디터의 작성/라이브 전환은 textarea 선택 위치, 라이브 편집 줄, 코드·콜아웃 같은 실제 편집 가능 줄 보정, 전환 후 DOM 포커스 실행 순서가 함께 얽힌다. 실제 DOM 조작은 부모 컴포넌트의 참조가 필요하지만, 어느 위치를 대기 상태로 저장하고 모드 변경 때 어떤 복원 콜백을 호출할지는 독립된 상태 흐름이다. 따라서 `useAdminMarkdownFocusBridge`가 pending focus와 모드 watch를 맡고, 에디터는 DOM 콜백만 주입해 전환 복원 정책을 테스트 가능한 단위로 둔다. diff --git a/docs/map.md b/docs/map.md index a9fa03a..fd6419a 100644 --- a/docs/map.md +++ b/docs/map.md @@ -28,6 +28,7 @@ | composables/useAdminMarkdownSlashMenu.js | 관리자 Markdown 에디터 슬래시 메뉴 상태·검색 결과·취소 당시 줄 내용 기준 억제·키보드 탐색 타이머 관리 | | composables/useAdminMarkdownHistory.js | 관리자 Markdown 에디터 undo/redo 스택·기록 그룹화·복원 중 상태 관리 | | composables/useAdminMarkdownFocusBridge.js | 관리자 Markdown 에디터 작성/라이브 모드 전환의 pending 포커스 상태, 위치 기억, 전환 후 textarea·라이브 편집 줄 복원 콜백 실행 순서 관리 | +| composables/useAdminMarkdownTextareaFocus.js | 관리자 Markdown 에디터 textarea 높이·거터 줄 높이·선택 기억·커서 줄 갱신·지정 줄 포커스·스크롤 실행 관리 | | 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 e01c469..af53129 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -3,7 +3,7 @@ ## v1.11 내부 개선 - 거대 컴포넌트 책임 분리 진행. - - `components/admin/AdminMarkdownEditor.vue`: textarea 줄 이동·라이브 편집 줄 포커스·스크롤 실행 콜백을 단계적으로 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 b4647b6..d86112d 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.11.114 + +- 관리자 Markdown 에디터의 textarea 높이 동기화·거터 줄 높이 측정·선택 기억·줄 포커스·스크롤 실행을 `useAdminMarkdownTextareaFocus` composable로 분리. +- 에디터 컴포넌트는 textarea DOM 참조와 블록/슬래시 상태 동기화 콜백만 주입하도록 정리. +- textarea 선택 기억, 커서 줄 갱신, 지정 줄 포커스, 선택 복원 단위 테스트 추가. + ## v1.11.113 - 관리자 Markdown 에디터의 작성/라이브 모드 전환 포커스 대기 상태와 복원 순서를 `useAdminMarkdownFocusBridge` composable로 분리. diff --git a/package-lock.json b/package-lock.json index 4f2df73..aafd6b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.113", + "version": "1.11.114", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.113", + "version": "1.11.114", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index e945945..b641ac0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.113", + "version": "1.11.114", "private": true, "type": "module", "imports": { diff --git a/tests/useAdminMarkdownTextareaFocus.test.js b/tests/useAdminMarkdownTextareaFocus.test.js new file mode 100644 index 0000000..4e93ed4 --- /dev/null +++ b/tests/useAdminMarkdownTextareaFocus.test.js @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { effectScope, nextTick, ref } from 'vue' +import { useAdminMarkdownTextareaFocus } from '../composables/useAdminMarkdownTextareaFocus.js' + +/** + * 테스트용 textarea DOM 대역을 만든다. + * @param {Object} options - textarea 옵션 + * @returns {Object} textarea 대역 + */ +const createTextareaStub = (options = {}) => { + const calls = { + focus: 0, + selections: [] + } + + const textarea = { + value: options.value ?? '첫 줄\n둘째 줄\n셋째 줄', + selectionStart: options.selectionStart ?? 0, + selectionEnd: options.selectionEnd ?? options.selectionStart ?? 0, + scrollHeight: options.scrollHeight ?? 700, + scrollTop: options.scrollTop ?? 0, + clientWidth: options.clientWidth ?? 720, + style: { + height: '' + }, + focus: () => { + calls.focus += 1 + }, + setSelectionRange: (start, end) => { + calls.selections.push([start, end]) + textarea.selectionStart = start + textarea.selectionEnd = end + }, + getBoundingClientRect: () => ({ + top: 0, + bottom: 700, + height: 700 + }), + calls + } + + return textarea +} + +/** + * 테스트용 textarea 포커스 composable 상태를 만든다. + * @param {Object} options - 테스트 옵션 + * @returns {Object} 테스트 상태 + */ +const createTextareaFocusState = (options = {}) => { + const scope = effectScope() + const value = ref(options.value ?? '첫 줄\n둘째 줄\n셋째 줄') + const activeMode = ref(options.activeMode ?? 'write') + const writeModeContextValue = ref(null) + const lastSelectionState = ref(options.lastSelectionState ?? { start: 0, end: 0 }) + const activeLogicalLineIndex = ref(0) + const gutterLineHeights = ref([28, 28, 28]) + const editorRootRef = ref(null) + const textarea = options.textarea ?? createTextareaStub({ value: value.value }) + const calls = { + syncBlockPanelState: 0, + syncWriteSlashState: 0 + } + let focusState + + scope.run(() => { + focusState = useAdminMarkdownTextareaFocus({ + value, + activeMode, + writeModeContextValue, + lastSelectionState, + activeLogicalLineIndex, + gutterLineHeights, + editorRootRef, + minTextareaHeight: 620, + getWriteTextareaElement: () => textarea, + getLineStartOffset: (lineIndex) => { + const lines = String(value.value).split('\n') + return lines.slice(0, lineIndex).join('\n').length + (lineIndex > 0 ? 1 : 0) + }, + syncBlockPanelState: () => { + calls.syncBlockPanelState += 1 + }, + syncWriteSlashState: () => { + calls.syncWriteSlashState += 1 + } + }) + }) + + return { + activeLogicalLineIndex, + calls, + focusState, + lastSelectionState, + textarea, + value, + writeModeContextValue, + stop: () => scope.stop() + } +} + +/** + * composable 내부 nextTick을 비운다. + * @returns {Promise} + */ +const flushTextareaFocusTicks = async () => { + await nextTick() + await nextTick() + await nextTick() +} + +test('textarea 선택 영역을 마지막 선택 상태로 기억한다', () => { + const textarea = createTextareaStub({ + value: 'abc\ndef', + selectionStart: 5, + selectionEnd: 7 + }) + const state = createTextareaFocusState({ + value: 'abc\ndef', + textarea + }) + + state.focusState.rememberTextareaSelection() + + assert.deepEqual(state.lastSelectionState.value, { + start: 5, + end: 7 + }) + state.stop() +}) + +test('커서 위치 기준으로 작성 컨텍스트와 활성 줄을 갱신한다', async () => { + const textarea = createTextareaStub({ + value: '첫 줄\n둘째 줄\n셋째 줄', + selectionStart: 8, + selectionEnd: 8 + }) + const state = createTextareaFocusState({ textarea }) + + state.focusState.refreshCaretLogicalLine() + await flushTextareaFocusTicks() + + assert.equal(state.writeModeContextValue.value, '첫 줄\n둘째 줄\n셋째 줄') + assert.deepEqual(state.lastSelectionState.value, { + start: 8, + end: 8 + }) + assert.equal(state.activeLogicalLineIndex.value, 1) + assert.equal(state.calls.syncBlockPanelState, 1) + assert.equal(textarea.style.height, '700px') + state.stop() +}) + +test('지정 줄로 textarea 포커스와 커서를 이동한다', async () => { + const textarea = createTextareaStub() + const state = createTextareaFocusState({ textarea }) + + state.focusState.focusTextareaAtLine(2) + await flushTextareaFocusTicks() + + assert.equal(textarea.calls.focus, 1) + assert.deepEqual(textarea.calls.selections[0], [9, 9]) + assert.equal(state.activeLogicalLineIndex.value, 2) + state.stop() +}) + +test('마지막 선택 상태로 textarea 포커스를 복원한다', async () => { + const textarea = createTextareaStub() + const state = createTextareaFocusState({ + textarea, + lastSelectionState: { + start: 5, + end: 8 + } + }) + + state.focusState.restoreTextareaFocus() + await flushTextareaFocusTicks() + + assert.equal(textarea.calls.focus, 1) + assert.deepEqual(textarea.calls.selections[0], [5, 8]) + assert.equal(state.activeLogicalLineIndex.value, 1) + state.stop() +})