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) => {
-
+
업로드 중
@@ -510,7 +628,7 @@ const handleKeydown = (event) => {
- 미리볼 본문이 없습니다.
+ 미리보기할 본문이 없습니다.
@@ -571,3 +689,11 @@ const handleKeydown = (event) => {
+
+
diff --git a/docs/history.md b/docs/history.md
index 537277b..f6ef715 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -1,5 +1,11 @@
# 의사결정 이력
+## 2026-05-13 v1.0.12
+
+### Markdown 에디터 줄 번호 거터
+
+옵시디언·CodeMirror는 편집 줄 왼쪽에 줄 번호와 현재 줄 하이라이트를 둔다. 본문 편집이 textarea 단일 호스트로 바뀐 뒤에도 동일한 방향의 안내가 있으면 긴 본문에서 위치 파악이 쉬워진다. CodeMirror 수준의 **시각 줄**(줄바꿈 wrap) 단위 번호는 별도 미러 레이아웃 없이는 맞추기 어렵고, 우선 `\\n` 기준 **논리 줄** 번호와 캐럿이 속한 논리 줄의 거터 셀 배경 강조, 스크롤 동기화만 구현했다.
+
## 2026-05-14 v1.0.11
### 관리자 글쓰기를 Markdown-first로 전환
diff --git a/docs/map.md b/docs/map.md
index 6c682cf..20a20fa 100644
--- a/docs/map.md
+++ b/docs/map.md
@@ -64,7 +64,7 @@
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
-| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, 작성/미리보기 전환, 툴바 마크다운 삽입, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입 |
+| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, 작성 모드 왼쪽 논리 줄 번호 거터·현재 줄 강조·거터 스크롤 동기화, 작성/미리보기 전환, 툴바 마크다운 삽입, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
diff --git a/docs/spec.md b/docs/spec.md
index 0311a87..8fcb93a 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -441,6 +441,7 @@ components/content/
### 관리자 글 편집
- 글 작성/수정 화면은 Markdown-first 에디터(`AdminMarkdownEditor`)를 사용한다.
+- 작성 모드 textarea 왼쪽에 **논리 줄 번호** 거터(`\\n` 기준 줄 수, 빈 본문은 1줄)를 두고, 캐럿이 있는 줄 번호 행에 배경색으로 **활성 표시**를 한다. textarea와 거터의 세로 스크롤은 동기화한다. 한 논리 줄이 화면에서 여러 줄로 줄바꿈될 때는 옵시디언·CodeMirror처럼 시각 줄마다 번호가 늘지 않으며, 논리 줄 단위로만 맞춘다.
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 그대로 유지한다.
- 본문 작성 모드는 textarea 기반으로 범위 선택, 다중 문단 복사/붙여넣기, 외부 마크다운 붙여넣기를 브라우저 기본 동작에 가깝게 처리한다.
- 본문 미리보기 모드는 공개 본문과 같은 `ContentMarkdownRenderer`를 사용한다.
diff --git a/docs/update.md b/docs/update.md
index b5749c3..05791a9 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,10 @@
# 업데이트 이력
+## v1.0.12
+
+- 관리자 `AdminMarkdownEditor` 작성 모드에 왼쪽 줄 번호 거터(`\\n` 기준 논리 줄)와 현재 줄 배경 강조 추가, textarea와 거터 세로 스크롤 동기화.
+- 패키지 버전 `1.0.12`로 갱신.
+
## v1.0.11
- 관리자 글 본문 에디터를 블록형 `AdminBlockEditor`에서 Markdown-first `AdminMarkdownEditor`로 교체.
diff --git a/package.json b/package.json
index 9c4ecfa..2c79f8c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sori.studio",
- "version": "1.0.11",
+ "version": "1.0.12",
"private": true,
"type": "module",
"imports": {