글쓰기 확장 블록 추가

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

@@ -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