v1.4.7: 라이브 인라인 서식·인용 배경·소스→라이브 스크롤 보정
- 라이브 모드 blur 시 인라인 마크다운(**·*)이 사라지던 문제 수정 - 인용 블록에 > [!bg=색상] 옵션으로 콜아웃과 동일한 배경 프리셋 지정 - 소스 모드에서 라이브 전환 시 현재 커서 줄을 화면 중앙에 가깝게 스크롤
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user