관리자 에디터를 마크다운 우선 방식으로 개편
This commit is contained in:
573
components/admin/AdminMarkdownEditor.vue
Normal file
573
components/admin/AdminMarkdownEditor.vue
Normal file
@@ -0,0 +1,573 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const textareaRef = ref(null)
|
||||
const activeMode = ref('write')
|
||||
const mediaItems = ref([])
|
||||
const isMediaPickerOpen = ref(false)
|
||||
const isLoadingMedia = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const mediaPickerTarget = ref('image')
|
||||
const selectedMediaUrls = ref([])
|
||||
const selectedImageWidth = ref('regular')
|
||||
|
||||
const imageWidthOptions = [
|
||||
{ value: 'regular', label: '기본' },
|
||||
{ value: 'wide', label: '와이드' },
|
||||
{ value: 'full', label: '풀사이즈' }
|
||||
]
|
||||
|
||||
const markdownValue = computed({
|
||||
get: () => props.modelValue || '',
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
/**
|
||||
* 본문 에디터에 포커스한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusFirstBlock = () => {
|
||||
nextTick(() => {
|
||||
textareaRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusFirstBlock
|
||||
})
|
||||
|
||||
/**
|
||||
* textarea 선택 영역 정보를 반환한다.
|
||||
* @returns {{ start: number, end: number, value: string }} 선택 정보
|
||||
*/
|
||||
const getSelectionState = () => {
|
||||
const textarea = textareaRef.value
|
||||
const value = markdownValue.value
|
||||
|
||||
if (!textarea) {
|
||||
return {
|
||||
start: value.length,
|
||||
end: value.length,
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: textarea.selectionStart,
|
||||
end: textarea.selectionEnd,
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* textarea 커서 위치를 갱신한다.
|
||||
* @param {number} start - 시작 위치
|
||||
* @param {number} end - 끝 위치
|
||||
* @returns {void}
|
||||
*/
|
||||
const setTextareaSelection = (start, end = start) => {
|
||||
nextTick(() => {
|
||||
const textarea = textareaRef.value
|
||||
if (!textarea) {
|
||||
return
|
||||
}
|
||||
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(start, end)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 영역을 지정 문자열로 교체한다.
|
||||
* @param {string} replacement - 대체 문자열
|
||||
* @param {number} cursorOffset - 교체 문자열 안 커서 위치
|
||||
* @param {number|null} selectionLength - 선택 길이
|
||||
* @returns {void}
|
||||
*/
|
||||
const replaceSelection = (replacement, cursorOffset = replacement.length, selectionLength = null) => {
|
||||
const { start, end, value } = getSelectionState()
|
||||
markdownValue.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`
|
||||
const nextStart = start + cursorOffset
|
||||
setTextareaSelection(nextStart, nextStart + (selectionLength ?? 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록형 마크다운 조각을 커서 위치에 삽입한다.
|
||||
* @param {string} snippet - 삽입할 마크다운
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertBlockSnippet = (snippet) => {
|
||||
const { start, end, value } = getSelectionState()
|
||||
const needsBeforeLine = start > 0 && value[start - 1] !== '\n'
|
||||
const needsAfterLine = end < value.length && value[end] !== '\n'
|
||||
const replacement = `${needsBeforeLine ? '\n\n' : ''}${snippet}${needsAfterLine ? '\n\n' : ''}`
|
||||
replaceSelection(replacement)
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 텍스트를 인라인 마크다운으로 감싼다.
|
||||
* @param {string} prefix - 앞 표식
|
||||
* @param {string} suffix - 뒤 표식
|
||||
* @param {string} placeholder - 선택이 없을 때 들어갈 텍스트
|
||||
* @returns {void}
|
||||
*/
|
||||
const wrapInline = (prefix, suffix, placeholder) => {
|
||||
const { start, end, value } = getSelectionState()
|
||||
const selected = value.slice(start, end)
|
||||
const inner = selected || placeholder
|
||||
const replacement = `${prefix}${inner}${suffix}`
|
||||
markdownValue.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`
|
||||
const selectionStart = start + prefix.length
|
||||
setTextareaSelection(selectionStart, selectionStart + inner.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 줄을 변환한다.
|
||||
* @param {(line: string) => string} transformLine - 줄 변환 함수
|
||||
* @returns {void}
|
||||
*/
|
||||
const transformSelectedLines = (transformLine) => {
|
||||
const { start, end, value } = getSelectionState()
|
||||
const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1
|
||||
const nextLineBreak = value.indexOf('\n', end)
|
||||
const lineEnd = nextLineBreak === -1 ? value.length : nextLineBreak
|
||||
const selectedBlock = value.slice(lineStart, lineEnd)
|
||||
const replacement = selectedBlock.split('\n').map(transformLine).join('\n')
|
||||
|
||||
markdownValue.value = `${value.slice(0, lineStart)}${replacement}${value.slice(lineEnd)}`
|
||||
setTextareaSelection(lineStart, lineStart + replacement.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 마크다운을 적용한다.
|
||||
* @param {number} level - 제목 레벨
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyHeading = (level) => {
|
||||
transformSelectedLines((line) => {
|
||||
const text = line.replace(/^#{1,6}\s+/, '').trimStart()
|
||||
return `${'#'.repeat(level)} ${text || '제목'}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 마크다운을 적용한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyQuote = () => {
|
||||
transformSelectedLines((line) => line.startsWith('> ') ? line : `> ${line}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 마크다운을 적용한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyList = () => {
|
||||
transformSelectedLines((line) => line.startsWith('- ') ? line : `- ${line}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 블록을 삽입한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertCodeBlock = () => {
|
||||
const { start, end, value } = getSelectionState()
|
||||
const selected = value.slice(start, end) || 'code'
|
||||
const snippet = `\`\`\`\n${selected}\n\`\`\``
|
||||
replaceSelection(snippet, 4, selected.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 문자열을 생성한다.
|
||||
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
|
||||
* @returns {string} 이미지 마크다운
|
||||
*/
|
||||
const createImageMarkdown = (image) => {
|
||||
const width = image.width && image.width !== 'regular'
|
||||
? `{width=${image.width}}`
|
||||
: ''
|
||||
return `${width}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 마크다운을 삽입한다.
|
||||
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertImage = (image) => {
|
||||
insertBlockSnippet(createImageMarkdown(image))
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 마크다운을 삽입한다.
|
||||
* @param {Array<{ url: string, alt?: string, width?: string }>} images - 이미지 목록
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertGallery = (images) => {
|
||||
if (!images.length) {
|
||||
return
|
||||
}
|
||||
|
||||
insertBlockSnippet([':::gallery', ...images.map(createImageMarkdown), ':::'].join('\n'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 목록을 불러온다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchMediaItems = async () => {
|
||||
isLoadingMedia.value = true
|
||||
|
||||
try {
|
||||
mediaItems.value = await $fetch('/admin/api/media')
|
||||
} finally {
|
||||
isLoadingMedia.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 선택 창을 연다.
|
||||
* @param {'image'|'gallery'} target - 삽입 대상
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const openMediaPicker = async (target) => {
|
||||
mediaPickerTarget.value = target
|
||||
selectedMediaUrls.value = []
|
||||
isMediaPickerOpen.value = true
|
||||
await fetchMediaItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 선택 창을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeMediaPicker = () => {
|
||||
isMediaPickerOpen.value = false
|
||||
selectedMediaUrls.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 선택 상태를 토글한다.
|
||||
* @param {Object} mediaItem - 미디어 항목
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleMediaSelection = (mediaItem) => {
|
||||
if (mediaPickerTarget.value === 'image') {
|
||||
selectedMediaUrls.value = [mediaItem.url]
|
||||
return
|
||||
}
|
||||
|
||||
selectedMediaUrls.value = selectedMediaUrls.value.includes(mediaItem.url)
|
||||
? selectedMediaUrls.value.filter((url) => url !== mediaItem.url)
|
||||
: [...selectedMediaUrls.value, mediaItem.url]
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 미디어를 마크다운에 삽입한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyMediaSelection = () => {
|
||||
const selectedItems = selectedMediaUrls.value
|
||||
.map((url) => mediaItems.value.find((item) => item.url === url))
|
||||
.filter(Boolean)
|
||||
|
||||
if (mediaPickerTarget.value === 'image') {
|
||||
const [item] = selectedItems
|
||||
if (item) {
|
||||
insertImage({
|
||||
url: item.url,
|
||||
alt: item.name || '',
|
||||
width: selectedImageWidth.value
|
||||
})
|
||||
}
|
||||
} else {
|
||||
insertGallery(selectedItems.map((item) => ({
|
||||
url: item.url,
|
||||
alt: item.name || ''
|
||||
})))
|
||||
}
|
||||
|
||||
closeMediaPicker()
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 파일을 업로드한다.
|
||||
* @param {FileList|Array<File>} files - 업로드 파일 목록
|
||||
* @returns {Promise<Array<Object>>} 업로드된 파일 목록
|
||||
*/
|
||||
const uploadImages = async (files) => {
|
||||
const formData = new FormData()
|
||||
Array.from(files).forEach((file) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
const result = await $fetch('/admin/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
return result.files || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 업로드 파일을 현재 커서 위치에 삽입한다.
|
||||
* @param {FileList|Array<File>} files - 파일 목록
|
||||
* @param {'image'|'gallery'} target - 삽입 대상
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadAndInsert = async (files, target = 'image') => {
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
|
||||
try {
|
||||
const uploadedFiles = await uploadImages(files)
|
||||
const images = uploadedFiles.map((file) => ({
|
||||
url: file.url,
|
||||
alt: file.name || '',
|
||||
width: target === 'image' ? selectedImageWidth.value : 'regular'
|
||||
}))
|
||||
|
||||
if (target === 'gallery') {
|
||||
insertGallery(images)
|
||||
} else if (images[0]) {
|
||||
insertImage(images[0])
|
||||
}
|
||||
|
||||
mediaItems.value = await $fetch('/admin/api/media').catch(() => mediaItems.value)
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 입력 변경 처리
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @param {'image'|'gallery'} target - 삽입 대상
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleFileInput = async (event, target) => {
|
||||
await uploadAndInsert(event.target.files, target)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 붙여넣은 이미지 파일을 업로드한다.
|
||||
* @param {ClipboardEvent} event - 붙여넣기 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handlePaste = async (event) => {
|
||||
const imageFiles = Array.from(event.clipboardData?.files || []).filter((file) => file.type.startsWith('image/'))
|
||||
|
||||
if (!imageFiles.length) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
await uploadAndInsert(imageFiles, imageFiles.length > 1 ? 'gallery' : 'image')
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭한 이미지 파일을 업로드한다.
|
||||
* @param {DragEvent} event - 드롭 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleDrop = async (event) => {
|
||||
const imageFiles = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
|
||||
|
||||
if (!imageFiles.length) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
await uploadAndInsert(imageFiles, imageFiles.length > 1 ? 'gallery' : 'image')
|
||||
}
|
||||
|
||||
/**
|
||||
* 키보드 단축키를 처리한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleKeydown = (event) => {
|
||||
if (!(event.metaKey || event.ctrlKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if (key === 'b') {
|
||||
event.preventDefault()
|
||||
wrapInline('**', '**', '굵은 글씨')
|
||||
} else if (key === 'i') {
|
||||
event.preventDefault()
|
||||
wrapInline('*', '*', '기울임')
|
||||
} else if (key === 'e') {
|
||||
event.preventDefault()
|
||||
wrapInline('`', '`', 'code')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-markdown-editor grid gap-3">
|
||||
<div class="admin-markdown-editor__toolbar flex flex-wrap items-center gap-1.5 rounded border border-[#e3e6e8] bg-white p-2">
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(1)">
|
||||
H1
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(2)">
|
||||
H2
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(3)">
|
||||
H3
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-bold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('**', '**', '굵은 글씨')">
|
||||
B
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm italic text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('*', '*', '기울임')">
|
||||
I
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 font-mono text-sm text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('`', '`', 'code')">
|
||||
Code
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyQuote">
|
||||
인용
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyList">
|
||||
목록
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="insertCodeBlock">
|
||||
코드블록
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="insertBlockSnippet('---')">
|
||||
구분선
|
||||
</button>
|
||||
<span class="admin-markdown-editor__divider mx-1 h-6 w-px bg-[#e3e6e8]" aria-hidden="true" />
|
||||
<select v-model="selectedImageWidth" class="admin-markdown-editor__image-width rounded border border-[#d7dde2] bg-white px-2 py-1.5 text-sm text-[#394047]">
|
||||
<option v-for="option in imageWidthOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('image')">
|
||||
미디어 이미지
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('gallery')">
|
||||
미디어 갤러리
|
||||
</button>
|
||||
<label class="admin-markdown-editor__upload cursor-pointer rounded bg-[#15171a] px-2.5 py-1.5 text-sm font-semibold text-white">
|
||||
이미지 업로드
|
||||
<input class="sr-only" type="file" accept="image/*" @change="handleFileInput($event, 'image')">
|
||||
</label>
|
||||
<label class="admin-markdown-editor__upload cursor-pointer rounded bg-[#15171a] px-2.5 py-1.5 text-sm font-semibold text-white">
|
||||
갤러리 업로드
|
||||
<input class="sr-only" type="file" accept="image/*" multiple @change="handleFileInput($event, 'gallery')">
|
||||
</label>
|
||||
<div class="admin-markdown-editor__mode ml-auto inline-flex rounded border border-[#d7dde2] bg-[#f6f7f8] p-0.5">
|
||||
<button
|
||||
class="admin-markdown-editor__mode-button rounded px-3 py-1.5 text-sm font-semibold"
|
||||
:class="activeMode === 'write' ? 'bg-white text-black shadow-sm' : 'text-[#6b7280]'"
|
||||
type="button"
|
||||
@click="activeMode = 'write'"
|
||||
>
|
||||
작성
|
||||
</button>
|
||||
<button
|
||||
class="admin-markdown-editor__mode-button rounded px-3 py-1.5 text-sm font-semibold"
|
||||
:class="activeMode === 'preview' ? 'bg-white text-black shadow-sm' : 'text-[#6b7280]'"
|
||||
type="button"
|
||||
@click="activeMode = 'preview'"
|
||||
>
|
||||
미리보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeMode === 'write'" class="admin-markdown-editor__write relative">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="markdownValue"
|
||||
class="admin-markdown-editor__textarea min-h-[620px] w-full resize-y rounded border border-[#e3e6e8] bg-white px-5 py-5 font-mono text-[15px] leading-7 text-[#15171a] outline-none transition-colors placeholder:text-[#8e9cac] focus:border-[#15171a]"
|
||||
placeholder="마크다운으로 글을 작성하세요."
|
||||
spellcheck="false"
|
||||
@keydown="handleKeydown"
|
||||
@paste="handlePaste"
|
||||
@drop="handleDrop"
|
||||
@dragover.prevent
|
||||
/>
|
||||
<div v-if="isUploading" class="admin-markdown-editor__uploading absolute right-3 top-3 rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white">
|
||||
업로드 중
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="admin-markdown-editor__preview min-h-[620px] rounded border border-[#e3e6e8] bg-white px-6 py-5">
|
||||
<ContentMarkdownRenderer v-if="markdownValue.trim()" :content="markdownValue" />
|
||||
<p v-else class="admin-markdown-editor__preview-empty text-sm text-[#8e9cac]">
|
||||
미리볼 본문이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isMediaPickerOpen" class="admin-markdown-editor__media-modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-4 py-6" @click.self="closeMediaPicker">
|
||||
<div class="admin-markdown-editor__media-panel flex max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded bg-white shadow-2xl">
|
||||
<header class="admin-markdown-editor__media-header flex items-center justify-between border-b border-[#e3e6e8] px-5 py-4">
|
||||
<div>
|
||||
<h2 class="admin-markdown-editor__media-title text-lg font-bold text-black">
|
||||
{{ mediaPickerTarget === 'gallery' ? '갤러리 미디어 선택' : '이미지 미디어 선택' }}
|
||||
</h2>
|
||||
<p class="admin-markdown-editor__media-count mt-1 text-sm text-[#6b7280]">
|
||||
{{ selectedMediaUrls.length }}개 선택됨
|
||||
</p>
|
||||
</div>
|
||||
<button class="admin-markdown-editor__media-close rounded px-3 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker">
|
||||
닫기
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="admin-markdown-editor__media-body overflow-y-auto p-5">
|
||||
<div v-if="isLoadingMedia" class="admin-markdown-editor__media-loading py-12 text-center text-sm text-[#8e9cac]">
|
||||
불러오는 중
|
||||
</div>
|
||||
<div v-else-if="mediaItems.length" class="admin-markdown-editor__media-grid grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<button
|
||||
v-for="item in mediaItems"
|
||||
:key="item.url"
|
||||
class="admin-markdown-editor__media-item group overflow-hidden rounded border bg-white text-left transition"
|
||||
:class="selectedMediaUrls.includes(item.url) ? 'border-[#15171a] ring-2 ring-[#15171a]/20' : 'border-[#e3e6e8] hover:border-[#8e9cac]'"
|
||||
type="button"
|
||||
@click="toggleMediaSelection(item)"
|
||||
>
|
||||
<img class="admin-markdown-editor__media-thumb aspect-[4/3] w-full bg-[#f6f7f8] object-cover" :src="item.url" :alt="item.name || ''">
|
||||
<span class="admin-markdown-editor__media-name block truncate px-3 py-2 text-xs font-semibold text-[#394047]">
|
||||
{{ item.name || item.url }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="admin-markdown-editor__media-empty py-12 text-center text-sm text-[#8e9cac]">
|
||||
선택할 미디어가 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="admin-markdown-editor__media-footer flex items-center justify-end gap-2 border-t border-[#e3e6e8] px-5 py-4">
|
||||
<button class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker">
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="admin-markdown-editor__media-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="selectedMediaUrls.length === 0"
|
||||
@click="applyMediaSelection"
|
||||
>
|
||||
삽입
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -905,7 +905,7 @@ defineExpose({
|
||||
>
|
||||
|
||||
<div class="admin-post-form__field admin-post-form__content-editor text-sm">
|
||||
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
|
||||
<AdminMarkdownEditor ref="blockEditor" v-model="form.content" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -389,6 +389,63 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
|
||||
|
||||
/**
|
||||
* 인라인 마크다운을 표시 세그먼트로 변환한다.
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {Array<{ type: string, text: string, href?: string }>} 인라인 세그먼트
|
||||
*/
|
||||
const parseInlineSegments = (value) => {
|
||||
const source = String(value || '')
|
||||
const segments = []
|
||||
const pattern = /(\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
|
||||
let lastIndex = 0
|
||||
let match = pattern.exec(source)
|
||||
|
||||
while (match) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
type: 'text',
|
||||
text: source.slice(lastIndex, match.index)
|
||||
})
|
||||
}
|
||||
|
||||
if (match[2] && match[3]) {
|
||||
segments.push({
|
||||
type: 'link',
|
||||
text: match[2],
|
||||
href: match[3]
|
||||
})
|
||||
} else if (match[4]) {
|
||||
segments.push({
|
||||
type: 'strong',
|
||||
text: match[4]
|
||||
})
|
||||
} else if (match[5]) {
|
||||
segments.push({
|
||||
type: 'code',
|
||||
text: match[5]
|
||||
})
|
||||
} else if (match[6]) {
|
||||
segments.push({
|
||||
type: 'em',
|
||||
text: match[6]
|
||||
})
|
||||
}
|
||||
|
||||
lastIndex = pattern.lastIndex
|
||||
match = pattern.exec(source)
|
||||
}
|
||||
|
||||
if (lastIndex < source.length) {
|
||||
segments.push({
|
||||
type: 'text',
|
||||
text: source.slice(lastIndex)
|
||||
})
|
||||
}
|
||||
|
||||
return segments.length ? segments : [{ type: 'text', text: source }]
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이트박스를 연다
|
||||
* @param {Array<Object>} images - 이미지 목록
|
||||
@@ -432,14 +489,32 @@ const showNextImage = () => {
|
||||
<div class="content-markdown-renderer">
|
||||
<template v-for="block in blocks" :key="block.id">
|
||||
<ProseHeading v-if="block.type === 'heading'" :level="block.level">
|
||||
{{ block.text }}
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</ProseHeading>
|
||||
<ProseBlockquote v-else-if="block.type === 'quote'" :variant="block.variant || 'default'">
|
||||
{{ block.text }}
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-quote-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</ProseBlockquote>
|
||||
<ProseList v-else-if="block.type === 'list'" :ordered="block.ordered || false">
|
||||
<li v-for="(item, itemIndex) in block.text" :key="`${block.id}-${itemIndex}`">
|
||||
{{ item }}
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(item)" :key="`${block.id}-${itemIndex}-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</li>
|
||||
</ProseList>
|
||||
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
|
||||
@@ -451,10 +526,22 @@ const showNextImage = () => {
|
||||
:emoji="block.calloutEmoji"
|
||||
:background="block.calloutBackground"
|
||||
>
|
||||
{{ block.text }}
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-callout-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-black/5 px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</ProseCallout>
|
||||
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
|
||||
{{ block.text }}
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-toggle-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</ProseToggle>
|
||||
<ProseBookmark
|
||||
v-else-if="block.type === 'bookmark' && block.meta.url"
|
||||
@@ -488,7 +575,13 @@ const showNextImage = () => {
|
||||
><code>{{ block.text }}</code></pre>
|
||||
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-10 border-line">
|
||||
<p v-else class="content-markdown-renderer__paragraph my-5 text-[15px] leading-8 text-[var(--site-text)]">
|
||||
{{ block.text }}
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-paragraph-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.0.11
|
||||
|
||||
- 관리자 글쓰기 본문을 Markdown-first 에디터로 교체해 범위 선택, 복사/붙여넣기, 미디어 이미지·갤러리 삽입 흐름을 단순화.
|
||||
|
||||
## v1.0.5
|
||||
|
||||
- Docker 운영 컨테이너가 빌드 시점 설정 대신 `.env.production`의 런타임 환경 변수를 우선 읽도록 보강.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-14 v1.0.11
|
||||
|
||||
### 관리자 글쓰기를 Markdown-first로 전환
|
||||
|
||||
블록형 에디터는 이미지·갤러리 같은 카드형 입력에는 편하지만, 여러 문단 범위 선택, 외부 블로그/옵시디언 복사·붙여넣기, 다중 블록 복사 같은 기본 글쓰기 동작이 브라우저 텍스트 편집 모델과 계속 충돌했다. 저장 포맷은 이미 마크다운 문자열이므로 본문 편집의 원본도 마크다운 문자열로 되돌리고, textarea 기반 작성 모드와 공개 렌더러 기반 미리보기를 제공한다. 이미지와 갤러리는 기존 업로드·미디어 라이브러리 API를 유지하고 커서 위치에 마크다운으로 삽입한다. 옵시디언식 토큰 숨김 Live Preview는 이 기반이 안정화된 뒤 별도 단계로 확장한다.
|
||||
|
||||
## 2026-05-13 v1.0.10
|
||||
|
||||
### 관리자 블록 에디터를 v1.0.5 파일 기준으로 복원
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
|------|-----------|
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, 작성/미리보기 전환, 툴바 마크다운 삽입, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||
|
||||
41
docs/spec.md
41
docs/spec.md
@@ -440,36 +440,17 @@ components/content/
|
||||
|
||||
### 관리자 글 편집
|
||||
|
||||
- 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다.
|
||||
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 유지한다.
|
||||
- `/` 입력 시 블록 선택 메뉴를 표시한다.
|
||||
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
|
||||
- `/` 명령 메뉴가 열린 블록 행은 아래 블록보다 위 stacking 순서로 표시해 메뉴와 본문 텍스트가 겹쳐 보이지 않게 한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
|
||||
- `/` 명령 메뉴의 검색어가 바뀌지 않은 경우에는 현재 강조 인덱스를 유지해 연속 방향키 이동이 가능해야 한다.
|
||||
- `/` 명령 메뉴 필터는 한글 조합 입력 완료와 방향키/Enter 입력 직전에 현재 DOM 텍스트를 기준으로 동기화한다.
|
||||
- 슬래시 메뉴 방향키 이동 로직은 현재 블록 텍스트가 `/`로 시작할 때만 동작한다.
|
||||
- 슬래시 메뉴는 화면 높이에 맞춰 최대 높이를 제한하고, 넘치는 항목은 내부 스크롤로 표시한다.
|
||||
- 슬래시 메뉴 방향키 이동 시 현재 선택 항목이 스크롤 영역 안에 유지되도록 자동 스크롤한다.
|
||||
- 일반 본문 블록에서는 위/아래 방향키 입력 시 커서가 블록 시작/끝에 도달하면 인접 블록으로 커서를 이동한다.
|
||||
- 관리자 에디터에서 의도적으로 만든 빈 문단은 `<!--sori:blank-paragraph-->` 마커로 저장해 저장/재진입 후에도 유지한다.
|
||||
- 공개 본문 렌더러는 빈 문단 마커를 빈 문단 블록으로 파싱해 문단 간 추가 여백 의도를 유지한다.
|
||||
- `/갤`처럼 필터 결과가 하나로 좁혀진 상태에서 Enter를 누르면 해당 블록 명령을 적용한다.
|
||||
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
|
||||
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르거나 ` ``` `을 입력하면 현재 블록 타입을 즉시 변환한다.
|
||||
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
|
||||
- 한글 등 조합형 입력 직후 확정된 텍스트로 슬래시 메뉴 필터와 Enter 블록 이동을 반영한다.
|
||||
- 한글 등 조합형 입력 중 Shift+Enter가 들어오면 조합 완료 직후 줄바꿈을 예약 적용한다.
|
||||
- Shift+Enter는 같은 텍스트 블록 안에 줄바꿈 문자를 직접 삽입하고 커서를 줄바꿈 뒤로 유지하며, Enter는 다음 문단 블록을 생성한다.
|
||||
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
|
||||
- 문단 간 기본 간격은 다음 블록의 `margin-top: 32px` 기준으로 조정한다.
|
||||
- 블록 왼쪽 핸들은 hover/focus 상태에서 AFFiNE 참고 스타일의 세로 막대로 표시되며, hover 시 해당 블록 높이만큼 확장해 선택 범위를 드러낸다.
|
||||
- 블록 왼쪽 핸들을 클릭하면 블록을 선택하고 Delete 또는 Backspace로 해당 블록을 삭제할 수 있다.
|
||||
- 블록 왼쪽 핸들을 드래그하면 블록 순서를 이동할 수 있다.
|
||||
- 블록 드래그 중에는 현재 포인터 위치 기준으로 대상 블록 위 또는 아래에 삽입선을 표시하고, 드롭 또는 드래그 종료 시 표시 위치와 같은 곳으로 이동한다.
|
||||
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
|
||||
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
|
||||
- 글 작성/수정 화면은 Markdown-first 에디터(`AdminMarkdownEditor`)를 사용한다.
|
||||
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 그대로 유지한다.
|
||||
- 본문 작성 모드는 textarea 기반으로 범위 선택, 다중 문단 복사/붙여넣기, 외부 마크다운 붙여넣기를 브라우저 기본 동작에 가깝게 처리한다.
|
||||
- 본문 미리보기 모드는 공개 본문과 같은 `ContentMarkdownRenderer`를 사용한다.
|
||||
- 툴바는 제목 1/2/3, 굵게, 기울임, 인라인 코드, 인용, 목록, 코드 블록, 구분선 삽입을 제공한다.
|
||||
- `Cmd/Ctrl+B`, `Cmd/Ctrl+I`, `Cmd/Ctrl+E`는 현재 선택 텍스트에 각각 굵게, 기울임, 인라인 코드 마크다운을 적용한다.
|
||||
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
|
||||
- 미디어 라이브러리에서 단일 이미지를 선택하면 `{width=...}` 형식으로 삽입한다.
|
||||
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
|
||||
- 이미지 너비 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
|
||||
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
|
||||
- 글 작성/수정 화면의 저장 버튼은 즉시 저장하지 않고, 전체 화면 발행 모달에서 상태(발행/초안/비공개)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 1차 관리자 개발
|
||||
|
||||
- [ ] 블록 핸들 액션 메뉴 확장: 잘라내기, 복사, 붙여넣기, 블록 타입 변경, 선택 블록 서식 적용
|
||||
- [ ] Markdown-first 에디터 2차 개선: 옵시디언식 Live Preview(마크다운 토큰 숨김/백스페이스 복원), 이미지·갤러리 카드형 편집, 표준 마크다운 파서 도입 검토
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.0.11
|
||||
|
||||
- 관리자 글 본문 에디터를 블록형 `AdminBlockEditor`에서 Markdown-first `AdminMarkdownEditor`로 교체.
|
||||
- 새 에디터에 textarea 기반 범위 선택·복사/붙여넣기, 작성/미리보기 전환, 마크다운 툴바, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입 추가.
|
||||
- 공개 본문 렌더러에 굵게, 기울임, 인라인 코드, 링크 인라인 마크다운 표시 추가.
|
||||
- 패키지 버전 `1.0.11`로 갱신.
|
||||
|
||||
## v1.0.10
|
||||
|
||||
- 관리자 `AdminBlockEditor.vue`를 저장소 태그 `v1.0.5` 시점과 동일한 내용으로 복원(다중 줄·마크다운 붙여넣기 분할, Cmd/Ctrl+A 전체 MD 복사 안내, 블록 단위 범위 선택 등 v1.0.6 이후 에디터 UX 변경 제거). 동작 불만에 따른 되돌림.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.11",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
Reference in New Issue
Block a user