글쓰기 확장 블록 추가

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

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>

View File

@@ -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
### 블록 에디터 조합 입력과 이미지 캡션 표시 보정

View File

@@ -27,7 +27,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리 블록, 한글 조합 입력 처리 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
## 콘텐츠 컴포넌트
@@ -35,7 +35,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 확장 블록 파싱 |
| components/content/ProseHeading.vue | h1~h6 제목 |
| components/content/ProseImage.vue | 본문 내 이미지 |
| components/content/ProseList.vue | 목록 |

View File

@@ -219,7 +219,7 @@ components/content/
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 인용, 목록, 코드, 구분선을 제공한다.
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르면 현재 블록 타입을 즉시 변환한다.
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
@@ -238,6 +238,10 @@ components/content/
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
- 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다.
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다.
- 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다.
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링하고, 그 외 URL은 외부 링크로 표시한다.
### 관리자 인증

View File

@@ -9,7 +9,7 @@
- [ ] 미디어 선택 창 브라우저 수동 QA: 기존 이미지 선택, 갤러리 추가, 빈 미디어 상태 확인
- [ ] 대표 이미지 브라우저 수동 QA: 기존 미디어 선택, 새 업로드, 썸네일 표시, 삭제/변경 확인
- [ ] 미디어 라이브러리 상세 모달 수동 QA: 검색, 사용 현황, 파일명 변경, 삭제 잠금 확인
- [ ] 콜아웃, 토글, 임베드 블록 추가
- [ ] 콜아웃, 토글, 임베드 블록 브라우저 수동 QA: `/` 메뉴 선택, 저장/수정 왕복, 공개 렌더링 확인
- [ ] 글 작성 중 자동 저장
## 2차 관리자 개발
@@ -49,7 +49,7 @@
- [ ] ProseFile 실제 파일 데이터 연결
- [ ] ProseProduct 실제 상품 카드 데이터 연결
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
- [ ] ProseEmbed YouTube, Twitter 실제 렌더링 연결
- [ ] ProseEmbed Twitter 실제 렌더링 연결
## 데이터베이스

View File

@@ -1,5 +1,14 @@
# 업데이트 이력
## v0.0.20
- 관리자 블록 에디터에 콜아웃 블록 추가.
- 관리자 블록 에디터에 토글 블록 추가.
- 관리자 블록 에디터에 임베드 블록 추가.
- 공개 본문 렌더러에 콜아웃, 토글, 임베드 마크다운 확장 파싱 추가.
- YouTube 임베드 URL을 공개 화면에서 iframe으로 렌더링하도록 수정.
- 패키지 버전을 0.0.20으로 갱신.
## v0.0.19
- 관리자 블록 에디터의 한글 조합 입력 중복 방지 처리 추가.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "0.0.19",
"version": "0.0.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "0.0.19",
"version": "0.0.20",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.19",
"version": "0.0.20",
"private": true,
"type": "module",
"scripts": {