글쓰기 확장 블록 추가
This commit is contained in:
@@ -62,6 +62,24 @@ const blockCommands = [
|
||||
description: '여러 이미지 업로드',
|
||||
keywords: ['gallery', 'images', '갤러리', '사진']
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
label: '콜아웃',
|
||||
description: '강조 안내 블록',
|
||||
keywords: ['callout', 'notice', 'info', '콜아웃', '안내']
|
||||
},
|
||||
{
|
||||
type: 'toggle',
|
||||
label: '토글',
|
||||
description: '접고 펼치는 본문 블록',
|
||||
keywords: ['toggle', 'details', '토글', '접기']
|
||||
},
|
||||
{
|
||||
type: 'embed',
|
||||
label: '임베드',
|
||||
description: 'YouTube 등 외부 링크 삽입',
|
||||
keywords: ['embed', 'youtube', 'link', '임베드', '유튜브']
|
||||
},
|
||||
{
|
||||
type: 'quote',
|
||||
label: '인용',
|
||||
@@ -104,6 +122,7 @@ const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '',
|
||||
level,
|
||||
url: options.url || '',
|
||||
alt: options.alt || '',
|
||||
title: options.title || '',
|
||||
width: options.width || 'regular',
|
||||
images: options.images || []
|
||||
})
|
||||
@@ -127,6 +146,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 - 마크다운 문자열
|
||||
@@ -147,21 +187,40 @@ const parseMarkdownToBlocks = (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(createEditorBlock('gallery', '', null, `editor-block-${blocks.length}`, { images }))
|
||||
index += 1
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::callout') {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
blocks.push(createEditorBlock('callout', contentLines.join('\n'), null, `editor-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(createEditorBlock('toggle', contentLines.join('\n'), null, `editor-block-${blocks.length}`, { title }))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::embed') {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
blocks.push(createEditorBlock('embed', '', null, `editor-block-${blocks.length}`, { url: contentLines.join('\n').trim() }))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -265,6 +324,25 @@ const serializeBlocks = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === 'callout') {
|
||||
return text
|
||||
? { type: block.type, value: `:::callout\n${text}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (block.type === 'toggle') {
|
||||
const title = block.title.trim()
|
||||
return title || text
|
||||
? { type: block.type, value: `:::toggle ${title || '더 보기'}\n${text}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (block.type === 'embed') {
|
||||
return block.url.trim()
|
||||
? { type: block.type, value: `:::embed\n${block.url.trim()}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
@@ -316,7 +394,7 @@ const emitContent = () => {
|
||||
* @param {Object} block - 에디터 블록
|
||||
* @returns {boolean} 텍스트 입력 블록 여부
|
||||
*/
|
||||
const isTextBlock = (block) => !['divider', 'image', 'gallery'].includes(block.type)
|
||||
const isTextBlock = (block) => !['divider', 'image', 'gallery', 'toggle', 'embed'].includes(block.type)
|
||||
|
||||
/**
|
||||
* 블록 DOM 요소를 저장
|
||||
@@ -361,6 +439,23 @@ const focusBlock = (index) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조형 블록의 첫 입력 필드로 커서 이동
|
||||
* @param {number} index - 블록 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusStructuredBlock = (index) => {
|
||||
nextTick(() => {
|
||||
const blockId = editorBlocks.value[index]?.id
|
||||
const row = document.querySelector(`[data-editor-block-id="${blockId}"]`)
|
||||
const field = row?.querySelector('input, textarea, button')
|
||||
|
||||
if (field) {
|
||||
field.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 슬래시 메뉴 표시 방향 갱신
|
||||
* @param {number} index - 블록 인덱스
|
||||
@@ -418,6 +513,7 @@ const getBlockClass = (block) => [
|
||||
'admin-block-editor__heading--h2 text-4xl': block.type === 'heading' && block.level === 2,
|
||||
'admin-block-editor__heading--h3 text-3xl': block.type === 'heading' && block.level === 3,
|
||||
'admin-block-editor__quote my-5 border-l-4 border-ink bg-surface px-5 py-3 text-xl font-medium leading-8': block.type === 'quote',
|
||||
'admin-block-editor__callout my-5 min-h-14 rounded border border-line bg-surface px-5 py-4 text-[16px] leading-7': block.type === 'callout',
|
||||
'admin-block-editor__list relative min-h-8 pl-7 text-[17px] leading-8 before:absolute before:left-2 before:top-3 before:h-2 before:w-2 before:rounded-full before:bg-current': block.type === 'list',
|
||||
'admin-block-editor__code my-5 min-h-14 whitespace-pre-wrap rounded bg-[#15171a] px-4 py-3 font-mono text-sm leading-6 text-white': block.type === 'code'
|
||||
}
|
||||
@@ -572,6 +668,7 @@ const applyCommand = (command) => {
|
||||
block.text = ''
|
||||
block.url = ''
|
||||
block.alt = ''
|
||||
block.title = ''
|
||||
block.width = 'regular'
|
||||
block.images = []
|
||||
const element = blockRefs.value[index]
|
||||
@@ -593,6 +690,11 @@ const applyCommand = (command) => {
|
||||
|
||||
if (isTextBlock(block)) {
|
||||
focusBlock(index)
|
||||
return
|
||||
}
|
||||
|
||||
if (['toggle', 'embed'].includes(block.type)) {
|
||||
focusStructuredBlock(index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,7 +938,7 @@ const handleEnter = (event, index) => {
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (['divider', 'image', 'gallery'].includes(currentBlock.type)) {
|
||||
if (['divider', 'image', 'gallery', 'toggle', 'embed'].includes(currentBlock.type)) {
|
||||
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
||||
emitContent()
|
||||
focusBlock(index + 1)
|
||||
@@ -870,7 +972,7 @@ const handleBackspace = (event, index) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isTextBlock(block) && (block.url || block.images.length)) {
|
||||
if (!isTextBlock(block) && (block.url || block.images.length || block.title || block.text)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -903,6 +1005,14 @@ const shouldShowPlaceholder = (block, index) => !block.text && (
|
||||
(index === 0 && editorBlocks.value.length === 1)
|
||||
)
|
||||
|
||||
/**
|
||||
* 텍스트 필드 변경 내용을 저장용 콘텐츠에 반영
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateStructuredBlock = () => {
|
||||
emitContent()
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
if (isApplyingExternalValue.value) {
|
||||
return
|
||||
@@ -936,6 +1046,7 @@ defineExpose({
|
||||
v-for="(block, index) in editorBlocks"
|
||||
:key="block.id"
|
||||
class="admin-block-editor__row relative"
|
||||
:data-editor-block-id="block.id"
|
||||
>
|
||||
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider my-6 border-line">
|
||||
|
||||
@@ -1026,6 +1137,50 @@ defineExpose({
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<section
|
||||
v-else-if="block.type === 'toggle'"
|
||||
class="admin-block-editor__toggle my-6 rounded border border-line bg-paper p-5"
|
||||
@focusin="activateBlock(block)"
|
||||
@click="activateBlock(block)"
|
||||
@keydown.enter="handleEnter($event, index)"
|
||||
@keydown.backspace="handleBackspace($event, index)"
|
||||
>
|
||||
<input
|
||||
v-model="block.title"
|
||||
class="admin-block-editor__toggle-title w-full border-0 bg-transparent text-base font-semibold text-ink outline-none placeholder:text-soft"
|
||||
type="text"
|
||||
placeholder="토글 제목"
|
||||
@input="updateStructuredBlock"
|
||||
>
|
||||
<textarea
|
||||
v-model="block.text"
|
||||
class="admin-block-editor__toggle-body mt-3 min-h-24 w-full resize-y border-0 bg-transparent text-sm leading-7 text-ink outline-none placeholder:text-soft"
|
||||
placeholder="펼쳤을 때 보일 내용을 입력하세요"
|
||||
@input="updateStructuredBlock"
|
||||
@keydown.enter.stop
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="block.type === 'embed'"
|
||||
class="admin-block-editor__embed my-6 rounded border border-dashed border-line bg-surface p-5"
|
||||
@focusin="activateBlock(block)"
|
||||
@click="activateBlock(block)"
|
||||
@keydown.enter="handleEnter($event, index)"
|
||||
@keydown.backspace="handleBackspace($event, index)"
|
||||
>
|
||||
<input
|
||||
v-model="block.url"
|
||||
class="admin-block-editor__embed-url w-full border-0 bg-transparent text-base font-semibold text-ink outline-none placeholder:text-soft"
|
||||
type="url"
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
@input="updateStructuredBlock"
|
||||
>
|
||||
<p class="admin-block-editor__embed-help mt-2 text-xs text-muted">
|
||||
YouTube 링크는 공개 화면에서 영상으로 표시됩니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<component
|
||||
:is="getBlockTag(block)"
|
||||
v-else
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user