블록 설정 패널 확장 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]">