Files
sori.studio/components/content/ContentMarkdownRenderer.vue

260 lines
7.5 KiB
Vue

<script setup>
const props = defineProps({
content: {
type: String,
default: ''
}
})
const activeLightboxImages = ref([])
const activeLightboxIndex = ref(0)
/**
* 마크다운 블록을 생성
* @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 || '',
width: options.width || 'regular',
images: options.images || []
})
/**
* 이미지 마크다운 행을 이미지 데이터로 변환
* @param {string} line - 마크다운 행
* @returns {Object|null} 이미지 데이터
*/
const parseImageLine = (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'
}
}
/**
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
* @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) {
index += 1
continue
}
if (trimmedLine === ':::gallery') {
const images = []
index += 1
while (index < lines.length && lines[index].trim() !== ':::') {
const image = parseImageLine(lines[index])
if (image) {
images.push(image)
}
index += 1
}
blocks.push(createBlock('gallery', '', null, `block-${blocks.length}`, { images }))
index += 1
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,3})\s+(.+)$/)
if (headingMatch) {
blocks.push(createBlock('heading', headingMatch[2], headingMatch[1].length, `block-${blocks.length}`))
index += 1
continue
}
if (trimmedLine.startsWith('> ')) {
blocks.push(createBlock('quote', trimmedLine.replace(/^>\s?/, ''), null, `block-${blocks.length}`))
index += 1
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
}
blocks.push(createBlock('paragraph', trimmedLine, null, `block-${blocks.length}`))
index += 1
}
return blocks
}
const blocks = computed(() => parseMarkdownBlocks(props.content))
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
/**
* 라이트박스를 연다
* @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
}
</script>
<template>
<div class="content-markdown-renderer">
<template v-for="block in blocks" :key="block.id">
<ProseHeading v-if="block.type === 'heading'" :level="block.level">
{{ block.text }}
</ProseHeading>
<ProseBlockquote v-else-if="block.type === 'quote'">
{{ block.text }}
</ProseBlockquote>
<ProseList v-else-if="block.type === 'list'">
<li v-for="(item, itemIndex) in block.text" :key="`${block.id}-${itemIndex}`">
{{ item }}
</li>
</ProseList>
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
{{ block.alt }}
</ProseImage>
<div v-else-if="block.type === 'gallery'" class="content-markdown-renderer__gallery my-8 grid grid-cols-2 gap-2 md:grid-cols-3">
<button
v-for="(image, imageIndex) in block.images"
:key="`${block.id}-${image.url}`"
class="content-markdown-renderer__gallery-button overflow-hidden rounded bg-surface"
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="image.alt">
</button>
</div>
<pre
v-else-if="block.type === 'code'"
class="content-markdown-renderer__code my-6 overflow-x-auto rounded bg-[#15171a] px-4 py-3 text-sm leading-6 text-white"
><code>{{ block.text }}</code></pre>
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-10 border-line">
<p v-else class="content-markdown-renderer__paragraph my-5 leading-8">
{{ block.text }}
</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"
@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="activeLightboxImage.alt">
<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>