관리자 에디터 작성 표면 컴포넌트 분리
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>
|
||||
Reference in New Issue
Block a user