diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index cef0cce..06b1a59 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -64,6 +64,7 @@ import { normalizeUploadMaxTotalSize } from '../../lib/upload-size-limit.js' import AdminMarkdownModeToggle from './AdminMarkdownModeToggle.vue' +import AdminMarkdownPreviewSurface from './AdminMarkdownPreviewSurface.vue' import AdminMarkdownSlashMenu from './AdminMarkdownSlashMenu.vue' import AdminMarkdownWriteSurface from './AdminMarkdownWriteSurface.vue' @@ -101,8 +102,7 @@ const activeMode = defineModel('editorMode', { const editorRootRef = ref(null) const writeSurfaceRef = ref(null) -const previewRef = ref(null) -const previewRendererRef = ref(null) +const previewSurfaceRef = ref(null) /** 커서가 있는 논리 줄(0-based, `\\n` 기준) */ const activeLogicalLineIndex = ref(0) /** 소스 모드 입력 이벤트 직후 textarea의 최신 본문 값 */ @@ -183,6 +183,36 @@ const MIN_TEXTAREA_HEIGHT_PX = 620 */ const getWriteTextareaElement = () => writeSurfaceRef.value?.getTextareaElement?.() ?? null +/** + * 미리보기 표면 DOM을 반환한다. + * @returns {HTMLElement|null} 미리보기 DOM + */ +const getPreviewSurfaceElement = () => previewSurfaceRef.value?.getPreviewElement?.() ?? null + +/** + * 미리보기 표면 안의 지정 줄 DOM을 반환한다. + * @param {number|string|null} sourceLine - 원본 줄 번호 + * @returns {Element|null} 줄 DOM + */ +const getPreviewSourceLineElement = (sourceLine) => previewSurfaceRef.value?.getSourceLineElement?.(sourceLine) ?? null + +/** + * 라이브 편집 줄에 포커스를 이동한다. + * @param {...unknown} args - 포커스 인자 + * @returns {void} + */ +const focusPreviewEditableAtLine = (...args) => { + previewSurfaceRef.value?.focusEditableAtLine?.(...args) +} + +/** + * 라이브 문서 전체를 선택한다. + * @returns {void} + */ +const selectAllPreviewDocument = () => { + previewSurfaceRef.value?.selectAllLiveDocument?.() +} + const markdownValue = computed({ get: () => normalizeMarkdownContent(props.modelValue), set: (value) => emit('update:modelValue', value) @@ -683,7 +713,7 @@ const restoreEditorFocusAfterHistory = (selection = null) => { ? Math.max(0, selection.start - getLineStartOffset(focusLine)) : 0 - previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'auto', offset, 'center') + focusPreviewEditableAtLine(focusLine, 0, 'auto', offset, 'center') }) } @@ -1214,7 +1244,7 @@ watch(activeMode, (mode) => { } nextTick(() => { - previewRendererRef.value?.focusEditableAtLine(previewFocus.line, 0, 'auto', previewFocus.offset, 'center') + focusPreviewEditableAtLine(previewFocus.line, 0, 'auto', previewFocus.offset, 'center') }) }) }) @@ -1224,8 +1254,8 @@ watch(activeMode, (mode) => { * @returns {void} */ const scrollPreviewToTop = () => { - if (previewRef.value) { - previewRef.value.scrollTop = 0 + if (getPreviewSurfaceElement()) { + getPreviewSurfaceElement().scrollTop = 0 } } @@ -1234,12 +1264,12 @@ const scrollPreviewToTop = () => { * @returns {number|null} 원본 줄 번호 */ const getCurrentPreviewSourceLine = () => { - if (!import.meta.client || !previewRef.value) { + if (!import.meta.client || !getPreviewSurfaceElement()) { return null } const activeElement = document.activeElement - const focusedLineElement = previewRef.value.contains(activeElement) + const focusedLineElement = getPreviewSurfaceElement().contains(activeElement) ? activeElement?.closest?.('[data-source-line]') : null const focusedLine = Number(focusedLineElement?.getAttribute?.('data-source-line')) @@ -1248,9 +1278,9 @@ const getCurrentPreviewSourceLine = () => { return focusedLine } - const previewRect = previewRef.value.getBoundingClientRect() + const previewRect = getPreviewSurfaceElement().getBoundingClientRect() const anchorTop = Math.max(previewRect.top, 0) + 24 - const candidates = [...previewRef.value.querySelectorAll('[data-source-line]')] + const candidates = [...getPreviewSurfaceElement().querySelectorAll('[data-source-line]')] .map((element) => { const rect = element.getBoundingClientRect() const line = Number(element.getAttribute('data-source-line')) @@ -1502,7 +1532,7 @@ const focusFirstBlock = () => { nextTick(() => { nextTick(() => { - previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'start') + focusPreviewEditableAtLine(focusLine, 0, 'start') }) }) @@ -2215,7 +2245,7 @@ const onPreviewInsertAfterLine = ({ afterLine, lines, focusLine }) => { syncBlockPanelState() nextTick(() => { - previewRendererRef.value?.focusEditableAtLine(nextFocusLine, 0, 'start') + focusPreviewEditableAtLine(nextFocusLine, 0, 'start') }) } @@ -2262,7 +2292,7 @@ const handlePreviewKeydownCapture = (event) => { event.preventDefault() event.stopPropagation() - previewRendererRef.value?.selectAllLiveDocument() + selectAllPreviewDocument() return } @@ -2355,7 +2385,7 @@ const getLiveSlashMenuElement = () => { * @returns {void} */ const updatePreviewSlashMenuPosition = () => { - const element = previewRef.value?.querySelector(`[data-source-line="${liveSlashSourceLine.value}"]`) + const element = getPreviewSourceLineElement(liveSlashSourceLine.value) if (!element) { return @@ -2534,7 +2564,7 @@ watch(liveSlashVisible, (visible) => { if (visible) { window.addEventListener('scroll', onReposition, true) window.addEventListener('resize', onReposition) - previewRef.value?.addEventListener('scroll', onReposition) + getPreviewSurfaceElement()?.addEventListener('scroll', onReposition) getWriteTextareaElement()?.addEventListener('scroll', onReposition) updateLiveSlashMenuPosition() scrollLiveSlashHighlightedIntoView() @@ -2543,7 +2573,7 @@ watch(liveSlashVisible, (visible) => { window.removeEventListener('scroll', onReposition, true) window.removeEventListener('resize', onReposition) - previewRef.value?.removeEventListener('scroll', onReposition) + getPreviewSurfaceElement()?.removeEventListener('scroll', onReposition) getWriteTextareaElement()?.removeEventListener('scroll', onReposition) }) @@ -2596,7 +2626,7 @@ const focusPreviewAfterSlashCommand = (line, replacementLines) => { nextTick(() => { requestAnimationFrame(() => { requestAnimationFrame(() => { - previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'end', offset) + focusPreviewEditableAtLine(focusLine, 0, 'end', offset) }) }) }) @@ -2936,7 +2966,7 @@ const updateActiveInlineColor = (color) => { offset: nextVisibleOffset } nextTick(() => { - previewRendererRef.value?.focusEditableAtLine( + focusPreviewEditableAtLine( context.sourceLine, 0, 'auto', @@ -3518,38 +3548,30 @@ const handleKeydown = (event) => { @blur="onTextareaBlur" /> -
- -
+ ref="previewSurfaceRef" + :content="markdownValue" + :slash-menu-active="liveSlashVisible" + :slash-suppressed-lines="liveSlashSuppressedLineList" + @keydown="handlePreviewKeydownCapture" + @gallery-reorder="onPreviewGalleryReorder" + @merge-images-to-gallery="onPreviewMergeImagesToGallery" + @insert-image-to-gallery="onPreviewInsertImageToGallery" + @extract-gallery-image="onPreviewExtractGalleryImage" + @remove-gallery-image="onPreviewRemoveGalleryImage" + @block-content-change="onPreviewBlockContentChange" + @content-replace="onPreviewContentReplace" + @append-paragraph="onPreviewAppendParagraph" + @insert-after-line="onPreviewInsertAfterLine" + @delete-line="onPreviewDeleteLine" + @merge-with-previous-line="onPreviewMergeWithPreviousLine" + @line-focus="handleLiveLineFocus" + @line-blur="handleLiveLineBlur" + @slash-update="onLiveSlashUpdate" + @slash-end="onLiveSlashEnd" + @slash-apply="onLiveSlashApply" + /> +const props = defineProps({ + content: { + type: String, + default: '' + }, + slashMenuActive: { + type: Boolean, + default: false + }, + slashSuppressedLines: { + type: Array, + default: () => [] + } +}) + +const emit = defineEmits([ + 'keydown', + 'gallery-reorder', + 'merge-images-to-gallery', + 'insert-image-to-gallery', + 'extract-gallery-image', + 'remove-gallery-image', + 'block-content-change', + 'content-replace', + 'append-paragraph', + 'insert-after-line', + 'delete-line', + 'merge-with-previous-line', + 'line-focus', + 'line-blur', + 'slash-update', + 'slash-end', + 'slash-apply' +]) + +const previewRef = ref(null) +const rendererRef = ref(null) + +/** + * 미리보기 표면 DOM을 반환한다. + * @returns {HTMLElement|null} 미리보기 DOM + */ +const getPreviewElement = () => previewRef.value + +/** + * 지정한 라이브 편집 줄에 포커스를 이동한다. + * @param {...unknown} args - ContentMarkdownRenderer 포커스 인자 + * @returns {void} + */ +const focusEditableAtLine = (...args) => { + rendererRef.value?.focusEditableAtLine?.(...args) +} + +/** + * 라이브 문서 전체를 선택한다. + * @returns {void} + */ +const selectAllLiveDocument = () => { + rendererRef.value?.selectAllLiveDocument?.() +} + +/** + * 원본 줄 번호에 해당하는 라이브 편집 DOM을 반환한다. + * @param {number|string|null} sourceLine - 원본 줄 번호 + * @returns {Element|null} 줄 DOM + */ +const getSourceLineElement = (sourceLine) => { + if (sourceLine === null || sourceLine === undefined) { + return null + } + + return previewRef.value?.querySelector?.(`[data-source-line="${sourceLine}"]`) ?? null +} + +defineExpose({ + getPreviewElement, + focusEditableAtLine, + selectAllLiveDocument, + getSourceLineElement +}) + + + diff --git a/docs/map.md b/docs/map.md index f701b0a..8b928f2 100644 --- a/docs/map.md +++ b/docs/map.md @@ -220,6 +220,7 @@ | components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·자동 저장 실패 payload 반복 재시도 차단·자동 저장 실패/진행 중 이탈 차단·신규 임시 슬러그·중복 슬러그 필드 하단 오류 표시·중복 슬러그 중 Publish/Update/최종 저장 비활성화·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 설정 패널 상태와 이벤트 연결, 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택(서버 페이지네이션·파일명/사용처 검색·번호형 페이지 이동·작은 파생 이미지 우선 미리보기·고정 높이 모달), 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 | | components/admin/AdminMarkdownMediaPickerModal.vue | 관리자 Markdown 에디터 미디어 선택 모달 UI(업로드·라이브러리 탭, 검색 입력, 미디어 그리드, 업로드 영역, 페이지네이션, 적용/취소 버튼) | | components/admin/AdminMarkdownModeToggle.vue | 관리자 Markdown 에디터 작성/미리보기 모드 전환 버튼 UI | +| components/admin/AdminMarkdownPreviewSurface.vue | 관리자 Markdown 에디터 라이브 미리보기 표면 UI(ContentMarkdownRenderer 연결, 라이브 편집 포커스·선택 메서드와 DOM 참조 노출) | | components/admin/AdminMarkdownSlashMenu.vue | 관리자 Markdown 에디터 슬래시 명령 메뉴 UI(명령 아이콘·라벨·슬러그 표시, 키보드 하이라이트 상태 표시, 루트 DOM 노출) | | components/admin/AdminMarkdownUploadingBadge.vue | 관리자 Markdown 에디터 소스 작성 표면의 업로드 중 상태 배지 | | components/admin/AdminMarkdownWriteSurface.vue | 관리자 Markdown 에디터 소스 작성 표면 UI(textarea, 라인 번호 gutter, 업로드 상태 배지, DOM 참조 노출) | diff --git a/docs/update.md b/docs/update.md index 16661f4..772a2fa 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 이력 +## v1.11.82 + +- 관리자 Markdown 에디터의 라이브 미리보기 표면을 `AdminMarkdownPreviewSurface` 컴포넌트로 분리. +- 미리보기 DOM·라이브 편집 메서드 접근을 컴포넌트 노출 메서드로 정리해 에디터 부모 파일의 렌더링 책임 축소. + ## v1.11.81 - 관리자 Markdown 에디터의 소스 작성 표면(textarea·라인 번호·업로드 배지)을 `AdminMarkdownWriteSurface` 컴포넌트로 분리. diff --git a/package-lock.json b/package-lock.json index 87ba02b..e18edd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.81", + "version": "1.11.82", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.81", + "version": "1.11.82", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index b9ca196..c9ca914 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.81", + "version": "1.11.82", "private": true, "type": "module", "imports": {