diff --git a/components/admin/AdminBlockEditor.vue b/components/admin/AdminBlockEditor.vue index 94e5d1c..f7b76eb 100644 --- a/components/admin/AdminBlockEditor.vue +++ b/components/admin/AdminBlockEditor.vue @@ -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} lines - 전체 마크다운 행 + * @param {number} startIndex - 본문 시작 인덱스 + * @returns {{contentLines: Array, 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" >
@@ -1026,6 +1137,50 @@ defineExpose({ +
+ +