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