관리자 에디터 작성 표면 컴포넌트 분리
This commit is contained in:
@@ -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>
|
||||
|
||||
120
components/admin/AdminMarkdownWriteSurface.vue
Normal file
120
components/admin/AdminMarkdownWriteSurface.vue
Normal 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>
|
||||
@@ -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 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 |
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.11.81
|
||||
|
||||
- 관리자 Markdown 에디터의 소스 작성 표면(textarea·라인 번호·업로드 배지)을 `AdminMarkdownWriteSurface` 컴포넌트로 분리.
|
||||
- 작성 표면 DOM 접근을 컴포넌트 노출 메서드로 정리해 부모 편집 로직과 UI 렌더링 경계 분리.
|
||||
|
||||
## v1.11.80
|
||||
|
||||
- 관리자 Markdown 에디터의 작성/미리보기 모드 전환 버튼과 업로드 상태 배지를 하위 컴포넌트로 분리.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.80",
|
||||
"version": "1.11.81",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
Reference in New Issue
Block a user