인라인 텍스트 색상 패널 추가
This commit is contained in:
@@ -34,7 +34,8 @@ const emit = defineEmits([
|
||||
'update-quote-background',
|
||||
'update-callout-options',
|
||||
'update-code-options',
|
||||
'update-toggle-options'
|
||||
'update-toggle-options',
|
||||
'update-inline-color'
|
||||
])
|
||||
|
||||
const backgroundLabels = {
|
||||
@@ -50,6 +51,15 @@ const backgroundSwatches = {
|
||||
...CALLOUT_BACKGROUND_SWATCHES
|
||||
}
|
||||
|
||||
const inlineColorSwatches = {
|
||||
gray: '#6b7280',
|
||||
blue: '#0055ff',
|
||||
green: '#16ae68',
|
||||
yellow: '#a16207',
|
||||
red: '#e60000',
|
||||
purple: '#8800ff'
|
||||
}
|
||||
|
||||
const languageOptions = ['', 'javascript', 'html', 'css', 'vue', 'json', 'bash', 'markdown', 'sql']
|
||||
|
||||
/**
|
||||
@@ -80,6 +90,13 @@ const getQuoteBackgroundLabel = (background) => QUOTE_BACKGROUND_LABELS[backgrou
|
||||
*/
|
||||
const getQuoteBackgroundSwatch = (background) => QUOTE_BACKGROUND_SWATCHES[background] || QUOTE_BACKGROUND_SWATCHES.gray
|
||||
|
||||
/**
|
||||
* 인라인 색상 스와치를 반환한다.
|
||||
* @param {string} color - 색상 키
|
||||
* @returns {string} CSS 색상
|
||||
*/
|
||||
const getInlineColorSwatch = (color) => inlineColorSwatches[color] || inlineColorSwatches.gray
|
||||
|
||||
/**
|
||||
* 블록 종류 라벨
|
||||
* @returns {string}
|
||||
@@ -93,6 +110,10 @@ const panelTitle = computed(() => {
|
||||
return '갤러리'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'inline-color') {
|
||||
return '텍스트 색상'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'embed') {
|
||||
return '임베드'
|
||||
}
|
||||
@@ -129,6 +150,10 @@ const panelMeta = computed(() => {
|
||||
return `${props.panel.images.length}개 이미지`
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'inline-color') {
|
||||
return '선택한 텍스트'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'embed') {
|
||||
return 'YouTube·X 등 URL'
|
||||
}
|
||||
@@ -198,7 +223,36 @@ const onPanelFocusOut = (event) => {
|
||||
</header>
|
||||
|
||||
<div class="admin-editor-block-panel__body flex-1 overflow-y-auto px-6 py-6">
|
||||
<template v-if="panel.kind === 'embed'">
|
||||
<template v-if="panel.kind === 'inline-color'">
|
||||
<div class="admin-editor-block-panel__inline-color-settings grid gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-[#394047]">
|
||||
글자색
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-[#8e9cac]">
|
||||
선택한 텍스트 범위에 인라인 색상을 적용합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
v-for="color in QUOTE_BACKGROUND_OPTIONS"
|
||||
:key="`inline-color-${color}`"
|
||||
class="flex items-center gap-2 rounded border border-[#dce0e5] bg-[#fafafa] px-3 py-2 text-left text-xs font-semibold text-[#657080] transition hover:bg-white"
|
||||
type="button"
|
||||
@click="emit('update-inline-color', color)"
|
||||
>
|
||||
<span
|
||||
class="size-5 shrink-0 rounded-full border border-black/5"
|
||||
:style="{ background: getInlineColorSwatch(color) }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ getQuoteBackgroundLabel(color) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="panel.kind === 'embed'">
|
||||
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
|
||||
<span class="font-semibold text-[#394047]">임베드 URL</span>
|
||||
<input
|
||||
|
||||
@@ -7,7 +7,15 @@ import {
|
||||
parseImageMarkdownLine,
|
||||
serializeImageMarkdown
|
||||
} from '../../lib/markdown-image.js'
|
||||
import { convertHtmlToMarkdown } from '../../lib/markdown-inline.js'
|
||||
import {
|
||||
convertHtmlToMarkdown,
|
||||
formatInlineColorMarkdown,
|
||||
readEditableTextFromElement
|
||||
} from '../../lib/markdown-inline.js'
|
||||
import {
|
||||
getMarkdownLineBodyPrefixLength,
|
||||
getSelectionOffsetInElement
|
||||
} from '../../lib/markdown-live-selection.js'
|
||||
import {
|
||||
filterSlashCommands,
|
||||
getSlashCommandFocusTarget,
|
||||
@@ -152,6 +160,8 @@ const isBlockPanelEngaged = ref(false)
|
||||
const isTextComposing = ref(false)
|
||||
/** 조합 입력 중 패널 닫힘을 방지하기 위한 마지막 블록 컨텍스트 */
|
||||
const lastStableBlockContext = ref(null)
|
||||
/** @type {import('vue').Ref<{ kind: 'inline-color', mode: 'source'|'live', start?: number, end?: number, sourceLine?: number, startOffset?: number, endOffset?: number, bodyText?: string, text: string }|null>} 선택 텍스트 색상 패널 컨텍스트 */
|
||||
const inlineColorSelectionContext = ref(null)
|
||||
let blockPanelFocusTimer = null
|
||||
|
||||
/**
|
||||
@@ -190,9 +200,156 @@ const closeBlockPanel = () => {
|
||||
window.clearTimeout(blockPanelFocusTimer)
|
||||
isBlockPanelEngaged.value = false
|
||||
isTextareaFocused.value = false
|
||||
inlineColorSelectionContext.value = null
|
||||
syncBlockPanelState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 오프셋이 속한 마크다운 줄 번호를 반환한다.
|
||||
* @param {string} value - 마크다운 본문
|
||||
* @param {number} offset - 문자 오프셋
|
||||
* @returns {number} 줄 번호
|
||||
*/
|
||||
const getLineIndexFromOffset = (value, offset) => {
|
||||
const safeOffset = Math.max(0, Math.min(String(value || '').length, offset))
|
||||
return String(value || '').slice(0, safeOffset).split('\n').length - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 줄이 인라인 색상 적용 대상인지 확인한다.
|
||||
* @param {number} lineIndex - 줄 번호
|
||||
* @returns {boolean} 적용 가능 여부
|
||||
*/
|
||||
const canApplyInlineColorAtLine = (lineIndex) => {
|
||||
const context = resolveActiveBlockContext(markdownValue.value, lineIndex)
|
||||
return context?.kind !== 'code'
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스 모드 textarea 선택 범위를 색상 패널 컨텍스트로 변환한다.
|
||||
* @returns {{ kind: 'inline-color', mode: 'source', start: number, end: number, text: string }|null} 선택 컨텍스트
|
||||
*/
|
||||
const getSourceInlineColorSelectionContext = () => {
|
||||
const textarea = textareaRef.value
|
||||
|
||||
if (activeMode.value !== 'write' || !textarea || document.activeElement !== textarea) {
|
||||
return null
|
||||
}
|
||||
|
||||
const start = Math.min(textarea.selectionStart, textarea.selectionEnd)
|
||||
const end = Math.max(textarea.selectionStart, textarea.selectionEnd)
|
||||
|
||||
if (start === end) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = markdownValue.value || ''
|
||||
const selectedText = value.slice(start, end)
|
||||
|
||||
if (!selectedText.trim() || selectedText.includes('\n')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startLine = getLineIndexFromOffset(value, start)
|
||||
const endLine = getLineIndexFromOffset(value, end)
|
||||
|
||||
if (startLine !== endLine || !canApplyInlineColorAtLine(startLine)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'inline-color',
|
||||
mode: 'source',
|
||||
start,
|
||||
end,
|
||||
text: selectedText
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DOM 노드가 속한 라이브 편집 요소를 반환한다.
|
||||
* @param {Node|null} node - 선택 노드
|
||||
* @returns {HTMLElement|null} 편집 요소
|
||||
*/
|
||||
const getEditableElementFromSelectionNode = (node) => {
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
|
||||
const element = node instanceof Element ? node : node.parentElement
|
||||
const editable = element?.closest?.('[contenteditable="true"][data-source-line]')
|
||||
return editable instanceof HTMLElement ? editable : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 모드 선택 범위를 색상 패널 컨텍스트로 변환한다.
|
||||
* @returns {{ kind: 'inline-color', mode: 'live', sourceLine: number, startOffset: number, endOffset: number, bodyText: string, text: string }|null} 선택 컨텍스트
|
||||
*/
|
||||
const getLiveInlineColorSelectionContext = () => {
|
||||
if (activeMode.value !== 'preview' || !import.meta.client) {
|
||||
return null
|
||||
}
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const startEditable = getEditableElementFromSelectionNode(range.startContainer)
|
||||
const endEditable = getEditableElementFromSelectionNode(range.endContainer)
|
||||
|
||||
if (!startEditable || startEditable !== endEditable) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sourceLine = Number(startEditable.getAttribute('data-source-line'))
|
||||
const sourceLineEnd = Number(startEditable.getAttribute('data-source-line-end') || sourceLine)
|
||||
|
||||
if (!Number.isInteger(sourceLine) || sourceLine !== sourceLineEnd || !canApplyInlineColorAtLine(sourceLine)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bodyText = readEditableTextFromElement(startEditable, { trimEnd: false })
|
||||
const startOffset = getSelectionOffsetInElement(startEditable, range, 'start')
|
||||
const endOffset = getSelectionOffsetInElement(startEditable, range, 'end')
|
||||
const safeStart = Math.max(0, Math.min(startOffset, endOffset))
|
||||
const safeEnd = Math.min(bodyText.length, Math.max(startOffset, endOffset))
|
||||
const selectedText = bodyText.slice(safeStart, safeEnd)
|
||||
|
||||
if (!selectedText.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'inline-color',
|
||||
mode: 'live',
|
||||
sourceLine,
|
||||
startOffset: safeStart,
|
||||
endOffset: safeEnd,
|
||||
bodyText,
|
||||
text: selectedText
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 선택 범위를 기준으로 텍스트 색상 패널 컨텍스트를 갱신한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncInlineColorSelectionContext = () => {
|
||||
if (isFocusInBlockPanel() || isMediaPickerOpen.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextContext = getSourceInlineColorSelectionContext() || getLiveInlineColorSelectionContext()
|
||||
inlineColorSelectionContext.value = nextContext
|
||||
|
||||
if (nextContext) {
|
||||
isBlockPanelEngaged.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 커서 줄 기준 활성 블록(이미지·갤러리·임베드·인용)
|
||||
* @returns {Object|null}
|
||||
@@ -210,6 +367,10 @@ const activeMediaBlock = activeBlockContext
|
||||
* @returns {Object|null} 패널 표시용 블록 컨텍스트
|
||||
*/
|
||||
const visibleBlockContext = computed(() => {
|
||||
if (inlineColorSelectionContext.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (activeBlockContext.value) {
|
||||
return activeBlockContext.value
|
||||
}
|
||||
@@ -217,12 +378,18 @@ const visibleBlockContext = computed(() => {
|
||||
return isTextComposing.value ? lastStableBlockContext.value : null
|
||||
})
|
||||
|
||||
/**
|
||||
* 오른쪽 설정 패널에 표시할 현재 컨텍스트를 반환한다.
|
||||
* @returns {Object|null} 패널 컨텍스트
|
||||
*/
|
||||
const activePanelContext = computed(() => inlineColorSelectionContext.value || visibleBlockContext.value)
|
||||
|
||||
/**
|
||||
* 블록 설정 패널 표시 여부
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isBlockPanelVisible = computed(() => (activeMode.value === 'write' || isBlockPanelEngaged.value)
|
||||
&& Boolean(visibleBlockContext.value)
|
||||
&& Boolean(activePanelContext.value)
|
||||
&& (isTextareaFocused.value || isBlockPanelEngaged.value))
|
||||
|
||||
/**
|
||||
@@ -232,7 +399,7 @@ const isBlockPanelVisible = computed(() => (activeMode.value === 'write' || isBl
|
||||
const syncBlockPanelState = () => {
|
||||
emit('block-panel', {
|
||||
open: isBlockPanelVisible.value,
|
||||
panel: visibleBlockContext.value
|
||||
panel: activePanelContext.value
|
||||
})
|
||||
}
|
||||
|
||||
@@ -285,7 +452,7 @@ watch(activeBlockContext, (context) => {
|
||||
}
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
watch([isBlockPanelVisible, visibleBlockContext], syncBlockPanelState, { deep: true })
|
||||
watch([isBlockPanelVisible, activePanelContext], syncBlockPanelState, { deep: true })
|
||||
|
||||
/**
|
||||
* 본문의 논리 줄(`\\n` 기준) 개수. 빈 본문은 1줄로 본다.
|
||||
@@ -1098,6 +1265,9 @@ onMounted(() => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const onSelectionChange = () => {
|
||||
syncInlineColorSelectionContext()
|
||||
syncBlockPanelState()
|
||||
|
||||
if (activeMode.value !== 'write') {
|
||||
return
|
||||
}
|
||||
@@ -1298,9 +1468,24 @@ const getSelectionState = () => {
|
||||
* textarea 커서 위치를 갱신한다.
|
||||
* @param {number} start - 시작 위치
|
||||
* @param {number} end - 끝 위치
|
||||
* @param {{ preserveScroll?: boolean }} options - 선택 옵션
|
||||
* @returns {void}
|
||||
*/
|
||||
const setTextareaSelection = (start, end = start) => {
|
||||
const setTextareaSelection = (start, end = start, options = {}) => {
|
||||
const previousScrollTop = options.preserveScroll ? textareaRef.value?.scrollTop : null
|
||||
/**
|
||||
* textarea 스크롤 위치를 복원한다.
|
||||
* @param {HTMLTextAreaElement} textarea - 대상 textarea
|
||||
* @returns {void}
|
||||
*/
|
||||
const restoreScroll = (textarea) => {
|
||||
if (!Number.isFinite(previousScrollTop)) {
|
||||
return
|
||||
}
|
||||
|
||||
textarea.scrollTop = previousScrollTop
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
const textarea = textareaRef.value
|
||||
if (!textarea) {
|
||||
@@ -1309,6 +1494,12 @@ const setTextareaSelection = (start, end = start) => {
|
||||
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(start, end)
|
||||
restoreScroll(textarea)
|
||||
|
||||
if (import.meta.client) {
|
||||
window.requestAnimationFrame(() => restoreScroll(textarea))
|
||||
}
|
||||
|
||||
refreshCaretLogicalLine()
|
||||
})
|
||||
}
|
||||
@@ -2669,6 +2860,67 @@ const updateActiveToggleOptions = (patch = {}) => {
|
||||
markdownValue.value = nextLines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 선택된 텍스트에 인라인 색상을 적용한다.
|
||||
* @param {string} color - 색상 이름
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateActiveInlineColor = (color) => {
|
||||
const context = inlineColorSelectionContext.value
|
||||
|
||||
if (!context || context.kind !== 'inline-color') {
|
||||
return
|
||||
}
|
||||
|
||||
ensureBlockPanelEngaged()
|
||||
|
||||
if (context.mode === 'source') {
|
||||
const value = markdownValue.value || ''
|
||||
const start = Math.max(0, Math.min(context.start ?? 0, value.length))
|
||||
const end = Math.max(start, Math.min(context.end ?? start, value.length))
|
||||
const selectedText = value.slice(start, end)
|
||||
const replacement = formatInlineColorMarkdown(color, selectedText)
|
||||
|
||||
markdownValue.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`
|
||||
inlineColorSelectionContext.value = null
|
||||
setTextareaSelection(start + replacement.length, start + replacement.length, { preserveScroll: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (context.mode === 'live' && Number.isInteger(context.sourceLine)) {
|
||||
const lines = (markdownValue.value || '').split('\n')
|
||||
const line = lines[context.sourceLine]
|
||||
|
||||
if (typeof line !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
const prefixLength = getMarkdownLineBodyPrefixLength(line, context.bodyText || '')
|
||||
const absoluteStart = prefixLength + Math.max(0, context.startOffset ?? 0)
|
||||
const absoluteEnd = prefixLength + Math.max(context.startOffset ?? 0, context.endOffset ?? 0)
|
||||
const selectedText = line.slice(absoluteStart, absoluteEnd)
|
||||
const replacement = formatInlineColorMarkdown(color, selectedText)
|
||||
const nextVisibleOffset = (context.startOffset ?? 0) + selectedText.length
|
||||
|
||||
lines.splice(context.sourceLine, 1, `${line.slice(0, absoluteStart)}${replacement}${line.slice(absoluteEnd)}`)
|
||||
markdownValue.value = lines.join('\n')
|
||||
inlineColorSelectionContext.value = null
|
||||
pendingPreviewFocus.value = {
|
||||
line: context.sourceLine,
|
||||
offset: nextVisibleOffset
|
||||
}
|
||||
nextTick(() => {
|
||||
previewRendererRef.value?.focusEditableAtLine(
|
||||
context.sourceLine,
|
||||
0,
|
||||
'auto',
|
||||
nextVisibleOffset,
|
||||
'nearest'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 마크다운을 삽입한다.
|
||||
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
|
||||
@@ -2739,6 +2991,7 @@ defineExpose({
|
||||
updateActiveCalloutOptions,
|
||||
updateActiveCodeOptions,
|
||||
updateActiveToggleOptions,
|
||||
updateActiveInlineColor,
|
||||
openMediaPicker
|
||||
})
|
||||
|
||||
|
||||
@@ -1198,6 +1198,15 @@ const onBlockPanelUpdateToggleOptions = (patch) => {
|
||||
blockEditor.value?.updateActiveToggleOptions?.(patch)
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널에서 선택 텍스트 색상을 수정한다.
|
||||
* @param {string} color - 인라인 색상
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBlockPanelUpdateInlineColor = (color) => {
|
||||
blockEditor.value?.updateActiveInlineColor?.(color)
|
||||
}
|
||||
|
||||
const focusContentEditor = (event) => {
|
||||
if (event?.isComposing || isTitleInputComposing.value || event?.keyCode === 229) {
|
||||
return
|
||||
@@ -2066,6 +2075,7 @@ defineExpose({
|
||||
@update-callout-options="onBlockPanelUpdateCalloutOptions"
|
||||
@update-code-options="onBlockPanelUpdateCodeOptions"
|
||||
@update-toggle-options="onBlockPanelUpdateToggleOptions"
|
||||
@update-inline-color="onBlockPanelUpdateInlineColor"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1540,4 +1540,48 @@ defineExpose({ focusEditor, readEditorValue })
|
||||
font-size: 0.9em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.content-markdown-editable-inline :deep([data-inline-color='gray']) {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
|
||||
.content-markdown-editable-inline :deep([data-inline-color='blue']) {
|
||||
color: #0055ff;
|
||||
}
|
||||
|
||||
.content-markdown-editable-inline :deep([data-inline-color='green']) {
|
||||
color: #16ae68;
|
||||
}
|
||||
|
||||
.content-markdown-editable-inline :deep([data-inline-color='yellow']) {
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.content-markdown-editable-inline :deep([data-inline-color='red']) {
|
||||
color: #e60000;
|
||||
}
|
||||
|
||||
.content-markdown-editable-inline :deep([data-inline-color='purple']) {
|
||||
color: #8800ff;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-editable-inline :deep([data-inline-color='blue']) {
|
||||
color: #7aa2ff;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-editable-inline :deep([data-inline-color='green']) {
|
||||
color: #5ee0a0;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-editable-inline :deep([data-inline-color='yellow']) {
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-editable-inline :deep([data-inline-color='red']) {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-editable-inline :deep([data-inline-color='purple']) {
|
||||
color: #c084fc;
|
||||
}
|
||||
</style>
|
||||
|
||||
103
components/content/ContentMarkdownInlineSegments.vue
Normal file
103
components/content/ContentMarkdownInlineSegments.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup>
|
||||
import { parseInlineSegments } from '../../lib/markdown-inline.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** 렌더링할 인라인 마크다운 텍스트 */
|
||||
text: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 링크에 적용할 클래스 */
|
||||
linkClass: {
|
||||
type: String,
|
||||
default: 'text-[var(--site-accent)] underline underline-offset-4'
|
||||
},
|
||||
/** 줄바꿈을 br로 렌더링할지 여부 */
|
||||
multiline: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const segmentLines = computed(() => {
|
||||
const source = String(props.text || '')
|
||||
const lines = props.multiline ? source.split('\n') : [source]
|
||||
return lines.map(parseInlineSegments)
|
||||
})
|
||||
|
||||
/**
|
||||
* 인라인 색상 클래스명을 반환한다.
|
||||
* @param {string} color - 색상 이름
|
||||
* @returns {string} 클래스명
|
||||
*/
|
||||
const getInlineColorClass = (color) => `content-markdown-inline-segments__color--${String(color || '').trim().toLowerCase()}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="(lineSegments, lineIndex) in segmentLines" :key="`inline-line-${lineIndex}`">
|
||||
<br v-if="lineIndex > 0">
|
||||
<template v-for="(segment, segmentIndex) in lineSegments" :key="`inline-segment-${lineIndex}-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-inline-segments__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" :class="linkClass" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<span
|
||||
v-else-if="segment.type === 'color'"
|
||||
class="content-markdown-inline-segments__color"
|
||||
:class="getInlineColorClass(segment.color)"
|
||||
:data-inline-color="segment.color"
|
||||
>
|
||||
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" />
|
||||
</span>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content-markdown-inline-segments__color--gray {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
|
||||
.content-markdown-inline-segments__color--blue {
|
||||
color: #0055ff;
|
||||
}
|
||||
|
||||
.content-markdown-inline-segments__color--green {
|
||||
color: #16ae68;
|
||||
}
|
||||
|
||||
.content-markdown-inline-segments__color--yellow {
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.content-markdown-inline-segments__color--red {
|
||||
color: #e60000;
|
||||
}
|
||||
|
||||
.content-markdown-inline-segments__color--purple {
|
||||
color: #8800ff;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--blue {
|
||||
color: #7aa2ff;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--green {
|
||||
color: #5ee0a0;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--yellow {
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--red {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--purple {
|
||||
color: #c084fc;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isImageUrl,
|
||||
parseImageMarkdownLine
|
||||
} from '../../lib/markdown-image.js'
|
||||
import { parseInlineSegments, readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js'
|
||||
import { readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js'
|
||||
import {
|
||||
applyLiveSelectionDelete,
|
||||
collapseLiveSelection,
|
||||
@@ -2482,15 +2482,6 @@ const onMergeWithPreviousLine = (lineIndex, payload) => {
|
||||
emit('merge-with-previous-line', { lineIndex, mergedLine })
|
||||
}
|
||||
|
||||
/**
|
||||
* 줄바꿈이 포함된 인라인 마크다운을 줄 단위 세그먼트로 변환한다.
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {Array<Array<{ type: string, text: string, href?: string }>>} 줄별 인라인 세그먼트
|
||||
*/
|
||||
const parseInlineSegmentLines = (value) => {
|
||||
return String(value || '').split('\n').map(parseInlineSegments)
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이트박스를 연다
|
||||
* @param {Array<Object>} images - 이미지 목록
|
||||
@@ -3057,15 +3048,7 @@ onBeforeUnmount(() => {
|
||||
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
|
||||
/>
|
||||
<ProseHeading v-else-if="block.type === 'heading'" :id="headingIdsByBlock[block.id]" :level="block.level">
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
<ContentMarkdownInlineSegments :text="block.text" />
|
||||
</ProseHeading>
|
||||
<ProseBlockquote
|
||||
v-else-if="block.type === 'quote' && interactive && block.variant !== 'alt'"
|
||||
@@ -3097,15 +3080,7 @@ onBeforeUnmount(() => {
|
||||
: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>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
<ContentMarkdownInlineSegments :text="block.text" />
|
||||
</ProseBlockquote>
|
||||
<ProseList v-else-if="block.type === 'list' && interactive" :ordered="block.ordered || false">
|
||||
<li
|
||||
@@ -3156,15 +3131,7 @@ onBeforeUnmount(() => {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(item)" :key="`${block.id}-${itemIndex}-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
<ContentMarkdownInlineSegments :text="item" />
|
||||
</span>
|
||||
</li>
|
||||
</ProseList>
|
||||
@@ -3244,15 +3211,7 @@ onBeforeUnmount(() => {
|
||||
:title="block.title"
|
||||
:background="block.calloutBackground"
|
||||
>
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-callout-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
<ContentMarkdownInlineSegments :text="block.text" link-class="underline underline-offset-4" />
|
||||
</ProseCallout>
|
||||
<ContentMarkdownToggleEditor
|
||||
v-else-if="block.type === 'toggle' && interactive"
|
||||
@@ -3267,15 +3226,7 @@ onBeforeUnmount(() => {
|
||||
@delete-line="onDeleteLine"
|
||||
/>
|
||||
<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>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
<ContentMarkdownInlineSegments :text="block.text" />
|
||||
</ProseToggle>
|
||||
<ProseBookmark
|
||||
v-else-if="block.type === 'bookmark' && block.meta.url"
|
||||
@@ -3466,15 +3417,7 @@ onBeforeUnmount(() => {
|
||||
class="border border-line bg-[color-mix(in_srgb,var(--site-text)_6%,transparent)] px-3 py-2 font-semibold"
|
||||
:class="getTableCellAlignClass(block.meta.alignments?.[headerIndex])"
|
||||
>
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(header)" :key="`${block.id}-table-head-${headerIndex}-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
<ContentMarkdownInlineSegments :text="header" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -3486,15 +3429,7 @@ onBeforeUnmount(() => {
|
||||
class="border border-line px-3 py-2 align-top"
|
||||
:class="getTableCellAlignClass(block.meta.alignments?.[cellIndex])"
|
||||
>
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(row[cellIndex] || '')" :key="`${block.id}-table-cell-${rowIndex}-${cellIndex}-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
<ContentMarkdownInlineSegments :text="row[cellIndex] || ''" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -3543,18 +3478,7 @@ onBeforeUnmount(() => {
|
||||
@slash-apply="emit('slash-apply', $event)"
|
||||
/>
|
||||
<p v-else-if="block.type === 'paragraph'" class="content-markdown-renderer__paragraph mb-2.5 text-base text-[var(--site-text)] last:mb-0">
|
||||
<template v-for="(lineSegments, lineIndex) in parseInlineSegmentLines(block.text)" :key="`${block.id}-paragraph-line-${lineIndex}`">
|
||||
<br v-if="lineIndex > 0">
|
||||
<template v-for="(segment, segmentIndex) in lineSegments" :key="`${block.id}-paragraph-${lineIndex}-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</template>
|
||||
<ContentMarkdownInlineSegments :text="block.text" multiline />
|
||||
</p>
|
||||
|
||||
<SiteAdSlot
|
||||
|
||||
Reference in New Issue
Block a user