에디터 textarea 포커스 실행 분리
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user