글쓰기 확장 블록 추가
This commit is contained in:
@@ -62,6 +62,24 @@ const blockCommands = [
|
|||||||
description: '여러 이미지 업로드',
|
description: '여러 이미지 업로드',
|
||||||
keywords: ['gallery', 'images', '갤러리', '사진']
|
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',
|
type: 'quote',
|
||||||
label: '인용',
|
label: '인용',
|
||||||
@@ -104,6 +122,7 @@ const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '',
|
|||||||
level,
|
level,
|
||||||
url: options.url || '',
|
url: options.url || '',
|
||||||
alt: options.alt || '',
|
alt: options.alt || '',
|
||||||
|
title: options.title || '',
|
||||||
width: options.width || 'regular',
|
width: options.width || 'regular',
|
||||||
images: options.images || []
|
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 - 마크다운 문자열
|
* @param {string} markdown - 마크다운 문자열
|
||||||
@@ -147,21 +187,40 @@ const parseMarkdownToBlocks = (markdown) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (trimmedLine === ':::gallery') {
|
if (trimmedLine === ':::gallery') {
|
||||||
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||||
const images = []
|
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) {
|
if (image) {
|
||||||
images.push(image)
|
images.push(image)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
index += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
blocks.push(createEditorBlock('gallery', '', null, `editor-block-${blocks.length}`, { images }))
|
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
|
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) {
|
if (!text) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -316,7 +394,7 @@ const emitContent = () => {
|
|||||||
* @param {Object} block - 에디터 블록
|
* @param {Object} block - 에디터 블록
|
||||||
* @returns {boolean} 텍스트 입력 블록 여부
|
* @returns {boolean} 텍스트 입력 블록 여부
|
||||||
*/
|
*/
|
||||||
const isTextBlock = (block) => !['divider', 'image', 'gallery'].includes(block.type)
|
const isTextBlock = (block) => !['divider', 'image', 'gallery', 'toggle', 'embed'].includes(block.type)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 블록 DOM 요소를 저장
|
* 블록 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 - 블록 인덱스
|
* @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--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__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__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__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'
|
'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.text = ''
|
||||||
block.url = ''
|
block.url = ''
|
||||||
block.alt = ''
|
block.alt = ''
|
||||||
|
block.title = ''
|
||||||
block.width = 'regular'
|
block.width = 'regular'
|
||||||
block.images = []
|
block.images = []
|
||||||
const element = blockRefs.value[index]
|
const element = blockRefs.value[index]
|
||||||
@@ -593,6 +690,11 @@ const applyCommand = (command) => {
|
|||||||
|
|
||||||
if (isTextBlock(block)) {
|
if (isTextBlock(block)) {
|
||||||
focusBlock(index)
|
focusBlock(index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['toggle', 'embed'].includes(block.type)) {
|
||||||
|
focusStructuredBlock(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,7 +938,7 @@ const handleEnter = (event, index) => {
|
|||||||
|
|
||||||
event.preventDefault()
|
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())
|
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
||||||
emitContent()
|
emitContent()
|
||||||
focusBlock(index + 1)
|
focusBlock(index + 1)
|
||||||
@@ -870,7 +972,7 @@ const handleBackspace = (event, index) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isTextBlock(block) && (block.url || block.images.length)) {
|
if (!isTextBlock(block) && (block.url || block.images.length || block.title || block.text)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -903,6 +1005,14 @@ const shouldShowPlaceholder = (block, index) => !block.text && (
|
|||||||
(index === 0 && editorBlocks.value.length === 1)
|
(index === 0 && editorBlocks.value.length === 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트 필드 변경 내용을 저장용 콘텐츠에 반영
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const updateStructuredBlock = () => {
|
||||||
|
emitContent()
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => props.modelValue, (value) => {
|
watch(() => props.modelValue, (value) => {
|
||||||
if (isApplyingExternalValue.value) {
|
if (isApplyingExternalValue.value) {
|
||||||
return
|
return
|
||||||
@@ -936,6 +1046,7 @@ defineExpose({
|
|||||||
v-for="(block, index) in editorBlocks"
|
v-for="(block, index) in editorBlocks"
|
||||||
:key="block.id"
|
:key="block.id"
|
||||||
class="admin-block-editor__row relative"
|
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">
|
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider my-6 border-line">
|
||||||
|
|
||||||
@@ -1026,6 +1137,50 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
</figure>
|
</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
|
<component
|
||||||
:is="getBlockTag(block)"
|
:is="getBlockTag(block)"
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
|
|||||||
level,
|
level,
|
||||||
url: options.url || '',
|
url: options.url || '',
|
||||||
alt: options.alt || '',
|
alt: options.alt || '',
|
||||||
|
title: options.title || '',
|
||||||
width: options.width || 'regular',
|
width: options.width || 'regular',
|
||||||
images: options.images || []
|
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 - 마크다운 문자열
|
* @param {string} markdown - 마크다운 문자열
|
||||||
@@ -68,21 +90,40 @@ const parseMarkdownBlocks = (markdown) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (trimmedLine === ':::gallery') {
|
if (trimmedLine === ':::gallery') {
|
||||||
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||||
const images = []
|
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) {
|
if (image) {
|
||||||
images.push(image)
|
images.push(image)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
index += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
blocks.push(createBlock('gallery', '', null, `block-${blocks.length}`, { images }))
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +247,13 @@ const showNextImage = () => {
|
|||||||
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
|
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
|
||||||
{{ block.alt }}
|
{{ block.alt }}
|
||||||
</ProseImage>
|
</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">
|
<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
|
<button
|
||||||
v-for="(image, imageIndex) in block.images"
|
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>
|
<template>
|
||||||
<div class="prose-embed my-8 border border-line bg-paper p-5">
|
<div class="prose-embed my-8 overflow-hidden border border-line bg-paper">
|
||||||
<slot />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.20
|
||||||
|
|
||||||
|
### 콜아웃, 토글, 임베드 블록 저장 방식 결정
|
||||||
|
|
||||||
|
콜아웃, 토글, 임베드는 기존 `content` 마크다운 문자열 안에 `:::callout`, `:::toggle`, `:::embed` fenced block으로 저장한다. 이미지 갤러리와 같은 확장 문법을 사용하면 DB 스키마를 바꾸지 않고도 관리자 작성 화면과 공개 렌더러를 함께 확장할 수 있기 때문이다.
|
||||||
|
|
||||||
|
임베드는 1차로 YouTube URL만 iframe으로 렌더링하고, 그 외 URL은 외부 링크로 표시한다. Twitter 등 외부 서비스별 스크립트 임베드는 SSR 안정성과 개인정보/스크립트 로딩 정책을 검토한 뒤 별도 단계에서 확장한다.
|
||||||
|
|
||||||
## 2026-05-02 v0.0.19
|
## 2026-05-02 v0.0.19
|
||||||
|
|
||||||
### 블록 에디터 조합 입력과 이미지 캡션 표시 보정
|
### 블록 에디터 조합 입력과 이미지 캡션 표시 보정
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택 |
|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택 |
|
||||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리 블록, 한글 조합 입력 처리 |
|
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리 |
|
||||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
||||||
|
|
||||||
## 콘텐츠 컴포넌트
|
## 콘텐츠 컴포넌트
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링 |
|
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 확장 블록 파싱 |
|
||||||
| components/content/ProseHeading.vue | h1~h6 제목 |
|
| components/content/ProseHeading.vue | h1~h6 제목 |
|
||||||
| components/content/ProseImage.vue | 본문 내 이미지 |
|
| components/content/ProseImage.vue | 본문 내 이미지 |
|
||||||
| components/content/ProseList.vue | 목록 |
|
| components/content/ProseList.vue | 목록 |
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ components/content/
|
|||||||
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
|
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
|
||||||
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
|
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
|
||||||
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
|
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
|
||||||
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 인용, 목록, 코드, 구분선을 제공한다.
|
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
|
||||||
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르면 현재 블록 타입을 즉시 변환한다.
|
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르면 현재 블록 타입을 즉시 변환한다.
|
||||||
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
|
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
|
||||||
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
|
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
|
||||||
@@ -238,6 +238,10 @@ components/content/
|
|||||||
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
||||||
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
|
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
|
||||||
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
|
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
|
||||||
|
- 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다.
|
||||||
|
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다.
|
||||||
|
- 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다.
|
||||||
|
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링하고, 그 외 URL은 외부 링크로 표시한다.
|
||||||
|
|
||||||
### 관리자 인증
|
### 관리자 인증
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- [ ] 미디어 선택 창 브라우저 수동 QA: 기존 이미지 선택, 갤러리 추가, 빈 미디어 상태 확인
|
- [ ] 미디어 선택 창 브라우저 수동 QA: 기존 이미지 선택, 갤러리 추가, 빈 미디어 상태 확인
|
||||||
- [ ] 대표 이미지 브라우저 수동 QA: 기존 미디어 선택, 새 업로드, 썸네일 표시, 삭제/변경 확인
|
- [ ] 대표 이미지 브라우저 수동 QA: 기존 미디어 선택, 새 업로드, 썸네일 표시, 삭제/변경 확인
|
||||||
- [ ] 미디어 라이브러리 상세 모달 수동 QA: 검색, 사용 현황, 파일명 변경, 삭제 잠금 확인
|
- [ ] 미디어 라이브러리 상세 모달 수동 QA: 검색, 사용 현황, 파일명 변경, 삭제 잠금 확인
|
||||||
- [ ] 콜아웃, 토글, 임베드 블록 추가
|
- [ ] 콜아웃, 토글, 임베드 블록 브라우저 수동 QA: `/` 메뉴 선택, 저장/수정 왕복, 공개 렌더링 확인
|
||||||
- [ ] 글 작성 중 자동 저장
|
- [ ] 글 작성 중 자동 저장
|
||||||
|
|
||||||
## 2차 관리자 개발
|
## 2차 관리자 개발
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
- [ ] ProseFile 실제 파일 데이터 연결
|
- [ ] ProseFile 실제 파일 데이터 연결
|
||||||
- [ ] ProseProduct 실제 상품 카드 데이터 연결
|
- [ ] ProseProduct 실제 상품 카드 데이터 연결
|
||||||
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
|
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
|
||||||
- [ ] ProseEmbed YouTube, Twitter 실제 렌더링 연결
|
- [ ] ProseEmbed Twitter 실제 렌더링 연결
|
||||||
|
|
||||||
## 데이터베이스
|
## 데이터베이스
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.20
|
||||||
|
|
||||||
|
- 관리자 블록 에디터에 콜아웃 블록 추가.
|
||||||
|
- 관리자 블록 에디터에 토글 블록 추가.
|
||||||
|
- 관리자 블록 에디터에 임베드 블록 추가.
|
||||||
|
- 공개 본문 렌더러에 콜아웃, 토글, 임베드 마크다운 확장 파싱 추가.
|
||||||
|
- YouTube 임베드 URL을 공개 화면에서 iframe으로 렌더링하도록 수정.
|
||||||
|
- 패키지 버전을 0.0.20으로 갱신.
|
||||||
|
|
||||||
## v0.0.19
|
## v0.0.19
|
||||||
|
|
||||||
- 관리자 블록 에디터의 한글 조합 입력 중복 방지 처리 추가.
|
- 관리자 블록 에디터의 한글 조합 입력 중복 방지 처리 추가.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.19",
|
"version": "0.0.20",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.19",
|
"version": "0.0.20",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.19",
|
"version": "0.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user