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 } }