574 lines
20 KiB
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 `${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>
|