From 5eb6c883816c1cf251803cd6c21993fd297fa538 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 14 May 2026 15:49:44 +0900 Subject: [PATCH] =?UTF-8?q?AdminMarkdownEditor:=20=EB=85=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=A4=84=20=EB=B2=88=ED=98=B8=20=EA=B1=B0=ED=84=B0=C2=B7?= =?UTF-8?q?=ED=98=84=EC=9E=AC=20=EC=A4=84=20=EA=B0=95=EC=A1=B0(v1.0.12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit textarea 왼쪽 거터, 스크롤 동기화, 미리보기 문구 오타 수정. 명세·맵·이력 반영. --- components/admin/AdminMarkdownEditor.vue | 150 +++++++++++++++++++++-- docs/history.md | 6 + docs/map.md | 2 +- docs/spec.md | 1 + docs/update.md | 5 + package.json | 2 +- 6 files changed, 152 insertions(+), 14 deletions(-) diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 16c12bc..52c8ff9 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -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) => {
-