v1.2.3: 마크다운 에디터 외부 스크롤 및 줄 번호 정렬

textarea 내부 스크롤을 없애고 본문 높이를 자동으로 늘려, 글 편집 영역 스크롤과 줄 번호가 어긋나지 않도록 했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-15 18:34:38 +09:00
parent c474a8b9a3
commit a867269d9b
4 changed files with 69 additions and 68 deletions

View File

@@ -42,10 +42,12 @@ const mediaSearchQuery = ref('')
const selectedMediaUrls = ref([])
const lastSelectionState = ref({
start: 0,
end: 0,
scrollTop: 0
end: 0
})
/** 작성 textarea 최소 높이(px) */
const MIN_TEXTAREA_HEIGHT_PX = 620
const markdownValue = computed({
get: () => normalizeMarkdownContent(props.modelValue),
set: (value) => emit('update:modelValue', value)
@@ -120,16 +122,20 @@ const gutterLineCount = computed(() => {
})
/**
* textarea와 줄 번호 거터의 세로 스크롤을 맞춘다.
* textarea 높이를 본문 길이에 맞춘다. 내부 스크롤 없이 부모(`editor-scroll`)만 스크롤한다.
* @returns {void}
*/
const syncGutterScroll = () => {
const gutter = gutterRef.value
const textarea = textareaRef.value
const syncTextareaHeight = () => {
nextTick(() => {
const textarea = textareaRef.value
if (gutter && textarea) {
gutter.scrollTop = textarea.scrollTop
}
if (!textarea) {
return
}
textarea.style.height = '0px'
textarea.style.height = `${Math.max(MIN_TEXTAREA_HEIGHT_PX, textarea.scrollHeight)}px`
})
}
/**
@@ -203,24 +209,14 @@ const refreshCaretLogicalLine = () => {
lastSelectionState.value = {
start: Math.min(textarea.selectionStart, value.length),
end: Math.min(textarea.selectionEnd, value.length),
scrollTop: textarea.scrollTop
end: Math.min(textarea.selectionEnd, value.length)
}
activeLogicalLineIndex.value = Math.max(0, lineIndex)
syncGutterScroll()
syncTextareaHeight()
syncBlockPanelState()
})
}
/**
* textarea 스크롤 시 선택 위치를 기억하고 거터 스크롤을 맞춘다.
* @returns {void}
*/
const onTextareaScroll = () => {
rememberTextareaSelection()
syncGutterScroll()
}
/**
* textarea의 선택 영역과 스크롤 위치를 기억한다.
* @returns {void}
@@ -232,16 +228,14 @@ const rememberTextareaSelection = () => {
if (!textarea) {
lastSelectionState.value = {
start: value.length,
end: value.length,
scrollTop: 0
end: value.length
}
return
}
lastSelectionState.value = {
start: Math.min(textarea.selectionStart, value.length),
end: Math.min(textarea.selectionEnd, value.length),
scrollTop: textarea.scrollTop
end: Math.min(textarea.selectionEnd, value.length)
}
}
@@ -263,7 +257,8 @@ const restoreTextareaFocus = () => {
textarea.focus()
textarea.setSelectionRange(start, end)
textarea.scrollTop = lastSelectionState.value.scrollTop
syncTextareaHeight()
textarea.scrollIntoView({ block: 'nearest', inline: 'nearest' })
refreshCaretLogicalLine()
})
}
@@ -1278,37 +1273,38 @@ const handleKeydown = (event) => {
<div v-if="activeMode === 'write'" class="admin-markdown-editor__write relative">
<div class="admin-markdown-editor__editor-surface min-h-[620px]">
<div
ref="gutterRef"
class="admin-markdown-editor__gutter absolute bottom-0 left-[-40px] top-0 w-10 select-none overflow-y-hidden overflow-x-hidden py-5 font-mono text-[13px] leading-7 text-[#a0a8b0]"
aria-hidden="true"
>
<div class="admin-markdown-editor__write-columns flex items-start">
<div
v-for="ln in gutterLineCount"
:key="`gutter-line-${ln}`"
class="admin-markdown-editor__gutter-line min-h-[28px] pr-2 text-right tabular-nums"
ref="gutterRef"
class="admin-markdown-editor__gutter w-10 shrink-0 select-none py-5 pr-2 font-mono text-[13px] leading-7 text-[#a0a8b0]"
aria-hidden="true"
>
{{ ln }}
<div
v-for="ln in gutterLineCount"
:key="`gutter-line-${ln}`"
class="admin-markdown-editor__gutter-line min-h-[28px] text-right tabular-nums"
>
{{ ln }}
</div>
</div>
<textarea
ref="textareaRef"
v-model="markdownValue"
class="admin-markdown-editor__textarea min-h-[620px] min-w-0 flex-1 resize-none overflow-hidden break-words border-0 bg-transparent py-5 pl-0 pr-5 text-[15px] leading-7 text-[#15171a] outline-none transition-colors placeholder:text-[#8e9cac] focus:ring-0"
placeholder="마크다운으로 글을 작성하세요."
spellcheck="false"
@keydown="handleKeydown"
@paste="handlePaste"
@drop="handleDrop"
@dragover.prevent
@input="refreshCaretLogicalLine"
@click="refreshCaretLogicalLine"
@keyup="refreshCaretLogicalLine"
@select="refreshCaretLogicalLine"
@focus="onTextareaFocus"
@blur="onTextareaBlur"
/>
</div>
<textarea
ref="textareaRef"
v-model="markdownValue"
class="admin-markdown-editor__textarea min-h-[620px] w-full resize-y border-0 bg-transparent py-5 pl-0 pr-5 text-[15px] leading-7 text-[#15171a] outline-none transition-colors placeholder:text-[#8e9cac] focus:ring-0"
placeholder="마크다운으로 글을 작성하세요."
spellcheck="false"
@keydown="handleKeydown"
@paste="handlePaste"
@drop="handleDrop"
@dragover.prevent
@scroll="onTextareaScroll"
@input="refreshCaretLogicalLine"
@click="refreshCaretLogicalLine"
@keyup="refreshCaretLogicalLine"
@select="refreshCaretLogicalLine"
@focus="onTextareaFocus"
@blur="onTextareaBlur"
/>
</div>
<div v-if="isUploading" class="admin-markdown-editor__uploading absolute right-3 top-3 rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white">
업로드