관리자 에디터 미리보기 표면 컴포넌트 분리

This commit is contained in:
2026-07-27 10:22:15 +09:00
parent 622f19235e
commit ce64ab7a9f
6 changed files with 196 additions and 52 deletions

View File

@@ -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"
/>
<div
<AdminMarkdownPreviewSurface
v-else
ref="previewRef"
class="admin-markdown-editor__preview min-h-[620px] px-0 py-5 text-[#15171a] outline-none"
style="--site-text: #15171a; --site-muted: #6b7280; --site-panel: #f6f7f8; --site-line: #e3e6e8; --site-accent: #2eb6ea;"
tabindex="0"
@keydown.capture="handlePreviewKeydownCapture"
>
<ContentMarkdownRenderer
ref="previewRendererRef"
:content="markdownValue"
interactive
:slash-menu-active="liveSlashVisible"
:slash-suppressed-lines="liveSlashSuppressedLineList"
@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"
/>
</div>
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"
/>
<Teleport to="body">
<AdminMarkdownSlashMenu

View File

@@ -0,0 +1,116 @@
<script setup>
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
})
</script>
<template>
<div
ref="previewRef"
class="admin-markdown-editor__preview min-h-[620px] px-0 py-5 text-[#15171a] outline-none"
style="--site-text: #15171a; --site-muted: #6b7280; --site-panel: #f6f7f8; --site-line: #e3e6e8; --site-accent: #2eb6ea;"
tabindex="0"
@keydown.capture="emit('keydown', $event)"
>
<ContentMarkdownRenderer
ref="rendererRef"
:content="props.content"
interactive
:slash-menu-active="props.slashMenuActive"
:slash-suppressed-lines="props.slashSuppressedLines"
@gallery-reorder="emit('gallery-reorder', $event)"
@merge-images-to-gallery="emit('merge-images-to-gallery', $event)"
@insert-image-to-gallery="emit('insert-image-to-gallery', $event)"
@extract-gallery-image="emit('extract-gallery-image', $event)"
@remove-gallery-image="emit('remove-gallery-image', $event)"
@block-content-change="emit('block-content-change', $event)"
@content-replace="emit('content-replace', $event)"
@append-paragraph="emit('append-paragraph', $event)"
@insert-after-line="emit('insert-after-line', $event)"
@delete-line="emit('delete-line', $event)"
@merge-with-previous-line="emit('merge-with-previous-line', $event)"
@line-focus="emit('line-focus', $event)"
@line-blur="emit('line-blur', $event)"
@slash-update="emit('slash-update', $event)"
@slash-end="emit('slash-end', $event)"
@slash-apply="emit('slash-apply', $event)"
/>
</div>
</template>