Files
sori.studio/components/admin/AdminMarkdownEditor.vue

1183 lines
38 KiB
Vue

<script setup>
const props = defineProps({
modelValue: {
type: String,
default: ''
}
})
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` 기준) */
const activeLogicalLineIndex = ref(0)
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)
})
/**
* 이미지 마크다운 한 줄을 구조화한다.
* @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}
*/
const gutterLineCount = computed(() => {
const raw = markdownValue.value ?? ''
const n = raw.split('\n').length
return Math.max(1, n)
})
/**
* textarea와 줄 번호 거터의 세로 스크롤을 맞춘다.
* @returns {void}
*/
const syncGutterScroll = () => {
const gutter = gutterRef.value
const textarea = textareaRef.value
if (gutter && textarea) {
gutter.scrollTop = textarea.scrollTop
}
}
/**
* 커서 위치 기준으로 활성 논리 줄 인덱스를 갱신하고 거터 스크롤을 맞춘다.
* @returns {void}
*/
const refreshCaretLogicalLine = () => {
nextTick(() => {
const textarea = textareaRef.value
if (!textarea) {
return
}
const value = markdownValue.value ?? ''
const pos = Math.min(textarea.selectionStart, value.length)
const lineIndex = value.slice(0, pos).split('\n').length - 1
activeLogicalLineIndex.value = Math.max(0, lineIndex)
syncGutterScroll()
})
}
/**
* textarea 스크롤 시 거터만 동기화한다.
* @returns {void}
*/
const onTextareaScroll = () => {
syncGutterScroll()
}
watch(() => props.modelValue, () => {
refreshCaretLogicalLine()
})
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가 포커스일 때만 활성 줄을 갱신한다.
* @returns {void}
*/
const onSelectionChange = () => {
if (activeMode.value !== 'write') {
return
}
const textarea = textareaRef.value
if (!textarea || document.activeElement !== textarea) {
return
}
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()
})
/**
* 본문 에디터에 포커스한다.
* @returns {void}
*/
const focusFirstBlock = () => {
nextTick(() => {
textareaRef.value?.focus()
refreshCaretLogicalLine()
})
}
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)
refreshCaretLogicalLine()
})
}
/**
* 선택 영역을 지정 문자열로 교체한다.
* @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 `![${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 - 이미지 정보
* @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'|'active-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 if (mediaPickerTarget.value === 'active-gallery') {
appendImagesToActiveGallery(selectedItems.map((item) => ({
url: item.url,
alt: item.name || ''
})))
} 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
}
}
/**
* 인라인 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 - 파일 입력 이벤트
* @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) {
const html = event.clipboardData?.getData('text/html')
if (html) {
const markdown = convertHtmlToMarkdown(html)
if (markdown) {
event.preventDefault()
replaceSelection(markdown)
}
}
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('*', '*', '기울임')
}
}
</script>
<template>
<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
</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">
<div class="admin-markdown-editor__editor-surface flex min-h-[620px] overflow-hidden rounded border border-[#e3e6e8] bg-white">
<div
ref="gutterRef"
class="admin-markdown-editor__gutter min-w-[2.75rem] shrink-0 select-none overflow-y-auto overflow-x-hidden border-r border-[#e3e6e8] bg-[#f6f7f8] py-5 font-mono text-[13px] leading-7 text-[#8e9cac]"
aria-hidden="true"
>
<div
v-for="ln in gutterLineCount"
:key="`gutter-line-${ln}`"
class="admin-markdown-editor__gutter-line min-h-[28px] pr-2 text-right tabular-nums"
:class="{ 'admin-markdown-editor__gutter-line--active': ln - 1 === activeLogicalLineIndex }"
>
{{ ln }}
</div>
</div>
<textarea
ref="textareaRef"
v-model="markdownValue"
class="admin-markdown-editor__textarea min-h-[620px] flex-1 resize-y border-0 bg-transparent py-5 pl-2 pr-5 font-mono text-[15px] leading-7 text-[#15171a] outline-none transition-colors placeholder:text-[#8e9cac] focus:ring-0"
placeholder="마크다운으로 글을 작성하세요."
spellcheck="false"
@keydown="handleKeydown"
@paste="handlePaste"
@drop="handleDrop"
@dragover.prevent
@scroll="onTextareaScroll"
@input="refreshCaretLogicalLine"
@click="refreshCaretLogicalLine"
@keyup="refreshCaretLogicalLine"
@select="refreshCaretLogicalLine"
@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
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]">
미리보기할 본문이 없습니다.
</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 === 'image' ? '이미지 미디어 선택' : '갤러리 미디어 선택' }}
</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>
<style scoped>
.admin-markdown-editor__gutter-line--active {
background-color: rgba(46, 182, 234, 0.16);
color: #15171a;
font-weight: 600;
}
</style>