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

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>