1283 lines
40 KiB
Vue
1283 lines
40 KiB
Vue
<script setup>
|
|
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: [String, Array, Object],
|
|
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 lastSelectionState = ref({
|
|
start: 0,
|
|
end: 0,
|
|
scrollTop: 0
|
|
})
|
|
|
|
const imageWidthOptions = [
|
|
{ value: 'regular', label: '기본' },
|
|
{ value: 'wide', label: '와이드' },
|
|
{ value: 'full', label: '풀사이즈' }
|
|
]
|
|
|
|
const markdownValue = computed({
|
|
get: () => normalizeMarkdownContent(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
|
|
|
|
lastSelectionState.value = {
|
|
start: Math.min(textarea.selectionStart, value.length),
|
|
end: Math.min(textarea.selectionEnd, value.length),
|
|
scrollTop: textarea.scrollTop
|
|
}
|
|
activeLogicalLineIndex.value = Math.max(0, lineIndex)
|
|
syncGutterScroll()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* textarea 스크롤 시 선택 위치를 기억하고 거터를 동기화한다.
|
|
* @returns {void}
|
|
*/
|
|
const onTextareaScroll = () => {
|
|
rememberTextareaSelection()
|
|
syncGutterScroll()
|
|
}
|
|
|
|
/**
|
|
* textarea의 선택 영역과 스크롤 위치를 기억한다.
|
|
* @returns {void}
|
|
*/
|
|
const rememberTextareaSelection = () => {
|
|
const textarea = textareaRef.value
|
|
const value = markdownValue.value ?? ''
|
|
|
|
if (!textarea) {
|
|
lastSelectionState.value = {
|
|
start: value.length,
|
|
end: value.length,
|
|
scrollTop: 0
|
|
}
|
|
return
|
|
}
|
|
|
|
lastSelectionState.value = {
|
|
start: Math.min(textarea.selectionStart, value.length),
|
|
end: Math.min(textarea.selectionEnd, value.length),
|
|
scrollTop: textarea.scrollTop
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 기억한 선택 영역과 스크롤 위치로 작성 textarea 포커스를 복원한다.
|
|
* @returns {void}
|
|
*/
|
|
const restoreTextareaFocus = () => {
|
|
nextTick(() => {
|
|
const textarea = textareaRef.value
|
|
const value = markdownValue.value ?? ''
|
|
|
|
if (!textarea) {
|
|
return
|
|
}
|
|
|
|
const start = Math.min(lastSelectionState.value.start, value.length)
|
|
const end = Math.min(lastSelectionState.value.end, value.length)
|
|
|
|
textarea.focus()
|
|
textarea.setSelectionRange(start, end)
|
|
textarea.scrollTop = lastSelectionState.value.scrollTop
|
|
refreshCaretLogicalLine()
|
|
})
|
|
}
|
|
|
|
watch(() => props.modelValue, () => {
|
|
refreshCaretLogicalLine()
|
|
})
|
|
|
|
watch(activeMode, (mode) => {
|
|
if (mode === 'write') {
|
|
restoreTextareaFocus()
|
|
return
|
|
}
|
|
|
|
nextTick(() => {
|
|
previewRef.value?.focus()
|
|
})
|
|
})
|
|
|
|
/**
|
|
* 작성 모드와 미리보기 모드를 전환한다.
|
|
* @returns {void}
|
|
*/
|
|
const toggleEditorMode = () => {
|
|
if (activeMode.value === 'write') {
|
|
rememberTextareaSelection()
|
|
}
|
|
|
|
activeMode.value = activeMode.value === 'write' ? 'preview' : 'write'
|
|
}
|
|
|
|
/**
|
|
* 작성/미리보기 모드를 지정한다.
|
|
* @param {'write'|'preview'} mode - 전환할 모드
|
|
* @returns {void}
|
|
*/
|
|
const setEditorMode = (mode) => {
|
|
if (activeMode.value === mode) {
|
|
return
|
|
}
|
|
|
|
if (activeMode.value === 'write') {
|
|
rememberTextareaSelection()
|
|
}
|
|
|
|
activeMode.value = mode
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
/**
|
|
* Enter 입력을 문단 분리 규칙에 맞게 처리한다.
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {boolean} 직접 처리했는지 여부
|
|
*/
|
|
const handleParagraphEnter = (event) => {
|
|
if (event.key !== 'Enter' || event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || event.isComposing) {
|
|
return false
|
|
}
|
|
|
|
event.preventDefault()
|
|
replaceSelection('\n\n')
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* 블록형 마크다운 조각을 커서 위치에 삽입한다.
|
|
* @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 {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 ? `` : ''
|
|
}
|
|
|
|
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 (handleParagraphEnter(event)) {
|
|
return
|
|
}
|
|
|
|
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="setEditorMode('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="setEditorMode('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>
|