Files
sori.studio/composables/useAdminMarkdownTextareaFocus.js

295 lines
10 KiB
JavaScript

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<string>} options.value - Markdown 본문
* @param {import('vue').Ref<'write'|'preview'|string>} options.activeMode - 현재 에디터 모드
* @param {import('vue').Ref<string|null>} options.writeModeContextValue - 작성 모드 최신 본문
* @param {import('vue').Ref<{ start: number, end: number }>} options.lastSelectionState - 마지막 선택 위치
* @param {import('vue').Ref<number>} options.activeLogicalLineIndex - 현재 논리 줄
* @param {import('vue').Ref<number[]>} options.gutterLineHeights - 거터 줄별 높이
* @param {import('vue').Ref<HTMLElement|null>} 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
}
}