From 622f19235e774f0822ffa50635fe434816b0abaf Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 27 Jul 2026 10:14:25 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=97=90?= =?UTF-8?q?=EB=94=94=ED=84=B0=20=EC=9E=91=EC=84=B1=20=ED=91=9C=EB=A9=B4=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminMarkdownEditor.vue | 117 +++++++---------- .../admin/AdminMarkdownWriteSurface.vue | 120 ++++++++++++++++++ docs/map.md | 1 + docs/update.md | 5 + package-lock.json | 4 +- package.json | 2 +- 6 files changed, 174 insertions(+), 75 deletions(-) create mode 100644 components/admin/AdminMarkdownWriteSurface.vue diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 9b8c0fd..cef0cce 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -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" /> -
-
-
- -