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

574 lines
20 KiB
Vue

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