에디터 textarea 포커스 실행 분리
This commit is contained in:
@@ -69,6 +69,7 @@ import { useAdminMarkdownSlashMenu } from '../../composables/useAdminMarkdownSla
|
|||||||
import { useAdminMarkdownHistory } from '../../composables/useAdminMarkdownHistory.js'
|
import { useAdminMarkdownHistory } from '../../composables/useAdminMarkdownHistory.js'
|
||||||
import { useAdminMarkdownMediaPicker } from '../../composables/useAdminMarkdownMediaPicker.js'
|
import { useAdminMarkdownMediaPicker } from '../../composables/useAdminMarkdownMediaPicker.js'
|
||||||
import { useAdminMarkdownFocusBridge } from '../../composables/useAdminMarkdownFocusBridge.js'
|
import { useAdminMarkdownFocusBridge } from '../../composables/useAdminMarkdownFocusBridge.js'
|
||||||
|
import { useAdminMarkdownTextareaFocus } from '../../composables/useAdminMarkdownTextareaFocus.js'
|
||||||
import {
|
import {
|
||||||
buildDefaultUploadSizeLimits,
|
buildDefaultUploadSizeLimits,
|
||||||
DEFAULT_UPLOAD_MAX_FILE_COUNT,
|
DEFAULT_UPLOAD_MAX_FILE_COUNT,
|
||||||
@@ -192,9 +193,6 @@ let liveSlashPreviewScrollElement = null
|
|||||||
/** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 작성 textarea */
|
/** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 작성 textarea */
|
||||||
let liveSlashTextareaScrollElement = null
|
let liveSlashTextareaScrollElement = null
|
||||||
|
|
||||||
/** 작성 textarea 최소 높이(px) */
|
|
||||||
const MIN_TEXTAREA_HEIGHT_PX = 620
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 작성 표면의 textarea DOM을 반환한다.
|
* 작성 표면의 textarea DOM을 반환한다.
|
||||||
* @returns {HTMLTextAreaElement|null} textarea DOM
|
* @returns {HTMLTextAreaElement|null} textarea DOM
|
||||||
@@ -583,17 +581,6 @@ const gutterLineCount = computed(() => {
|
|||||||
return Math.max(1, n)
|
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의 줄 시작 오프셋을 반환한다.
|
* 소스 textarea의 줄 시작 오프셋을 반환한다.
|
||||||
* @param {number} lineIndex - 줄 번호(0-based)
|
* @param {number} lineIndex - 줄 번호(0-based)
|
||||||
@@ -612,6 +599,30 @@ const getLineIndexAtOffset = (offset) => {
|
|||||||
return getMarkdownLineIndexAtOffset(markdownValue.value, 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 - 복원할 선택 영역
|
* @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 포커스 진입
|
* textarea 포커스 진입
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -817,36 +750,6 @@ const handleTextareaCompositionEnd = () => {
|
|||||||
nextTick(syncBlockPanelState)
|
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 현재 줄의 슬래시 입력 상태를 갱신한다.
|
* 소스(작성) 모드 textarea 현재 줄의 슬래시 입력 상태를 갱신한다.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -886,149 +789,6 @@ const syncWriteSlashState = () => {
|
|||||||
nextTick(updateLiveSlashMenuPosition)
|
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, () => {
|
watch(() => props.modelValue, () => {
|
||||||
if (isBlockPanelEngaged.value) {
|
if (isBlockPanelEngaged.value) {
|
||||||
syncBlockPanelState()
|
syncBlockPanelState()
|
||||||
|
|||||||
294
composables/useAdminMarkdownTextareaFocus.js
Normal file
294
composables/useAdminMarkdownTextareaFocus.js
Normal file
@@ -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<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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 — 모드 전환 포커스 상태는 별도 브리지가 맡는다
|
## 2026-07-28 v1.11.113 — 모드 전환 포커스 상태는 별도 브리지가 맡는다
|
||||||
|
|
||||||
관리자 Markdown 에디터의 작성/라이브 전환은 textarea 선택 위치, 라이브 편집 줄, 코드·콜아웃 같은 실제 편집 가능 줄 보정, 전환 후 DOM 포커스 실행 순서가 함께 얽힌다. 실제 DOM 조작은 부모 컴포넌트의 참조가 필요하지만, 어느 위치를 대기 상태로 저장하고 모드 변경 때 어떤 복원 콜백을 호출할지는 독립된 상태 흐름이다. 따라서 `useAdminMarkdownFocusBridge`가 pending focus와 모드 watch를 맡고, 에디터는 DOM 콜백만 주입해 전환 복원 정책을 테스트 가능한 단위로 둔다.
|
관리자 Markdown 에디터의 작성/라이브 전환은 textarea 선택 위치, 라이브 편집 줄, 코드·콜아웃 같은 실제 편집 가능 줄 보정, 전환 후 DOM 포커스 실행 순서가 함께 얽힌다. 실제 DOM 조작은 부모 컴포넌트의 참조가 필요하지만, 어느 위치를 대기 상태로 저장하고 모드 변경 때 어떤 복원 콜백을 호출할지는 독립된 상태 흐름이다. 따라서 `useAdminMarkdownFocusBridge`가 pending focus와 모드 watch를 맡고, 에디터는 DOM 콜백만 주입해 전환 복원 정책을 테스트 가능한 단위로 둔다.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
| composables/useAdminMarkdownSlashMenu.js | 관리자 Markdown 에디터 슬래시 메뉴 상태·검색 결과·취소 당시 줄 내용 기준 억제·키보드 탐색 타이머 관리 |
|
| composables/useAdminMarkdownSlashMenu.js | 관리자 Markdown 에디터 슬래시 메뉴 상태·검색 결과·취소 당시 줄 내용 기준 억제·키보드 탐색 타이머 관리 |
|
||||||
| composables/useAdminMarkdownHistory.js | 관리자 Markdown 에디터 undo/redo 스택·기록 그룹화·복원 중 상태 관리 |
|
| composables/useAdminMarkdownHistory.js | 관리자 Markdown 에디터 undo/redo 스택·기록 그룹화·복원 중 상태 관리 |
|
||||||
| composables/useAdminMarkdownFocusBridge.js | 관리자 Markdown 에디터 작성/라이브 모드 전환의 pending 포커스 상태, 위치 기억, 전환 후 textarea·라이브 편집 줄 복원 콜백 실행 순서 관리 |
|
| composables/useAdminMarkdownFocusBridge.js | 관리자 Markdown 에디터 작성/라이브 모드 전환의 pending 포커스 상태, 위치 기억, 전환 후 textarea·라이브 편집 줄 복원 콜백 실행 순서 관리 |
|
||||||
|
| composables/useAdminMarkdownTextareaFocus.js | 관리자 Markdown 에디터 textarea 높이·거터 줄 높이·선택 기억·커서 줄 갱신·지정 줄 포커스·스크롤 실행 관리 |
|
||||||
| composables/useAdminMarkdownMediaPicker.js | 관리자 Markdown 에디터 미디어 선택 모달 열기·닫기·탭·검색어·페이지·선택 상태·표시 계산, 서버 미디어 목록 조회, 업로드 한도 검증·전송, 업로드 후 라이브러리 탭 전환·선택 상태 갱신 관리 |
|
| composables/useAdminMarkdownMediaPicker.js | 관리자 Markdown 에디터 미디어 선택 모달 열기·닫기·탭·검색어·페이지·선택 상태·표시 계산, 서버 미디어 목록 조회, 업로드 한도 검증·전송, 업로드 후 라이브러리 탭 전환·선택 상태 갱신 관리 |
|
||||||
| composables/useContentMarkdownBlocks.js | 공개 Markdown 본문 파싱·제목 ID·인아티클 광고 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산 |
|
| composables/useContentMarkdownBlocks.js | 공개 Markdown 본문 파싱·제목 ID·인아티클 광고 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산 |
|
||||||
| composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 |
|
| composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## v1.11 내부 개선
|
## v1.11 내부 개선
|
||||||
|
|
||||||
- 거대 컴포넌트 책임 분리 진행.
|
- 거대 컴포넌트 책임 분리 진행.
|
||||||
- `components/admin/AdminMarkdownEditor.vue`: textarea 줄 이동·라이브 편집 줄 포커스·스크롤 실행 콜백을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
- `components/admin/AdminMarkdownEditor.vue`: 라이브 편집 줄 포커스·블록 패널 표시 계산을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
||||||
- 미디어 카탈로그 전환 코드 수명 정리.
|
- 미디어 카탈로그 전환 코드 수명 정리.
|
||||||
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
||||||
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.
|
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.11.114
|
||||||
|
|
||||||
|
- 관리자 Markdown 에디터의 textarea 높이 동기화·거터 줄 높이 측정·선택 기억·줄 포커스·스크롤 실행을 `useAdminMarkdownTextareaFocus` composable로 분리.
|
||||||
|
- 에디터 컴포넌트는 textarea DOM 참조와 블록/슬래시 상태 동기화 콜백만 주입하도록 정리.
|
||||||
|
- textarea 선택 기억, 커서 줄 갱신, 지정 줄 포커스, 선택 복원 단위 테스트 추가.
|
||||||
|
|
||||||
## v1.11.113
|
## v1.11.113
|
||||||
|
|
||||||
- 관리자 Markdown 에디터의 작성/라이브 모드 전환 포커스 대기 상태와 복원 순서를 `useAdminMarkdownFocusBridge` composable로 분리.
|
- 관리자 Markdown 에디터의 작성/라이브 모드 전환 포커스 대기 상태와 복원 순서를 `useAdminMarkdownFocusBridge` composable로 분리.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.113",
|
"version": "1.11.114",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.113",
|
"version": "1.11.114",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.113",
|
"version": "1.11.114",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
185
tests/useAdminMarkdownTextareaFocus.test.js
Normal file
185
tests/useAdminMarkdownTextareaFocus.test.js
Normal file
@@ -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<void>}
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user