파일명 alt와 캡션을 분리해 공개·미리보기에 캡션이 보이도록 하고, 관리자 미리보기에서 갤러리 순서를 드래그로 바꿀 수 있게 했다. Co-authored-by: Cursor <cursoragent@cursor.com>
857 lines
28 KiB
Vue
857 lines
28 KiB
Vue
<script setup>
|
|
import {
|
|
getImageAltAttribute,
|
|
getImageDisplayCaption,
|
|
parseImageMarkdownLine
|
|
} from '../../lib/markdown-image.js'
|
|
|
|
const props = defineProps({
|
|
content: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
/** 관리자 미리보기: 갤러리 드래그 정렬 등 */
|
|
interactive: {
|
|
type: Boolean,
|
|
default: false
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['gallery-reorder'])
|
|
|
|
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
|
|
|
|
const activeLightboxImages = ref([])
|
|
const activeLightboxIndex = ref(0)
|
|
/** @type {import('vue').Ref<{ blockId: string, imageIndex: number }|null>} */
|
|
const galleryDragState = ref(null)
|
|
|
|
/**
|
|
* 마크다운 블록을 생성
|
|
* @param {string} type - 블록 타입
|
|
* @param {string|Array<string>} text - 블록 텍스트
|
|
* @param {number|null} level - 제목 레벨
|
|
* @param {string} id - 블록 ID
|
|
* @param {Object} options - 추가 블록 옵션
|
|
* @returns {Object} 블록
|
|
*/
|
|
const createBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
|
|
id,
|
|
type,
|
|
text,
|
|
level,
|
|
url: options.url || '',
|
|
alt: options.alt || '',
|
|
caption: options.caption || '',
|
|
useAlt: options.useAlt === true,
|
|
title: options.title || '',
|
|
variant: options.variant || '',
|
|
ordered: options.ordered || false,
|
|
width: options.width || 'regular',
|
|
images: options.images || [],
|
|
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {},
|
|
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
|
|
calloutEmoji: options.calloutEmoji || '💡',
|
|
calloutBackground: options.calloutBackground || 'blue'
|
|
})
|
|
|
|
const calloutBackgroundOptions = ['gray', 'blue', 'green', 'yellow', 'red', 'purple', 'pink']
|
|
|
|
/**
|
|
* 콜아웃 선언부 옵션을 파싱
|
|
* @param {string} line - 콜아웃 선언 라인
|
|
* @returns {{calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string}} 콜아웃 옵션
|
|
*/
|
|
const parseCalloutOptions = (line) => {
|
|
const options = {
|
|
calloutEmojiEnabled: true,
|
|
calloutEmoji: '💡',
|
|
calloutBackground: 'blue'
|
|
}
|
|
const tokens = line.trim().split(/\s+/).slice(1)
|
|
|
|
tokens.forEach((token) => {
|
|
const [rawKey, ...rawValueParts] = token.split('=')
|
|
if (!rawKey || !rawValueParts.length) {
|
|
return
|
|
}
|
|
const key = rawKey.toLowerCase()
|
|
const value = rawValueParts.join('=').trim()
|
|
|
|
if (key === 'emoji') {
|
|
if (!value || value === 'none') {
|
|
options.calloutEmojiEnabled = false
|
|
options.calloutEmoji = '💡'
|
|
} else {
|
|
options.calloutEmojiEnabled = true
|
|
options.calloutEmoji = value
|
|
}
|
|
return
|
|
}
|
|
|
|
if (key === 'bg' && calloutBackgroundOptions.includes(value)) {
|
|
options.calloutBackground = value
|
|
}
|
|
})
|
|
|
|
return options
|
|
}
|
|
|
|
/**
|
|
* 이미지 마크다운 행을 이미지 데이터로 변환
|
|
* @param {string} line - 마크다운 행
|
|
* @returns {Object|null} 이미지 데이터
|
|
*/
|
|
const parseImageLine = (line) => parseImageMarkdownLine(line)
|
|
|
|
/**
|
|
* 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다.
|
|
* @param {string} line - 마크다운 행
|
|
* @returns {boolean} 블록 시작 여부
|
|
*/
|
|
const isMarkdownBlockStart = (line) => {
|
|
const trimmedLine = line.trim()
|
|
|
|
return trimmedLine === BLANK_PARAGRAPH_MARKER ||
|
|
trimmedLine === '>>>' ||
|
|
trimmedLine === ':::bookmark' ||
|
|
trimmedLine === ':::signup' ||
|
|
trimmedLine === ':::gallery' ||
|
|
trimmedLine === ':::embed' ||
|
|
trimmedLine.startsWith(':::callout') ||
|
|
trimmedLine.startsWith(':::toggle') ||
|
|
trimmedLine.startsWith('```') ||
|
|
trimmedLine === '---' ||
|
|
/^(#{1,6})\s+(.+)$/.test(trimmedLine) ||
|
|
trimmedLine.startsWith('> ') ||
|
|
/^- /.test(trimmedLine) ||
|
|
/^\d+\.\s+/.test(trimmedLine) ||
|
|
Boolean(parseImageLine(trimmedLine))
|
|
}
|
|
|
|
/**
|
|
* 마크다운 hard break 표식이 있는 행인지 확인한다.
|
|
* @param {string} line - 마크다운 행
|
|
* @returns {boolean} hard break 여부
|
|
*/
|
|
const hasMarkdownHardBreak = (line) => /( {2,}|\\)$/.test(line)
|
|
|
|
/**
|
|
* 문단 행에서 hard break 표식을 제거한다.
|
|
* @param {string} line - 마크다운 행
|
|
* @returns {string} 정리된 문단 행
|
|
*/
|
|
const cleanParagraphLine = (line) => line.replace(/( {2,}|\\)$/, '').trim()
|
|
|
|
/**
|
|
* 빈 줄 공백 블록 높이를 반환한다.
|
|
* @param {Object} block - 렌더링 블록
|
|
* @returns {string} Tailwind 높이 클래스
|
|
*/
|
|
const getSpacerHeightClass = (block) => block.meta?.legacy ? 'h-6' : 'h-8'
|
|
|
|
/**
|
|
* 닫힘 표식까지의 행 목록을 반환
|
|
* @param {Array<string>} lines - 전체 마크다운 행
|
|
* @param {number} startIndex - 본문 시작 인덱스
|
|
* @returns {{contentLines: Array<string>, nextIndex: number}} 블록 본문과 다음 인덱스
|
|
*/
|
|
const collectFencedLines = (lines, startIndex) => {
|
|
const contentLines = []
|
|
let index = startIndex
|
|
|
|
while (index < lines.length && lines[index].trim() !== ':::') {
|
|
contentLines.push(lines[index])
|
|
index += 1
|
|
}
|
|
|
|
return {
|
|
contentLines,
|
|
nextIndex: index + 1
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 북마크 fenced 블록 본문에서 URL·제목·설명·썸네일을 파싱한다.
|
|
* @param {string} raw - fenced 내부 텍스트
|
|
* @returns {{url: string, title: string, description: string, thumbnail: string}} 북마크 메타
|
|
*/
|
|
const parseBookmarkMeta = (raw) => {
|
|
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
const meta = {
|
|
url: '',
|
|
title: '',
|
|
description: '',
|
|
thumbnail: ''
|
|
}
|
|
|
|
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') {
|
|
meta.url = val
|
|
} else if (key === 'title') {
|
|
meta.title = val
|
|
} else if (key === 'description' || key === 'desc') {
|
|
meta.description = val
|
|
} else if (key === 'thumbnail' || key === 'image') {
|
|
meta.thumbnail = val
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
if (!meta.url && /^https?:\/\//i.test(line)) {
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 회원가입(CTA) fenced 블록 본문에서 표시 문구를 파싱한다.
|
|
* @param {string} raw - fenced 내부 텍스트
|
|
* @returns {{title: string, description: string, button: string, placeholder: string}} CTA 메타
|
|
*/
|
|
const parseSignupMeta = (raw) => {
|
|
const meta = {
|
|
title: '뉴스레터에 가입하세요',
|
|
description: '새 글이 올라오면 받아보실 수 있어요.',
|
|
button: '구독하기',
|
|
placeholder: 'you@example.com'
|
|
}
|
|
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
|
|
for (const line of lines) {
|
|
const kv = line.match(/^(\w+)=(.*)$/)
|
|
|
|
if (!kv) {
|
|
continue
|
|
}
|
|
|
|
const key = kv[1].toLowerCase()
|
|
const val = kv[2].trim()
|
|
|
|
if (key === 'title') {
|
|
meta.title = val
|
|
} else if (key === 'description' || key === 'desc') {
|
|
meta.description = val
|
|
} else if (key === 'button') {
|
|
meta.button = val
|
|
} else if (key === 'placeholder') {
|
|
meta.placeholder = val
|
|
}
|
|
}
|
|
|
|
return meta
|
|
}
|
|
|
|
/**
|
|
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
|
|
* @param {string} markdown - 마크다운 문자열
|
|
* @returns {Array<Object>} 블록 목록
|
|
*/
|
|
const parseMarkdownBlocks = (markdown) => {
|
|
const lines = markdown.split('\n')
|
|
const blocks = []
|
|
let index = 0
|
|
|
|
while (index < lines.length) {
|
|
const line = lines[index]
|
|
const trimmedLine = line.trim()
|
|
|
|
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
|
|
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`, { meta: { legacy: true } }))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (!trimmedLine) {
|
|
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine === '>>>') {
|
|
const contentLines = []
|
|
index += 1
|
|
|
|
while (index < lines.length && lines[index].trim() !== '<<<') {
|
|
contentLines.push(lines[index])
|
|
index += 1
|
|
}
|
|
|
|
blocks.push(createBlock('quote', contentLines.join('\n').trim(), null, `block-${blocks.length}`, { variant: 'alt' }))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine === ':::bookmark') {
|
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
|
const bookmarkMeta = parseBookmarkMeta(contentLines.join('\n'))
|
|
|
|
if (bookmarkMeta.url) {
|
|
blocks.push(createBlock('bookmark', '', null, `block-${blocks.length}`, { meta: bookmarkMeta }))
|
|
}
|
|
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine === ':::signup') {
|
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
|
const signupMeta = parseSignupMeta(contentLines.join('\n'))
|
|
blocks.push(createBlock('signup', '', null, `block-${blocks.length}`, { meta: signupMeta }))
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine === ':::gallery') {
|
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
|
const images = []
|
|
|
|
contentLines.forEach((contentLine) => {
|
|
const image = parseImageLine(contentLine)
|
|
if (image) {
|
|
images.push(image)
|
|
}
|
|
})
|
|
|
|
blocks.push(createBlock('gallery', '', null, `block-${blocks.length}`, {
|
|
images,
|
|
meta: {
|
|
startLine: index,
|
|
endLine: nextIndex
|
|
}
|
|
}))
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine.startsWith(':::callout')) {
|
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
|
blocks.push(createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`, parseCalloutOptions(trimmedLine)))
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine.startsWith(':::toggle')) {
|
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
|
const title = trimmedLine.replace(/^:::toggle\s*/, '').trim()
|
|
blocks.push(createBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, { title }))
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine === ':::embed') {
|
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
|
blocks.push(createBlock('embed', '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }))
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
|
|
const image = parseImageLine(trimmedLine)
|
|
|
|
if (image) {
|
|
blocks.push(createBlock('image', '', null, `block-${blocks.length}`, image))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine.startsWith('```')) {
|
|
const codeLines = []
|
|
index += 1
|
|
|
|
while (index < lines.length && !lines[index].trim().startsWith('```')) {
|
|
codeLines.push(lines[index])
|
|
index += 1
|
|
}
|
|
|
|
blocks.push(createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine === '---') {
|
|
blocks.push(createBlock('divider', '', null, `block-${blocks.length}`))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
const headingMatch = trimmedLine.match(/^(#{1,6})\s+(.+)$/)
|
|
|
|
if (headingMatch) {
|
|
blocks.push(createBlock('heading', headingMatch[2], headingMatch[1].length, `block-${blocks.length}`))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (trimmedLine.startsWith('> ')) {
|
|
const quoteLines = []
|
|
|
|
while (index < lines.length && lines[index].trim().startsWith('>')) {
|
|
quoteLines.push(lines[index].trim().replace(/^>\s?/, ''))
|
|
index += 1
|
|
}
|
|
|
|
blocks.push(createBlock('quote', quoteLines.join('\n').trim(), null, `block-${blocks.length}`))
|
|
continue
|
|
}
|
|
|
|
if (/^- /.test(trimmedLine)) {
|
|
const items = []
|
|
|
|
while (index < lines.length && /^- /.test(lines[index].trim())) {
|
|
items.push(lines[index].trim().replace(/^- /, ''))
|
|
index += 1
|
|
}
|
|
|
|
blocks.push(createBlock('list', items, null, `block-${blocks.length}`))
|
|
continue
|
|
}
|
|
|
|
if (/^\d+\.\s+/.test(trimmedLine)) {
|
|
const items = []
|
|
|
|
while (index < lines.length && /^\d+\.\s+/.test(lines[index].trim())) {
|
|
items.push(lines[index].trim().replace(/^\d+\.\s+/, ''))
|
|
index += 1
|
|
}
|
|
|
|
blocks.push(createBlock('list', items, null, `block-${blocks.length}`, { ordered: true }))
|
|
continue
|
|
}
|
|
|
|
const paragraphLines = [cleanParagraphLine(line)]
|
|
let shouldJoinNextLine = hasMarkdownHardBreak(line)
|
|
index += 1
|
|
|
|
while (shouldJoinNextLine && index < lines.length) {
|
|
const nextLine = lines[index]
|
|
const nextTrimmedLine = nextLine.trim()
|
|
|
|
if (!nextTrimmedLine || isMarkdownBlockStart(nextTrimmedLine)) {
|
|
break
|
|
}
|
|
|
|
paragraphLines.push(cleanParagraphLine(nextLine))
|
|
shouldJoinNextLine = hasMarkdownHardBreak(nextLine)
|
|
index += 1
|
|
}
|
|
|
|
blocks.push(createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`))
|
|
}
|
|
|
|
return blocks
|
|
}
|
|
|
|
const blocks = computed(() => parseMarkdownBlocks(props.content))
|
|
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
|
|
|
|
/**
|
|
* 인라인 마크다운을 표시 세그먼트로 변환한다.
|
|
* @param {string} value - 원본 문자열
|
|
* @returns {Array<{ type: string, text: string, href?: string }>} 인라인 세그먼트
|
|
*/
|
|
const parseInlineSegments = (value) => {
|
|
const source = String(value || '')
|
|
const segments = []
|
|
const pattern = /(\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
|
|
let lastIndex = 0
|
|
let match = pattern.exec(source)
|
|
|
|
while (match) {
|
|
if (match.index > lastIndex) {
|
|
segments.push({
|
|
type: 'text',
|
|
text: source.slice(lastIndex, match.index)
|
|
})
|
|
}
|
|
|
|
if (match[2] && match[3]) {
|
|
segments.push({
|
|
type: 'link',
|
|
text: match[2],
|
|
href: match[3]
|
|
})
|
|
} else if (match[4]) {
|
|
segments.push({
|
|
type: 'strong',
|
|
text: match[4]
|
|
})
|
|
} else if (match[5]) {
|
|
segments.push({
|
|
type: 'code',
|
|
text: match[5]
|
|
})
|
|
} else if (match[6]) {
|
|
segments.push({
|
|
type: 'em',
|
|
text: match[6]
|
|
})
|
|
}
|
|
|
|
lastIndex = pattern.lastIndex
|
|
match = pattern.exec(source)
|
|
}
|
|
|
|
if (lastIndex < source.length) {
|
|
segments.push({
|
|
type: 'text',
|
|
text: source.slice(lastIndex)
|
|
})
|
|
}
|
|
|
|
return segments.length ? segments : [{ type: 'text', text: source }]
|
|
}
|
|
|
|
/**
|
|
* 줄바꿈이 포함된 인라인 마크다운을 줄 단위 세그먼트로 변환한다.
|
|
* @param {string} value - 원본 문자열
|
|
* @returns {Array<Array<{ type: string, text: string, href?: string }>>} 줄별 인라인 세그먼트
|
|
*/
|
|
const parseInlineSegmentLines = (value) => {
|
|
return String(value || '').split('\n').map(parseInlineSegments)
|
|
}
|
|
|
|
/**
|
|
* 라이트박스를 연다
|
|
* @param {Array<Object>} images - 이미지 목록
|
|
* @param {number} index - 시작 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const openLightbox = (images, index) => {
|
|
activeLightboxImages.value = images
|
|
activeLightboxIndex.value = index
|
|
}
|
|
|
|
/**
|
|
* 라이트박스를 닫는다
|
|
* @returns {void}
|
|
*/
|
|
const closeLightbox = () => {
|
|
activeLightboxImages.value = []
|
|
activeLightboxIndex.value = 0
|
|
}
|
|
|
|
/**
|
|
* 라이트박스 이전 이미지로 이동
|
|
* @returns {void}
|
|
*/
|
|
const showPreviousImage = () => {
|
|
activeLightboxIndex.value = activeLightboxIndex.value === 0
|
|
? activeLightboxImages.value.length - 1
|
|
: activeLightboxIndex.value - 1
|
|
}
|
|
|
|
/**
|
|
* 라이트박스 다음 이미지로 이동
|
|
* @returns {void}
|
|
*/
|
|
const showNextImage = () => {
|
|
activeLightboxIndex.value = (activeLightboxIndex.value + 1) % activeLightboxImages.value.length
|
|
}
|
|
|
|
/**
|
|
* 갤러리 드래그 시작
|
|
* @param {DragEvent} event - 드래그 이벤트
|
|
* @param {string} blockId - 블록 ID
|
|
* @param {number} imageIndex - 이미지 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const onGalleryDragStart = (event, blockId, imageIndex) => {
|
|
if (!props.interactive) {
|
|
return
|
|
}
|
|
|
|
galleryDragState.value = { blockId, imageIndex }
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
event.dataTransfer.setData('text/plain', String(imageIndex))
|
|
}
|
|
|
|
/**
|
|
* 갤러리 드래그 종료
|
|
* @returns {void}
|
|
*/
|
|
const onGalleryDragEnd = () => {
|
|
galleryDragState.value = null
|
|
}
|
|
|
|
/**
|
|
* 갤러리 드롭으로 순서 변경
|
|
* @param {DragEvent} event - 드롭 이벤트
|
|
* @param {Object} block - 갤러리 블록
|
|
* @param {number} targetIndex - 대상 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const onGalleryDrop = (event, block, targetIndex) => {
|
|
if (!props.interactive || !galleryDragState.value) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
|
|
const { blockId, imageIndex: fromIndex } = galleryDragState.value
|
|
|
|
if (blockId !== block.id || fromIndex === targetIndex) {
|
|
galleryDragState.value = null
|
|
return
|
|
}
|
|
|
|
const images = [...block.images]
|
|
const [moved] = images.splice(fromIndex, 1)
|
|
images.splice(targetIndex, 0, moved)
|
|
|
|
emit('gallery-reorder', {
|
|
startLine: block.meta?.startLine,
|
|
endLine: block.meta?.endLine,
|
|
images
|
|
})
|
|
|
|
galleryDragState.value = null
|
|
}
|
|
|
|
/**
|
|
* 라이트박스 키보드 조작(Esc 닫기, 좌우 이전·다음)
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const handleLightboxKeydown = (event) => {
|
|
if (!activeLightboxImage.value) {
|
|
return
|
|
}
|
|
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
closeLightbox()
|
|
return
|
|
}
|
|
|
|
if (activeLightboxImages.value.length <= 1) {
|
|
return
|
|
}
|
|
|
|
if (event.key === 'ArrowLeft') {
|
|
event.preventDefault()
|
|
showPreviousImage()
|
|
return
|
|
}
|
|
|
|
if (event.key === 'ArrowRight') {
|
|
event.preventDefault()
|
|
showNextImage()
|
|
}
|
|
}
|
|
|
|
watch(activeLightboxImage, (image) => {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
|
|
if (image) {
|
|
window.addEventListener('keydown', handleLightboxKeydown)
|
|
} else {
|
|
window.removeEventListener('keydown', handleLightboxKeydown)
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (import.meta.client) {
|
|
window.removeEventListener('keydown', handleLightboxKeydown)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="content-markdown-renderer">
|
|
<template v-for="block in blocks" :key="block.id">
|
|
<div v-if="block.type === 'spacer'" class="content-markdown-renderer__spacer" :class="getSpacerHeightClass(block)" aria-hidden="true" />
|
|
<ProseHeading v-else-if="block.type === 'heading'" :level="block.level">
|
|
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
|
|
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
|
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
|
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
|
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
|
<template v-else>{{ segment.text }}</template>
|
|
</template>
|
|
</ProseHeading>
|
|
<ProseBlockquote v-else-if="block.type === 'quote'" :variant="block.variant || 'default'">
|
|
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-quote-${segmentIndex}`">
|
|
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
|
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
|
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
|
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
|
<template v-else>{{ segment.text }}</template>
|
|
</template>
|
|
</ProseBlockquote>
|
|
<ProseList v-else-if="block.type === 'list'" :ordered="block.ordered || false">
|
|
<li v-for="(item, itemIndex) in block.text" :key="`${block.id}-${itemIndex}`">
|
|
<template v-for="(segment, segmentIndex) in parseInlineSegments(item)" :key="`${block.id}-${itemIndex}-${segmentIndex}`">
|
|
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
|
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
|
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
|
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
|
<template v-else>{{ segment.text }}</template>
|
|
</template>
|
|
</li>
|
|
</ProseList>
|
|
<ProseImage
|
|
v-else-if="block.type === 'image'"
|
|
:src="block.url"
|
|
:alt="getImageAltAttribute(block)"
|
|
:caption="getImageDisplayCaption(block)"
|
|
:variant="block.width"
|
|
/>
|
|
<ProseCallout
|
|
v-else-if="block.type === 'callout'"
|
|
:emoji-enabled="block.calloutEmojiEnabled"
|
|
:emoji="block.calloutEmoji"
|
|
:background="block.calloutBackground"
|
|
>
|
|
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-callout-${segmentIndex}`">
|
|
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
|
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
|
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
|
<a v-else-if="segment.type === 'link'" class="underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
|
<template v-else>{{ segment.text }}</template>
|
|
</template>
|
|
</ProseCallout>
|
|
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
|
|
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-toggle-${segmentIndex}`">
|
|
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
|
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
|
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
|
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
|
<template v-else>{{ segment.text }}</template>
|
|
</template>
|
|
</ProseToggle>
|
|
<ProseBookmark
|
|
v-else-if="block.type === 'bookmark' && block.meta.url"
|
|
:url="block.meta.url"
|
|
:title="block.meta.title"
|
|
:description="block.meta.description"
|
|
:thumbnail="block.meta.thumbnail"
|
|
/>
|
|
<ProseSignup
|
|
v-else-if="block.type === 'signup'"
|
|
:title="block.meta.title"
|
|
:description="block.meta.description"
|
|
:button-label="block.meta.button"
|
|
:placeholder="block.meta.placeholder"
|
|
/>
|
|
<ProseEmbed v-else-if="block.type === 'embed'" :url="block.url" />
|
|
<div
|
|
v-else-if="block.type === 'gallery'"
|
|
class="content-markdown-renderer__gallery my-8 grid grid-cols-2 gap-2 md:grid-cols-3"
|
|
>
|
|
<figure
|
|
v-for="(image, imageIndex) in block.images"
|
|
:key="`${block.id}-${imageIndex}-${image.url}`"
|
|
class="content-markdown-renderer__gallery-item min-w-0"
|
|
:class="interactive ? 'content-markdown-renderer__gallery-item--interactive' : ''"
|
|
:draggable="interactive"
|
|
@dragstart="onGalleryDragStart($event, block.id, imageIndex)"
|
|
@dragend="onGalleryDragEnd"
|
|
@dragover.prevent
|
|
@drop="onGalleryDrop($event, block, imageIndex)"
|
|
>
|
|
<button
|
|
class="content-markdown-renderer__gallery-button w-full overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
|
|
type="button"
|
|
@click="openLightbox(block.images, imageIndex)"
|
|
>
|
|
<img
|
|
class="content-markdown-renderer__gallery-image aspect-[4/3] w-full object-cover transition-transform hover:scale-[1.02]"
|
|
:src="image.url"
|
|
:alt="getImageAltAttribute(image)"
|
|
>
|
|
</button>
|
|
<figcaption
|
|
v-if="getImageDisplayCaption(image)"
|
|
class="content-markdown-renderer__gallery-caption mt-1.5 text-center text-xs text-[var(--site-muted)]"
|
|
>
|
|
{{ getImageDisplayCaption(image) }}
|
|
</figcaption>
|
|
</figure>
|
|
</div>
|
|
<pre
|
|
v-else-if="block.type === 'code'"
|
|
class="content-markdown-renderer__code overflow-x-auto rounded bg-[#15171a] px-4 py-3 mb-2.5 text-sm leading-6 text-white"
|
|
><code>{{ block.text }}</code></pre>
|
|
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-5 border-line">
|
|
<p v-else class="content-markdown-renderer__paragraph mb-2.5 text-base text-[var(--site-text)] last:mb-0">
|
|
<template v-for="(lineSegments, lineIndex) in parseInlineSegmentLines(block.text)" :key="`${block.id}-paragraph-line-${lineIndex}`">
|
|
<br v-if="lineIndex > 0">
|
|
<template v-for="(segment, segmentIndex) in lineSegments" :key="`${block.id}-paragraph-${lineIndex}-${segmentIndex}`">
|
|
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
|
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
|
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
|
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
|
<template v-else>{{ segment.text }}</template>
|
|
</template>
|
|
</template>
|
|
</p>
|
|
</template>
|
|
|
|
<div
|
|
v-if="activeLightboxImage"
|
|
class="content-markdown-renderer__lightbox fixed inset-0 z-50 grid place-items-center bg-black/90 px-5 py-8"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="갤러리 이미지 보기"
|
|
tabindex="-1"
|
|
@click.self="closeLightbox"
|
|
>
|
|
<button class="content-markdown-renderer__lightbox-close absolute right-5 top-5 rounded bg-white px-3 py-2 text-sm font-semibold text-ink" type="button" @click="closeLightbox">
|
|
닫기
|
|
</button>
|
|
<button
|
|
v-if="activeLightboxImages.length > 1"
|
|
class="content-markdown-renderer__lightbox-prev absolute left-5 top-1/2 rounded bg-white px-3 py-2 text-sm font-semibold text-ink"
|
|
type="button"
|
|
@click="showPreviousImage"
|
|
>
|
|
이전
|
|
</button>
|
|
<img
|
|
class="content-markdown-renderer__lightbox-image max-h-[84vh] max-w-[92vw] object-contain"
|
|
:src="activeLightboxImage.url"
|
|
:alt="getImageAltAttribute(activeLightboxImage)"
|
|
>
|
|
<button
|
|
v-if="activeLightboxImages.length > 1"
|
|
class="content-markdown-renderer__lightbox-next absolute right-5 top-1/2 rounded bg-white px-3 py-2 text-sm font-semibold text-ink"
|
|
type="button"
|
|
@click="showNextImage"
|
|
>
|
|
다음
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.content-markdown-renderer__gallery-item--interactive {
|
|
cursor: grab;
|
|
}
|
|
|
|
.content-markdown-renderer__gallery-item--interactive:active {
|
|
cursor: grabbing;
|
|
}
|
|
</style>
|