글쓰기 확장 블록 추가

This commit is contained in:
2026-05-02 10:31:17 +09:00
parent 77191ef7da
commit 6bc697bd95
10 changed files with 305 additions and 29 deletions

View File

@@ -25,6 +25,7 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
level,
url: options.url || '',
alt: options.alt || '',
title: options.title || '',
width: options.width || 'regular',
images: options.images || []
})
@@ -48,6 +49,27 @@ const parseImageLine = (line) => {
}
}
/**
* 닫힘 표식까지의 행 목록을 반환
* @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
}
}
/**
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
* @param {string} markdown - 마크다운 문자열
@@ -68,21 +90,40 @@ const parseMarkdownBlocks = (markdown) => {
}
if (trimmedLine === ':::gallery') {
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const images = []
index += 1
while (index < lines.length && lines[index].trim() !== ':::') {
const image = parseImageLine(lines[index])
contentLines.forEach((contentLine) => {
const image = parseImageLine(contentLine)
if (image) {
images.push(image)
}
index += 1
}
})
blocks.push(createBlock('gallery', '', null, `block-${blocks.length}`, { images }))
index += 1
index = nextIndex
continue
}
if (trimmedLine === ':::callout') {
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
blocks.push(createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`))
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
}
@@ -206,6 +247,13 @@ const showNextImage = () => {
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
{{ block.alt }}
</ProseImage>
<ProseCallout v-else-if="block.type === 'callout'">
{{ block.text }}
</ProseCallout>
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
{{ block.text }}
</ProseToggle>
<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">
<button
v-for="(image, imageIndex) in block.images"

View File

@@ -1,5 +1,57 @@
<script setup>
const props = defineProps({
url: {
type: String,
default: ''
}
})
/**
* YouTube 영상 ID를 추출
* @param {string} value - 임베드 URL
* @returns {string} YouTube 영상 ID
*/
const getYouTubeId = (value) => {
try {
const parsedUrl = new URL(value)
if (parsedUrl.hostname.includes('youtu.be')) {
return parsedUrl.pathname.replace('/', '')
}
if (parsedUrl.hostname.includes('youtube.com')) {
return parsedUrl.searchParams.get('v') || parsedUrl.pathname.split('/').pop() || ''
}
} catch {
return ''
}
return ''
}
const youtubeId = computed(() => getYouTubeId(props.url))
const youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.com/embed/${youtubeId.value}` : '')
</script>
<template>
<div class="prose-embed my-8 border border-line bg-paper p-5">
<slot />
<div class="prose-embed my-8 overflow-hidden border border-line bg-paper">
<iframe
v-if="youtubeEmbedUrl"
class="prose-embed__frame aspect-video w-full"
:src="youtubeEmbedUrl"
title="Embedded video"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
/>
<a
v-else
class="prose-embed__link block p-5 text-sm font-semibold text-ink hover:opacity-70"
:href="url"
target="_blank"
rel="noreferrer"
>
{{ url }}
</a>
</div>
</template>