관리자 에디터 작성 표면 컴포넌트 분리

This commit is contained in:
2026-07-27 10:14:25 +09:00
parent 636d4f68a4
commit 622f19235e
6 changed files with 174 additions and 75 deletions

View File

@@ -65,7 +65,7 @@ import {
} from '../../lib/upload-size-limit.js'
import AdminMarkdownModeToggle from './AdminMarkdownModeToggle.vue'
import AdminMarkdownSlashMenu from './AdminMarkdownSlashMenu.vue'
import AdminMarkdownUploadingBadge from './AdminMarkdownUploadingBadge.vue'
import AdminMarkdownWriteSurface from './AdminMarkdownWriteSurface.vue'
const { toast, showToast } = useAdminToast()
const runtimeConfig = useRuntimeConfig()
@@ -100,10 +100,9 @@ const activeMode = defineModel('editorMode', {
})
const editorRootRef = ref(null)
const textareaRef = ref(null)
const writeSurfaceRef = ref(null)
const previewRef = ref(null)
const previewRendererRef = ref(null)
const gutterRef = ref(null)
/** 커서가 있는 논리 줄(0-based, `\\n` 기준) */
const activeLogicalLineIndex = ref(0)
/** 소스 모드 입력 이벤트 직후 textarea의 최신 본문 값 */
@@ -178,13 +177,19 @@ const mediaPickerKind = computed(() => getMediaPickerKindForTarget(mediaPickerTa
/** 작성 textarea 최소 높이(px) */
const MIN_TEXTAREA_HEIGHT_PX = 620
/**
* 작성 표면의 textarea DOM을 반환한다.
* @returns {HTMLTextAreaElement|null} textarea DOM
*/
const getWriteTextareaElement = () => writeSurfaceRef.value?.getTextareaElement?.() ?? null
const markdownValue = computed({
get: () => normalizeMarkdownContent(props.modelValue),
set: (value) => emit('update:modelValue', value)
})
watch(markdownValue, () => {
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
if (activeMode.value !== 'write'
|| !textarea
@@ -270,7 +275,7 @@ const canApplyInlineColorAtLine = (lineIndex) => {
* @returns {{ kind: 'inline-color', mode: 'source', start: number, end: number, text: string }|null} 선택 컨텍스트
*/
const getSourceInlineColorSelectionContext = () => {
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
if (activeMode.value !== 'write' || !textarea || document.activeElement !== textarea) {
return null
@@ -785,7 +790,7 @@ const resolvePreviewFocusPosition = (line, offset) => {
* @returns {void}
*/
const rememberWritePositionForPreview = () => {
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
const value = markdownValue.value ?? ''
const start = textarea
? Math.min(textarea.selectionStart, value.length)
@@ -810,7 +815,7 @@ const syncGutterLineHeights = () => {
}
nextTick(() => {
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
if (!textarea) {
gutterLineHeights.value = []
@@ -866,7 +871,7 @@ const getGutterLineHeight = (lineIndex) => gutterLineHeights.value[lineIndex] ||
*/
const syncTextareaHeight = () => {
nextTick(() => {
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
if (!textarea) {
return
@@ -958,7 +963,7 @@ const handleTextareaCompositionEnd = () => {
* @returns {void}
*/
const refreshCaretLogicalLine = () => {
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
if (!textarea) {
return
@@ -1028,7 +1033,7 @@ const syncWriteSlashState = () => {
* @returns {void}
*/
const rememberTextareaSelection = () => {
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
const value = markdownValue.value ?? ''
if (!textarea) {
@@ -1072,7 +1077,7 @@ const getNearestScrollContainer = (element) => {
* @returns {void}
*/
const scrollTextareaLineIntoView = (lineIndex) => {
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
if (!textarea || !import.meta.client) {
return
@@ -1125,7 +1130,7 @@ const scrollTextareaLineIntoView = (lineIndex) => {
*/
const focusTextareaAtLine = (lineIndex) => {
nextTick(() => {
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
const value = markdownValue.value ?? ''
if (!textarea) {
@@ -1147,7 +1152,7 @@ const focusTextareaAtLine = (lineIndex) => {
*/
const restoreTextareaFocus = () => {
nextTick(() => {
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
const value = markdownValue.value ?? ''
if (!textarea) {
@@ -1350,7 +1355,7 @@ onMounted(() => {
return
}
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
if (!textarea || document.activeElement !== textarea) {
return
@@ -1505,7 +1510,7 @@ const focusFirstBlock = () => {
}
nextTick(() => {
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
const lineStart = getLineStartOffset(focusLine)
if (textarea) {
@@ -1524,7 +1529,7 @@ const focusFirstBlock = () => {
* @returns {{ start: number, end: number, value: string }} 선택 정보
*/
const getSelectionState = () => {
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
const value = markdownValue.value
if (!textarea) {
@@ -1555,7 +1560,7 @@ const setTextareaSelection = (start, end = start, options = {}) => {
: null
const previousScrollState = options.preserveScroll
? {
textareaTop: textareaRef.value?.scrollTop,
textareaTop: getWriteTextareaElement()?.scrollTop,
containerTop: scrollContainer?.scrollTop,
windowX: import.meta.client ? window.scrollX : 0,
windowY: import.meta.client ? window.scrollY : 0
@@ -1586,7 +1591,7 @@ const setTextareaSelection = (start, end = start, options = {}) => {
}
nextTick(() => {
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
if (!textarea) {
return
}
@@ -2376,7 +2381,7 @@ const updatePreviewSlashMenuPosition = () => {
* @returns {void}
*/
const updateWriteSlashMenuPosition = () => {
const textarea = textareaRef.value
const textarea = getWriteTextareaElement()
const line = liveSlashSourceLine.value
if (!textarea || line === null) {
@@ -2530,7 +2535,7 @@ watch(liveSlashVisible, (visible) => {
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
previewRef.value?.addEventListener('scroll', onReposition)
textareaRef.value?.addEventListener('scroll', onReposition)
getWriteTextareaElement()?.addEventListener('scroll', onReposition)
updateLiveSlashMenuPosition()
scrollLiveSlashHighlightedIntoView()
return
@@ -2539,7 +2544,7 @@ watch(liveSlashVisible, (visible) => {
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
previewRef.value?.removeEventListener('scroll', onReposition)
textareaRef.value?.removeEventListener('scroll', onReposition)
getWriteTextareaElement()?.removeEventListener('scroll', onReposition)
})
/**
@@ -3493,46 +3498,25 @@ const handleKeydown = (event) => {
@toggle="toggleEditorMode"
/>
<div v-if="activeMode === 'write'" class="admin-markdown-editor__write relative">
<div class="admin-markdown-editor__editor-surface min-h-[620px]">
<div class="admin-markdown-editor__write-columns flex items-start">
<div
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"
>
<div
v-for="ln in gutterLineCount"
:key="`gutter-line-${ln}`"
class="admin-markdown-editor__gutter-line text-right tabular-nums"
:style="{ height: `${getGutterLineHeight(ln - 1)}px` }"
>
{{ 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"
@compositionstart="handleTextareaCompositionStart"
@compositionend="handleTextareaCompositionEnd"
@click="refreshCaretLogicalLine"
@keyup="refreshCaretLogicalLine"
@select="refreshCaretLogicalLine"
@focus="onTextareaFocus"
@blur="onTextareaBlur"
/>
</div>
</div>
<AdminMarkdownUploadingBadge v-if="isUploading" />
</div>
<AdminMarkdownWriteSurface
v-if="activeMode === 'write'"
ref="writeSurfaceRef"
v-model="markdownValue"
:gutter-line-count="gutterLineCount"
:get-gutter-line-height="getGutterLineHeight"
:is-uploading="isUploading"
@keydown="handleKeydown"
@paste="handlePaste"
@drop="handleDrop"
@input="refreshCaretLogicalLine"
@compositionstart="handleTextareaCompositionStart"
@compositionend="handleTextareaCompositionEnd"
@click="refreshCaretLogicalLine"
@keyup="refreshCaretLogicalLine"
@select="refreshCaretLogicalLine"
@focus="onTextareaFocus"
@blur="onTextareaBlur"
/>
<div
v-else
@@ -3613,14 +3597,3 @@ const handleKeydown = (event) => {
<AdminToast :toast="toast" />
</template>
<style scoped>
.admin-markdown-editor__gutter {
scrollbar-width: none;
}
.admin-markdown-editor__gutter::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -0,0 +1,120 @@
<script setup>
import AdminMarkdownUploadingBadge from './AdminMarkdownUploadingBadge.vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
gutterLineCount: {
type: Number,
default: 1
},
getGutterLineHeight: {
type: Function,
required: true
},
isUploading: {
type: Boolean,
default: false
}
})
const emit = defineEmits([
'update:modelValue',
'keydown',
'paste',
'drop',
'input',
'compositionstart',
'compositionend',
'click',
'keyup',
'select',
'focus',
'blur'
])
const textareaRef = ref(null)
const gutterRef = ref(null)
/**
* textarea 입력 값을 부모 모델에 반영하고 입력 이벤트를 전달한다.
* @param {Event} event - textarea 입력 이벤트
* @returns {void}
*/
const onInput = (event) => {
emit('update:modelValue', event.target?.value || '')
emit('input', event)
}
/**
* 작성 textarea DOM을 반환한다.
* @returns {HTMLTextAreaElement|null} textarea DOM
*/
const getTextareaElement = () => textareaRef.value
/**
* 줄 번호 gutter DOM을 반환한다.
* @returns {HTMLElement|null} gutter DOM
*/
const getGutterElement = () => gutterRef.value
defineExpose({
getTextareaElement,
getGutterElement
})
</script>
<template>
<div class="admin-markdown-editor__write relative">
<div class="admin-markdown-editor__editor-surface min-h-[620px]">
<div class="admin-markdown-editor__write-columns flex items-start">
<div
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"
>
<div
v-for="ln in props.gutterLineCount"
:key="`gutter-line-${ln}`"
class="admin-markdown-editor__gutter-line text-right tabular-nums"
:style="{ height: `${props.getGutterLineHeight(ln - 1)}px` }"
>
{{ ln }}
</div>
</div>
<textarea
ref="textareaRef"
:value="modelValue"
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="emit('keydown', $event)"
@paste="emit('paste', $event)"
@drop="emit('drop', $event)"
@dragover.prevent
@input="onInput"
@compositionstart="emit('compositionstart', $event)"
@compositionend="emit('compositionend', $event)"
@click="emit('click', $event)"
@keyup="emit('keyup', $event)"
@select="emit('select', $event)"
@focus="emit('focus', $event)"
@blur="emit('blur', $event)"
/>
</div>
</div>
<AdminMarkdownUploadingBadge v-if="isUploading" />
</div>
</template>
<style scoped>
.admin-markdown-editor__gutter {
scrollbar-width: none;
}
.admin-markdown-editor__gutter::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -222,6 +222,7 @@
| components/admin/AdminMarkdownModeToggle.vue | 관리자 Markdown 에디터 작성/미리보기 모드 전환 버튼 UI |
| components/admin/AdminMarkdownSlashMenu.vue | 관리자 Markdown 에디터 슬래시 명령 메뉴 UI(명령 아이콘·라벨·슬러그 표시, 키보드 하이라이트 상태 표시, 루트 DOM 노출) |
| components/admin/AdminMarkdownUploadingBadge.vue | 관리자 Markdown 에디터 소스 작성 표면의 업로드 중 상태 배지 |
| components/admin/AdminMarkdownWriteSurface.vue | 관리자 Markdown 에디터 소스 작성 표면 UI(textarea, 라인 번호 gutter, 업로드 상태 배지, DOM 참조 노출) |
| components/admin/AdminPostSettingsSidebar.vue | 관리자 게시물 작성/수정 우측 설정 사이드바(Post URL·상태·발행 시각·요약·태그·대표 이미지 표시·추천 글·검색엔진 노출·본문 통계·삭제 버튼·블록 설정 패널 오버레이) |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 저장 payload 기준 미저장 비교·라우트/브라우저 이탈 확인, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입, HTML 태그 내부 스마트 따옴표 정규화 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 라이브러리 탭은 `/admin/api/media?paged=1` 서버 페이지 응답·파일명/사용처 제목 검색·번호형 페이지 이동, 카드·본문·라이트박스·Pages 커버 자동 생성 이미지 숨김, 원본 URL 선택 유지·갤러리 선택 순서 배지·목록 미리보기는 720px 본문 파생 이미지 또는 카드 썸네일 우선, 검색 결과 수와 무관한 고정 높이 패널, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 소스 모드 입력 직후 textarea 최신 본문 기준 커서 논리 줄·블록 컨텍스트 갱신, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 현재 블록 범위 안에서만 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 |

View File

@@ -1,5 +1,10 @@
# 업데이트 이력
## v1.11.81
- 관리자 Markdown 에디터의 소스 작성 표면(textarea·라인 번호·업로드 배지)을 `AdminMarkdownWriteSurface` 컴포넌트로 분리.
- 작성 표면 DOM 접근을 컴포넌트 노출 메서드로 정리해 부모 편집 로직과 UI 렌더링 경계 분리.
## v1.11.80
- 관리자 Markdown 에디터의 작성/미리보기 모드 전환 버튼과 업로드 상태 배지를 하위 컴포넌트로 분리.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.11.80",
"version": "1.11.81",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.11.80",
"version": "1.11.81",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

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