마크다운 에디터 붙여넣기와 미디어 편집 개선
This commit is contained in:
@@ -8,7 +8,9 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const editorRootRef = ref(null)
|
||||
const textareaRef = ref(null)
|
||||
const previewRef = ref(null)
|
||||
const gutterRef = ref(null)
|
||||
const activeMode = ref('write')
|
||||
/** 커서가 있는 논리 줄(0-based, `\\n` 기준) */
|
||||
@@ -32,6 +34,84 @@ const markdownValue = computed({
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 한 줄을 구조화한다.
|
||||
* @param {string} line - 이미지 마크다운 줄
|
||||
* @returns {{ alt: string, url: string, width: string }|null} 이미지 정보
|
||||
*/
|
||||
const parseImageMarkdownLine = (line) => {
|
||||
const match = line.trim().match(/^!\[(.*?)\]\((.*?)\)(?:\{width=(regular|wide|full)\})?$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
alt: match[1] || '',
|
||||
url: match[2] || '',
|
||||
width: match[3] || 'regular'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 커서가 속한 이미지 또는 갤러리 블록 정보를 찾는다.
|
||||
* @returns {{ type: 'image'|'gallery', startLine: number, endLine: number, images: Array<{ alt: string, url: string, width: string }> }|null}
|
||||
*/
|
||||
const activeMediaBlock = computed(() => {
|
||||
const lines = (markdownValue.value || '').split('\n')
|
||||
const currentLine = Math.min(activeLogicalLineIndex.value, lines.length - 1)
|
||||
const activeImage = parseImageMarkdownLine(lines[currentLine] || '')
|
||||
|
||||
if (activeImage) {
|
||||
return {
|
||||
type: 'image',
|
||||
startLine: currentLine,
|
||||
endLine: currentLine,
|
||||
images: [activeImage]
|
||||
}
|
||||
}
|
||||
|
||||
let galleryStart = -1
|
||||
|
||||
for (let index = currentLine; index >= 0; index -= 1) {
|
||||
if ((lines[index] || '').trim() === ':::gallery') {
|
||||
galleryStart = index
|
||||
break
|
||||
}
|
||||
|
||||
if ((lines[index] || '').trim() === ':::') {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (galleryStart === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
let galleryEnd = -1
|
||||
|
||||
for (let index = galleryStart + 1; index < lines.length; index += 1) {
|
||||
if ((lines[index] || '').trim() === ':::') {
|
||||
galleryEnd = index
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (galleryEnd === -1 || currentLine > galleryEnd) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'gallery',
|
||||
startLine: galleryStart,
|
||||
endLine: galleryEnd,
|
||||
images: lines
|
||||
.slice(galleryStart + 1, galleryEnd)
|
||||
.map(parseImageMarkdownLine)
|
||||
.filter(Boolean)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 본문의 논리 줄(`\\n` 기준) 개수. 빈 본문은 1줄로 본다.
|
||||
* @returns {number}
|
||||
@@ -92,9 +172,22 @@ watch(() => props.modelValue, () => {
|
||||
watch(activeMode, (mode) => {
|
||||
if (mode === 'write') {
|
||||
refreshCaretLogicalLine()
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
previewRef.value?.focus()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 작성 모드와 미리보기 모드를 전환한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleEditorMode = () => {
|
||||
activeMode.value = activeMode.value === 'write' ? 'preview' : 'write'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
/**
|
||||
* document selectionchange에서 작성 textarea가 포커스일 때만 활성 줄을 갱신한다.
|
||||
@@ -114,10 +207,32 @@ onMounted(() => {
|
||||
refreshCaretLogicalLine()
|
||||
}
|
||||
|
||||
/**
|
||||
* 에디터 안에서 Cmd/Ctrl+E를 누르면 작성/미리보기 모드를 전환한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onDocumentKeydown = (event) => {
|
||||
const root = editorRootRef.value
|
||||
|
||||
if (!root || !root.contains(document.activeElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'e') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
toggleEditorMode()
|
||||
}
|
||||
|
||||
document.addEventListener('selectionchange', onSelectionChange)
|
||||
document.addEventListener('keydown', onDocumentKeydown)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('selectionchange', onSelectionChange)
|
||||
document.removeEventListener('keydown', onDocumentKeydown)
|
||||
})
|
||||
|
||||
refreshCaretLogicalLine()
|
||||
@@ -292,6 +407,136 @@ const createImageMarkdown = (image) => {
|
||||
return `${width}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 줄 범위를 새 줄 목록으로 교체한다.
|
||||
* @param {number} startLine - 시작 줄
|
||||
* @param {number} endLine - 끝 줄
|
||||
* @param {string[]} replacementLines - 대체 줄 목록
|
||||
* @param {boolean} focusEditor - 교체 후 textarea에 포커스를 돌릴지 여부
|
||||
* @returns {void}
|
||||
*/
|
||||
const replaceLineRange = (startLine, endLine, replacementLines, focusEditor = true) => {
|
||||
const lines = (markdownValue.value || '').split('\n')
|
||||
const nextLines = [
|
||||
...lines.slice(0, startLine),
|
||||
...replacementLines,
|
||||
...lines.slice(endLine + 1)
|
||||
]
|
||||
|
||||
markdownValue.value = nextLines.join('\n')
|
||||
|
||||
if (focusEditor) {
|
||||
setTextareaSelection(nextLines.slice(0, startLine + replacementLines.length).join('\n').length)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 미디어 블록을 이미지 목록 기준으로 다시 작성한다.
|
||||
* @param {Array<{ alt: string, url: string, width?: string }>} images - 이미지 목록
|
||||
* @returns {void}
|
||||
*/
|
||||
const replaceActiveMediaImages = (images) => {
|
||||
const block = activeMediaBlock.value
|
||||
|
||||
if (!block) {
|
||||
return
|
||||
}
|
||||
|
||||
if (block.type === 'image') {
|
||||
if (!images[0]) {
|
||||
replaceLineRange(block.startLine, block.endLine, [], false)
|
||||
return
|
||||
}
|
||||
|
||||
replaceLineRange(block.startLine, block.endLine, [createImageMarkdown(images[0])], false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!images.length) {
|
||||
replaceLineRange(block.startLine, block.endLine, [], false)
|
||||
return
|
||||
}
|
||||
|
||||
replaceLineRange(block.startLine, block.endLine, [
|
||||
':::gallery',
|
||||
...images.map(createImageMarkdown),
|
||||
':::'
|
||||
], false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 미디어 블록의 특정 이미지를 수정한다.
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @param {Partial<{ alt: string, url: string, width: string }>} patch - 변경 값
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateActiveMediaImage = (imageIndex, patch) => {
|
||||
const block = activeMediaBlock.value
|
||||
|
||||
if (!block) {
|
||||
return
|
||||
}
|
||||
|
||||
const images = block.images.map((image, index) => index === imageIndex ? { ...image, ...patch } : image)
|
||||
replaceActiveMediaImages(images)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 갤러리 이미지 순서를 바꾼다.
|
||||
* @param {number} imageIndex - 이동할 이미지 인덱스
|
||||
* @param {-1|1} direction - 이동 방향
|
||||
* @returns {void}
|
||||
*/
|
||||
const moveActiveGalleryImage = (imageIndex, direction) => {
|
||||
const block = activeMediaBlock.value
|
||||
|
||||
if (!block || block.type !== 'gallery') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndex = imageIndex + direction
|
||||
|
||||
if (nextIndex < 0 || nextIndex >= block.images.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const images = [...block.images]
|
||||
const [target] = images.splice(imageIndex, 1)
|
||||
images.splice(nextIndex, 0, target)
|
||||
replaceActiveMediaImages(images)
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 미디어 블록에서 이미지를 삭제한다.
|
||||
* @param {number} imageIndex - 삭제할 이미지 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeActiveMediaImage = (imageIndex) => {
|
||||
const block = activeMediaBlock.value
|
||||
|
||||
if (!block) {
|
||||
return
|
||||
}
|
||||
|
||||
replaceActiveMediaImages(block.images.filter((_, index) => index !== imageIndex))
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 갤러리에 이미지를 추가한다.
|
||||
* @param {Array<{ url: string, alt?: string, width?: string }>} images - 추가 이미지 목록
|
||||
* @returns {void}
|
||||
*/
|
||||
const appendImagesToActiveGallery = (images) => {
|
||||
const block = activeMediaBlock.value
|
||||
|
||||
if (!block || block.type !== 'gallery') {
|
||||
insertGallery(images)
|
||||
return
|
||||
}
|
||||
|
||||
replaceActiveMediaImages([...block.images, ...images])
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 마크다운을 삽입한다.
|
||||
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
|
||||
@@ -330,7 +575,7 @@ const fetchMediaItems = async () => {
|
||||
|
||||
/**
|
||||
* 미디어 선택 창을 연다.
|
||||
* @param {'image'|'gallery'} target - 삽입 대상
|
||||
* @param {'image'|'gallery'|'active-gallery'} target - 삽입 대상
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const openMediaPicker = async (target) => {
|
||||
@@ -383,6 +628,11 @@ const applyMediaSelection = () => {
|
||||
width: selectedImageWidth.value
|
||||
})
|
||||
}
|
||||
} else if (mediaPickerTarget.value === 'active-gallery') {
|
||||
appendImagesToActiveGallery(selectedItems.map((item) => ({
|
||||
url: item.url,
|
||||
alt: item.name || ''
|
||||
})))
|
||||
} else {
|
||||
insertGallery(selectedItems.map((item) => ({
|
||||
url: item.url,
|
||||
@@ -445,6 +695,131 @@ const uploadAndInsert = async (files, target = 'image') => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인라인 HTML 노드를 마크다운 문자열로 변환한다.
|
||||
* @param {Node} node - HTML 노드
|
||||
* @returns {string} 마크다운 문자열
|
||||
*/
|
||||
const convertHtmlInlineNodeToMarkdown = (node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent || ''
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const element = /** @type {HTMLElement} */ (node)
|
||||
const tagName = element.tagName.toLowerCase()
|
||||
const childText = Array.from(element.childNodes).map(convertHtmlInlineNodeToMarkdown).join('')
|
||||
|
||||
if (tagName === 'strong' || tagName === 'b') {
|
||||
return `**${childText}**`
|
||||
}
|
||||
|
||||
if (tagName === 'em' || tagName === 'i') {
|
||||
return `*${childText}*`
|
||||
}
|
||||
|
||||
if (tagName === 'code') {
|
||||
return `\`${childText}\``
|
||||
}
|
||||
|
||||
if (tagName === 'a') {
|
||||
const href = element.getAttribute('href')
|
||||
return href ? `[${childText || href}](${href})` : childText
|
||||
}
|
||||
|
||||
if (tagName === 'img') {
|
||||
const src = element.getAttribute('src')
|
||||
const alt = element.getAttribute('alt') || ''
|
||||
return src ? `` : ''
|
||||
}
|
||||
|
||||
if (tagName === 'br') {
|
||||
return '\n'
|
||||
}
|
||||
|
||||
return childText
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 블록 노드를 마크다운 문자열로 변환한다.
|
||||
* @param {Node} node - HTML 노드
|
||||
* @param {number} listIndex - 순서 목록 번호
|
||||
* @returns {string} 마크다운 문자열
|
||||
*/
|
||||
const convertHtmlBlockNodeToMarkdown = (node, listIndex = 1) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return (node.textContent || '').trim()
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const element = /** @type {HTMLElement} */ (node)
|
||||
const tagName = element.tagName.toLowerCase()
|
||||
const inlineText = Array.from(element.childNodes).map(convertHtmlInlineNodeToMarkdown).join('').trim()
|
||||
|
||||
if (/^h[1-6]$/.test(tagName)) {
|
||||
return `${'#'.repeat(Number(tagName.slice(1)))} ${inlineText}`
|
||||
}
|
||||
|
||||
if (tagName === 'p') {
|
||||
return inlineText
|
||||
}
|
||||
|
||||
if (tagName === 'blockquote') {
|
||||
return inlineText.split('\n').map((line) => `> ${line}`).join('\n')
|
||||
}
|
||||
|
||||
if (tagName === 'pre') {
|
||||
return `\`\`\`\n${element.textContent?.trim() || ''}\n\`\`\``
|
||||
}
|
||||
|
||||
if (tagName === 'li') {
|
||||
return `${listIndex}. ${inlineText}`
|
||||
}
|
||||
|
||||
if (tagName === 'ul' || tagName === 'ol') {
|
||||
return Array.from(element.children)
|
||||
.filter((child) => child.tagName.toLowerCase() === 'li')
|
||||
.map((child, index) => {
|
||||
const marker = tagName === 'ol' ? `${index + 1}.` : '-'
|
||||
const text = Array.from(child.childNodes).map(convertHtmlInlineNodeToMarkdown).join('').trim()
|
||||
return `${marker} ${text}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
if (tagName === 'div' || tagName === 'section' || tagName === 'article') {
|
||||
const childBlocks = Array.from(element.childNodes)
|
||||
.map(convertHtmlBlockNodeToMarkdown)
|
||||
.filter(Boolean)
|
||||
|
||||
return childBlocks.length ? childBlocks.join('\n\n') : inlineText
|
||||
}
|
||||
|
||||
return inlineText
|
||||
}
|
||||
|
||||
/**
|
||||
* 클립보드 HTML을 마크다운 문서 조각으로 변환한다.
|
||||
* @param {string} html - HTML 문자열
|
||||
* @returns {string} 마크다운 문자열
|
||||
*/
|
||||
const convertHtmlToMarkdown = (html) => {
|
||||
const document = new DOMParser().parseFromString(html, 'text/html')
|
||||
|
||||
return Array.from(document.body.childNodes)
|
||||
.map(convertHtmlBlockNodeToMarkdown)
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 입력 변경 처리
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
@@ -465,6 +840,17 @@ const handlePaste = async (event) => {
|
||||
const imageFiles = Array.from(event.clipboardData?.files || []).filter((file) => file.type.startsWith('image/'))
|
||||
|
||||
if (!imageFiles.length) {
|
||||
const html = event.clipboardData?.getData('text/html')
|
||||
|
||||
if (html) {
|
||||
const markdown = convertHtmlToMarkdown(html)
|
||||
|
||||
if (markdown) {
|
||||
event.preventDefault()
|
||||
replaceSelection(markdown)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -506,15 +892,12 @@ const handleKeydown = (event) => {
|
||||
} 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 ref="editorRootRef" 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
|
||||
@@ -620,12 +1003,112 @@ const handleKeydown = (event) => {
|
||||
@focus="refreshCaretLogicalLine"
|
||||
/>
|
||||
</div>
|
||||
<section
|
||||
v-if="activeMediaBlock"
|
||||
class="admin-markdown-editor__media-editor mt-3 rounded border border-[#e3e6e8] bg-white p-4"
|
||||
aria-label="현재 미디어 블록 편집"
|
||||
>
|
||||
<div class="admin-markdown-editor__media-editor-header flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="admin-markdown-editor__media-editor-title text-sm font-bold text-[#15171a]">
|
||||
{{ activeMediaBlock.type === 'gallery' ? '현재 갤러리 편집' : '현재 이미지 편집' }}
|
||||
</h3>
|
||||
<p class="admin-markdown-editor__media-editor-meta mt-1 text-xs text-[#6b7280]">
|
||||
{{ activeMediaBlock.type === 'gallery' ? `${activeMediaBlock.images.length}개 이미지` : '커서가 위치한 이미지 줄' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="activeMediaBlock.type === 'gallery'"
|
||||
class="admin-markdown-editor__media-editor-add rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white"
|
||||
type="button"
|
||||
@click="openMediaPicker('active-gallery')"
|
||||
>
|
||||
이미지 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-markdown-editor__media-editor-list mt-4 grid gap-3">
|
||||
<div
|
||||
v-for="(image, imageIndex) in activeMediaBlock.images"
|
||||
:key="`media-editor-image-${imageIndex}`"
|
||||
class="admin-markdown-editor__media-editor-row grid gap-3 rounded border border-[#edf0f2] bg-[#fafafa] p-3 md:grid-cols-[120px_minmax(0,1fr)]"
|
||||
>
|
||||
<img
|
||||
class="admin-markdown-editor__media-editor-thumb aspect-[4/3] w-full rounded bg-[#eff1f2] object-cover"
|
||||
:src="image.url"
|
||||
:alt="image.alt || ''"
|
||||
>
|
||||
<div class="admin-markdown-editor__media-editor-fields grid gap-2">
|
||||
<label class="admin-markdown-editor__media-editor-field grid gap-1 text-xs font-semibold text-[#394047]">
|
||||
대체 텍스트
|
||||
<input
|
||||
class="admin-markdown-editor__media-editor-input rounded border border-[#d7dde2] bg-white px-3 py-2 text-sm font-normal text-[#15171a]"
|
||||
:value="image.alt"
|
||||
type="text"
|
||||
@input="updateActiveMediaImage(imageIndex, { alt: $event.target.value })"
|
||||
>
|
||||
</label>
|
||||
<label class="admin-markdown-editor__media-editor-field grid gap-1 text-xs font-semibold text-[#394047]">
|
||||
이미지 URL
|
||||
<input
|
||||
class="admin-markdown-editor__media-editor-input rounded border border-[#d7dde2] bg-white px-3 py-2 text-sm font-normal text-[#15171a]"
|
||||
:value="image.url"
|
||||
type="text"
|
||||
@input="updateActiveMediaImage(imageIndex, { url: $event.target.value })"
|
||||
>
|
||||
</label>
|
||||
<div class="admin-markdown-editor__media-editor-actions flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
class="admin-markdown-editor__media-editor-select rounded border border-[#d7dde2] bg-white px-2 py-1.5 text-sm text-[#394047]"
|
||||
:value="image.width"
|
||||
@change="updateActiveMediaImage(imageIndex, { width: $event.target.value })"
|
||||
>
|
||||
<option v-for="option in imageWidthOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="activeMediaBlock.type === 'gallery'"
|
||||
class="admin-markdown-editor__media-editor-action rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="imageIndex === 0"
|
||||
@click="moveActiveGalleryImage(imageIndex, -1)"
|
||||
>
|
||||
위로
|
||||
</button>
|
||||
<button
|
||||
v-if="activeMediaBlock.type === 'gallery'"
|
||||
class="admin-markdown-editor__media-editor-action rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="imageIndex === activeMediaBlock.images.length - 1"
|
||||
@click="moveActiveGalleryImage(imageIndex, 1)"
|
||||
>
|
||||
아래로
|
||||
</button>
|
||||
<button
|
||||
class="admin-markdown-editor__media-editor-remove rounded px-2.5 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50"
|
||||
type="button"
|
||||
@click="removeActiveMediaImage(imageIndex)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<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">
|
||||
<div
|
||||
v-else
|
||||
ref="previewRef"
|
||||
class="admin-markdown-editor__preview min-h-[620px] rounded border border-[#e3e6e8] bg-white px-6 py-5 text-[#15171a]"
|
||||
style="--site-text: #15171a; --site-muted: #6b7280; --site-panel: #f6f7f8; --site-line: #e3e6e8; --site-accent: #2eb6ea;"
|
||||
tabindex="0"
|
||||
>
|
||||
<ContentMarkdownRenderer v-if="markdownValue.trim()" :content="markdownValue" />
|
||||
<p v-else class="admin-markdown-editor__preview-empty text-sm text-[#8e9cac]">
|
||||
미리보기할 본문이 없습니다.
|
||||
@@ -637,7 +1120,7 @@ const handleKeydown = (event) => {
|
||||
<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' ? '갤러리 미디어 선택' : '이미지 미디어 선택' }}
|
||||
{{ mediaPickerTarget === 'image' ? '이미지 미디어 선택' : '갤러리 미디어 선택' }}
|
||||
</h2>
|
||||
<p class="admin-markdown-editor__media-count mt-1 text-sm text-[#6b7280]">
|
||||
{{ selectedMediaUrls.length }}개 선택됨
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.0.13
|
||||
|
||||
- 관리자 글쓰기에서 외부 웹 글 붙여넣기를 기본 마크다운으로 정리하고, 커서가 위치한 이미지·갤러리 블록을 바로 편집할 수 있도록 개선.
|
||||
|
||||
## v1.0.11
|
||||
|
||||
- 관리자 글쓰기 본문을 Markdown-first 에디터로 교체해 범위 선택, 복사/붙여넣기, 미디어 이미지·갤러리 삽입 흐름을 단순화.
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
- TailwindCSS 기본 사용
|
||||
- 다크 인증(`signin`/`signup`)의 텍스트 입력에는 `auth-form-input` 클래스를 붙여 `main.css`의 글자색·캐럿·placeholder를 적용한다(폼 컨트롤은 부모 `color`를 상속하지 않는 경우가 많음).
|
||||
- Tailwind 엔트리는 `nuxt.config.js`의 `tailwindcss.cssPath: '~/assets/css/main.css'`로 통일한다(`@nuxtjs/tailwindcss` 기본 `assets/css/tailwind.css` 부재 시 패키지 `tailwind.css`가 중복 주입될 수 있음).
|
||||
- 관리자 글 에디터는 블록 단위 UI로 작성하되 저장 값은 기존 마크다운 문자열을 유지
|
||||
- 관리자 글 에디터는 Markdown-first textarea 편집을 기준으로 하며 저장 값은 기존 마크다운 문자열을 유지
|
||||
- 관리자 미디어 화면에서 모달에 가릴 수 있는 오류·안내는 `useAdminToast`의 `showToast`로 우측 상단(`z-[100]`)에 표시한다.
|
||||
- 관리자 메뉴 관리는 상단/하단 탭으로 나누고, 순서는 드래그로 조정한다(상단은 동일 부모의 형제끼리만).
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-14 v1.0.13
|
||||
|
||||
### Markdown 에디터 붙여넣기와 미디어 편집 보강
|
||||
|
||||
textarea 기반 Markdown-first 편집은 범위 선택과 복사/붙여넣기 문제를 줄였지만, 외부 웹 글을 붙여넣을 때 HTML 구조가 일반 텍스트로 무너지거나 이미지·갤러리 마크다운을 직접 고쳐야 하는 불편이 남았다. 우선 브라우저 클립보드의 `text/html`을 제목·문단·목록·링크·강조·이미지 중심의 마크다운 조각으로 변환하고, 커서가 이미지 또는 갤러리 블록 안에 있을 때 별도 편집 패널을 보여 alt·URL·너비·갤러리 순서를 수정하도록 했다. 작성과 미리보기 전환은 반복 작업이므로 `Cmd/Ctrl+E` 단축키로 접근하게 하고, 관리자 미리보기는 공개 렌더러를 쓰되 밝은 관리자 패널에 맞는 색상 변수를 별도로 고정한다. 완전한 옵시디언식 토큰 숨김 Live Preview와 표준 마크다운 파서는 더 큰 편집 엔진 선택이 필요하므로 후속 단계로 둔다.
|
||||
|
||||
## 2026-05-13 v1.0.12
|
||||
|
||||
### Markdown 에디터 줄 번호 거터
|
||||
|
||||
@@ -64,7 +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/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, HTML 클립보드 마크다운 변환, 작성 모드 왼쪽 논리 줄 번호 거터·현재 줄 강조·거터 스크롤 동기화, 작성/미리보기 전환(`Cmd/Ctrl+E`), 툴바 마크다운 삽입, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입, 현재 이미지·갤러리 편집 패널 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||
|
||||
@@ -444,13 +444,18 @@ components/content/
|
||||
- 작성 모드 textarea 왼쪽에 **논리 줄 번호** 거터(`\\n` 기준 줄 수, 빈 본문은 1줄)를 두고, 캐럿이 있는 줄 번호 행에 배경색으로 **활성 표시**를 한다. textarea와 거터의 세로 스크롤은 동기화한다. 한 논리 줄이 화면에서 여러 줄로 줄바꿈될 때는 옵시디언·CodeMirror처럼 시각 줄마다 번호가 늘지 않으며, 논리 줄 단위로만 맞춘다.
|
||||
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 그대로 유지한다.
|
||||
- 본문 작성 모드는 textarea 기반으로 범위 선택, 다중 문단 복사/붙여넣기, 외부 마크다운 붙여넣기를 브라우저 기본 동작에 가깝게 처리한다.
|
||||
- 클립보드에 `text/html`이 있으면 제목, 문단, 목록, 인용, 코드, 링크, 굵게, 기울임, 이미지를 기본 마크다운 조각으로 변환해 삽입한다.
|
||||
- 본문 미리보기 모드는 공개 본문과 같은 `ContentMarkdownRenderer`를 사용한다.
|
||||
- 툴바는 제목 1/2/3, 굵게, 기울임, 인라인 코드, 인용, 목록, 코드 블록, 구분선 삽입을 제공한다.
|
||||
- `Cmd/Ctrl+B`, `Cmd/Ctrl+I`, `Cmd/Ctrl+E`는 현재 선택 텍스트에 각각 굵게, 기울임, 인라인 코드 마크다운을 적용한다.
|
||||
- `Cmd/Ctrl+B`, `Cmd/Ctrl+I`는 현재 선택 텍스트에 각각 굵게, 기울임 마크다운을 적용한다.
|
||||
- `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환한다.
|
||||
- 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다.
|
||||
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
|
||||
- 미디어 라이브러리에서 단일 이미지를 선택하면 `{width=...}` 형식으로 삽입한다.
|
||||
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
|
||||
- 이미지 너비 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||
- 작성 모드에서 커서가 이미지 마크다운 줄 또는 `:::gallery` 블록 안에 있으면 현재 미디어 블록 편집 패널을 표시한다.
|
||||
- 현재 미디어 블록 편집 패널은 alt, URL, 너비 값을 수정하고 갤러리 이미지 순서 변경, 삭제, 미디어 라이브러리 이미지 추가를 지원한다.
|
||||
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
|
||||
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 1차 관리자 개발
|
||||
|
||||
- [ ] Markdown-first 에디터 2차 개선: 옵시디언식 Live Preview(마크다운 토큰 숨김/백스페이스 복원), 이미지·갤러리 카드형 편집, 표준 마크다운 파서 도입 검토
|
||||
- [ ] Markdown-first 에디터 3차 개선: 옵시디언식 Live Preview(마크다운 토큰 숨김/백스페이스 복원), 표준 마크다운 파서 도입 검토, HTML 붙여넣기 변환 범위 확대
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.0.13
|
||||
|
||||
- 관리자 `AdminMarkdownEditor`에 HTML 클립보드 붙여넣기 기본 변환을 추가해 외부 블로그/웹 문서를 붙여넣을 때 제목·문단·목록·링크·굵게·기울임·이미지를 마크다운 조각으로 정리.
|
||||
- 작성 모드에서 커서가 이미지 줄 또는 `:::gallery` 블록 안에 있을 때 현재 미디어 블록 편집 패널을 표시하고 alt·URL·너비 수정, 갤러리 순서 변경·삭제·이미지 추가를 지원.
|
||||
- 관리자 `AdminMarkdownEditor`에서 `Cmd/Ctrl+E`로 작성/미리보기 모드를 전환하도록 변경하고, 관리자 미리보기의 본문 색상 변수를 밝은 배경 기준으로 고정.
|
||||
- 패키지 버전 `1.0.13`으로 갱신.
|
||||
|
||||
## v1.0.12
|
||||
|
||||
- 관리자 `AdminMarkdownEditor` 작성 모드에 왼쪽 줄 번호 거터(`\\n` 기준 논리 줄)와 현재 줄 배경 강조 추가, textarea와 거터 세로 스크롤 동기화.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.13",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.12",
|
||||
"version": "1.0.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
Reference in New Issue
Block a user