인용 블록 색상과 라이브 설정 패널 정리

This commit is contained in:
2026-06-04 10:28:43 +09:00
parent 2cb1ff4651
commit b38fc9f154
15 changed files with 222 additions and 221 deletions

View File

@@ -1,6 +1,12 @@
<script setup> <script setup>
import { getImageAltAttribute, getImageDefaultAltLabel } from '../../lib/markdown-image.js' import { getImageAltAttribute, getImageDefaultAltLabel } from '../../lib/markdown-image.js'
import { CALLOUT_BACKGROUND_OPTIONS, CALLOUT_EMOJI_OPTIONS } from '../../lib/markdown-callout.js' import {
CALLOUT_BACKGROUND_OPTIONS,
CALLOUT_EMOJI_OPTIONS,
QUOTE_BACKGROUND_LABELS,
QUOTE_BACKGROUND_OPTIONS,
QUOTE_BACKGROUND_SWATCHES
} from '../../lib/markdown-callout.js'
const props = defineProps({ const props = defineProps({
/** 패널 표시 여부 */ /** 패널 표시 여부 */
@@ -66,6 +72,20 @@ const getBackgroundLabel = (background) => backgroundLabels[background] || backg
*/ */
const getBackgroundSwatch = (background) => backgroundSwatches[background] || 'rgba(100,116,139,0.28)' const getBackgroundSwatch = (background) => backgroundSwatches[background] || 'rgba(100,116,139,0.28)'
/**
* 인용 배경 라벨을 반환한다.
* @param {string} background - 배경 키
* @returns {string} 배경 라벨
*/
const getQuoteBackgroundLabel = (background) => QUOTE_BACKGROUND_LABELS[background] || background
/**
* 인용 배경 스와치를 반환한다.
* @param {string} background - 배경 키
* @returns {string} CSS 배경
*/
const getQuoteBackgroundSwatch = (background) => QUOTE_BACKGROUND_SWATCHES[background] || QUOTE_BACKGROUND_SWATCHES.gray
/** /**
* 블록 종류 라벨 * 블록 종류 라벨
* @returns {string} * @returns {string}
@@ -212,7 +232,7 @@ const onPanelFocusOut = (event) => {
</div> </div>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<button <button
v-for="background in CALLOUT_BACKGROUND_OPTIONS" v-for="background in QUOTE_BACKGROUND_OPTIONS"
:key="`quote-background-${background}`" :key="`quote-background-${background}`"
class="flex items-center gap-2 rounded border px-3 py-2 text-left text-xs font-semibold transition" class="flex items-center gap-2 rounded border px-3 py-2 text-left text-xs font-semibold transition"
:class="panel.quoteBackground === background ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'" :class="panel.quoteBackground === background ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
@@ -221,10 +241,10 @@ const onPanelFocusOut = (event) => {
> >
<span <span
class="size-5 shrink-0 rounded-full border border-black/5" class="size-5 shrink-0 rounded-full border border-black/5"
:style="{ background: getBackgroundSwatch(background) }" :style="{ background: getQuoteBackgroundSwatch(background) }"
aria-hidden="true" aria-hidden="true"
/> />
<span>{{ getBackgroundLabel(background) }}</span> <span>{{ getQuoteBackgroundLabel(background) }}</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -14,7 +14,7 @@ import {
parseSlashInput, parseSlashInput,
resolveSlashCommand resolveSlashCommand
} from '../../lib/markdown-slash-commands.js' } from '../../lib/markdown-slash-commands.js'
import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js' import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js' import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js' import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js'
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js' import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
@@ -219,6 +219,49 @@ const syncBlockPanelState = () => {
}) })
} }
/**
* 라이브 모드 포커스 줄을 기준으로 블록 패널을 동기화한다.
* @param {number} sourceLine - 원본 마크다운 줄(0-based)
* @returns {void}
*/
const handleLiveLineFocus = async (sourceLine) => {
const nextLine = Number(sourceLine)
if (!Number.isInteger(nextLine) || nextLine < 0) {
return
}
activeLogicalLineIndex.value = nextLine
isTextareaFocused.value = false
isTextComposing.value = false
await nextTick()
if (activeBlockContext.value) {
lastStableBlockContext.value = activeBlockContext.value
isBlockPanelEngaged.value = true
} else {
isBlockPanelEngaged.value = false
}
syncBlockPanelState()
}
/**
* 라이브 모드 포커스가 블록·패널 밖으로 나가면 패널을 닫는다.
* @returns {void}
*/
const handleLiveLineBlur = () => {
blockPanelFocusTimer = window.setTimeout(() => {
if (isMediaPickerOpen.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
return
}
isBlockPanelEngaged.value = false
syncBlockPanelState()
}, 0)
}
watch(activeBlockContext, (context) => { watch(activeBlockContext, (context) => {
if (context) { if (context) {
lastStableBlockContext.value = context lastStableBlockContext.value = context
@@ -2136,7 +2179,7 @@ const updateActiveQuoteBackground = (background) => {
ensureBlockPanelEngaged() ensureBlockPanelEngaged()
const value = String(background || '').trim() const value = String(background || '').trim()
if (!CALLOUT_BACKGROUND_OPTIONS.includes(value)) { if (!QUOTE_BACKGROUND_OPTIONS.includes(value)) {
return return
} }
@@ -2839,6 +2882,8 @@ const handleKeydown = (event) => {
@delete-line="onPreviewDeleteLine" @delete-line="onPreviewDeleteLine"
@merge-with-previous-line="onPreviewMergeWithPreviousLine" @merge-with-previous-line="onPreviewMergeWithPreviousLine"
@edit-image="onPreviewEditImage" @edit-image="onPreviewEditImage"
@line-focus="handleLiveLineFocus"
@line-blur="handleLiveLineBlur"
@slash-update="onLiveSlashUpdate" @slash-update="onLiveSlashUpdate"
@slash-end="onLiveSlashEnd" @slash-end="onLiveSlashEnd"
@slash-apply="onLiveSlashApply" @slash-apply="onLiveSlashApply"

View File

@@ -1,9 +1,5 @@
<script setup> <script setup>
import { import { buildCalloutOpenerLine } from '../../lib/markdown-callout.js'
buildCalloutOpenerLine,
CALLOUT_BACKGROUND_OPTIONS,
CALLOUT_EMOJI_OPTIONS
} from '../../lib/markdown-callout.js'
import ProseCallout from './ProseCallout.vue' import ProseCallout from './ProseCallout.vue'
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue' import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
@@ -29,37 +25,16 @@ const props = defineProps({
bodySourceLine: { bodySourceLine: {
type: Number, type: Number,
required: true required: true
},
/** 콜아웃 선언 줄 source-line(0-based) */
blockSourceLine: {
type: Number,
required: true
} }
}) })
const emit = defineEmits(['commit']) const emit = defineEmits(['commit'])
const settingsOpen = ref(false)
const emojiDraft = ref(props.calloutEmoji)
const emojiEnabled = ref(props.calloutEmojiEnabled)
const background = ref(props.calloutBackground)
/** @type {import('vue').ComputedRef<Array<{ value: string, label: string }>>} */
const backgroundOptions = computed(() => CALLOUT_BACKGROUND_OPTIONS.map((value) => ({
value,
label: value
})))
/** @type {import('vue').ComputedRef<string[]>} */
const emojiOptions = computed(() => CALLOUT_EMOJI_OPTIONS)
watch(() => props.calloutEmoji, (value) => {
emojiDraft.value = value
})
watch(() => props.calloutEmojiEnabled, (value) => {
emojiEnabled.value = value
})
watch(() => props.calloutBackground, (value) => {
background.value = value
})
/** /**
* 콜아웃 마크다운 줄을 반영한다. * 콜아웃 마크다운 줄을 반영한다.
* @param {string} body - 본문 * @param {string} body - 본문
@@ -70,9 +45,9 @@ const commitCallout = (body) => {
emit('commit', [ emit('commit', [
buildCalloutOpenerLine({ buildCalloutOpenerLine({
calloutEmojiEnabled: emojiEnabled.value, calloutEmojiEnabled: props.calloutEmojiEnabled,
calloutEmoji: emojiDraft.value, calloutEmoji: props.calloutEmoji,
calloutBackground: background.value calloutBackground: props.calloutBackground
}), }),
...contentLines, ...contentLines,
':::' ':::'
@@ -87,71 +62,25 @@ const commitCallout = (body) => {
const onBodyCommit = (body) => { const onBodyCommit = (body) => {
commitCallout(body) commitCallout(body)
} }
/**
* 설정 모달을 연다.
* @returns {void}
*/
const openSettings = () => {
settingsOpen.value = true
}
/**
* 설정 모달을 닫는다.
* @returns {void}
*/
const closeSettings = () => {
settingsOpen.value = false
}
/**
* 이모지 표시를 토글한다.
* @returns {void}
*/
const toggleEmoji = () => {
emojiEnabled.value = !emojiEnabled.value
commitCallout(props.modelValue)
}
/**
* 배경색을 변경한다.
* @param {string} value - 배경 키
* @returns {void}
*/
const setBackground = (value) => {
background.value = value
commitCallout(props.modelValue)
}
/**
* 프리셋 이모지를 선택한다.
* @param {string} value - 이모지
* @returns {void}
*/
const setEmoji = (value) => {
emojiDraft.value = value
emojiEnabled.value = true
commitCallout(props.modelValue)
}
</script> </script>
<template> <template>
<div class="content-markdown-callout-editor relative"> <div
class="content-markdown-callout-editor relative"
:data-source-line="blockSourceLine"
>
<ProseCallout <ProseCallout
:emoji-enabled="false" :emoji-enabled="false"
:background="background" :background="calloutBackground"
> >
<div class="content-markdown-callout-editor__inner flex items-start gap-2"> <div class="content-markdown-callout-editor__inner flex items-start gap-2">
<button <span
class="content-markdown-callout-editor__emoji-btn inline-flex size-9 shrink-0 items-center justify-center rounded-md text-xl text-[var(--site-text)] transition-colors hover:bg-black/10" class="content-markdown-callout-editor__emoji inline-flex size-9 shrink-0 items-center justify-center rounded-md text-xl text-[var(--site-text)]"
type="button" aria-hidden="true"
aria-label="콜아웃 아이콘·색상 설정"
@mousedown.prevent
@click.stop="openSettings"
> >
<span v-if="emojiEnabled">{{ emojiDraft || '💡' }}</span> <span v-if="calloutEmojiEnabled">{{ calloutEmoji || '💡' }}</span>
<span v-else class="text-base text-[#8e9cac]">+</span> <span v-else class="text-base text-[#8e9cac]">+</span>
</button> </span>
<ContentMarkdownEditableInline <ContentMarkdownEditableInline
block-class="content-markdown-callout-editor__body min-w-0 flex-1 text-[15px] leading-8 text-[var(--site-text)]" block-class="content-markdown-callout-editor__body min-w-0 flex-1 text-[15px] leading-8 text-[var(--site-text)]"
enter-mode="multiline" enter-mode="multiline"
@@ -161,97 +90,5 @@ const setEmoji = (value) => {
/> />
</div> </div>
</ProseCallout> </ProseCallout>
<Teleport to="body">
<div
v-if="settingsOpen"
class="content-markdown-callout-editor__modal fixed inset-0 z-[80] grid place-items-center bg-black/35 px-4"
@click.self="closeSettings"
>
<section
class="content-markdown-callout-editor__panel w-full max-w-sm rounded-lg bg-white p-6 shadow-xl"
role="dialog"
aria-labelledby="callout-settings-title"
@click.stop
>
<header class="content-markdown-callout-editor__panel-header mb-4 flex items-center justify-between gap-3">
<h2 id="callout-settings-title" class="text-base font-semibold text-[#15171a]">
콜아웃 설정
</h2>
<button
class="content-markdown-callout-editor__close rounded px-2 py-1 text-sm text-[#6b7280] hover:bg-[#f1f3f4]"
type="button"
@click="closeSettings"
>
닫기
</button>
</header>
<div class="content-markdown-callout-editor__icon-toggle mb-4 flex items-center justify-between gap-3">
<span class="text-sm font-medium text-[#15171a]">아이콘</span>
<button
class="content-markdown-callout-editor__switch relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8e9cac]"
:class="emojiEnabled ? 'bg-[#15171a]' : 'bg-[#d0d7de]'"
type="button"
role="switch"
:aria-checked="emojiEnabled"
@click="toggleEmoji"
>
<span
class="content-markdown-callout-editor__switch-knob pointer-events-none inline-block size-5 rounded-full bg-white shadow transition-transform duration-200 ease-out"
:class="emojiEnabled ? 'translate-x-[22px]' : 'translate-x-0.5'"
/>
<span class="sr-only">{{ emojiEnabled ? '아이콘 표시' : '아이콘 숨김' }}</span>
</button>
</div>
<div
v-if="emojiEnabled"
class="content-markdown-callout-editor__emoji-presets mb-4"
>
<ul class="flex flex-wrap gap-2">
<li
v-for="emoji in emojiOptions"
:key="`callout-emoji-${emoji}`"
>
<button
class="content-markdown-callout-editor__emoji-swatch inline-flex size-9 items-center justify-center rounded-full border-2 text-xl transition-transform"
:class="emojiDraft === emoji ? 'border-[#22c55e] scale-110 bg-[#f6f7f8]' : 'border-transparent hover:bg-[#f1f3f4]'"
type="button"
:aria-label="`이모지 ${emoji}`"
@click="setEmoji(emoji)"
>
{{ emoji }}
</button>
</li>
</ul>
</div>
<div class="content-markdown-callout-editor__colors">
<p class="mb-2 text-sm font-medium text-[#15171a]">배경색</p>
<ul class="flex flex-wrap gap-2">
<li v-for="option in backgroundOptions" :key="`callout-bg-${option.value}`">
<button
class="content-markdown-callout-editor__color-swatch size-8 rounded-full border-2 transition-transform"
:class="background === option.value ? 'border-[#22c55e] scale-110' : 'border-transparent'"
type="button"
:aria-label="option.label"
:style="{
background: option.value === 'gray' ? 'rgba(100,116,139,0.28)'
: option.value === 'blue' ? 'rgba(59,130,246,0.3)'
: option.value === 'green' ? 'rgba(34,197,94,0.3)'
: option.value === 'yellow' ? 'rgba(245,158,11,0.34)'
: option.value === 'red' ? 'rgba(239,68,68,0.3)'
: option.value === 'purple' ? 'rgba(168,85,247,0.3)'
: 'rgba(236,72,153,0.3)'
}"
@click="setBackground(option.value)"
/>
</li>
</ul>
</div>
</section>
</div>
</Teleport>
</div> </div>
</template> </template>

View File

@@ -20,7 +20,7 @@ import {
} from '../../lib/markdown-live-edit.js' } from '../../lib/markdown-live-edit.js'
import { buildCodeBlockLines, parseCodeFenceLine } from '../../lib/markdown-code-block.js' import { buildCodeBlockLines, parseCodeFenceLine } from '../../lib/markdown-code-block.js'
import { buildToggleBlockLines, parseToggleOpenerLine } from '../../lib/markdown-toggle.js' import { buildToggleBlockLines, parseToggleOpenerLine } from '../../lib/markdown-toggle.js'
import { CALLOUT_BACKGROUND_OPTIONS, parseCalloutOptions } from '../../lib/markdown-callout.js' import { CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS, parseCalloutOptions } from '../../lib/markdown-callout.js'
import { createHeadingIdFactory } from '../../lib/markdown-toc.js' import { createHeadingIdFactory } from '../../lib/markdown-toc.js'
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue' import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
import ProseCodeBlock from './ProseCodeBlock.vue' import ProseCodeBlock from './ProseCodeBlock.vue'
@@ -61,6 +61,8 @@ const emit = defineEmits([
'delete-line', 'delete-line',
'merge-with-previous-line', 'merge-with-previous-line',
'edit-image', 'edit-image',
'line-focus',
'line-blur',
'slash-update', 'slash-update',
'slash-end', 'slash-end',
'slash-apply' 'slash-apply'
@@ -141,7 +143,7 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true, calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
calloutEmoji: options.calloutEmoji || '💡', calloutEmoji: options.calloutEmoji || '💡',
calloutBackground: options.calloutBackground || 'blue', calloutBackground: options.calloutBackground || 'blue',
quoteBackground: options.quoteBackground || 'pink', quoteBackground: options.quoteBackground || 'gray',
codeLanguage: options.codeLanguage || '', codeLanguage: options.codeLanguage || '',
codeShowLineNumbers: options.codeShowLineNumbers !== false codeShowLineNumbers: options.codeShowLineNumbers !== false
}) })
@@ -192,7 +194,7 @@ const parseQuoteOptions = (value) => {
const [key, rawOptionValue] = token.split('=') const [key, rawOptionValue] = token.split('=')
const optionValue = String(rawOptionValue || '').trim() const optionValue = String(rawOptionValue || '').trim()
if (key?.toLowerCase() === 'bg' && CALLOUT_BACKGROUND_OPTIONS.includes(optionValue)) { if (key?.toLowerCase() === 'bg' && QUOTE_BACKGROUND_OPTIONS.includes(optionValue)) {
quoteBackground = optionValue quoteBackground = optionValue
} }
}) })
@@ -1181,6 +1183,51 @@ const commitInlineBlockLines = (block, replacementLines) => {
}) })
} }
/**
* 라이브 편집 포커스/클릭 위치의 원본 줄을 상위에 알린다.
* @param {Event} event - 포커스 또는 포인터 이벤트
* @returns {void}
*/
const emitLiveLineFocus = (event) => {
if (!props.interactive) {
return
}
const target = event.target
if (!(target instanceof Element)) {
return
}
const sourceElement = target.closest('[data-source-line]')
const sourceLine = Number(sourceElement?.getAttribute('data-source-line'))
if (!Number.isInteger(sourceLine) || sourceLine < 0) {
return
}
emit('line-focus', sourceLine)
}
/**
* 라이브 편집 영역을 벗어난 포커스를 상위에 알린다.
* @param {FocusEvent} event - 포커스 이탈 이벤트
* @returns {void}
*/
const emitLiveLineBlur = (event) => {
if (!props.interactive || !rendererRootRef.value) {
return
}
const nextTarget = event.relatedTarget
if (nextTarget instanceof Node && rendererRootRef.value.contains(nextTarget)) {
return
}
emit('line-blur')
}
/** /**
* 문단 인라인 편집 반영 * 문단 인라인 편집 반영
* @param {Object} block - 블록 * @param {Object} block - 블록
@@ -2297,7 +2344,13 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<div ref="rendererRootRef" class="content-markdown-renderer"> <div
ref="rendererRootRef"
class="content-markdown-renderer"
@mousedown.capture="emitLiveLineFocus"
@focusin.capture="emitLiveLineFocus"
@focusout.capture="emitLiveLineBlur"
>
<template v-for="block in blocks" :key="block.id"> <template v-for="block in blocks" :key="block.id">
<div <div
v-if="interactive && getBlockInsertBeforeLine(block) !== null" v-if="interactive && getBlockInsertBeforeLine(block) !== null"
@@ -2350,6 +2403,7 @@ onBeforeUnmount(() => {
v-else-if="block.type === 'quote' && interactive && block.variant !== 'alt'" v-else-if="block.type === 'quote' && interactive && block.variant !== 'alt'"
:variant="block.variant || 'default'" :variant="block.variant || 'default'"
:background="block.quoteBackground" :background="block.quoteBackground"
:data-source-line="block.meta.startLine"
> >
<ContentMarkdownEditableInline <ContentMarkdownEditableInline
v-for="quoteLine in getQuoteLineEntries(block)" v-for="quoteLine in getQuoteLineEntries(block)"
@@ -2497,6 +2551,7 @@ onBeforeUnmount(() => {
:callout-emoji-enabled="block.calloutEmojiEnabled" :callout-emoji-enabled="block.calloutEmojiEnabled"
:callout-emoji="block.calloutEmoji" :callout-emoji="block.calloutEmoji"
:callout-background="block.calloutBackground" :callout-background="block.calloutBackground"
:block-source-line="block.meta.startLine"
:body-source-line="block.meta.startLine + 1" :body-source-line="block.meta.startLine + 1"
:model-value="block.text" :model-value="block.text"
@commit="onCalloutBlockCommit(block, $event)" @commit="onCalloutBlockCommit(block, $event)"

View File

@@ -6,7 +6,7 @@ const props = defineProps({
}, },
background: { background: {
type: String, type: String,
default: 'pink' default: 'gray'
} }
}) })
@@ -35,7 +35,7 @@ const backgroundClass = computed(() => {
return 'prose-blockquote--purple' return 'prose-blockquote--purple'
} }
return 'prose-blockquote--pink' return 'prose-blockquote--gray'
}) })
</script> </script>
@@ -54,37 +54,32 @@ const backgroundClass = computed(() => {
<style scoped> <style scoped>
.prose-blockquote--gray { .prose-blockquote--gray {
border-color: rgba(100, 116, 139, 0.72); border-color: #050505;
background: rgba(100, 116, 139, 0.12); background: color-mix(in srgb, #050505 10%, #ffffff);
} }
.prose-blockquote--blue { .prose-blockquote--blue {
border-color: rgba(59, 130, 246, 0.78); border-color: #0055ff;
background: rgba(59, 130, 246, 0.14); background: color-mix(in srgb, #0055ff 10%, #ffffff);
} }
.prose-blockquote--green { .prose-blockquote--green {
border-color: rgba(34, 197, 94, 0.78); border-color: #16ae68;
background: rgba(34, 197, 94, 0.14); background: color-mix(in srgb, #16ae68 10%, #ffffff);
} }
.prose-blockquote--yellow { .prose-blockquote--yellow {
border-color: rgba(245, 158, 11, 0.82); border-color: #ffff00;
background: rgba(245, 158, 11, 0.16); background: color-mix(in srgb, #ffff00 10%, #ffffff);
} }
.prose-blockquote--red { .prose-blockquote--red {
border-color: rgba(239, 68, 68, 0.78); border-color: #ff0000;
background: rgba(239, 68, 68, 0.14); background: color-mix(in srgb, #ff0000 10%, #ffffff);
} }
.prose-blockquote--purple { .prose-blockquote--purple {
border-color: rgba(168, 85, 247, 0.78); border-color: #8800ff;
background: rgba(168, 85, 247, 0.14); background: color-mix(in srgb, #8800ff 10%, #ffffff);
}
.prose-blockquote--pink {
border-color: #ff1a75;
background: color-mix(in srgb, #ff1a75 10%, #ffffff);
} }
</style> </style>

View File

@@ -1,5 +1,11 @@
# 업데이트 요약 # 업데이트 요약
## v1.5.45
- 인용 블록 기본 색상을 회색으로 바꾸고 분홍 선택지를 제거했다.
- 인용 색상 선택 배지를 실제 인용 블록 색상과 맞췄다.
- 라이브 작성 모드의 콜아웃·인용 설정을 소스 모드와 같은 오른쪽 패널 방식으로 통일했다.
## v1.5.44 ## v1.5.44
- 관리자 사이트 설정의 제목·설명, 사이트 정보, 사이트 코드 읽기 화면을 긴 문구가 잘리지 않는 14px 라벨/값 행 레이아웃으로 정리했다. - 관리자 사이트 설정의 제목·설명, 사이트 정보, 사이트 코드 읽기 화면을 긴 문구가 잘리지 않는 14px 라벨/값 행 레이아웃으로 정리했다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드 # 배포 가이드
> 로컬 기준 v1.5.44에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. > 로컬 기준 v1.5.45에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형 ## 빌드 유형
@@ -68,6 +68,12 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;' docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
``` ```
### v1.5.45 참고
- 추가 DB 마이그레이션은 없다.
- 관리자 글쓰기에서 라이브 모드 콜아웃·인용 블록 포커스 시 오른쪽 블록 설정 패널이 열리는지 확인한다.
- 인용 블록 기본 배경이 회색이고 분홍 옵션이 노출되지 않는지 확인한다.
### v1.5.44 참고 ### v1.5.44 참고
- 추가 DB 마이그레이션은 없다. - 추가 DB 마이그레이션은 없다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력 # 의사결정 이력
## 2026-06-04 v1.5.45 — 라이브 작성 블록 옵션도 오른쪽 패널로 통일한다
소스 모드에서는 커서가 옵션을 가진 블록 안에 들어가는 것만으로 오른쪽 설정 패널이 열리는데, 라이브 모드 콜아웃은 별도 모달을 열어 같은 기능을 다른 방식으로 제공하고 있었다. 작성자가 모드에 따라 다른 조작을 기억하지 않도록 콜아웃과 인용 블록 모두 라이브 영역에 포커스가 들어오면 오른쪽 패널을 쓰도록 통일한다. 인용 색상은 새 팔레트 기준으로 회색을 기본값으로 두고, 빨강과 역할이 겹치는 분홍은 선택지에서 제거한다.
## 2026-06-02 v1.5.43 — RSS는 공개 발행글 피드로만 제공한다 ## 2026-06-02 v1.5.43 — RSS는 공개 발행글 피드로만 제공한다
RSS 리더는 공개 화면을 구독하는 용도이므로 멤버십, 비공개, 초안, 아직 발행 시간이 오지 않은 예약 글은 피드에 포함하지 않는다. 호환성을 위해 대표 경로 `/rss.xml`과 흔히 쓰는 `/feed.xml`, `/rss` 별칭을 함께 제공하되, 관리자 SNS 프리셋은 표준적인 `/rss.xml`을 기본 주소로 둔다. RSS 리더는 공개 화면을 구독하는 용도이므로 멤버십, 비공개, 초안, 아직 발행 시간이 오지 않은 예약 글은 피드에 포함하지 않는다. 호환성을 위해 대표 경로 `/rss.xml`과 흔히 쓰는 `/feed.xml`, `/rss` 별칭을 함께 제공하되, 관리자 SNS 프리셋은 표준적인 `/rss.xml`을 기본 주소로 둔다.

View File

@@ -29,7 +29,7 @@
| lib/external-favicon-url.js | 외부 URL 호스트 기준 Google `s2/favicons` 썸네일 URL 생성(내부 경로는 빈 문자열) | | lib/external-favicon-url.js | 외부 URL 호스트 기준 Google `s2/favicons` 썸네일 URL 생성(내부 경로는 빈 문자열) |
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` | | lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 | | 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/brand-color.js | 사이트 브랜드 컬러 기본값·hex 검증·정규화 |
| lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 | | lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 |
| lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 | | lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 |
@@ -111,11 +111,11 @@
| 파일 | 화면 위치 | | 파일 | 화면 위치 |
|------|-----------| |------|-----------|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 | | components/content/ContentRenderer.vue | 게시물/페이지 본문 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱, 인용 배경 옵션(`> [!bg=...]`), 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 | | components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱, 인용 배경 옵션(`> [!bg=...]`), 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 | | components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
| components/content/ProseImage.vue | 본문 내 이미지, 로드 실패·빈 URL placeholder | | components/content/ProseImage.vue | 본문 내 이미지, 로드 실패·빈 URL placeholder |
| components/content/ProseList.vue | 목록 | | components/content/ProseList.vue | 목록 |
| components/content/ProseBlockquote.vue | 인용구, 콜아웃과 같은 배경 프리셋, 다크모드 기본 인용 텍스트 가독성 보정 | | components/content/ProseBlockquote.vue | 인용구, 회색 기본값과 인용 전용 배경 프리셋, 다크모드 기본 인용 텍스트 가독성 보정 |
| components/content/ProseCodeBlock.vue | 코드 블록 공통 셸(다크 배경, 줄번호 gutter, 공개 복사 버튼) | | components/content/ProseCodeBlock.vue | 코드 블록 공통 셸(다크 배경, 줄번호 gutter, 공개 복사 버튼) |
| components/content/ContentMarkdownCodeBlockEditor.vue | 라이브 모드 코드 블록 인라인 편집(Language·줄번호 토글) | | components/content/ContentMarkdownCodeBlockEditor.vue | 라이브 모드 코드 블록 인라인 편집(Language·줄번호 토글) |
| components/content/ProseButton.vue | 버튼 | | components/content/ProseButton.vue | 버튼 |

View File

@@ -203,6 +203,8 @@ components/content/
- 기본: `> 한 줄` 또는 `>` 연속 여러 줄(멀티라인) - 기본: `> 한 줄` 또는 `>` 연속 여러 줄(멀티라인)
- 대체 스타일(Alternative): `>>>`로 시작해 `<<<`로 끝나는 블록 - 대체 스타일(Alternative): `>>>`로 시작해 `<<<`로 끝나는 블록
- 렌더링: `ProseBlockquote.vue` (`variant=default|alt`, 기본 인용은 다크모드에서도 밝은 배경 위 어두운 텍스트 유지) - 렌더링: `ProseBlockquote.vue` (`variant=default|alt`, 기본 인용은 다크모드에서도 밝은 배경 위 어두운 텍스트 유지)
- 기본 인용 배경은 회색이며, `> [!bg=gray|blue|green|yellow|red|purple]` 옵션 줄로 색상을 지정한다. 분홍 옵션은 사용하지 않는다.
- 관리자 라이브 작성 모드에서도 인용 블록에 포커스가 들어오면 오른쪽 블록 설정 패널에서 배경색을 수정한다.
- 이미지 - 이미지
- 기본: `![](url)` — 이미지 아래 캡션 없음 - 기본: `![](url)` — 이미지 아래 캡션 없음
- 캡션(표시용): `![](url "캡션")` — 따옴표 안 문자열만 `ProseImage` figcaption으로 표시 - 캡션(표시용): `![](url "캡션")` — 따옴표 안 문자열만 `ProseImage` figcaption으로 표시

View File

@@ -1,5 +1,13 @@
# 업데이트 이력 # 업데이트 이력
## v1.5.45
- 게시물 글쓰기: 인용 블록 기본 배경색을 회색으로 변경.
- 게시물 글쓰기: 인용 블록 배경색 선택에서 분홍 옵션 제거.
- 게시물 글쓰기: 인용 블록 오른쪽 설정 패널의 색상 배지를 실제 인용 블록 색상과 동일하게 수정.
- 게시물 글쓰기: 라이브 모드에서 콜아웃·인용 블록에 포커스가 들어오면 오른쪽 블록 설정 패널이 열리도록 수정.
- 게시물 글쓰기: 라이브 모드 콜아웃 내부 설정 모달을 제거하고 소스 모드와 같은 오른쪽 패널 방식으로 통일.
## v1.5.44 ## v1.5.44
- 관리자 사이트 설정: 블로그 제목·설명 읽기 화면을 라벨/값 세로 행 레이아웃으로 정리. - 관리자 사이트 설정: 블로그 제목·설명 읽기 화면을 라벨/값 세로 행 레이아웃으로 정리.

View File

@@ -1,5 +1,5 @@
import { isImageUrl, parseImageMarkdownLine } from './markdown-image.js' import { isImageUrl, parseImageMarkdownLine } from './markdown-image.js'
import { CALLOUT_BACKGROUND_OPTIONS, parseCalloutOptions } from './markdown-callout.js' import { CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS, parseCalloutOptions } from './markdown-callout.js'
import { parseCodeFenceLine } from './markdown-code-block.js' import { parseCodeFenceLine } from './markdown-code-block.js'
import { parseToggleOpenerLine } from './markdown-toggle.js' import { parseToggleOpenerLine } from './markdown-toggle.js'
@@ -114,7 +114,7 @@ const parseQuoteOptionsLine = (value) => {
const [key, rawOptionValue] = token.split('=') const [key, rawOptionValue] = token.split('=')
const optionValue = String(rawOptionValue || '').trim() const optionValue = String(rawOptionValue || '').trim()
if (key?.toLowerCase() === 'bg' && CALLOUT_BACKGROUND_OPTIONS.includes(optionValue)) { if (key?.toLowerCase() === 'bg' && QUOTE_BACKGROUND_OPTIONS.includes(optionValue)) {
quoteBackground = optionValue quoteBackground = optionValue
} }
}) })
@@ -151,7 +151,7 @@ const resolveQuoteBlock = (lines, currentLine) => {
kind: 'quote', kind: 'quote',
startLine, startLine,
endLine, endLine,
quoteBackground: quoteOptions?.quoteBackground || 'pink', quoteBackground: quoteOptions?.quoteBackground || 'gray',
hasQuoteOptions: Boolean(quoteOptions) hasQuoteOptions: Boolean(quoteOptions)
} }
} }

View File

@@ -1,6 +1,29 @@
/** @type {string[]} */ /** @type {string[]} */
export const CALLOUT_BACKGROUND_OPTIONS = ['gray', 'blue', 'green', 'yellow', 'red', 'purple', 'pink'] export const CALLOUT_BACKGROUND_OPTIONS = ['gray', 'blue', 'green', 'yellow', 'red', 'purple', 'pink']
/** @type {string[]} */
export const QUOTE_BACKGROUND_OPTIONS = ['gray', 'blue', 'green', 'yellow', 'red', 'purple']
/** @type {Record<string, string>} */
export const QUOTE_BACKGROUND_LABELS = {
gray: '회색',
blue: '파랑',
green: '초록',
yellow: '노랑',
red: '빨강',
purple: '보라'
}
/** @type {Record<string, string>} */
export const QUOTE_BACKGROUND_SWATCHES = {
gray: 'color-mix(in srgb, #050505 10%, #ffffff)',
blue: 'color-mix(in srgb, #0055ff 10%, #ffffff)',
green: 'color-mix(in srgb, #16ae68 10%, #ffffff)',
yellow: 'color-mix(in srgb, #ffff00 10%, #ffffff)',
red: 'color-mix(in srgb, #ff0000 10%, #ffffff)',
purple: 'color-mix(in srgb, #8800ff 10%, #ffffff)'
}
/** @type {string[]} */ /** @type {string[]} */
export const CALLOUT_EMOJI_OPTIONS = ['💡', '⚠️', '❗', '✅', '📌', '🔥', '💬'] export const CALLOUT_EMOJI_OPTIONS = ['💡', '⚠️', '❗', '✅', '📌', '🔥', '💬']

4
package-lock.json generated
View File

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

View File

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