AdminMarkdownEditor: 논리 줄 번호 거터·현재 줄 강조(v1.0.12)

textarea 왼쪽 거터, 스크롤 동기화, 미리보기 문구 오타 수정.
명세·맵·이력 반영.
This commit is contained in:
2026-05-14 15:49:44 +09:00
parent eab81697e5
commit 5eb6c88381
6 changed files with 152 additions and 14 deletions

View File

@@ -9,7 +9,10 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const textareaRef = ref(null)
const gutterRef = ref(null)
const activeMode = ref('write')
/** 커서가 있는 논리 줄(0-based, `\\n` 기준) */
const activeLogicalLineIndex = ref(0)
const mediaItems = ref([])
const isMediaPickerOpen = ref(false)
const isLoadingMedia = ref(false)
@@ -29,6 +32,97 @@ const markdownValue = computed({
set: (value) => emit('update:modelValue', value)
})
/**
* 본문의 논리 줄(`\\n` 기준) 개수. 빈 본문은 1줄로 본다.
* @returns {number}
*/
const gutterLineCount = computed(() => {
const raw = markdownValue.value ?? ''
const n = raw.split('\n').length
return Math.max(1, n)
})
/**
* textarea와 줄 번호 거터의 세로 스크롤을 맞춘다.
* @returns {void}
*/
const syncGutterScroll = () => {
const gutter = gutterRef.value
const textarea = textareaRef.value
if (gutter && textarea) {
gutter.scrollTop = textarea.scrollTop
}
}
/**
* 커서 위치 기준으로 활성 논리 줄 인덱스를 갱신하고 거터 스크롤을 맞춘다.
* @returns {void}
*/
const refreshCaretLogicalLine = () => {
nextTick(() => {
const textarea = textareaRef.value
if (!textarea) {
return
}
const value = markdownValue.value ?? ''
const pos = Math.min(textarea.selectionStart, value.length)
const lineIndex = value.slice(0, pos).split('\n').length - 1
activeLogicalLineIndex.value = Math.max(0, lineIndex)
syncGutterScroll()
})
}
/**
* textarea 스크롤 시 거터만 동기화한다.
* @returns {void}
*/
const onTextareaScroll = () => {
syncGutterScroll()
}
watch(() => props.modelValue, () => {
refreshCaretLogicalLine()
})
watch(activeMode, (mode) => {
if (mode === 'write') {
refreshCaretLogicalLine()
}
})
onMounted(() => {
/**
* document selectionchange에서 작성 textarea가 포커스일 때만 활성 줄을 갱신한다.
* @returns {void}
*/
const onSelectionChange = () => {
if (activeMode.value !== 'write') {
return
}
const textarea = textareaRef.value
if (!textarea || document.activeElement !== textarea) {
return
}
refreshCaretLogicalLine()
}
document.addEventListener('selectionchange', onSelectionChange)
onBeforeUnmount(() => {
document.removeEventListener('selectionchange', onSelectionChange)
})
refreshCaretLogicalLine()
})
/**
* 본문 에디터에 포커스한다.
* @returns {void}
@@ -36,6 +130,7 @@ const markdownValue = computed({
const focusFirstBlock = () => {
nextTick(() => {
textareaRef.value?.focus()
refreshCaretLogicalLine()
})
}
@@ -81,6 +176,7 @@ const setTextareaSelection = (start, end = start) => {
textarea.focus()
textarea.setSelectionRange(start, end)
refreshCaretLogicalLine()
})
}
@@ -491,17 +587,39 @@ const handleKeydown = (event) => {
</div>
<div v-if="activeMode === 'write'" class="admin-markdown-editor__write relative">
<textarea
ref="textareaRef"
v-model="markdownValue"
class="admin-markdown-editor__textarea min-h-[620px] w-full resize-y rounded border border-[#e3e6e8] bg-white px-5 py-5 font-mono text-[15px] leading-7 text-[#15171a] outline-none transition-colors placeholder:text-[#8e9cac] focus:border-[#15171a]"
placeholder="마크다운으로 글을 작성하세요."
spellcheck="false"
@keydown="handleKeydown"
@paste="handlePaste"
@drop="handleDrop"
@dragover.prevent
/>
<div class="admin-markdown-editor__editor-surface flex min-h-[620px] overflow-hidden rounded border border-[#e3e6e8] bg-white">
<div
ref="gutterRef"
class="admin-markdown-editor__gutter min-w-[2.75rem] shrink-0 select-none overflow-y-auto overflow-x-hidden border-r border-[#e3e6e8] bg-[#f6f7f8] py-5 font-mono text-[13px] leading-7 text-[#8e9cac]"
aria-hidden="true"
>
<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"
:class="{ 'admin-markdown-editor__gutter-line--active': ln - 1 === activeLogicalLineIndex }"
>
{{ ln }}
</div>
</div>
<textarea
ref="textareaRef"
v-model="markdownValue"
class="admin-markdown-editor__textarea min-h-[620px] flex-1 resize-y border-0 bg-transparent py-5 pl-2 pr-5 font-mono 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="refreshCaretLogicalLine"
/>
</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">
업로드
</div>
@@ -510,7 +628,7 @@ const handleKeydown = (event) => {
<div v-else class="admin-markdown-editor__preview min-h-[620px] rounded border border-[#e3e6e8] bg-white px-6 py-5">
<ContentMarkdownRenderer v-if="markdownValue.trim()" :content="markdownValue" />
<p v-else class="admin-markdown-editor__preview-empty text-sm text-[#8e9cac]">
미리 본문이 없습니다.
미리보기할 본문이 없습니다.
</p>
</div>
@@ -571,3 +689,11 @@ const handleKeydown = (event) => {
</div>
</div>
</template>
<style scoped>
.admin-markdown-editor__gutter-line--active {
background-color: rgba(46, 182, 234, 0.16);
color: #15171a;
font-weight: 600;
}
</style>