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>

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-13 v1.0.12
### Markdown 에디터 줄 번호 거터
옵시디언·CodeMirror는 편집 줄 왼쪽에 줄 번호와 현재 줄 하이라이트를 둔다. 본문 편집이 textarea 단일 호스트로 바뀐 뒤에도 동일한 방향의 안내가 있으면 긴 본문에서 위치 파악이 쉬워진다. CodeMirror 수준의 **시각 줄**(줄바꿈 wrap) 단위 번호는 별도 미러 레이아웃 없이는 맞추기 어렵고, 우선 `\\n` 기준 **논리 줄** 번호와 캐럿이 속한 논리 줄의 거터 셀 배경 강조, 스크롤 동기화만 구현했다.
## 2026-05-14 v1.0.11
### 관리자 글쓰기를 Markdown-first로 전환

View File

@@ -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 | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |

View File

@@ -441,6 +441,7 @@ components/content/
### 관리자 글 편집
- 글 작성/수정 화면은 Markdown-first 에디터(`AdminMarkdownEditor`)를 사용한다.
- 작성 모드 textarea 왼쪽에 **논리 줄 번호** 거터(`\\n` 기준 줄 수, 빈 본문은 1줄)를 두고, 캐럿이 있는 줄 번호 행에 배경색으로 **활성 표시**를 한다. textarea와 거터의 세로 스크롤은 동기화한다. 한 논리 줄이 화면에서 여러 줄로 줄바꿈될 때는 옵시디언·CodeMirror처럼 시각 줄마다 번호가 늘지 않으며, 논리 줄 단위로만 맞춘다.
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 그대로 유지한다.
- 본문 작성 모드는 textarea 기반으로 범위 선택, 다중 문단 복사/붙여넣기, 외부 마크다운 붙여넣기를 브라우저 기본 동작에 가깝게 처리한다.
- 본문 미리보기 모드는 공개 본문과 같은 `ContentMarkdownRenderer`를 사용한다.

View File

@@ -1,5 +1,10 @@
# 업데이트 이력
## v1.0.12
- 관리자 `AdminMarkdownEditor` 작성 모드에 왼쪽 줄 번호 거터(`\\n` 기준 논리 줄)와 현재 줄 배경 강조 추가, textarea와 거터 세로 스크롤 동기화.
- 패키지 버전 `1.0.12`로 갱신.
## v1.0.11
- 관리자 글 본문 에디터를 블록형 `AdminBlockEditor`에서 Markdown-first `AdminMarkdownEditor`로 교체.

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.0.11",
"version": "1.0.12",
"private": true,
"type": "module",
"imports": {