블록 설정 패널 확장 v1.5.37

This commit is contained in:
2026-06-02 16:13:38 +09:00
parent 093d09c8bf
commit ba17e3aa18
14 changed files with 538 additions and 28 deletions

View File

@@ -1,6 +1,6 @@
<script setup>
import { getImageAltAttribute, getImageDefaultAltLabel } from '../../lib/markdown-image.js'
import { CALLOUT_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
import { CALLOUT_BACKGROUND_OPTIONS, CALLOUT_EMOJI_OPTIONS } from '../../lib/markdown-callout.js'
const props = defineProps({
/** 패널 표시 여부 */
@@ -24,10 +24,13 @@ const emit = defineEmits([
'remove-media-image',
'add-gallery-images',
'update-embed-url',
'update-quote-background'
'update-quote-background',
'update-callout-options',
'update-code-options',
'update-toggle-options'
])
const quoteBackgroundLabels = {
const backgroundLabels = {
gray: '회색',
blue: '파랑',
green: '초록',
@@ -37,7 +40,7 @@ const quoteBackgroundLabels = {
pink: '분홍'
}
const quoteBackgroundSwatches = {
const backgroundSwatches = {
gray: 'rgba(100,116,139,0.28)',
blue: 'rgba(59,130,246,0.3)',
green: 'rgba(34,197,94,0.3)',
@@ -47,6 +50,22 @@ const quoteBackgroundSwatches = {
pink: 'rgba(236,72,153,0.28)'
}
const languageOptions = ['', 'javascript', 'html', 'css', 'vue', 'json', 'bash', 'markdown', 'sql']
/**
* 배경 라벨을 반환한다.
* @param {string} background - 배경 키
* @returns {string} 배경 라벨
*/
const getBackgroundLabel = (background) => backgroundLabels[background] || background
/**
* 배경 스와치를 반환한다.
* @param {string} background - 배경 키
* @returns {string} CSS 배경
*/
const getBackgroundSwatch = (background) => backgroundSwatches[background] || 'rgba(100,116,139,0.28)'
/**
* 블록 종류 라벨
* @returns {string}
@@ -68,6 +87,18 @@ const panelTitle = computed(() => {
return '인용'
}
if (props.panel.kind === 'callout') {
return '콜아웃'
}
if (props.panel.kind === 'code') {
return '코드 블록'
}
if (props.panel.kind === 'toggle') {
return '토글'
}
return '이미지'
})
@@ -92,6 +123,18 @@ const panelMeta = computed(() => {
return '인용 배경색'
}
if (props.panel.kind === 'callout') {
return '아이콘·배경색'
}
if (props.panel.kind === 'code') {
return '언어·줄번호'
}
if (props.panel.kind === 'toggle') {
return '기본 펼침 상태'
}
return '커서가 위치한 이미지 줄'
})
@@ -178,15 +221,135 @@ const onPanelFocusOut = (event) => {
>
<span
class="size-5 shrink-0 rounded-full border border-black/5"
:style="{ background: quoteBackgroundSwatches[background] }"
:style="{ background: getBackgroundSwatch(background) }"
aria-hidden="true"
/>
<span>{{ quoteBackgroundLabels[background] || background }}</span>
<span>{{ getBackgroundLabel(background) }}</span>
</button>
</div>
</div>
</template>
<template v-else-if="panel.kind === 'callout'">
<div class="admin-editor-block-panel__callout-settings grid gap-5">
<label class="flex cursor-pointer items-center justify-between gap-3 rounded border border-[#edf0f2] bg-[#fafafa] px-3 py-3 text-sm font-semibold text-[#394047]">
<span>아이콘 표시</span>
<input
class="size-4 rounded border-[#c8ced3] text-[#15171a]"
type="checkbox"
:checked="panel.calloutEmojiEnabled"
@change="emit('update-callout-options', { calloutEmojiEnabled: $event.target.checked })"
>
</label>
<div class="grid gap-2">
<p class="text-sm font-semibold text-[#394047]">
아이콘
</p>
<input
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac] disabled:opacity-50"
:value="panel.calloutEmoji"
type="text"
maxlength="4"
placeholder="💡"
:disabled="!panel.calloutEmojiEnabled"
@input="emit('update-callout-options', { calloutEmojiEnabled: true, calloutEmoji: $event.target.value })"
>
<div class="grid grid-cols-5 gap-2">
<button
v-for="emoji in CALLOUT_EMOJI_OPTIONS"
:key="`callout-emoji-${emoji}`"
class="grid h-10 place-items-center rounded border text-lg transition"
:class="panel.calloutEmoji === emoji && panel.calloutEmojiEnabled ? 'border-[#15171a] bg-white' : 'border-[#dce0e5] bg-[#fafafa] hover:bg-white'"
type="button"
:disabled="!panel.calloutEmojiEnabled"
@click="emit('update-callout-options', { calloutEmojiEnabled: true, calloutEmoji: emoji })"
>
{{ emoji }}
</button>
</div>
</div>
<div class="grid gap-2">
<p class="text-sm font-semibold text-[#394047]">
배경색
</p>
<div class="grid grid-cols-2 gap-2">
<button
v-for="background in CALLOUT_BACKGROUND_OPTIONS"
:key="`callout-background-${background}`"
class="flex items-center gap-2 rounded border px-3 py-2 text-left text-xs font-semibold transition"
:class="panel.calloutBackground === background ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
type="button"
@click="emit('update-callout-options', { calloutBackground: background })"
>
<span
class="size-5 shrink-0 rounded-full border border-black/5"
:style="{ background: getBackgroundSwatch(background) }"
aria-hidden="true"
/>
<span>{{ getBackgroundLabel(background) }}</span>
</button>
</div>
</div>
</div>
</template>
<template v-else-if="panel.kind === 'code'">
<div class="admin-editor-block-panel__code-settings grid gap-4">
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
<span class="font-semibold text-[#394047]">언어</span>
<input
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
:value="panel.language"
type="text"
list="admin-editor-block-panel-languages"
placeholder="javascript"
@input="emit('update-code-options', { language: $event.target.value })"
>
<datalist id="admin-editor-block-panel-languages">
<option
v-for="language in languageOptions"
:key="`code-language-${language || 'plain'}`"
:value="language"
/>
</datalist>
</label>
<label class="flex cursor-pointer items-center justify-between gap-3 rounded border border-[#edf0f2] bg-[#fafafa] px-3 py-3 text-sm font-semibold text-[#394047]">
<span>줄번호 표시</span>
<input
class="size-4 rounded border-[#c8ced3] text-[#15171a]"
type="checkbox"
:checked="panel.showLineNumbers"
@change="emit('update-code-options', { showLineNumbers: $event.target.checked })"
>
</label>
</div>
</template>
<template v-else-if="panel.kind === 'toggle'">
<div class="admin-editor-block-panel__toggle-settings grid gap-3">
<button
class="flex items-center justify-between rounded border px-3 py-3 text-left text-sm font-semibold transition"
:class="panel.defaultOpen ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
type="button"
@click="emit('update-toggle-options', { defaultOpen: true })"
>
<span>기본 펼침</span>
<span v-if="panel.defaultOpen" class="text-xs text-[#15171a]">선택됨</span>
</button>
<button
class="flex items-center justify-between rounded border px-3 py-3 text-left text-sm font-semibold transition"
:class="!panel.defaultOpen ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
type="button"
@click="emit('update-toggle-options', { defaultOpen: false })"
>
<span>기본 닫힘</span>
<span v-if="!panel.defaultOpen" class="text-xs text-[#15171a]">선택됨</span>
</button>
</div>
</template>
<template v-else>
<div class="admin-editor-block-panel__media-list grid gap-3">
<div

View File

@@ -14,7 +14,9 @@ import {
parseSlashInput,
resolveSlashCommand
} from '../../lib/markdown-slash-commands.js'
import { CALLOUT_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js'
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
import {
buildDefaultUploadSizeLimits,
@@ -1212,7 +1214,7 @@ const createMediaBlockMarkdown = (blockType, item) => {
return [
':::file',
`url=${item.url}`,
`title=${title}`,
`title=${item.name || title}`,
'description=',
`name=${item.name || title}`,
`size=${formatMediaFileSize(item.size)}`,
@@ -2107,6 +2109,82 @@ const updateActiveQuoteBackground = (background) => {
markdownValue.value = nextLines.join('\n')
}
/**
* 현재 콜아웃 블록 옵션을 수정한다.
* @param {Partial<{ calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string }>} patch - 변경 옵션
* @returns {void}
*/
const updateActiveCalloutOptions = (patch = {}) => {
const block = activeBlockContext.value
if (!block || block.kind !== 'callout') {
return
}
ensureBlockPanelEngaged()
const nextBackground = CALLOUT_BACKGROUND_OPTIONS.includes(patch.calloutBackground)
? patch.calloutBackground
: block.calloutBackground
const nextLine = buildCalloutOpenerLine({
calloutEmojiEnabled: patch.calloutEmojiEnabled ?? block.calloutEmojiEnabled,
calloutEmoji: patch.calloutEmoji ?? block.calloutEmoji,
calloutBackground: nextBackground
})
const lines = (markdownValue.value || '').split('\n')
const nextLines = [...lines]
nextLines.splice(block.startLine, 1, nextLine)
markdownValue.value = nextLines.join('\n')
}
/**
* 현재 코드 블록 옵션을 수정한다.
* @param {Partial<{ language: string, showLineNumbers: boolean }>} patch - 변경 옵션
* @returns {void}
*/
const updateActiveCodeOptions = (patch = {}) => {
const block = activeBlockContext.value
if (!block || block.kind !== 'code') {
return
}
ensureBlockPanelEngaged()
const nextLine = buildCodeFenceOpener({
language: patch.language ?? block.language,
showLineNumbers: patch.showLineNumbers ?? block.showLineNumbers
})
const lines = (markdownValue.value || '').split('\n')
const nextLines = [...lines]
nextLines.splice(block.startLine, 1, nextLine)
markdownValue.value = nextLines.join('\n')
}
/**
* 현재 토글 블록 옵션을 수정한다.
* @param {Partial<{ defaultOpen: boolean }>} patch - 변경 옵션
* @returns {void}
*/
const updateActiveToggleOptions = (patch = {}) => {
const block = activeBlockContext.value
if (!block || block.kind !== 'toggle') {
return
}
ensureBlockPanelEngaged()
const nextLine = buildToggleOpenerLine({
title: block.title,
defaultOpen: patch.defaultOpen ?? block.defaultOpen
})
const lines = (markdownValue.value || '').split('\n')
const nextLines = [...lines]
nextLines.splice(block.startLine, 1, nextLine)
markdownValue.value = nextLines.join('\n')
}
/**
* 이미지 마크다운을 삽입한다.
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
@@ -2174,6 +2252,9 @@ defineExpose({
appendImagesToActiveGallery,
updateActiveEmbedUrl,
updateActiveQuoteBackground,
updateActiveCalloutOptions,
updateActiveCodeOptions,
updateActiveToggleOptions,
openMediaPicker
})

View File

@@ -1062,6 +1062,33 @@ const onBlockPanelUpdateQuoteBackground = (background) => {
blockEditor.value?.updateActiveQuoteBackground?.(background)
}
/**
* 블록 패널에서 콜아웃 옵션을 수정한다.
* @param {Object} patch - 콜아웃 변경 값
* @returns {void}
*/
const onBlockPanelUpdateCalloutOptions = (patch) => {
blockEditor.value?.updateActiveCalloutOptions?.(patch)
}
/**
* 블록 패널에서 코드 블록 옵션을 수정한다.
* @param {Object} patch - 코드 블록 변경 값
* @returns {void}
*/
const onBlockPanelUpdateCodeOptions = (patch) => {
blockEditor.value?.updateActiveCodeOptions?.(patch)
}
/**
* 블록 패널에서 토글 옵션을 수정한다.
* @param {Object} patch - 토글 변경 값
* @returns {void}
*/
const onBlockPanelUpdateToggleOptions = (patch) => {
blockEditor.value?.updateActiveToggleOptions?.(patch)
}
const focusContentEditor = (event) => {
if (event?.isComposing || isTitleInputComposing.value || event?.keyCode === 229) {
return
@@ -1884,6 +1911,9 @@ defineExpose({
@add-gallery-images="onBlockPanelAddGalleryImages"
@update-embed-url="onBlockPanelUpdateEmbedUrl"
@update-quote-background="onBlockPanelUpdateQuoteBackground"
@update-callout-options="onBlockPanelUpdateCalloutOptions"
@update-code-options="onBlockPanelUpdateCodeOptions"
@update-toggle-options="onBlockPanelUpdateToggleOptions"
/>
</div>
</aside>

View File

@@ -19,7 +19,7 @@ import {
stripQuoteMarker
} from '../../lib/markdown-live-edit.js'
import { buildCodeBlockLines, parseCodeFenceLine } from '../../lib/markdown-code-block.js'
import { buildToggleBlockLines } from '../../lib/markdown-toggle.js'
import { buildToggleBlockLines, parseToggleOpenerLine } from '../../lib/markdown-toggle.js'
import { CALLOUT_BACKGROUND_OPTIONS, parseCalloutOptions } from '../../lib/markdown-callout.js'
import { createHeadingIdFactory } from '../../lib/markdown-toc.js'
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
@@ -566,9 +566,9 @@ const parseMarkdownBlocks = (markdown) => {
if (trimmedLine.startsWith(':::toggle')) {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const title = trimmedLine.replace(/^:::toggle\s*/, '').trim()
const toggleOptions = parseToggleOpenerLine(trimmedLine)
blocks.push(attachSourceRange(
createBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, { title }),
createBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, toggleOptions),
startLine,
nextIndex
))
@@ -1245,6 +1245,7 @@ const onToggleBlockInsertBelow = (block, payload) => {
onToggleBlockCommit(block, buildToggleBlockLines({
title: block.title,
defaultOpen: block.defaultOpen,
body: value
}))
pendingFocusPosition.value = 'start'
@@ -2517,13 +2518,14 @@ onBeforeUnmount(() => {
<ContentMarkdownToggleEditor
v-else-if="block.type === 'toggle' && interactive"
:title="block.title"
:default-open="block.defaultOpen"
:title-source-line="block.meta.startLine"
:body-source-line="block.meta.startLine + 1"
:model-value="block.text"
@commit="onToggleBlockCommit(block, $event)"
@insert-below="onToggleBlockInsertBelow(block, $event)"
/>
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'" animated>
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'" :default-open="block.defaultOpen" animated>
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-toggle-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>

View File

@@ -14,6 +14,11 @@ const props = defineProps({
type: String,
default: ''
},
/** 기본 펼침 여부 */
defaultOpen: {
type: Boolean,
default: false
},
/** 선언 줄 source-line(0-based) */
titleSourceLine: {
type: Number,
@@ -44,7 +49,8 @@ watch(() => props.title, (value) => {
const commitToggle = (options = {}) => {
emit('commit', buildToggleBlockLines({
title: options.title ?? titleDraft.value,
body: options.body ?? props.modelValue
body: options.body ?? props.modelValue,
defaultOpen: options.defaultOpen ?? props.defaultOpen
}))
}
@@ -104,7 +110,7 @@ const onExitBelow = (payload) => {
class="content-markdown-toggle-editor"
data-editable-scope
:title="titleDraft"
default-open
:default-open="true"
animated
>
<template #title>

View File

@@ -28,11 +28,24 @@ const props = defineProps({
*/
const isSafeFileUrl = computed(() => Boolean(props.href && (props.href.startsWith('/') || /^https?:\/\//i.test(props.href))))
/**
* 파일 확장자를 제거한 표시명을 반환한다.
* @param {string} filename - 파일명
* @returns {string} 확장자를 제외한 이름
*/
const stripFileExtension = (filename) => String(filename || '').replace(/\.[^.]+$/, '')
/**
* 카드 제목을 반환한다.
* @returns {string} 제목
*/
const displayTitle = computed(() => props.title || props.fileName || 'File')
const displayTitle = computed(() => {
if (props.fileName && (!props.title || props.title === stripFileExtension(props.fileName))) {
return props.fileName
}
return props.title || props.fileName || 'File'
})
/**
* 표시 파일명을 반환한다.
@@ -51,6 +64,25 @@ const displayFileName = computed(() => {
return ''
}
})
/**
* 파일 카드 보조 정보를 반환한다.
* @returns {string} 보조 정보
*/
const displayMeta = computed(() => {
const title = String(displayTitle.value || '').trim()
const fileName = String(displayFileName.value || '').trim()
if (title && fileName && title === fileName) {
return props.size || ''
}
if (fileName) {
return props.size ? `${fileName} · ${props.size}` : fileName
}
return props.size || props.href
})
</script>
<template>
@@ -67,8 +99,8 @@ const displayFileName = computed(() => {
<span v-if="description" class="prose-file__description mt-1 block text-sm leading-relaxed text-[var(--site-muted)]">
{{ description }}
</span>
<span class="prose-file__meta mt-3 block truncate text-sm font-semibold text-[var(--site-text)]">
{{ displayFileName || href }}<template v-if="size"> · {{ size }}</template>
<span v-if="displayMeta" class="prose-file__meta mt-3 block truncate text-sm font-semibold text-[var(--site-text)]">
{{ displayMeta }}
</span>
</span>
<span class="prose-file__download flex h-20 w-20 shrink-0 items-center justify-center rounded-[6px] bg-[color-mix(in_srgb,var(--site-line)_36%,var(--site-panel))] text-[var(--site-accent)] transition-transform group-hover:scale-[1.02] sm:h-[86px] sm:w-[86px]">

View File

@@ -1,5 +1,11 @@
# 업데이트 요약
## v1.5.37
- 게시물 글쓰기 오른쪽 블록 설정 패널에서 콜아웃, 코드 블록, 토글 옵션을 수정할 수 있게 했다.
- 토글 블록은 기본 펼침 또는 기본 닫힘 상태를 저장할 수 있게 했다.
- 파일 다운로드 카드에서 반복되는 파일명 표시를 줄이고 용량 중심으로 보이게 정리했다.
## v1.5.36
- 관리자 사이트 설정에서 브랜드 포인트 컬러를 지정할 수 있게 했다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-06-02 v1.5.37 — 블록 옵션은 본문 옆 설정 패널로 모은다
콜아웃, 코드 블록, 토글은 본문 안에서 직접 편집할 수 있지만 옵션 조작 위치가 블록마다 다르면 작성 흐름이 끊긴다. 인용문 배경색처럼 커서가 놓인 블록을 오른쪽 패널에서 조정하도록 통일하면, 본문 텍스트와 구조 옵션을 분리해 다룰 수 있다. 옵션은 별도 메타 저장소를 만들지 않고 각 fenced 블록의 선언 줄에 반영해 Markdown 백업·Import 흐름과도 계속 호환되게 둔다.
## 2026-06-02 v1.5.36 — 포인트 컬러는 테마 파일이 아니라 운영 설정이다
사용자 화면의 활성 네비게이션, TOC, 댓글 버튼은 사이트의 브랜드 인상을 만드는 공통 강조 요소다. 코드에 고정된 오렌지색을 각 컴포넌트에서 따로 바꾸면 운영자가 브랜드를 바꾸기 어렵고 누락도 생기므로, 사이트 설정의 `brand_color`를 공개 앱 루트의 `--site-accent` CSS 변수로 주입한다. 기존 색상은 기본값으로 유지해 저장값이 없거나 마이그레이션 전 환경에서도 같은 화면을 보여 준다.

View File

@@ -29,7 +29,7 @@
| lib/external-favicon-url.js | 외부 URL 호스트 기준 Google `s2/favicons` 썸네일 URL 생성(내부 경로는 빈 문자열) |
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
| lib/markdown-block-context.js | 관리자 Markdown textarea 커서 위치 기준 이미지·갤러리·임베드·인용 블록 설정 패널 대상 판별 |
| lib/markdown-block-context.js | 관리자 Markdown textarea 커서 위치 기준 이미지·갤러리·임베드·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별 |
| lib/brand-color.js | 사이트 브랜드 컬러 기본값·hex 검증·정규화 |
| lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 |
| lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 |
@@ -88,8 +88,8 @@
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드·인용 배경색), 갤러리 선택 이미지 강조 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드·인용 배경색·콜아웃·코드·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
@@ -119,7 +119,7 @@
| components/content/ContentMarkdownToggleEditor.vue | 라이브 모드 토글 제목·본문 인라인 편집 |
| components/content/ProseVideo.vue | `:::video` 공개 비디오 카드 |
| components/content/ProseAudio.vue | `:::audio` 공개 오디오 플레이어 카드 |
| components/content/ProseFile.vue | `:::file` 공개 다운로드 파일 카드 |
| components/content/ProseFile.vue | `:::file` 공개 다운로드 파일 카드, 제목·파일명이 같으면 용량 중심 보조 정보 표시 |
| components/content/ProseProduct.vue | 상품 카드 |
| components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·`http(s)` 외부 링크) |
| components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 |

View File

@@ -76,6 +76,7 @@
- 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
- 인용문(`>`)은 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`로 배경색을 지정할 수 있으며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`, `pink`이다.
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정을 지원한다. 콜아웃은 아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다.
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 추천 사이트 대신 본문 H1~H3 제목 기반 TOC를 표시한다. TOC 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 TOC 항목을 강조하고, 목차 항목이 많으면 TOC 내부 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 TOC를 숨긴다.
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
- 로그인 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다.
@@ -625,8 +626,8 @@ components/content/
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
- 미디어 라이브러리에서 단일 이미지를 선택하면 `![alt](url)` 형식으로 삽입한다.
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, 단독 URL 임베드 줄 또는 기존 `:::embed` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문·패널 바깥을 클릭하면 슬라이드 아웃한다. 갤러리 이미지 추가 미디어 모달을 여는 동안에는 활성 갤러리 컨텍스트와 패널 상태를 유지한다.
- 블록 설정 패널: 이미지·갤러리(캡션, **파일명을 캡션으로 사용** 토글·기본 끔, URL, 갤러리 순서·삭제·추가), 임베드(URL). `AdminMarkdownEditor``block-panel` 이벤트로 상태를 `AdminPostForm`에 전달한다.
- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, 단독 URL 임베드 줄, 기존 `:::embed`, 인용문, `:::callout`, 코드 fenced 블록, `:::toggle` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문·패널 바깥을 클릭하면 슬라이드 아웃한다. 갤러리 이미지 추가 미디어 모달을 여는 동안에는 활성 갤러리 컨텍스트와 패널 상태를 유지한다.
- 블록 설정 패널: 이미지·갤러리(캡션, **파일명을 캡션으로 사용** 토글·기본 끔, URL, 갤러리 순서·삭제·추가), 임베드(URL), 인용 배경색, 콜아웃 아이콘·배경색, 코드 언어·줄번호, 토글 기본 펼침 상태. `AdminMarkdownEditor``block-panel` 이벤트로 상태를 `AdminPostForm`에 전달한다.
- 미디어 라이브러리 갤러리 다중 선택 시 선택 항목은 **주황(`#ff7a00`) 굵은 테두리**로 표시한다.
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v1.5.37
- 게시물 글쓰기: 콜아웃 블록 오른쪽 설정 패널에서 아이콘 표시 여부·아이콘·배경색을 수정할 수 있도록 추가.
- 게시물 글쓰기: 코드 블록 오른쪽 설정 패널에서 언어와 줄번호 표시 여부를 수정할 수 있도록 추가.
- 게시물 글쓰기: 토글 블록 오른쪽 설정 패널에서 기본 펼침·닫힘 상태를 선택할 수 있도록 추가.
- 콘텐츠 파일 카드: 파일명 반복 표시를 줄이고 제목에는 확장자 포함 파일명을, 보조 영역에는 용량 중심 정보를 표시하도록 수정.
## v1.5.36
- 관리자 사이트 설정: 메인 화면 아래 브랜드 컬러 설정 카드 추가.

View File

@@ -1,5 +1,7 @@
import { isImageUrl, parseImageMarkdownLine } from './markdown-image.js'
import { CALLOUT_BACKGROUND_OPTIONS } from './markdown-callout.js'
import { CALLOUT_BACKGROUND_OPTIONS, parseCalloutOptions } from './markdown-callout.js'
import { parseCodeFenceLine } from './markdown-code-block.js'
import { parseToggleOpenerLine } from './markdown-toggle.js'
/**
* fenced 블록 시작 줄 인덱스를 찾는다.
@@ -22,6 +24,27 @@ const findFencedBlockStart = (lines, currentLine, opener) => {
return -1
}
/**
* 조건에 맞는 fenced 블록 시작 줄 인덱스를 찾는다.
* @param {string[]} lines - 본문 줄 목록
* @param {number} currentLine - 현재 줄
* @param {(line: string) => boolean} predicate - 시작 줄 판별 함수
* @returns {number} 시작 줄 또는 -1
*/
const findFencedBlockStartBy = (lines, currentLine, predicate) => {
for (let index = currentLine; index >= 0; index -= 1) {
if (predicate((lines[index] || '').trim())) {
return index
}
if ((lines[index] || '').trim() === ':::') {
break
}
}
return -1
}
/**
* fenced 블록 종료 줄 인덱스를 찾는다.
* @param {string[]} lines - 본문 줄 목록
@@ -204,6 +227,113 @@ const resolveEmbedBlock = (lines, currentLine) => {
}
}
/**
* 콜아웃 fenced 블록을 파싱한다.
* @param {string[]} lines - 본문 줄 목록
* @param {number} currentLine - 현재 줄
* @returns {{ kind: 'callout', startLine: number, endLine: number, calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string }|null}
*/
const resolveCalloutBlock = (lines, currentLine) => {
const calloutStart = findFencedBlockStartBy(lines, currentLine, (line) => line.startsWith(':::callout'))
if (calloutStart === -1) {
return null
}
const calloutEnd = findFencedBlockEnd(lines, calloutStart)
if (calloutEnd === -1 || currentLine > calloutEnd) {
return null
}
return {
kind: 'callout',
startLine: calloutStart,
endLine: calloutEnd,
...parseCalloutOptions(lines[calloutStart])
}
}
/**
* 토글 fenced 블록을 파싱한다.
* @param {string[]} lines - 본문 줄 목록
* @param {number} currentLine - 현재 줄
* @returns {{ kind: 'toggle', startLine: number, endLine: number, title: string, defaultOpen: boolean }|null}
*/
const resolveToggleBlock = (lines, currentLine) => {
const toggleStart = findFencedBlockStartBy(lines, currentLine, (line) => line.startsWith(':::toggle'))
if (toggleStart === -1) {
return null
}
const toggleEnd = findFencedBlockEnd(lines, toggleStart)
if (toggleEnd === -1 || currentLine > toggleEnd) {
return null
}
return {
kind: 'toggle',
startLine: toggleStart,
endLine: toggleEnd,
...parseToggleOpenerLine(lines[toggleStart])
}
}
/**
* 코드 fenced 블록 종료 줄 인덱스를 찾는다.
* @param {string[]} lines - 본문 줄 목록
* @param {number} startLine - 시작 줄
* @returns {number} 종료 줄 또는 -1
*/
const findCodeFenceEnd = (lines, startLine) => {
for (let index = startLine + 1; index < lines.length; index += 1) {
if ((lines[index] || '').trim().startsWith('```')) {
return index
}
}
return -1
}
/**
* 코드 fenced 블록을 파싱한다.
* @param {string[]} lines - 본문 줄 목록
* @param {number} currentLine - 현재 줄
* @returns {{ kind: 'code', startLine: number, endLine: number, language: string, showLineNumbers: boolean }|null}
*/
const resolveCodeBlock = (lines, currentLine) => {
let codeStart = -1
for (let index = currentLine; index >= 0; index -= 1) {
if ((lines[index] || '').trim().startsWith('```')) {
codeStart = index
break
}
}
if (codeStart === -1) {
return null
}
const codeEnd = findCodeFenceEnd(lines, codeStart)
if (codeEnd === -1 || currentLine > codeEnd) {
return null
}
const options = parseCodeFenceLine(lines[codeStart]) || { language: '', showLineNumbers: true }
return {
kind: 'code',
startLine: codeStart,
endLine: codeEnd,
language: options.language,
showLineNumbers: options.showLineNumbers
}
}
/**
* 커서 줄 기준 활성 블록 컨텍스트를 반환한다.
* @param {string} markdown - 본문 마크다운
@@ -213,6 +343,12 @@ const resolveEmbedBlock = (lines, currentLine) => {
export const resolveActiveBlockContext = (markdown, lineIndex) => {
const lines = String(markdown || '').split('\n')
const currentLine = Math.min(Math.max(0, lineIndex), Math.max(0, lines.length - 1))
const code = resolveCodeBlock(lines, currentLine)
if (code) {
return code
}
const activeImage = parseImageMarkdownLine(lines[currentLine] || '')
const activeImageUrl = String(lines[currentLine] || '').trim()
@@ -246,5 +382,17 @@ export const resolveActiveBlockContext = (markdown, lineIndex) => {
return quote
}
const callout = resolveCalloutBlock(lines, currentLine)
if (callout) {
return callout
}
const toggle = resolveToggleBlock(lines, currentLine)
if (toggle) {
return toggle
}
return resolveEmbedBlock(lines, currentLine)
}

View File

@@ -1,15 +1,45 @@
/**
* 토글 선언 줄을 파싱한다.
* @param {string} line - 토글 선언 줄
* @returns {{ title: string, defaultOpen: boolean }} 토글 옵션
*/
export const parseToggleOpenerLine = (line) => {
const raw = String(line ?? '').trim().replace(/^:::toggle\s*/, '').trim()
const tokens = raw.split(/\s+/).filter(Boolean)
const state = tokens[0]?.toLowerCase()
const hasStateToken = ['open', 'opened', 'default-open', 'closed', 'close', 'default-closed'].includes(state)
const defaultOpen = ['open', 'opened', 'default-open'].includes(state)
const title = (hasStateToken ? tokens.slice(1).join(' ') : raw).trim()
return {
title,
defaultOpen
}
}
/**
* 토글 선언 줄을 만든다.
* @param {{ title?: string, defaultOpen?: boolean }} options - 옵션
* @returns {string} 토글 선언 줄
*/
export const buildToggleOpenerLine = (options = {}) => {
const title = String(options.title ?? '').trim() || '더 보기'
const state = options.defaultOpen ? 'open' : 'closed'
return `:::toggle ${state} ${title}`
}
/**
* 토글 블록 마크다운 줄 배열을 만든다.
* @param {{ title?: string, body?: string }} options - 옵션
* @param {{ title?: string, body?: string, defaultOpen?: boolean }} options - 옵션
* @returns {string[]} 마크다운 줄
*/
export const buildToggleBlockLines = (options = {}) => {
const title = String(options.title ?? '').trim() || '더 보기'
const body = String(options.body ?? '').replace(/\r/g, '')
const bodyLines = body.length ? body.split('\n') : ['']
return [
`:::toggle ${title}`,
buildToggleOpenerLine(options),
...bodyLines,
':::'
]

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.5.36",
"version": "1.5.37",
"private": true,
"type": "module",
"imports": {