v1.4.7: 라이브 인라인 서식·인용 배경·소스→라이브 스크롤 보정

- 라이브 모드 blur 시 인라인 마크다운(**·*)이 사라지던 문제 수정
- 인용 블록에 > [!bg=색상] 옵션으로 콜아웃과 동일한 배경 프리셋 지정
- 소스 모드에서 라이브 전환 시 현재 커서 줄을 화면 중앙에 가깝게 스크롤
This commit is contained in:
2026-05-26 10:07:01 +09:00
parent dcd1060ec7
commit 6536465b12
9 changed files with 222 additions and 38 deletions

View File

@@ -20,7 +20,7 @@ import {
} from '../../lib/markdown-live-edit.js'
import { buildCodeBlockLines, parseCodeFenceLine } from '../../lib/markdown-code-block.js'
import { buildToggleBlockLines } from '../../lib/markdown-toggle.js'
import { parseCalloutOptions } from '../../lib/markdown-callout.js'
import { CALLOUT_BACKGROUND_OPTIONS, parseCalloutOptions } from '../../lib/markdown-callout.js'
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
import ProseCodeBlock from './ProseCodeBlock.vue'
import ContentMarkdownCalloutEditor from './ContentMarkdownCalloutEditor.vue'
@@ -140,6 +140,7 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
calloutEmoji: options.calloutEmoji || '💡',
calloutBackground: options.calloutBackground || 'blue',
quoteBackground: options.quoteBackground || 'pink',
codeLanguage: options.codeLanguage || '',
codeShowLineNumbers: options.codeShowLineNumbers !== false
})
@@ -161,6 +162,43 @@ const isQuoteMarkerLine = (line) => {
return trimmed === '>' || /^>\s/.test(trimmed)
}
/**
* 인용 마커를 제거한 본문을 반환한다.
* @param {string} line - 마크다운 행
* @returns {string} 인용 본문
*/
const getQuoteLineBody = (line) => String(line ?? '').trim().replace(/^>\s?/, '')
/**
* 인용 옵션 줄을 파싱한다.
* @param {string} value - 인용 본문 줄
* @returns {{ quoteBackground: string }|null} 인용 옵션
*/
const parseQuoteOptions = (value) => {
const raw = String(value ?? '').trim()
const bracketMatch = raw.match(/^\[!(.+)\]$/)
const braceMatch = raw.match(/^\{(.+)\}$/)
const optionSource = bracketMatch?.[1] || braceMatch?.[1] || ''
if (!optionSource) {
return null
}
const tokens = optionSource.trim().split(/\s+/)
let quoteBackground = ''
tokens.forEach((token) => {
const [key, rawOptionValue] = token.split('=')
const optionValue = String(rawOptionValue || '').trim()
if (key?.toLowerCase() === 'bg' && CALLOUT_BACKGROUND_OPTIONS.includes(optionValue)) {
quoteBackground = optionValue
}
})
return quoteBackground ? { quoteBackground } : null
}
/**
* 불릿 목록 마커 줄인지 확인한다.
* @param {string} line - 마크다운 행
@@ -624,15 +662,24 @@ const parseMarkdownBlocks = (markdown) => {
if (isQuoteMarkerLine(line)) {
const startLine = index
const quoteLines = []
const rawQuoteLines = []
while (index < lines.length && isQuoteMarkerLine(lines[index])) {
quoteLines.push(lines[index].trim().replace(/^>\s?/, ''))
rawQuoteLines.push(getQuoteLineBody(lines[index]))
index += 1
}
const quoteOptions = parseQuoteOptions(rawQuoteLines[0])
const quoteLines = quoteOptions ? rawQuoteLines.slice(1) : rawQuoteLines
const contentStartLine = startLine + (quoteOptions ? 1 : 0)
blocks.push(attachSourceRange(
createBlock('quote', quoteLines.join('\n'), null, `block-${blocks.length}`),
createBlock('quote', (quoteLines.length ? quoteLines : ['']).join('\n'), null, `block-${blocks.length}`, {
...(quoteOptions || {}),
meta: {
quoteContentStartLine: contentStartLine
}
}),
startLine,
index - 1
))
@@ -785,9 +832,10 @@ watch(() => props.content, () => {
* @param {number} [attempt=0] - DOM 탐색 재시도 횟수
* @param {'start'|'end'|'auto'} [cursorPosition='auto'] - 커서 위치
* @param {number|null} [caretOffset=null] - 텍스트 오프셋
* @param {'nearest'|'center'} [scrollBlock='nearest'] - 스크롤 정렬 위치
* @returns {void}
*/
const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', caretOffset = null) => {
const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', caretOffset = null, scrollBlock = 'nearest') => {
if (!import.meta.client) {
return
}
@@ -803,7 +851,7 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
if (!element) {
if (attempt < 8) {
requestAnimationFrame(() => {
focusEditableAtLine(lineIndex, attempt + 1, cursorPosition, caretOffset)
focusEditableAtLine(lineIndex, attempt + 1, cursorPosition, caretOffset, scrollBlock)
})
}
@@ -823,28 +871,31 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
element.focus({ preventScroll: true })
if (element.getAttribute('contenteditable') !== 'true') {
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
return
}
if (typeof caretOffset === 'number' && caretOffset >= 0) {
setEditableCaretOffset(/** @type {HTMLElement} */ (element), caretOffset)
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
return
}
if (cursorPosition === 'start' || (cursorPosition === 'auto' && (isBlankMarker || !line.trim()))) {
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
return
}
if (cursorPosition === 'end') {
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
setEditableCaretOffset(/** @type {HTMLElement} */ (element), text.length)
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
return
}
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
}
defineExpose({
@@ -1053,23 +1104,38 @@ const createEmptyListMarkerLine = (block, itemIndex) => {
}
/**
* 인용 블록을 줄 단위로 분리한다.
* 인용 블록의 본문 시작 줄을 반환한다.
* @param {Object} block - 인용 블록
* @returns {string[]} 줄 목록
* @returns {number} 본문 시작 줄
*/
const getQuoteLines = (block) => {
const lineCount = (block.meta.endLine ?? block.meta.startLine) - block.meta.startLine + 1
const getQuoteContentStartLine = (block) => {
if (typeof block.meta?.quoteContentStartLine === 'number') {
return block.meta.quoteContentStartLine
}
return block.meta.startLine
}
/**
* 인용 블록을 편집 가능한 줄 단위로 분리한다.
* @param {Object} block - 인용 블록
* @returns {Array<{ text: string, sourceLine: number, sourceIndex: number }>} 줄 목록
*/
const getQuoteLineEntries = (block) => {
const contentStartLine = getQuoteContentStartLine(block)
const endLine = block.meta.endLine ?? block.meta.startLine
const lineCount = Math.max(1, endLine - contentStartLine + 1)
const fromText = String(block.text ?? '').split('\n')
while (fromText.length < lineCount) {
fromText.push('')
}
if (!fromText.length) {
return ['']
}
return fromText.slice(0, lineCount)
return fromText.slice(0, lineCount).map((text, index) => ({
text,
sourceLine: contentStartLine + index,
sourceIndex: contentStartLine + index - block.meta.startLine
}))
}
/**
@@ -2269,24 +2335,29 @@ onBeforeUnmount(() => {
<ProseBlockquote
v-else-if="block.type === 'quote' && interactive && block.variant !== 'alt'"
:variant="block.variant || 'default'"
:background="block.quoteBackground"
>
<ContentMarkdownEditableInline
v-for="(quoteLine, quoteLineIndex) in getQuoteLines(block)"
:key="`quote-line-${block.meta.startLine + quoteLineIndex}`"
v-for="quoteLine in getQuoteLineEntries(block)"
:key="`quote-line-${quoteLine.sourceLine}`"
block-class="content-markdown-renderer__quote-line"
:model-value="quoteLine"
:model-value="quoteLine.text"
enter-mode="insert-below"
allow-raw-toggle
:raw-line="getMarkdownLine(block.meta.startLine + quoteLineIndex)"
:source-line="block.meta.startLine + quoteLineIndex"
@commit="onQuoteLineInlineCommit(block, quoteLineIndex, $event)"
@insert-below="onQuoteLineInsertBelow(block, quoteLineIndex, $event)"
:raw-line="getMarkdownLine(quoteLine.sourceLine)"
:source-line="quoteLine.sourceLine"
@commit="onQuoteLineInlineCommit(block, quoteLine.sourceIndex, $event)"
@insert-below="onQuoteLineInsertBelow(block, quoteLine.sourceIndex, $event)"
@delete-line="onDeleteLine"
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine + quoteLineIndex, $event)"
@merge-with-previous="onMergeWithPreviousLine(quoteLine.sourceLine, $event)"
@raw-mode="onInlineRawMode"
/>
</ProseBlockquote>
<ProseBlockquote v-else-if="block.type === 'quote'" :variant="block.variant || 'default'">
<ProseBlockquote
v-else-if="block.type === 'quote'"
:variant="block.variant || 'default'"
:background="block.quoteBackground"
>
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-quote-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>

View File

@@ -1,10 +1,42 @@
<script setup>
defineProps({
const props = defineProps({
variant: {
type: String,
default: 'default'
},
background: {
type: String,
default: 'pink'
}
})
const backgroundClass = computed(() => {
if (props.background === 'gray') {
return 'prose-blockquote--gray'
}
if (props.background === 'blue') {
return 'prose-blockquote--blue'
}
if (props.background === 'green') {
return 'prose-blockquote--green'
}
if (props.background === 'yellow') {
return 'prose-blockquote--yellow'
}
if (props.background === 'red') {
return 'prose-blockquote--red'
}
if (props.background === 'purple') {
return 'prose-blockquote--purple'
}
return 'prose-blockquote--pink'
})
</script>
<template>
@@ -12,10 +44,47 @@ defineProps({
class="prose-blockquote mb-2.5 text-[15px] leading-8"
:class="variant === 'alt'
? 'rounded-[14px] border border-[var(--site-line)] bg-[var(--site-panel)] px-6 py-5 italic text-[var(--site-text)]'
: 'rounded-[10px] border-l-2 border-[#FF1A75] bg-[color-mix(in_srgb,#FF1A75_10%,#ffffff)] px-5 py-4 font-medium text-[#15171a]'"
: ['rounded-[10px] border-l-2 px-5 py-4 font-medium text-[#15171a]', backgroundClass]"
>
<span class="whitespace-pre-line">
<slot />
</span>
</blockquote>
</template>
<style scoped>
.prose-blockquote--gray {
border-color: rgba(100, 116, 139, 0.72);
background: rgba(100, 116, 139, 0.12);
}
.prose-blockquote--blue {
border-color: rgba(59, 130, 246, 0.78);
background: rgba(59, 130, 246, 0.14);
}
.prose-blockquote--green {
border-color: rgba(34, 197, 94, 0.78);
background: rgba(34, 197, 94, 0.14);
}
.prose-blockquote--yellow {
border-color: rgba(245, 158, 11, 0.82);
background: rgba(245, 158, 11, 0.16);
}
.prose-blockquote--red {
border-color: rgba(239, 68, 68, 0.78);
background: rgba(239, 68, 68, 0.14);
}
.prose-blockquote--purple {
border-color: rgba(168, 85, 247, 0.78);
background: rgba(168, 85, 247, 0.14);
}
.prose-blockquote--pink {
border-color: #ff1a75;
background: color-mix(in srgb, #ff1a75 10%, #ffffff);
}
</style>