v1.4.1: 관리자 미디어 업로드 한도·라이브 에디터 UX 개선
종류별 업로드 크기 한도와 413 안내를 추가하고, 임베드·미디어 라이브 프리뷰·제목 Enter 포커스·스크롤 동작을 보정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -421,7 +421,7 @@ const serializeBlocks = () => {
|
||||
|
||||
if (block.type === 'embed') {
|
||||
return block.url.trim()
|
||||
? { type: block.type, value: `:::embed\n${block.url.trim()}\n:::` }
|
||||
? { type: block.type, value: block.url.trim() }
|
||||
: null
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,23 @@ import {
|
||||
resolveSlashCommand
|
||||
} from '../../lib/markdown-slash-commands.js'
|
||||
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
|
||||
import {
|
||||
buildDefaultUploadSizeLimits,
|
||||
formatUploadSizeLimit,
|
||||
getMaxUploadBytesForKind,
|
||||
getUploadKind
|
||||
} from '../../lib/upload-size-limit.js'
|
||||
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'
|
||||
|
||||
const { toast, showToast } = useAdminToast()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const uploadSizeLimits = computed(() => buildDefaultUploadSizeLimits({
|
||||
image: Number(runtimeConfig.public.maxFileSize || 10485760),
|
||||
video: Number(runtimeConfig.public.maxVideoFileSize || 209715200),
|
||||
audio: Number(runtimeConfig.public.maxAudioFileSize || 52428800),
|
||||
document: Number(runtimeConfig.public.maxDocumentFileSize || 52428800)
|
||||
}))
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Array, Object],
|
||||
@@ -75,6 +90,21 @@ const liveSlashVisible = computed(() => liveSlashSourceLine.value !== null)
|
||||
const liveSlashSuppressedLineList = computed(() => [...liveSlashSuppressedLines.value])
|
||||
|
||||
const visibleLiveSlashCommands = computed(() => filterSlashCommands(liveSlashQuery.value))
|
||||
const mediaPickerAccept = computed(() => {
|
||||
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
|
||||
return 'image/*'
|
||||
}
|
||||
|
||||
if (mediaPickerTarget.value === 'video') {
|
||||
return 'video/*'
|
||||
}
|
||||
|
||||
if (mediaPickerTarget.value === 'audio') {
|
||||
return 'audio/*'
|
||||
}
|
||||
|
||||
return '.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx'
|
||||
})
|
||||
|
||||
/** 작성 textarea 최소 높이(px) */
|
||||
const MIN_TEXTAREA_HEIGHT_PX = 620
|
||||
@@ -346,15 +376,65 @@ watch(activeMode, (mode) => {
|
||||
nextTick(() => {
|
||||
syncTextareaHeight()
|
||||
restoreTextareaFocus()
|
||||
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.scrollTop = 0
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
previewRef.value?.focus()
|
||||
scrollPreviewToTop()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 라이브 미리보기 스크롤을 맨 위로 맞춘다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const scrollPreviewToTop = () => {
|
||||
if (previewRef.value) {
|
||||
previewRef.value.scrollTop = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 작성 시작 시 포커스할 첫 줄 인덱스를 반환한다.
|
||||
* @returns {number} 줄 번호(0-based)
|
||||
*/
|
||||
const getInitialContentFocusLineIndex = () => {
|
||||
const lines = (markdownValue.value ?? '').split('\n')
|
||||
|
||||
if (!lines.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const firstLine = lines[0]?.trim() ?? ''
|
||||
|
||||
if (/^#{1,6}\s+/.test(firstLine)) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 작성 시작 줄이 없으면 빈 줄을 추가한다.
|
||||
* @param {number} lineIndex - 포커스할 줄
|
||||
* @returns {number} 보정된 줄 번호
|
||||
*/
|
||||
const ensureContentFocusLineExists = (lineIndex) => {
|
||||
const lines = (markdownValue.value ?? '').split('\n')
|
||||
|
||||
if (lineIndex < lines.length) {
|
||||
return lineIndex
|
||||
}
|
||||
|
||||
markdownValue.value = lines.length ? `${markdownValue.value}\n` : ''
|
||||
return lines.length
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성 모드와 미리보기 모드를 전환한다.
|
||||
* @returns {void}
|
||||
@@ -484,16 +564,36 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 본문 에디터에 포커스한다.
|
||||
* 본문 에디터에 포커스한다. 현재 모드(write/preview)를 유지한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusFirstBlock = () => {
|
||||
if (activeMode.value !== 'write') {
|
||||
activeMode.value = 'write'
|
||||
const focusLine = ensureContentFocusLineExists(getInitialContentFocusLineIndex())
|
||||
|
||||
if (activeMode.value === 'preview') {
|
||||
scrollPreviewToTop()
|
||||
|
||||
nextTick(() => {
|
||||
nextTick(() => {
|
||||
previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'start')
|
||||
})
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
textareaRef.value?.focus()
|
||||
const textarea = textareaRef.value
|
||||
const lines = (markdownValue.value ?? '').split('\n')
|
||||
const lineStart = lines.slice(0, focusLine).join('\n').length + (focusLine > 0 ? 1 : 0)
|
||||
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(lineStart, lineStart)
|
||||
textarea.scrollTop = 0
|
||||
}
|
||||
|
||||
activeLogicalLineIndex.value = focusLine
|
||||
refreshCaretLogicalLine()
|
||||
})
|
||||
}
|
||||
@@ -647,6 +747,120 @@ const insertCodeBlock = () => {
|
||||
*/
|
||||
const createImageMarkdown = (image) => serializeImageMarkdown(image)
|
||||
|
||||
/**
|
||||
* 파일 크기를 표시용 문자열로 변환한다.
|
||||
* @param {number} size - 바이트 크기
|
||||
* @returns {string} 표시용 크기
|
||||
*/
|
||||
const formatMediaFileSize = (size) => {
|
||||
const value = Number(size || 0)
|
||||
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (value < 1024) {
|
||||
return `${value} B`
|
||||
}
|
||||
|
||||
if (value < 1024 * 1024) {
|
||||
return `${Math.round(value / 1024)} KB`
|
||||
}
|
||||
|
||||
return `${(value / 1024 / 1024).toFixed(1).replace(/\.0$/, '')} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 항목 종류를 반환한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {'image'|'video'|'audio'|'file'} 미디어 종류
|
||||
*/
|
||||
const getMediaItemKind = (item) => {
|
||||
if (item?.kind) {
|
||||
return item.kind
|
||||
}
|
||||
|
||||
const source = `${item?.extension || ''} ${item?.url || ''}`.toLowerCase()
|
||||
|
||||
if (/\.(jpe?g|png|webp|gif)(\?|$)/i.test(source)) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
if (/\.(mp4|webm|mov)(\?|$)/i.test(source)) {
|
||||
return 'video'
|
||||
}
|
||||
|
||||
if (/\.(mp3|wav|ogg|m4a)(\?|$)/i.test(source)) {
|
||||
return 'audio'
|
||||
}
|
||||
|
||||
return 'file'
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 항목이 현재 선택 대상과 맞는지 확인한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {boolean} 선택 가능 여부
|
||||
*/
|
||||
const isMediaItemSelectableForTarget = (item) => {
|
||||
const kind = getMediaItemKind(item)
|
||||
|
||||
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
|
||||
return kind === 'image'
|
||||
}
|
||||
|
||||
return kind === mediaPickerTarget.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭된 파일이 현재 미디어 선택 대상에 맞는지 확인한다.
|
||||
* @param {File} file - 브라우저 파일
|
||||
* @returns {boolean} 허용 여부
|
||||
*/
|
||||
const isUploadFileAllowedForPicker = (file) => {
|
||||
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
|
||||
return file.type.startsWith('image/')
|
||||
}
|
||||
|
||||
if (mediaPickerTarget.value === 'video') {
|
||||
return file.type.startsWith('video/')
|
||||
}
|
||||
|
||||
if (mediaPickerTarget.value === 'audio') {
|
||||
return file.type.startsWith('audio/')
|
||||
}
|
||||
|
||||
return !file.type.startsWith('image/') && !file.type.startsWith('video/') && !file.type.startsWith('audio/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 항목으로 Prose 미디어 블록을 만든다.
|
||||
* @param {'video'|'audio'|'file'} blockType - 블록 유형
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {string[]} 마크다운 줄
|
||||
*/
|
||||
const createMediaBlockMarkdown = (blockType, item) => {
|
||||
const title = item.title || item.name || ''
|
||||
|
||||
if (blockType === 'video') {
|
||||
return [':::video', `url=${item.url}`, `title=${title}`, 'caption=', ':::']
|
||||
}
|
||||
|
||||
if (blockType === 'audio') {
|
||||
return [':::audio', `url=${item.url}`, `title=${title}`, 'description=', ':::']
|
||||
}
|
||||
|
||||
return [
|
||||
':::file',
|
||||
`url=${item.url}`,
|
||||
`title=${title}`,
|
||||
'description=',
|
||||
`name=${item.name || title}`,
|
||||
`size=${formatMediaFileSize(item.size)}`,
|
||||
':::'
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 줄 범위를 새 줄 목록으로 교체한다.
|
||||
* @param {number} startLine - 시작 줄
|
||||
@@ -1143,11 +1357,11 @@ const applyLiveSlashCommand = (command) => {
|
||||
|
||||
const line = liveSlashSourceLine.value
|
||||
|
||||
if (command.action === 'media-image' || command.action === 'media-gallery') {
|
||||
if (['media-image', 'media-gallery', 'media-video', 'media-audio', 'media-file'].includes(command.action)) {
|
||||
liveSlashInsertLine.value = line
|
||||
replaceLineRange(line, line, [], false)
|
||||
onLiveSlashEnd()
|
||||
openMediaPicker(command.action === 'media-image' ? 'image' : 'gallery')
|
||||
openMediaPicker(command.action.replace('media-', ''))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1295,11 +1509,7 @@ const updateActiveEmbedUrl = (url) => {
|
||||
return
|
||||
}
|
||||
|
||||
replaceLineRange(block.startLine, block.endLine, [
|
||||
':::embed',
|
||||
trimmed,
|
||||
':::'
|
||||
], false)
|
||||
replaceLineRange(block.startLine, block.endLine, [trimmed], false)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1400,12 +1610,13 @@ const mediaItemMatchesQuery = (item, query) => {
|
||||
*/
|
||||
const filteredMediaItems = computed(() => {
|
||||
const query = mediaSearchQuery.value.trim().toLowerCase()
|
||||
const targetItems = mediaItems.value.filter(isMediaItemSelectableForTarget)
|
||||
|
||||
if (!query) {
|
||||
return mediaItems.value
|
||||
return targetItems
|
||||
}
|
||||
|
||||
return mediaItems.value.filter((item) => mediaItemMatchesQuery(item, query))
|
||||
return targetItems.filter((item) => mediaItemMatchesQuery(item, query))
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1414,7 +1625,7 @@ const filteredMediaItems = computed(() => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleMediaSelection = (mediaItem) => {
|
||||
if (mediaPickerTarget.value === 'image') {
|
||||
if (!isGalleryMediaPicker.value) {
|
||||
selectedMediaUrls.value = [mediaItem.url]
|
||||
return
|
||||
}
|
||||
@@ -1425,14 +1636,11 @@ const toggleMediaSelection = (mediaItem) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 미디어를 마크다운에 삽입한다.
|
||||
* 미디어 항목 목록을 마크다운에 삽입한다.
|
||||
* @param {Array<Object>} selectedItems - 선택 또는 업로드된 미디어 항목
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyMediaSelection = () => {
|
||||
const selectedItems = selectedMediaUrls.value
|
||||
.map((url) => mediaItems.value.find((item) => item.url === url))
|
||||
.filter(Boolean)
|
||||
|
||||
const insertSelectedMediaItems = (selectedItems) => {
|
||||
if (liveSlashInsertLine.value !== null) {
|
||||
const line = liveSlashInsertLine.value
|
||||
|
||||
@@ -1446,7 +1654,7 @@ const applyMediaSelection = () => {
|
||||
caption: ''
|
||||
})])
|
||||
}
|
||||
} else if (selectedItems.length) {
|
||||
} else if (mediaPickerTarget.value === 'gallery' && selectedItems.length) {
|
||||
insertMarkdownAtLine(line, [
|
||||
':::gallery',
|
||||
...selectedItems.map((item) => createImageMarkdown({
|
||||
@@ -1456,10 +1664,15 @@ const applyMediaSelection = () => {
|
||||
})),
|
||||
':::'
|
||||
])
|
||||
} else if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
|
||||
const [item] = selectedItems
|
||||
|
||||
if (item) {
|
||||
insertMarkdownAtLine(line, createMediaBlockMarkdown(mediaPickerTarget.value, item))
|
||||
}
|
||||
}
|
||||
|
||||
liveSlashInsertLine.value = null
|
||||
closeMediaPicker()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1478,23 +1691,87 @@ const applyMediaSelection = () => {
|
||||
useAlt: false,
|
||||
caption: ''
|
||||
})))
|
||||
} else {
|
||||
} else if (mediaPickerTarget.value === 'gallery') {
|
||||
insertGallery(selectedItems.map((item) => ({
|
||||
url: item.url,
|
||||
useAlt: false,
|
||||
caption: ''
|
||||
})))
|
||||
}
|
||||
} else if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
|
||||
const [item] = selectedItems
|
||||
|
||||
if (item) {
|
||||
insertBlockSnippet(createMediaBlockMarkdown(mediaPickerTarget.value, item).join('\n'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 미디어를 마크다운에 삽입한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyMediaSelection = () => {
|
||||
const selectedItems = selectedMediaUrls.value
|
||||
.map((url) => mediaItems.value.find((item) => item.url === url))
|
||||
.filter(Boolean)
|
||||
|
||||
insertSelectedMediaItems(selectedItems)
|
||||
closeMediaPicker()
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 파일을 업로드한다.
|
||||
* 업로드 API 오류 메시지를 사용자용 문장으로 변환한다.
|
||||
* @param {unknown} error - fetch 오류
|
||||
* @returns {string} 오류 메시지
|
||||
*/
|
||||
const resolveUploadFetchErrorMessage = (error) => {
|
||||
const responseMessage = error?.data?.message || error?.response?._data?.message
|
||||
|
||||
if (typeof responseMessage === 'string' && responseMessage.trim()) {
|
||||
return responseMessage.trim()
|
||||
}
|
||||
|
||||
if (error?.statusCode === 413 || error?.status === 413) {
|
||||
return '업로드 가능한 파일 크기를 초과했습니다.'
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return '파일 업로드에 실패했습니다.'
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 파일이 업로드 한도를 넘지 않는지 검사한다.
|
||||
* @param {FileList|Array<File>} files - 업로드 파일 목록
|
||||
* @returns {string|null} 초과 시 오류 메시지, 아니면 null
|
||||
*/
|
||||
const findUploadSizeLimitError = (files) => {
|
||||
for (const file of Array.from(files)) {
|
||||
const uploadKind = getUploadKind(file.type, file.name)
|
||||
const maxBytes = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits.value)
|
||||
|
||||
if (file.size > maxBytes) {
|
||||
return `파일 크기가 너무 큽니다. (최대 ${formatUploadSizeLimit(maxBytes)})`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 파일을 업로드한다.
|
||||
* @param {FileList|Array<File>} files - 업로드 파일 목록
|
||||
* @returns {Promise<Array<Object>>} 업로드된 파일 목록
|
||||
*/
|
||||
const uploadImages = async (files) => {
|
||||
const uploadMediaFiles = async (files) => {
|
||||
const sizeLimitError = findUploadSizeLimitError(files)
|
||||
|
||||
if (sizeLimitError) {
|
||||
throw new Error(sizeLimitError)
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
Array.from(files).forEach((file) => {
|
||||
formData.append('files', file)
|
||||
@@ -1511,7 +1788,7 @@ const uploadImages = async (files) => {
|
||||
/**
|
||||
* 업로드 파일을 현재 커서 위치에 삽입한다.
|
||||
* @param {FileList|Array<File>} files - 파일 목록
|
||||
* @param {'image'|'gallery'} target - 삽입 대상
|
||||
* @param {'image'|'gallery'|'video'|'audio'|'file'} target - 삽입 대상
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadAndInsert = async (files, target = 'image') => {
|
||||
@@ -1522,20 +1799,24 @@ const uploadAndInsert = async (files, target = 'image') => {
|
||||
isUploading.value = true
|
||||
|
||||
try {
|
||||
const uploadedFiles = await uploadImages(files)
|
||||
const images = uploadedFiles.map((file) => ({
|
||||
const uploadedFiles = await uploadMediaFiles(files)
|
||||
const imageItems = uploadedFiles.map((file) => ({
|
||||
url: file.url,
|
||||
useAlt: false,
|
||||
caption: ''
|
||||
}))
|
||||
|
||||
if (target === 'gallery') {
|
||||
insertGallery(images)
|
||||
} else if (images[0]) {
|
||||
insertImage(images[0])
|
||||
insertGallery(imageItems)
|
||||
} else if (target === 'image' && imageItems[0]) {
|
||||
insertImage(imageItems[0])
|
||||
} else if (['video', 'audio', 'file'].includes(target) && uploadedFiles[0]) {
|
||||
insertBlockSnippet(createMediaBlockMarkdown(target, uploadedFiles[0]).join('\n'))
|
||||
}
|
||||
|
||||
mediaItems.value = await $fetch('/admin/api/media').catch(() => mediaItems.value)
|
||||
} catch (error) {
|
||||
showToast('error', resolveUploadFetchErrorMessage(error))
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
@@ -1544,7 +1825,7 @@ const uploadAndInsert = async (files, target = 'image') => {
|
||||
/**
|
||||
* 파일 입력 변경 처리
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @param {'image'|'gallery'} target - 삽입 대상
|
||||
* @param {'image'|'gallery'|'video'|'audio'|'file'} target - 삽입 대상
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleFileInput = async (event, target) => {
|
||||
@@ -1564,10 +1845,23 @@ const uploadFromMediaModal = async (files) => {
|
||||
|
||||
const target = mediaPickerTarget.value === 'gallery' || mediaPickerTarget.value === 'active-gallery'
|
||||
? 'gallery'
|
||||
: 'image'
|
||||
: mediaPickerTarget.value
|
||||
|
||||
await uploadAndInsert(files, target)
|
||||
closeMediaPicker()
|
||||
isUploading.value = true
|
||||
|
||||
try {
|
||||
const uploadedFiles = await uploadMediaFiles(files)
|
||||
mediaItems.value = await $fetch('/admin/api/media').catch(() => [
|
||||
...uploadedFiles,
|
||||
...mediaItems.value
|
||||
])
|
||||
insertSelectedMediaItems(target === 'gallery' ? uploadedFiles : uploadedFiles.slice(0, 1))
|
||||
closeMediaPicker()
|
||||
} catch (error) {
|
||||
showToast('error', resolveUploadFetchErrorMessage(error))
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1576,7 +1870,7 @@ const uploadFromMediaModal = async (files) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleMediaModalDrop = async (event) => {
|
||||
const files = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
|
||||
const files = Array.from(event.dataTransfer?.files || []).filter(isUploadFileAllowedForPicker)
|
||||
|
||||
if (!files.length) {
|
||||
return
|
||||
@@ -1595,6 +1889,18 @@ const mediaPickerTitle = computed(() => {
|
||||
return '갤러리 이미지 선택'
|
||||
}
|
||||
|
||||
if (mediaPickerTarget.value === 'video') {
|
||||
return '비디오 선택'
|
||||
}
|
||||
|
||||
if (mediaPickerTarget.value === 'audio') {
|
||||
return '오디오 선택'
|
||||
}
|
||||
|
||||
if (mediaPickerTarget.value === 'file') {
|
||||
return '파일 선택'
|
||||
}
|
||||
|
||||
return '이미지 선택'
|
||||
})
|
||||
|
||||
@@ -1605,6 +1911,30 @@ const mediaPickerTitle = computed(() => {
|
||||
const isGalleryMediaPicker = computed(() => mediaPickerTarget.value === 'gallery'
|
||||
|| mediaPickerTarget.value === 'active-gallery')
|
||||
|
||||
/**
|
||||
* 미디어 업로드 안내 문구를 반환한다.
|
||||
* @returns {string} 안내 문구
|
||||
*/
|
||||
const mediaPickerUploadHint = computed(() => {
|
||||
if (isGalleryMediaPicker.value) {
|
||||
return `여러 이미지를 한 번에 선택할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.image)})`
|
||||
}
|
||||
|
||||
if (mediaPickerTarget.value === 'video') {
|
||||
return `MP4, WebM, MOV 비디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.video)})`
|
||||
}
|
||||
|
||||
if (mediaPickerTarget.value === 'audio') {
|
||||
return `MP3, WAV, OGG, M4A 오디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.audio)})`
|
||||
}
|
||||
|
||||
if (mediaPickerTarget.value === 'file') {
|
||||
return `PDF, ZIP, 문서 파일을 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.document)})`
|
||||
}
|
||||
|
||||
return `단일 이미지가 본문에 삽입됩니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.image)})`
|
||||
})
|
||||
|
||||
/**
|
||||
* 붙여넣은 이미지 파일을 업로드한다.
|
||||
* @param {ClipboardEvent} event - 붙여넣기 이벤트
|
||||
@@ -1881,7 +2211,18 @@ const handleKeydown = (event) => {
|
||||
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 || ''">
|
||||
<img
|
||||
v-if="getMediaItemKind(item) === 'image'"
|
||||
class="admin-markdown-editor__media-thumb aspect-[4/3] w-full bg-[#f6f7f8] object-cover"
|
||||
:src="item.url"
|
||||
:alt="item.name || ''"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="admin-markdown-editor__media-thumb flex aspect-[4/3] w-full items-center justify-center bg-[#f6f7f8] text-xs font-bold uppercase tracking-[0.18em] text-[#6b7280]"
|
||||
>
|
||||
{{ getMediaItemKind(item) }}
|
||||
</span>
|
||||
<span class="admin-markdown-editor__media-name block truncate px-3 py-2 text-xs font-semibold text-[#394047]">
|
||||
{{ item.name || item.url }}
|
||||
</span>
|
||||
@@ -1892,7 +2233,7 @@ const handleKeydown = (event) => {
|
||||
「{{ mediaSearchQuery.trim() }}」에 맞는 미디어가 없습니다.
|
||||
</template>
|
||||
<template v-else>
|
||||
선택할 미디어가 없습니다. 업로드 탭에서 이미지를 추가할 수 있습니다.
|
||||
선택할 미디어가 없습니다. 업로드 탭에서 파일을 추가할 수 있습니다.
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1914,13 +2255,13 @@ const handleKeydown = (event) => {
|
||||
<input
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
:accept="mediaPickerAccept"
|
||||
:multiple="isGalleryMediaPicker"
|
||||
@change="uploadFromMediaModal($event.target.files); $event.target.value = ''"
|
||||
>
|
||||
</label>
|
||||
<p class="admin-markdown-editor__media-upload-hint text-xs text-[#8e9cac]">
|
||||
{{ isGalleryMediaPicker ? '여러 이미지를 한 번에 선택할 수 있습니다.' : '단일 이미지가 본문에 삽입됩니다.' }}
|
||||
{{ mediaPickerUploadHint }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1942,6 +2283,19 @@ const handleKeydown = (event) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-markdown-editor__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
'border-[#e3e6e8] bg-white text-[#15171a]': toast.type === 'info'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -168,11 +168,15 @@ const isMarkdownBlockStart = (line) => {
|
||||
trimmedLine === ':::bookmark' ||
|
||||
trimmedLine === ':::signup' ||
|
||||
trimmedLine === ':::gallery' ||
|
||||
trimmedLine === ':::video' ||
|
||||
trimmedLine === ':::audio' ||
|
||||
trimmedLine === ':::file' ||
|
||||
trimmedLine === ':::embed' ||
|
||||
trimmedLine.startsWith(':::callout') ||
|
||||
trimmedLine.startsWith(':::toggle') ||
|
||||
trimmedLine.startsWith('```') ||
|
||||
trimmedLine === '---' ||
|
||||
isStandaloneUrlLine(trimmedLine) ||
|
||||
/^(#{1,6})\s+(.+)$/.test(trimmedLine) ||
|
||||
trimmedLine.startsWith('> ') ||
|
||||
/^- /.test(trimmedLine) ||
|
||||
@@ -187,6 +191,13 @@ const isMarkdownBlockStart = (line) => {
|
||||
*/
|
||||
const cleanParagraphLine = (line) => line.replace(/( {2,}|\\)$/, '').trim()
|
||||
|
||||
/**
|
||||
* 단독 URL 행인지 확인한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {boolean} 단독 URL 여부
|
||||
*/
|
||||
const isStandaloneUrlLine = (line) => /^https?:\/\/\S+$/i.test(String(line || '').trim())
|
||||
|
||||
/**
|
||||
* 빈 줄 공백 블록 높이를 반환한다.
|
||||
* @param {Object} block - 렌더링 블록
|
||||
@@ -305,6 +316,67 @@ const parseSignupMeta = (raw) => {
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 fenced 블록 본문에서 URL과 표시 메타를 파싱한다.
|
||||
* @param {string} raw - fenced 내부 텍스트
|
||||
* @returns {{url: string, title: string, description: string, poster: string, caption: string, fileName: string, size: string}} 미디어 메타
|
||||
*/
|
||||
const parseMediaMeta = (raw) => {
|
||||
const meta = {
|
||||
url: '',
|
||||
title: '',
|
||||
description: '',
|
||||
poster: '',
|
||||
caption: '',
|
||||
fileName: '',
|
||||
size: ''
|
||||
}
|
||||
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
|
||||
for (const line of lines) {
|
||||
const kv = line.match(/^(\w+)=(.*)$/)
|
||||
|
||||
if (kv) {
|
||||
const key = kv[1].toLowerCase()
|
||||
const val = kv[2].trim()
|
||||
|
||||
if (key === 'url' || key === 'src') {
|
||||
meta.url = val
|
||||
} else if (key === 'title') {
|
||||
meta.title = val
|
||||
} else if (key === 'description' || key === 'desc') {
|
||||
meta.description = val
|
||||
} else if (key === 'poster' || key === 'thumbnail') {
|
||||
meta.poster = val
|
||||
} else if (key === 'caption') {
|
||||
meta.caption = val
|
||||
} else if (key === 'name' || key === 'filename' || key === 'file') {
|
||||
meta.fileName = val
|
||||
} else if (key === 'size') {
|
||||
meta.size = val
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!meta.url && (/^https?:\/\//i.test(line) || line.startsWith('/'))) {
|
||||
meta.url = line
|
||||
continue
|
||||
}
|
||||
|
||||
if (meta.url && !meta.title) {
|
||||
meta.title = line
|
||||
continue
|
||||
}
|
||||
|
||||
if (meta.url && meta.title && !meta.description) {
|
||||
meta.description = line
|
||||
}
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
|
||||
* @param {string} markdown - 마크다운 문자열
|
||||
@@ -401,6 +473,22 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
if ([':::video', ':::audio', ':::file'].includes(trimmedLine)) {
|
||||
const startLine = index
|
||||
const blockType = trimmedLine.replace(':::', '')
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const mediaMeta = parseMediaMeta(contentLines.join('\n'))
|
||||
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock(blockType, '', null, `block-${blocks.length}`, { meta: mediaMeta }),
|
||||
startLine,
|
||||
nextIndex
|
||||
))
|
||||
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith(':::callout')) {
|
||||
const startLine = index
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
@@ -438,6 +526,17 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isStandaloneUrlLine(trimmedLine)) {
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('embed', '', null, `block-${blocks.length}`, { url: trimmedLine }),
|
||||
startLine,
|
||||
startLine
|
||||
))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const image = parseImageLine(trimmedLine)
|
||||
|
||||
if (image) {
|
||||
@@ -586,7 +685,13 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
|
||||
return
|
||||
}
|
||||
|
||||
const element = rendererRootRef.value?.querySelector(`[data-source-line="${lineIndex}"]`)
|
||||
const matches = rendererRootRef.value
|
||||
? [...rendererRootRef.value.querySelectorAll(`[data-source-line="${lineIndex}"]`)]
|
||||
: []
|
||||
|
||||
const element = matches.find((node) => node.getAttribute('contenteditable') === 'true')
|
||||
|| matches[0]
|
||||
|| null
|
||||
|
||||
if (!element) {
|
||||
if (attempt < 8) {
|
||||
@@ -602,12 +707,19 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
|
||||
const isBlankMarker = /^>\s*$/.test(line) || /^[-*+]\s*$/.test(line) || /^\d+\.\s*$/.test(line.trim())
|
||||
|
||||
if (isBlankMarker || !line.trim()) {
|
||||
element.textContent = ''
|
||||
element.innerHTML = ''
|
||||
if (element.getAttribute('contenteditable') === 'true') {
|
||||
element.textContent = ''
|
||||
element.innerHTML = ''
|
||||
}
|
||||
}
|
||||
|
||||
element.focus({ preventScroll: true })
|
||||
|
||||
if (element.getAttribute('contenteditable') !== 'true') {
|
||||
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof caretOffset === 'number' && caretOffset >= 0) {
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), caretOffset)
|
||||
return
|
||||
@@ -625,6 +737,7 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
|
||||
}
|
||||
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
|
||||
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@@ -683,6 +796,18 @@ const getHeadingEditableClass = (level) => {
|
||||
return `${base} text-[clamp(0.9rem,0.875rem+0.1vw,1rem)]`
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 블록 마크다운 줄을 만든다.
|
||||
* @param {number} level - 제목 레벨
|
||||
* @param {string} value - 제목 텍스트
|
||||
* @returns {string} 제목 마크다운 줄
|
||||
*/
|
||||
const buildHeadingLine = (level, value) => {
|
||||
const headingPrefix = `${'#'.repeat(Math.min(Math.max(level, 1), 6))} `
|
||||
const cleanText = String(value ?? '').replace(/\s+/g, ' ').trim()
|
||||
return `${headingPrefix}${cleanText}`.trimEnd() || headingPrefix.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* commit 이벤트 페이로드를 정규화한다.
|
||||
* @param {string|{ value: string, raw?: boolean }} payload - 페이로드
|
||||
@@ -1029,6 +1154,120 @@ const onInsertBelowBlock = (block, options = {}) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택형 프리뷰 카드 블록에 포커스를 준다.
|
||||
* @param {Event} event - 포커스 유도 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusPreviewCardBlock = (event) => {
|
||||
const target = event.currentTarget
|
||||
const element = target instanceof HTMLElement
|
||||
? target.closest('[data-preview-card-block="true"]') || target
|
||||
: null
|
||||
|
||||
if (element instanceof HTMLElement) {
|
||||
element.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택형 프리뷰 카드에서 이전/다음 줄로 이동한다.
|
||||
* @param {Object} block - 프리뷰 카드 블록
|
||||
* @param {1|-1} direction - 이동 방향
|
||||
* @returns {void}
|
||||
*/
|
||||
const moveFromPreviewCardBlock = (block, direction) => {
|
||||
const startLine = block.meta?.startLine
|
||||
const endLine = block.meta?.endLine ?? startLine
|
||||
|
||||
if (typeof startLine !== 'number' || typeof endLine !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
if (direction > 0) {
|
||||
const lines = String(props.content || '').split('\n')
|
||||
const nextLine = endLine + 1
|
||||
|
||||
if (nextLine >= lines.length) {
|
||||
onInsertBelowBlock(block)
|
||||
return
|
||||
}
|
||||
|
||||
focusEditableAtLine(nextLine, 0, 'start')
|
||||
return
|
||||
}
|
||||
|
||||
if (startLine <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
focusEditableAtLine(startLine - 1, 0, 'end')
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택형 프리뷰 카드 블록 전체를 삭제한다.
|
||||
* @param {Object} block - 프리뷰 카드 블록
|
||||
* @returns {void}
|
||||
*/
|
||||
const deletePreviewCardBlock = (block) => {
|
||||
if (!props.interactive || typeof block.meta?.startLine !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
const startLine = block.meta.startLine
|
||||
const endLine = block.meta.endLine ?? startLine
|
||||
pendingFocusLine.value = startLine > 0 ? startLine - 1 : 0
|
||||
pendingFocusPosition.value = startLine > 0 ? 'end' : 'start'
|
||||
emit('block-content-change', {
|
||||
startLine,
|
||||
endLine,
|
||||
replacementLines: []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택형 프리뷰 카드 블록 키보드 조작을 처리한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @param {Object} block - 프리뷰 카드 블록
|
||||
* @returns {void}
|
||||
*/
|
||||
const onPreviewCardKeydown = (event, block) => {
|
||||
const isDeleteShortcut = (event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'k'
|
||||
const isActionButton = event.target instanceof HTMLElement
|
||||
&& Boolean(event.target.closest('.content-markdown-renderer__preview-card-action'))
|
||||
|
||||
if (isActionButton && (event.key === 'Enter' || event.key === ' ')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' || event.key === 'Delete' || isDeleteShortcut) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
deletePreviewCardBlock(block)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
moveFromPreviewCardBlock(block, 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
moveFromPreviewCardBlock(block, -1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onInsertBelowBlock(block)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 항목 Enter — 빈 마커 줄이면 문단으로 탈출, 내용이 있으면 아래에 빈 줄만 삽입한다.
|
||||
* @param {Object} block - 목록 블록
|
||||
@@ -1118,9 +1357,20 @@ const onHeadingInlineCommit = (block, payload) => {
|
||||
return
|
||||
}
|
||||
|
||||
const headingPrefix = `${'#'.repeat(Math.min(Math.max(block.level, 1), 6))} `
|
||||
const cleanText = String(value ?? '').replace(/\s+/g, ' ').trim()
|
||||
commitInlineBlockLines(block, [`${headingPrefix}${cleanText}`.trimEnd() || headingPrefix.trim()])
|
||||
commitInlineBlockLines(block, [buildHeadingLine(block.level, value)])
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 Enter — 현재 제목 값을 저장하고 아래에 빈 줄을 만든다.
|
||||
* @param {Object} block - 제목 블록
|
||||
* @param {string|Object} payload - insert-below 페이로드
|
||||
* @returns {void}
|
||||
*/
|
||||
const onHeadingInsertBelow = (block, payload) => {
|
||||
const { value } = normalizeInsertBelowPayload(payload)
|
||||
pendingFocusLine.value = block.meta.startLine + 1
|
||||
pendingFocusPosition.value = 'start'
|
||||
commitInlineBlockLines(block, [buildHeadingLine(block.level, value), ''])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1490,12 +1740,10 @@ onBeforeUnmount(() => {
|
||||
:tag="`h${Math.min(Math.max(block.level, 1), 6)}`"
|
||||
:block-class="getHeadingEditableClass(block.level)"
|
||||
enter-mode="insert-below"
|
||||
allow-raw-toggle
|
||||
:raw-line="getMarkdownLine(block.meta.startLine)"
|
||||
:source-line="block.meta.startLine"
|
||||
:model-value="block.text"
|
||||
@commit="onHeadingInlineCommit(block, $event)"
|
||||
@insert-below="onInsertBelowBlock(block)"
|
||||
@insert-below="onHeadingInsertBelow(block, $event)"
|
||||
@delete-line="onDeleteLine"
|
||||
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
|
||||
/>
|
||||
@@ -1658,6 +1906,89 @@ onBeforeUnmount(() => {
|
||||
:button-label="block.meta.button"
|
||||
:placeholder="block.meta.placeholder"
|
||||
/>
|
||||
<section
|
||||
v-else-if="['video', 'audio', 'file'].includes(block.type) && interactive"
|
||||
class="content-markdown-renderer__preview-card group relative my-8 rounded-[12px] outline-none transition-shadow focus-within:ring-2 focus-within:ring-[var(--site-accent)] focus-within:ring-offset-2 focus-visible:ring-2 focus-visible:ring-[var(--site-accent)] focus-visible:ring-offset-2"
|
||||
:data-source-line="block.meta.startLine"
|
||||
data-preview-card-block="true"
|
||||
tabindex="0"
|
||||
role="group"
|
||||
:aria-label="`${block.type} 블록`"
|
||||
@mousedown.capture="focusPreviewCardBlock"
|
||||
@keydown="onPreviewCardKeydown($event, block)"
|
||||
>
|
||||
<button
|
||||
class="content-markdown-renderer__preview-card-delete content-markdown-renderer__preview-card-action absolute right-2 top-2 z-10 rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white opacity-0 shadow transition-opacity hover:bg-[#15171a] focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)] group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
type="button"
|
||||
:aria-label="`${block.type} 삭제`"
|
||||
@click.stop="deletePreviewCardBlock(block)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<ProseVideo
|
||||
v-if="block.type === 'video'"
|
||||
:src="block.meta.url"
|
||||
:title="block.meta.title"
|
||||
:poster="block.meta.poster"
|
||||
:caption="block.meta.caption"
|
||||
/>
|
||||
<ProseAudio
|
||||
v-else-if="block.type === 'audio'"
|
||||
:src="block.meta.url"
|
||||
:title="block.meta.title"
|
||||
:description="block.meta.description"
|
||||
/>
|
||||
<ProseFile
|
||||
v-else
|
||||
:href="block.meta.url"
|
||||
:title="block.meta.title"
|
||||
:description="block.meta.description"
|
||||
:file-name="block.meta.fileName"
|
||||
:size="block.meta.size"
|
||||
/>
|
||||
</section>
|
||||
<ProseVideo
|
||||
v-else-if="block.type === 'video'"
|
||||
:src="block.meta.url"
|
||||
:title="block.meta.title"
|
||||
:poster="block.meta.poster"
|
||||
:caption="block.meta.caption"
|
||||
/>
|
||||
<ProseAudio
|
||||
v-else-if="block.type === 'audio'"
|
||||
:src="block.meta.url"
|
||||
:title="block.meta.title"
|
||||
:description="block.meta.description"
|
||||
/>
|
||||
<ProseFile
|
||||
v-else-if="block.type === 'file'"
|
||||
:href="block.meta.url"
|
||||
:title="block.meta.title"
|
||||
:description="block.meta.description"
|
||||
:file-name="block.meta.fileName"
|
||||
:size="block.meta.size"
|
||||
/>
|
||||
<section
|
||||
v-else-if="block.type === 'embed' && interactive"
|
||||
class="content-markdown-renderer__embed-live group relative my-8 rounded-[12px] outline-none transition-shadow focus-within:ring-2 focus-within:ring-[var(--site-accent)] focus-within:ring-offset-2 focus-visible:ring-2 focus-visible:ring-[var(--site-accent)] focus-visible:ring-offset-2"
|
||||
:data-source-line="block.meta.startLine"
|
||||
data-preview-card-block="true"
|
||||
tabindex="0"
|
||||
role="group"
|
||||
aria-label="임베드 블록"
|
||||
@mousedown.capture="focusPreviewCardBlock"
|
||||
@keydown="onPreviewCardKeydown($event, block)"
|
||||
>
|
||||
<button
|
||||
class="content-markdown-renderer__embed-delete content-markdown-renderer__preview-card-action absolute right-2 top-2 z-10 rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white opacity-0 shadow transition-opacity hover:bg-[#15171a] focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)] group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
type="button"
|
||||
aria-label="임베드 삭제"
|
||||
@click.stop="deletePreviewCardBlock(block)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<ProseEmbed :url="block.url" />
|
||||
</section>
|
||||
<ProseEmbed v-else-if="block.type === 'embed'" :url="block.url" />
|
||||
<div
|
||||
v-else-if="block.type === 'gallery'"
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 표시용 오디오 제목을 반환한다.
|
||||
* @returns {string} 오디오 제목
|
||||
*/
|
||||
const displayTitle = computed(() => props.title || 'Audio')
|
||||
|
||||
/**
|
||||
* 재생 가능한 오디오 URL인지 확인한다.
|
||||
* @returns {boolean} 오디오 URL 여부
|
||||
*/
|
||||
const hasAudioSource = computed(() => Boolean(props.src && (props.src.startsWith('/') || /^https?:\/\//i.test(props.src))))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose-audio my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||
<slot />
|
||||
</div>
|
||||
<section class="prose-audio my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-4 shadow-[0_14px_36px_rgba(15,23,42,0.06)] sm:p-5">
|
||||
<div class="prose-audio__inner flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div class="prose-audio__icon flex h-20 w-20 shrink-0 items-center justify-center rounded-[6px] bg-[var(--site-accent)] text-white sm:h-[86px] sm:w-[86px]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-9 w-9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 18V5l10-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="16" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="prose-audio__body min-w-0 flex-1">
|
||||
<p class="prose-audio__title mb-2 text-base font-semibold leading-snug text-[var(--site-text)] sm:text-lg">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<p v-if="description" class="prose-audio__description mb-3 text-sm leading-relaxed text-[var(--site-muted)]">
|
||||
{{ description }}
|
||||
</p>
|
||||
<audio
|
||||
v-if="hasAudioSource"
|
||||
class="prose-audio__player w-full accent-[var(--site-accent)]"
|
||||
:src="src"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
<p v-else class="prose-audio__empty text-sm font-semibold text-[var(--site-muted)]">
|
||||
오디오 URL이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -59,9 +59,48 @@ const getTweetId = (value) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Mastodon 공개 게시물 URL인지 확인하고 embed URL을 반환한다.
|
||||
* @param {string} value - Mastodon 게시물 URL
|
||||
* @returns {string} Mastodon embed URL
|
||||
*/
|
||||
const getMastodonEmbedUrl = (value) => {
|
||||
try {
|
||||
const parsedUrl = new URL(value.trim())
|
||||
const path = parsedUrl.pathname.replace(/\/$/, '')
|
||||
const isKnownNonMastodonHost = [
|
||||
'twitter.com',
|
||||
'x.com',
|
||||
'mobile.twitter.com',
|
||||
'youtube.com',
|
||||
'www.youtube.com',
|
||||
'youtu.be'
|
||||
].includes(parsedUrl.hostname.replace(/^www\./, ''))
|
||||
|
||||
if (
|
||||
isKnownNonMastodonHost ||
|
||||
!['http:', 'https:'].includes(parsedUrl.protocol)
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (/^\/@[^/]+\/\d+$/.test(path) || /^\/users\/[^/]+\/statuses\/\d+$/.test(path)) {
|
||||
return `${parsedUrl.origin}${path}/embed`
|
||||
}
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const youtubeId = computed(() => getYouTubeId(props.url))
|
||||
const youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.com/embed/${youtubeId.value}` : '')
|
||||
const tweetId = computed(() => getTweetId(props.url))
|
||||
const mastodonEmbedUrl = computed(() => getMastodonEmbedUrl(props.url))
|
||||
const mastodonIframeRef = ref(null)
|
||||
const mastodonEmbedHeight = ref(640)
|
||||
const mastodonEmbedId = ref(0)
|
||||
|
||||
/**
|
||||
* 외부 링크로 열어도 되는 URL인지 확인한다.
|
||||
@@ -92,10 +131,74 @@ const tweetEmbedUrl = computed(() => {
|
||||
|
||||
return `https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(tweetId.value)}&theme=${twitterTheme}&dnt=true`
|
||||
})
|
||||
|
||||
/**
|
||||
* Mastodon embed iframe에 실제 콘텐츠 높이 계산을 요청한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const requestMastodonEmbedHeight = () => {
|
||||
if (!mastodonIframeRef.value?.contentWindow || !mastodonEmbedId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
mastodonIframeRef.value.contentWindow.postMessage({
|
||||
type: 'setHeight',
|
||||
id: mastodonEmbedId.value
|
||||
}, '*')
|
||||
}
|
||||
|
||||
/**
|
||||
* Mastodon embed 높이 응답을 반영한다.
|
||||
* @param {MessageEvent} event - iframe 메시지 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleMastodonEmbedMessage = (event) => {
|
||||
const data = event.data || {}
|
||||
|
||||
if (
|
||||
!mastodonIframeRef.value ||
|
||||
event.source !== mastodonIframeRef.value.contentWindow ||
|
||||
typeof data !== 'object' ||
|
||||
data.type !== 'setHeight' ||
|
||||
data.id !== mastodonEmbedId.value ||
|
||||
typeof data.height !== 'number'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const expectedOrigin = new URL(mastodonEmbedUrl.value).origin
|
||||
|
||||
if (event.origin !== expectedOrigin) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
mastodonEmbedHeight.value = Math.max(320, Math.ceil(data.height))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mastodonEmbedId.value = Math.floor(Math.random() * 1000000000) + 1
|
||||
window.addEventListener('message', handleMastodonEmbedMessage)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', handleMastodonEmbedMessage)
|
||||
})
|
||||
|
||||
watch(mastodonEmbedUrl, () => {
|
||||
mastodonEmbedHeight.value = 640
|
||||
requestMastodonEmbedHeight()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose-embed prose-embed-card my-8 overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||
<div
|
||||
class="prose-embed prose-embed-card my-8 overflow-hidden rounded-[10px]"
|
||||
:class="tweetEmbedUrl ? 'mx-auto max-w-[550px]' : mastodonEmbedUrl ? 'mx-auto max-w-[560px] border border-[var(--site-line)] bg-[var(--site-panel)]' : 'border border-[var(--site-line)] bg-[var(--site-panel)]'"
|
||||
>
|
||||
<iframe
|
||||
v-if="youtubeEmbedUrl"
|
||||
class="prose-embed__frame aspect-video w-full"
|
||||
@@ -108,11 +211,25 @@ const tweetEmbedUrl = computed(() => {
|
||||
<iframe
|
||||
v-else-if="tweetEmbedUrl"
|
||||
:key="tweetEmbedUrl"
|
||||
class="prose-embed__tweet min-h-[420px] w-full border-0 sm:min-h-[458px]"
|
||||
class="prose-embed__tweet block min-h-[560px] w-full border-0 sm:min-h-[620px]"
|
||||
:src="tweetEmbedUrl"
|
||||
title="Embedded post"
|
||||
loading="lazy"
|
||||
/>
|
||||
<iframe
|
||||
v-else-if="mastodonEmbedUrl"
|
||||
:key="mastodonEmbedUrl"
|
||||
ref="mastodonIframeRef"
|
||||
class="prose-embed__mastodon block w-full border-0"
|
||||
:src="mastodonEmbedUrl"
|
||||
:height="mastodonEmbedHeight"
|
||||
title="Embedded Mastodon post"
|
||||
allow="fullscreen"
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||
scrolling="no"
|
||||
@load="requestMastodonEmbedHeight"
|
||||
/>
|
||||
<a
|
||||
v-else-if="safeExternalUrl"
|
||||
class="prose-embed__link block p-5 text-sm font-semibold text-[var(--site-text)] hover:opacity-70"
|
||||
|
||||
@@ -1,5 +1,85 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
href: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
fileName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 다운로드 가능한 파일 URL인지 확인한다.
|
||||
* @returns {boolean} 파일 URL 여부
|
||||
*/
|
||||
const isSafeFileUrl = computed(() => Boolean(props.href && (props.href.startsWith('/') || /^https?:\/\//i.test(props.href))))
|
||||
|
||||
/**
|
||||
* 카드 제목을 반환한다.
|
||||
* @returns {string} 제목
|
||||
*/
|
||||
const displayTitle = computed(() => props.title || props.fileName || 'File')
|
||||
|
||||
/**
|
||||
* 표시 파일명을 반환한다.
|
||||
* @returns {string} 파일명
|
||||
*/
|
||||
const displayFileName = computed(() => {
|
||||
if (props.fileName) {
|
||||
return props.fileName
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = props.href.startsWith('/') ? new URL(props.href, 'https://local.invalid') : new URL(props.href)
|
||||
const lastSegment = parsedUrl.pathname.split('/').filter(Boolean).pop()
|
||||
return lastSegment ? decodeURIComponent(lastSegment) : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose-file my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
|
||||
<slot />
|
||||
</div>
|
||||
<a
|
||||
v-if="isSafeFileUrl"
|
||||
class="prose-file group my-8 flex items-center gap-4 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-4 no-underline shadow-[0_14px_36px_rgba(15,23,42,0.06)] transition-[background-color,box-shadow] hover:bg-[color-mix(in_srgb,var(--site-panel)_86%,var(--site-text)_14%)] sm:p-5"
|
||||
:href="href"
|
||||
download
|
||||
>
|
||||
<span class="prose-file__body min-w-0 flex-1">
|
||||
<span class="prose-file__title block text-base font-semibold leading-snug text-[var(--site-text)] sm:text-lg">
|
||||
{{ displayTitle }}
|
||||
</span>
|
||||
<span v-if="description" class="prose-file__description mt-1 block text-sm leading-relaxed text-[var(--site-muted)]">
|
||||
{{ description }}
|
||||
</span>
|
||||
<span class="prose-file__meta mt-3 block truncate text-sm font-semibold text-[var(--site-text)]">
|
||||
{{ displayFileName || href }}<template v-if="size"> · {{ size }}</template>
|
||||
</span>
|
||||
</span>
|
||||
<span class="prose-file__download flex h-20 w-20 shrink-0 items-center justify-center rounded-[6px] bg-[color-mix(in_srgb,var(--site-line)_36%,var(--site-panel))] text-[var(--site-accent)] transition-transform group-hover:scale-[1.02] sm:h-[86px] sm:w-[86px]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 4v11" />
|
||||
<path d="m8 11 4 4 4-4" />
|
||||
<path d="M5 20h14" />
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
<p v-else class="prose-file prose-file-invalid my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5 text-sm font-semibold text-[var(--site-muted)]">
|
||||
파일 URL이 없습니다.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,54 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
poster: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
caption: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 재생 가능한 비디오 URL인지 확인한다.
|
||||
* @returns {boolean} 비디오 URL 여부
|
||||
*/
|
||||
const hasVideoSource = computed(() => Boolean(props.src && (props.src.startsWith('/') || /^https?:\/\//i.test(props.src))))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose-video my-8 aspect-video overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||
<slot />
|
||||
</div>
|
||||
<figure class="prose-video my-8">
|
||||
<div class="prose-video__shell overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] shadow-[0_16px_40px_rgba(15,23,42,0.08)]">
|
||||
<video
|
||||
v-if="hasVideoSource"
|
||||
class="prose-video__media aspect-video w-full bg-black object-cover"
|
||||
:src="src"
|
||||
:poster="poster || undefined"
|
||||
:title="title || 'Video'"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="prose-video__empty flex aspect-video w-full items-center justify-center bg-[color-mix(in_srgb,var(--site-line)_45%,var(--site-panel))] text-sm font-semibold text-[var(--site-muted)]"
|
||||
>
|
||||
비디오 URL이 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
<figcaption
|
||||
v-if="caption || title"
|
||||
class="prose-video__caption mt-3 text-center text-sm leading-relaxed text-[var(--site-muted)]"
|
||||
>
|
||||
{{ caption || title }}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user