import { nextTick, unref } from 'vue' /** * 관리자 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.activeLogicalLineIndex - 현재 논리 줄 * @param {() => HTMLTextAreaElement|null} options.getWriteTextareaElement - 작성 textarea 반환 함수 * @param {(offset: number) => number} options.getLineIndexAtOffset - 오프셋 기준 줄 계산 함수 * @param {(lineIndex: number) => number} options.getLineStartOffset - 줄 시작 오프셋 계산 함수 * @param {(start: number, end?: number, options?: { preserveScroll?: boolean }) => void} options.setTextareaSelection - textarea 선택 복원 함수 * @param {(lineIndex: number) => void} options.focusTextareaAtLine - 지정 줄 textarea 포커스 함수 * @param {() => void} options.refreshCaretLogicalLine - 현재 커서 줄 갱신 함수 * @param {() => void} options.scrollPreviewToTop - 라이브 미리보기 상단 이동 함수 * @param {(line: number, childIndex: number, position: string, offset?: number, scrollAlign?: string) => void} options.focusPreviewEditableAtLine - 라이브 편집 줄 포커스 함수 * @returns {{ * focusFirstBlock: () => void, * getInitialContentFocusLineIndex: () => number, * ensureContentFocusLineExists: (lineIndex: number) => number, * restoreEditorFocusAfterHistory: (selection?: { start: number, end: number }|null) => void * }} 공통 포커스 액션 */ export const useAdminMarkdownEditorFocusActions = (options) => { /** * 본문 작성 시작 시 포커스할 첫 줄 인덱스를 반환한다. * @returns {number} 줄 번호(0-based) */ const getInitialContentFocusLineIndex = () => { const lines = String(unref(options.value) ?? '').split('\n') if (!lines.length) { return 0 } const firstLine = lines[0]?.trim() ?? '' if (/^#{1,6}\s+/.test(firstLine)) { return 1 } return 0 } /** * 본문 작성 시작 줄이 없으면 빈 줄을 추가한다. * @param {number} lineIndex - 포커스할 줄 * @returns {number} 보정된 줄 번호 */ const ensureContentFocusLineExists = (lineIndex) => { const value = String(unref(options.value) ?? '') const lines = value.split('\n') if (lineIndex < lines.length) { return lineIndex } options.value.value = lines.length ? `${value}\n` : '' return lines.length } /** * 본문 에디터에 포커스한다. 현재 모드(write/preview)를 유지한다. * @returns {void} */ const focusFirstBlock = () => { const focusLine = ensureContentFocusLineExists(getInitialContentFocusLineIndex()) if (options.activeMode.value === 'preview') { options.scrollPreviewToTop() nextTick(() => { nextTick(() => { options.focusPreviewEditableAtLine(focusLine, 0, 'start') }) }) return } nextTick(() => { const textarea = options.getWriteTextareaElement() const lineStart = options.getLineStartOffset(focusLine) if (textarea) { textarea.focus() textarea.setSelectionRange(lineStart, lineStart) textarea.scrollTop = 0 } options.activeLogicalLineIndex.value = focusLine options.refreshCaretLogicalLine() }) } /** * 히스토리 복원 후 현재 모드에 맞게 포커스를 복원한다. * @param {{ start: number, end: number }|null} [selection=null] - 복원할 선택 영역 * @returns {void} */ const restoreEditorFocusAfterHistory = (selection = null) => { const focusLine = selection ? options.getLineIndexAtOffset(selection.start) : options.activeLogicalLineIndex.value if (options.activeMode.value === 'write') { if (selection) { options.setTextareaSelection(selection.start, selection.end, { preserveScroll: true }) return } options.focusTextareaAtLine(focusLine) return } nextTick(() => { const offset = selection ? Math.max(0, selection.start - options.getLineStartOffset(focusLine)) : 0 options.focusPreviewEditableAtLine(focusLine, 0, 'auto', offset, 'center') }) } return { focusFirstBlock, getInitialContentFocusLineIndex, ensureContentFocusLineExists, restoreEditorFocusAfterHistory } }