마크다운 에디터 붙여넣기와 미디어 편집 개선

This commit is contained in:
2026-05-14 15:59:20 +09:00
parent 5eb6c88381
commit 91b7369a07
10 changed files with 519 additions and 14 deletions

View File

@@ -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 `![${image.alt || ''}](${image.url})${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 ? `![${alt}](${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 }} 선택됨