v1.2.9: 라이브 에디터·홈 피드·메인 커버 개선

라이브 모드 코드/콜아웃/토글 편집, 슬래시 명령, 홈 Latest List·Compact·Cards 보기,
사이트 설정 메인 화면 커버(720px) 및 HomeHero 반영.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-18 16:57:30 +09:00
parent 666bd304fc
commit 3fb8a40031
34 changed files with 3823 additions and 443 deletions

View File

@@ -0,0 +1,257 @@
<script setup>
import {
buildCalloutOpenerLine,
CALLOUT_BACKGROUND_OPTIONS,
CALLOUT_EMOJI_OPTIONS
} from '../../lib/markdown-callout.js'
import ProseCallout from './ProseCallout.vue'
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
const props = defineProps({
/** 콜아웃 본문 */
modelValue: {
type: String,
default: ''
},
calloutEmojiEnabled: {
type: Boolean,
default: true
},
calloutEmoji: {
type: String,
default: '💡'
},
calloutBackground: {
type: String,
default: 'blue'
},
/** 본문 첫 줄 source-line(0-based) */
bodySourceLine: {
type: Number,
required: true
}
})
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 - 본문
* @returns {void}
*/
const commitCallout = (body) => {
const contentLines = String(body ?? '').replace(/\r/g, '').split('\n')
emit('commit', [
buildCalloutOpenerLine({
calloutEmojiEnabled: emojiEnabled.value,
calloutEmoji: emojiDraft.value,
calloutBackground: background.value
}),
...contentLines,
':::'
])
}
/**
* 본문 편집 반영
* @param {string} body - 본문
* @returns {void}
*/
const onBodyCommit = (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>
<template>
<div class="content-markdown-callout-editor relative">
<ProseCallout
:emoji-enabled="false"
:background="background"
>
<div class="content-markdown-callout-editor__inner flex items-start gap-2">
<button
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"
type="button"
aria-label="콜아웃 아이콘·색상 설정"
@mousedown.prevent
@click.stop="openSettings"
>
<span v-if="emojiEnabled">{{ emojiDraft || '💡' }}</span>
<span v-else class="text-base text-[#8e9cac]">+</span>
</button>
<ContentMarkdownEditableInline
block-class="content-markdown-callout-editor__body min-w-0 flex-1 text-[15px] leading-8 text-[var(--site-text)]"
enter-mode="multiline"
:source-line="bodySourceLine"
:model-value="modelValue"
@commit="onBodyCommit"
/>
</div>
</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>
</template>

View File

@@ -0,0 +1,185 @@
<script setup>
import { buildCodeBlockLines } from '../../lib/markdown-code-block.js'
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
import ProseCodeBlock from './ProseCodeBlock.vue'
const props = defineProps({
/** 코드 본문 */
modelValue: {
type: String,
default: ''
},
/** 언어(slug) */
language: {
type: String,
default: ''
},
/** 줄 번호 표시 */
showLineNumbers: {
type: Boolean,
default: true
},
/** 본문 첫 줄 source-line(0-based) */
bodySourceLine: {
type: Number,
required: true
}
})
const emit = defineEmits(['commit', 'insert-below'])
const languageDraft = ref(props.language)
const lineNumbersEnabled = ref(props.showLineNumbers)
const liveBody = ref(props.modelValue)
watch(() => props.language, (value) => {
languageDraft.value = value
})
watch(() => props.showLineNumbers, (value) => {
lineNumbersEnabled.value = value
})
watch(() => props.modelValue, (value) => {
liveBody.value = value
})
/** @type {import('vue').ComputedRef<string[]>} */
const bodyLines = computed(() => {
const text = String(liveBody.value ?? '')
if (!text.length) {
return ['']
}
return text.split('\n')
})
/** @type {import('vue').ComputedRef<number[]>} */
const gutterLines = computed(() => bodyLines.value.map((_, index) => index + 1))
/**
* 마크다운에 코드 블록을 반영한다.
* @param {string} body - 본문
* @returns {void}
*/
const commitCodeBlock = (body) => {
emit('commit', buildCodeBlockLines({
language: languageDraft.value,
showLineNumbers: lineNumbersEnabled.value,
body
}))
}
/**
* 본문 편집 반영
* @param {string} body - 본문
* @returns {void}
*/
const onBodyCommit = (body) => {
liveBody.value = body
commitCodeBlock(body)
}
/**
* 입력 중 줄 번호 갱신용 본문 동기화
* @param {string} body - 본문
* @returns {void}
*/
const onBodyInput = (body) => {
liveBody.value = body
}
/**
* 코드 블록 아래로 이탈(다음 문단 생성)
* @param {Object} payload - insert-below 페이로드
* @returns {void}
*/
const onExitBelow = (payload) => {
emit('insert-below', payload)
}
/**
* 언어 입력 반영
* @returns {void}
*/
const onLanguageCommit = () => {
commitCodeBlock(props.modelValue)
}
/**
* 줄 번호 표시를 토글한다.
* @returns {void}
*/
const toggleLineNumbers = () => {
lineNumbersEnabled.value = !lineNumbersEnabled.value
commitCodeBlock(props.modelValue)
}
</script>
<template>
<ProseCodeBlock
class="content-markdown-code-block-editor"
:show-line-numbers="lineNumbersEnabled"
:line-numbers="gutterLines"
>
<template #header-tools>
<div
class="content-markdown-code-block-editor__toolbar pointer-events-none flex items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
>
<button
class="content-markdown-code-block-editor__line-numbers pointer-events-auto rounded border border-white/15 bg-white/10 px-2 py-0.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/15 hover:text-white"
type="button"
:aria-pressed="lineNumbersEnabled"
:title="lineNumbersEnabled ? '줄 번호 숨기기' : '줄 번호 표시'"
@mousedown.prevent
@click="toggleLineNumbers"
>
{{ lineNumbersEnabled ? '줄번호' : '줄번호 끔' }}
</button>
<input
v-model="languageDraft"
class="content-markdown-code-block-editor__language pointer-events-auto w-[7.5rem] rounded border border-white/15 bg-white/10 px-2 py-0.5 text-xs text-white outline-none transition-colors placeholder:text-white/35 focus:border-white/30 focus:bg-white/15"
type="text"
placeholder="Language..."
spellcheck="false"
@mousedown.stop
@keydown.stop
@blur="onLanguageCommit"
@keydown.enter.prevent="onLanguageCommit"
>
</div>
</template>
<ContentMarkdownEditableInline
tag="pre"
block-class="content-markdown-code-block-editor__editor m-0 min-w-0 border-0 bg-transparent p-0 font-mono text-sm leading-6 text-white outline-none"
enter-mode="multiline"
plain-text
:source-line="bodySourceLine"
:model-value="modelValue"
@input="onBodyInput"
@commit="onBodyCommit"
@insert-below="onExitBelow"
/>
</ProseCodeBlock>
</template>
<style scoped>
.content-markdown-code-block-editor :deep(.prose-code-block__content) {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.content-markdown-code-block-editor :deep(.prose-code-block__header) {
pointer-events: none;
}
.content-markdown-code-block-editor__toolbar {
pointer-events: none;
}
.content-markdown-code-block-editor__toolbar > * {
pointer-events: auto;
}
</style>

View File

@@ -1,10 +1,12 @@
<script setup>
import {
convertEditableHtmlToMarkdown,
getRangeInnerHtml,
escapeHtml,
getEditableCaretOffset,
markdownInlineToHtml,
markdownMultilineInlineToHtml
readEditableTextFromElement,
setEditableCaretOffset
} from '../../lib/markdown-inline.js'
import { parseSlashInput } from '../../lib/markdown-slash-commands.js'
const props = defineProps({
/** 인라인 마크다운 원문 */
@@ -12,31 +14,32 @@ const props = defineProps({
type: String,
default: ''
},
/** 여러 줄(Shift+Enter 줄바꿈) 허용 */
multiline: {
type: Boolean,
default: false
},
/**
* Enter 동작
* - split-paragraph: 문단 분리
* - insert-below: 블록 아래 새 줄 삽입
* - none: 기본(제목 등 단일 줄 blur)
* - focus-next: Enter 시 blur 없이 enter-advance 발생
* - multiline: Enter 기본 동작(코드·콜아웃 본문 등)
*/
enterMode: {
type: String,
default: 'none'
},
/**
* ↑↓ 이동 범위
* - document: 렌더러 전체 블록
* - parent: 가장 가까운 data-editable-scope 컨테이너 안
*/
navigationScope: {
type: String,
default: 'document'
},
/** @deprecated enterMode 사용 */
splitOnEnter: {
type: Boolean,
default: false
},
/** Shift+Enter 줄바꿈 허용 */
allowHardBreak: {
type: Boolean,
default: false
},
/** 원본 마크다운 줄 번호(0-based) */
sourceLine: {
type: Number,
@@ -61,10 +64,38 @@ const props = defineProps({
allowRawToggle: {
type: Boolean,
default: false
},
/** 슬래시 메뉴가 열려 키보드 탐색 중인지 */
slashMenuActive: {
type: Boolean,
default: false
},
/** ESC 등으로 이 줄의 슬래시 메뉴를 닫은 상태(문자 `/`는 유지) */
slashCommandSuppressed: {
type: Boolean,
default: false
},
/** true면 인라인 마크다운 변환 없이 줄바꿈 유지(코드 블록 등) */
plainText: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['commit', 'split', 'insert-below', 'delete-line', 'raw-mode'])
const emit = defineEmits([
'commit',
'input',
'split',
'insert-below',
'delete-line',
'merge-with-previous',
'raw-mode',
'slash-update',
'slash-end',
'slash-apply',
'enter-advance',
'leave-block'
])
const rootRef = ref(null)
const isFocused = ref(false)
@@ -87,9 +118,14 @@ const resolvedEnterMode = computed(() => {
* modelValue를 편집용 HTML로 변환한다.
* @returns {string} HTML
*/
const toEditorHtml = () => (props.multiline
? markdownMultilineInlineToHtml(props.modelValue)
: markdownInlineToHtml(props.modelValue))
const toEditorHtml = () => markdownInlineToHtml(props.modelValue.replace(/\n/g, ' '))
/**
* plainText 모드용 편집 HTML을 만든다.
* @param {string} value - 본문
* @returns {string} HTML
*/
const plainTextToEditorHtml = (value) => escapeHtml(String(value ?? '')).replace(/\n/g, '<br>')
/**
* 편집 영역 HTML을 동기화한다.
@@ -105,15 +141,38 @@ const syncEditorHtml = () => {
return
}
if (props.plainText) {
rootRef.value.innerHTML = plainTextToEditorHtml(props.modelValue)
return
}
rootRef.value.innerHTML = toEditorHtml()
}
watch(() => [props.modelValue, props.rawLine], () => {
if (!isFocused.value) {
showingRaw.value = false
syncEditorHtml()
return
}
syncEditorHtml()
if (showingRaw.value) {
if (props.rawLine && rootRef.value && rootRef.value.textContent !== props.rawLine) {
rootRef.value.textContent = props.rawLine
}
return
}
const current = readEditorValue()
if (current !== props.modelValue) {
if (props.plainText) {
rootRef.value.innerHTML = plainTextToEditorHtml(props.modelValue)
} else {
rootRef.value.innerHTML = toEditorHtml()
}
}
})
onMounted(() => {
@@ -126,6 +185,43 @@ onMounted(() => {
*/
const onFocus = () => {
isFocused.value = true
syncSlashState()
}
/**
* 슬래시 명령 입력 상태를 부모에 알린다.
* @returns {void}
*/
const syncSlashState = () => {
if (props.sourceLine === null || showingRaw.value) {
emit('slash-end')
return
}
if (props.slashCommandSuppressed) {
return
}
const parsed = parseSlashInput(readEditorValue())
if (!parsed) {
emit('slash-end', { sourceLine: props.sourceLine })
return
}
emit('slash-update', {
sourceLine: props.sourceLine,
query: parsed.query,
value: parsed.raw
})
}
/**
* 입력 시 슬래시 상태를 갱신한다.
* @returns {void}
*/
const onEditorInput = () => {
syncSlashState()
}
/**
@@ -145,7 +241,7 @@ const readEditorValue = () => {
return rootRef.value.textContent ?? ''
}
return convertEditableHtmlToMarkdown(rootRef.value.innerHTML)
return readEditableTextFromElement(rootRef.value)
}
const onBlur = () => {
@@ -265,6 +361,30 @@ const getRawPrefixLength = () => {
return match ? match[1].length : 0
}
/**
* 편집 영역에 접힙지 않은 텍스트 선택이 있는지 확인한다.
* @returns {boolean}
*/
const hasNonCollapsedSelection = () => {
if (!import.meta.client || !rootRef.value) {
return false
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
return false
}
const range = selection.getRangeAt(0)
if (!rootRef.value.contains(range.commonAncestorContainer)) {
return false
}
return !range.collapsed
}
/**
* 커서가 줄의 논리적 맨 앞인지 확인한다(원문 접두사 직후 포함).
* @returns {boolean}
@@ -290,25 +410,109 @@ const isCaretAtLogicalStart = () => {
const isCaretAtLogicalEnd = () => isCaretAtEdge('end')
/**
* 이전·다음 편집 줄로 포커스를 옮긴다.
* @param {number} direction - -1 이전 줄, 1 다음 줄
* @param {boolean} cursorAtStart - 커서를 줄 앞에 둘지
* 커서가 위치한 시각 줄 정보를 반환한다.
* @returns {{ column: number, lineIndex: number, lines: string[], isFirstLine: boolean, isLastLine: boolean }}
*/
const getCaretLineContext = () => {
const fallback = {
column: 0,
lineIndex: 0,
lines: [''],
isFirstLine: true,
isLastLine: true
}
if (!import.meta.client || !rootRef.value) {
return fallback
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
return fallback
}
const range = selection.getRangeAt(0)
if (!rootRef.value.contains(range.commonAncestorContainer)) {
return fallback
}
const full = readEditorValue()
const offset = getEditableCaretOffset(rootRef.value, range)
const lines = full.length ? full.split('\n') : ['']
const before = full.slice(0, offset)
const lineIndex = before.split('\n').length - 1
const lineStart = before.lastIndexOf('\n') + 1
const column = offset - lineStart
return {
column,
lineIndex,
lines,
isFirstLine: lineIndex === 0,
isLastLine: lineIndex >= lines.length - 1
}
}
/**
* 텍스트 줄 배열에서 지정 줄·열의 오프셋을 계산한다.
* @param {string[]} lines - 줄 목록
* @param {number} lineIndex - 줄 인덱스
* @param {number} column - 열
* @returns {number} 오프셋
*/
const getOffsetForLineColumn = (lines, lineIndex, column) => {
let offset = 0
for (let index = 0; index < lineIndex; index += 1) {
offset += (lines[index] ?? '').length + 1
}
const line = lines[lineIndex] ?? ''
return offset + Math.min(Math.max(column, 0), line.length)
}
/**
* 인접 편집 블록으로 포커스를 옮긴다.
* @param {number} direction - -1 이전, 1 다음
* @param {number} column - 유지할 열(column 모드)
* @param {'column'|'block-start'|'block-end'} [caretMode='column'] - 커서 배치
* @returns {void}
*/
const navigateToAdjacentLine = (direction, cursorAtStart) => {
/**
* ↑↓ 이동 대상 편집 요소 목록을 반환한다.
* @returns {HTMLElement[]}
*/
const getNavigationElements = () => {
if (!import.meta.client || !rootRef.value) {
return []
}
const root = props.navigationScope === 'parent'
? rootRef.value.closest('[data-editable-scope]')
: rootRef.value.closest('.content-markdown-renderer')
if (!root) {
return []
}
return [...root.querySelectorAll('[data-source-line]')]
.sort((left, right) => Number(left.dataset.sourceLine) - Number(right.dataset.sourceLine))
}
const navigateToAdjacentBlock = (direction, column, caretMode = 'column') => {
if (!import.meta.client || props.sourceLine === null) {
return
}
const container = rootRef.value?.closest('.content-markdown-renderer')
const elements = getNavigationElements()
if (!container) {
if (!elements.length) {
return
}
const elements = [...container.querySelectorAll('[data-source-line]')]
.sort((left, right) => Number(left.dataset.sourceLine) - Number(right.dataset.sourceLine))
const currentIndex = elements.findIndex(
(element) => Number(element.dataset.sourceLine) === props.sourceLine
)
@@ -320,16 +524,113 @@ const navigateToAdjacentLine = (direction, cursorAtStart) => {
const target = elements[currentIndex + direction]
if (!target) {
if (resolvedEnterMode.value === 'multiline' && direction === 1) {
emit('insert-below', buildInsertBelowPayload())
}
return
}
target.focus()
const range = document.createRange()
range.selectNodeContents(target)
range.collapse(cursorAtStart)
if (props.navigationScope === 'parent') {
emit('leave-block', { direction })
}
target.focus({ preventScroll: true })
const scopedCaretMode = props.navigationScope === 'parent'
? (direction === 1 ? 'block-start' : 'block-end')
: caretMode
const targetText = readEditableTextFromElement(target)
const targetLines = targetText.length ? targetText.split('\n') : ['']
let targetLineIndex = direction === -1 ? targetLines.length - 1 : 0
let targetColumn = column
if (scopedCaretMode === 'block-start') {
targetLineIndex = 0
targetColumn = 0
} else if (scopedCaretMode === 'block-end') {
targetLineIndex = targetLines.length - 1
targetColumn = (targetLines[targetLineIndex] ?? '').length
} else {
targetColumn = Math.min(column, (targetLines[targetLineIndex] ?? '').length)
}
const targetOffset = getOffsetForLineColumn(targetLines, targetLineIndex, targetColumn)
setEditableCaretOffset(target, targetOffset)
}
/**
* 현재 블록의 맨 앞·맨 끝으로 커서를 옮긴다.
* @param {'start'|'end'} edge - 위치
* @returns {void}
*/
const setCaretAtBlockEdge = (edge) => {
if (!rootRef.value) {
return
}
if (edge === 'start') {
if (showingRaw.value) {
placeCursorAfterPrefix()
return
}
setEditableCaretOffset(rootRef.value, 0)
return
}
const full = readEditorValue()
setEditableCaretOffset(rootRef.value, full.length)
}
/**
* 커서 기준 앞·뒤 텍스트를 읽는다.
* @returns {{ before: string, after: string }}
*/
const readCaretSplit = () => {
if (!import.meta.client || !rootRef.value) {
return { before: props.modelValue, after: '' }
}
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
if (!selection || selection.rangeCount === 0) {
return { before: props.modelValue, after: '' }
}
const range = selection.getRangeAt(0)
if (!rootRef.value.contains(range.commonAncestorContainer)) {
return { before: props.modelValue, after: '' }
}
const full = readEditorValue()
const offset = getEditableCaretOffset(rootRef.value, range)
return {
before: full.slice(0, offset),
after: full.slice(offset)
}
}
/**
* insert-below 이벤트 페이로드를 만든다.
* @returns {{ value: string, raw: boolean, before: string, after: string, caretAtStart: boolean, caretAtEnd: boolean }}
*/
const buildInsertBelowPayload = () => {
const { before, after } = readCaretSplit()
return {
value: readEditorValue(),
raw: showingRaw.value,
before,
after,
caretAtStart: isCaretAtLogicalStart(),
caretAtEnd: isCaretAtLogicalEnd()
}
}
/**
@@ -337,40 +638,7 @@ const navigateToAdjacentLine = (direction, cursorAtStart) => {
* @returns {void}
*/
const splitAtCaret = () => {
if (!import.meta.client || !rootRef.value) {
return
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
emit('split', { before: props.modelValue, after: '' })
return
}
const range = selection.getRangeAt(0)
if (!rootRef.value.contains(range.commonAncestorContainer)) {
return
}
const beforeRange = document.createRange()
beforeRange.selectNodeContents(rootRef.value)
beforeRange.setEnd(range.startContainer, range.startOffset)
const afterRange = document.createRange()
afterRange.setStart(range.endContainer, range.endOffset)
if (rootRef.value.lastChild) {
afterRange.setEndAfter(rootRef.value.lastChild)
} else {
afterRange.setEnd(rootRef.value, 0)
}
const before = convertEditableHtmlToMarkdown(getRangeInnerHtml(beforeRange))
const after = convertEditableHtmlToMarkdown(getRangeInnerHtml(afterRange))
emit('split', { before, after })
emit('split', readCaretSplit())
}
/**
@@ -389,15 +657,14 @@ const scheduleEnterAction = (action) => {
if (action === 'split') {
splitAtCaret()
} else {
const nextValue = readEditorValue()
emit('insert-below', showingRaw.value ? { value: nextValue, raw: true } : nextValue)
emit('insert-below', buildInsertBelowPayload())
}
nextTick(() => {
splitLock.value = false
window.setTimeout(() => {
suppressBlurCommit.value = false
}, 50)
}, 180)
})
}
@@ -477,7 +744,22 @@ const onKeydown = (event) => {
if (
event.key === 'Backspace'
&& isCaretAtEdge('start')
&& !hasNonCollapsedSelection()
&& isCaretAtLogicalStart()
&& !showingRaw.value
&& props.sourceLine !== null
&& readEditorValue().trim()
) {
event.preventDefault()
event.stopPropagation()
emit('merge-with-previous', buildInsertBelowPayload())
return
}
if (
event.key === 'Backspace'
&& !hasNonCollapsedSelection()
&& isCaretAtLogicalStart()
&& !showingRaw.value
&& props.sourceLine !== null
&& !readEditorValue().trim()
@@ -490,6 +772,7 @@ const onKeydown = (event) => {
if (
event.key === 'Backspace'
&& !hasNonCollapsedSelection()
&& props.allowRawToggle
&& props.rawLine
&& !showingRaw.value
@@ -510,37 +793,80 @@ const onKeydown = (event) => {
return
}
if (event.key === 'ArrowUp' && isCaretAtLogicalStart()) {
event.preventDefault()
event.stopPropagation()
navigateToAdjacentLine(-1, false)
const hasCommandModifier = event.metaKey || event.ctrlKey
if (hasCommandModifier && !event.shiftKey && !event.altKey) {
if (event.key === 'ArrowLeft') {
event.preventDefault()
event.stopPropagation()
setCaretAtBlockEdge('start')
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
event.stopPropagation()
setCaretAtBlockEdge('end')
return
}
}
if (props.slashMenuActive) {
return
}
if (event.key === 'ArrowDown' && isCaretAtLogicalEnd()) {
if (!hasCommandModifier && !event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
if (event.isComposing || event.keyCode === 229) {
return
}
const lineContext = getCaretLineContext()
const direction = event.key === 'ArrowUp' ? -1 : 1
const isMultilineEditor = resolvedEnterMode.value === 'multiline'
if (isMultilineEditor) {
if (direction === -1 && !lineContext.isFirstLine) {
return
}
if (direction === 1 && !lineContext.isLastLine) {
return
}
}
event.preventDefault()
event.stopPropagation()
navigateToAdjacentLine(1, true)
navigateToAdjacentBlock(direction, lineContext.column)
return
}
if (event.key === 'ArrowLeft' && isCaretAtLogicalStart()) {
if (!hasCommandModifier && !event.altKey && event.key === 'ArrowLeft' && isCaretAtLogicalStart()) {
event.preventDefault()
event.stopPropagation()
navigateToAdjacentLine(-1, false)
navigateToAdjacentBlock(-1, 0, 'block-end')
return
}
if (event.key === 'ArrowRight' && isCaretAtLogicalEnd()) {
if (!hasCommandModifier && !event.altKey && event.key === 'ArrowRight' && isCaretAtLogicalEnd()) {
event.preventDefault()
event.stopPropagation()
navigateToAdjacentLine(1, true)
navigateToAdjacentBlock(1, 0, 'block-start')
return
}
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
if (event.key === 'Enter' && !event.shiftKey && (enterMode === 'split-paragraph' || enterMode === 'insert-below')) {
if (event.key === 'Enter' && !event.shiftKey && parseSlashInput(readEditorValue())) {
event.preventDefault()
event.stopPropagation()
emit('slash-apply', {
sourceLine: props.sourceLine,
value: readEditorValue()
})
return
}
if (event.key === 'Enter' && (enterMode === 'split-paragraph' || enterMode === 'insert-below')) {
event.preventDefault()
event.stopPropagation()
@@ -554,11 +880,19 @@ const onKeydown = (event) => {
return
}
if (event.key === 'Enter' && event.shiftKey && props.allowHardBreak) {
if (event.key === 'Enter' && enterMode === 'focus-next') {
event.preventDefault()
event.stopPropagation()
if (event.isComposing || event.keyCode === 229) {
return
}
emit('enter-advance')
return
}
if (event.key === 'Enter' && !props.multiline && enterMode === 'none') {
if (event.key === 'Enter' && enterMode === 'none') {
event.preventDefault()
event.stopPropagation()
rootRef.value?.blur()
@@ -596,16 +930,13 @@ const focusEditor = (position = 'end') => {
return
}
rootRef.value.focus()
const range = document.createRange()
range.selectNodeContents(rootRef.value)
range.collapse(position === 'start')
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
rootRef.value.focus({ preventScroll: true })
const textLength = readEditorValue().length
const offset = position === 'start' ? 0 : textLength
setEditableCaretOffset(rootRef.value, offset)
}
defineExpose({ focusEditor })
defineExpose({ focusEditor, readEditorValue })
</script>
<template>
@@ -623,6 +954,7 @@ defineExpose({ focusEditor })
spellcheck="true"
@focus="onFocus"
@blur="onBlur"
@input="onEditorInput"
@paste="onPaste"
@keydown="onKeydown"
@compositionend="onCompositionEnd"
@@ -630,6 +962,10 @@ defineExpose({ focusEditor })
</template>
<style scoped>
.content-markdown-editable-inline {
user-select: text;
}
.content-markdown-editable-inline--idle {
cursor: text;
border-radius: 4px;

View File

@@ -4,8 +4,11 @@ import {
getImageDisplayCaption,
parseImageMarkdownLine
} from '../../lib/markdown-image.js'
import { paragraphTextToSourceLines, parseInlineSegments } from '../../lib/markdown-inline.js'
import { parseInlineSegments, readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js'
import {
appendTextToMarkdownLine,
getAppendTextForMerge,
getMergeJunctionDisplayOffset,
hasListMarker,
hasQuoteMarker,
isEmptyListMarkerLine,
@@ -14,6 +17,13 @@ import {
stripListMarker,
stripQuoteMarker
} 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 ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
import ProseCodeBlock from './ProseCodeBlock.vue'
import ContentMarkdownCalloutEditor from './ContentMarkdownCalloutEditor.vue'
import ContentMarkdownToggleEditor from './ContentMarkdownToggleEditor.vue'
const props = defineProps({
content: {
@@ -24,10 +34,30 @@ const props = defineProps({
interactive: {
type: Boolean,
default: false
},
/** 슬래시 명령 메뉴 키보드 탐색 중 */
slashMenuActive: {
type: Boolean,
default: false
},
/** ESC로 슬래시 메뉴를 닫은 원본 줄 번호(0-based) */
slashSuppressedLines: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['gallery-reorder', 'block-content-change', 'append-paragraph', 'insert-after-line', 'delete-line'])
const emit = defineEmits([
'gallery-reorder',
'block-content-change',
'append-paragraph',
'insert-after-line',
'delete-line',
'merge-with-previous-line',
'slash-update',
'slash-end',
'slash-apply'
])
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
@@ -41,6 +71,8 @@ const galleryDropTarget = ref(null)
const pendingFocusLine = ref(null)
/** @type {import('vue').Ref<'start'|'end'|'auto'>} 포커스 후 커서 위치 */
const pendingFocusPosition = ref('auto')
/** @type {import('vue').Ref<number|null>} 포커스 후 텍스트 오프셋 */
const pendingFocusOffset = ref(null)
const rendererRootRef = ref(null)
/** @type {import('vue').Ref<Set<number>>} 원문(raw) 편집 중인 목록 줄 */
const rawEditingSourceLines = ref(new Set())
@@ -91,51 +123,11 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {},
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
calloutEmoji: options.calloutEmoji || '💡',
calloutBackground: options.calloutBackground || 'blue'
calloutBackground: options.calloutBackground || 'blue',
codeLanguage: options.codeLanguage || '',
codeShowLineNumbers: options.codeShowLineNumbers !== false
})
const calloutBackgroundOptions = ['gray', 'blue', 'green', 'yellow', 'red', 'purple', 'pink']
/**
* 콜아웃 선언부 옵션을 파싱
* @param {string} line - 콜아웃 선언 라인
* @returns {{calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string}} 콜아웃 옵션
*/
const parseCalloutOptions = (line) => {
const options = {
calloutEmojiEnabled: true,
calloutEmoji: '💡',
calloutBackground: 'blue'
}
const tokens = line.trim().split(/\s+/).slice(1)
tokens.forEach((token) => {
const [rawKey, ...rawValueParts] = token.split('=')
if (!rawKey || !rawValueParts.length) {
return
}
const key = rawKey.toLowerCase()
const value = rawValueParts.join('=').trim()
if (key === 'emoji') {
if (!value || value === 'none') {
options.calloutEmojiEnabled = false
options.calloutEmoji = '💡'
} else {
options.calloutEmojiEnabled = true
options.calloutEmoji = value
}
return
}
if (key === 'bg' && calloutBackgroundOptions.includes(value)) {
options.calloutBackground = value
}
})
return options
}
/**
* 이미지 마크다운 행을 이미지 데이터로 변환
* @param {string} line - 마크다운 행
@@ -143,6 +135,26 @@ const parseCalloutOptions = (line) => {
*/
const parseImageLine = (line) => parseImageMarkdownLine(line)
/**
* 인용 마커 줄인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean}
*/
const isQuoteMarkerLine = (line) => {
const trimmed = String(line ?? '').trim()
return trimmed === '>' || /^>\s/.test(trimmed)
}
/**
* 불릿 목록 마커 줄인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean}
*/
const isBulletListMarkerLine = (line) => {
const trimmed = String(line ?? '').trim()
return trimmed === '-' || /^-\s/.test(trimmed)
}
/**
* 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다.
* @param {string} line - 마크다운 행
@@ -169,14 +181,7 @@ const isMarkdownBlockStart = (line) => {
}
/**
* 마크다운 hard break 표식이 있는 행인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean} hard break 여부
*/
const hasMarkdownHardBreak = (line) => /( {2,}|\\)$/.test(line)
/**
* 문단 행에서 hard break 표식을 제거한다.
* 문단 행에서 hard break 표식(레거시)을 제거한다.
* @param {string} line - 마크다운 행
* @returns {string} 정리된 문단 행
*/
@@ -444,6 +449,7 @@ const parseMarkdownBlocks = (markdown) => {
if (trimmedLine.startsWith('```')) {
const startLine = index
const fenceOptions = parseCodeFenceLine(trimmedLine) || { language: '', showLineNumbers: true }
const codeLines = []
index += 1
@@ -453,7 +459,10 @@ const parseMarkdownBlocks = (markdown) => {
}
blocks.push(attachSourceRange(
createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`),
createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`, {
codeLanguage: fenceOptions.language,
codeShowLineNumbers: fenceOptions.showLineNumbers
}),
startLine,
index
))
@@ -481,11 +490,11 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
if (trimmedLine.startsWith('> ')) {
if (isQuoteMarkerLine(line)) {
const startLine = index
const quoteLines = []
while (index < lines.length && lines[index].trim().startsWith('>')) {
while (index < lines.length && isQuoteMarkerLine(lines[index])) {
quoteLines.push(lines[index].trim().replace(/^>\s?/, ''))
index += 1
}
@@ -498,12 +507,12 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
if (/^- /.test(trimmedLine)) {
if (isBulletListMarkerLine(line)) {
const startLine = index
const items = []
while (index < lines.length && /^- /.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^- /, ''))
while (index < lines.length && isBulletListMarkerLine(lines[index])) {
items.push(lines[index].trim().replace(/^-\s?/, ''))
index += 1
}
@@ -531,29 +540,12 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
const paragraphStartLine = index
const paragraphLines = [cleanParagraphLine(line)]
let shouldJoinNextLine = hasMarkdownHardBreak(line)
index += 1
while (shouldJoinNextLine && index < lines.length) {
const nextLine = lines[index]
const nextTrimmedLine = nextLine.trim()
if (!nextTrimmedLine || isMarkdownBlockStart(nextTrimmedLine)) {
break
}
paragraphLines.push(cleanParagraphLine(nextLine))
shouldJoinNextLine = hasMarkdownHardBreak(nextLine)
index += 1
}
blocks.push(attachSourceRange(
createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`),
paragraphStartLine,
index - 1
createBlock('paragraph', cleanParagraphLine(line), null, `block-${blocks.length}`),
index,
index
))
index += 1
}
return blocks
@@ -569,12 +561,14 @@ watch(() => props.content, () => {
const line = pendingFocusLine.value
const position = pendingFocusPosition.value
const offset = pendingFocusOffset.value
pendingFocusLine.value = null
pendingFocusPosition.value = 'auto'
pendingFocusOffset.value = null
nextTick(() => {
nextTick(() => {
focusEditableAtLine(line, 0, position)
focusEditableAtLine(line, 0, position, offset)
})
})
})
@@ -584,9 +578,10 @@ watch(() => props.content, () => {
* @param {number} lineIndex - 줄 번호(0-based)
* @param {number} [attempt=0] - DOM 탐색 재시도 횟수
* @param {'start'|'end'|'auto'} [cursorPosition='auto'] - 커서 위치
* @param {number|null} [caretOffset=null] - 텍스트 오프셋
* @returns {void}
*/
const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto') => {
const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', caretOffset = null) => {
if (!import.meta.client) {
return
}
@@ -596,7 +591,7 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto') =>
if (!element) {
if (attempt < 8) {
requestAnimationFrame(() => {
focusEditableAtLine(lineIndex, attempt + 1, cursorPosition)
focusEditableAtLine(lineIndex, attempt + 1, cursorPosition, caretOffset)
})
}
@@ -611,17 +606,31 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto') =>
element.innerHTML = ''
}
element.focus()
const range = document.createRange()
range.selectNodeContents(element)
const collapseToStart = cursorPosition === 'start'
|| (cursorPosition === 'auto' && (isBlankMarker || !line.trim()))
range.collapse(collapseToStart)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
element.focus({ preventScroll: true })
if (typeof caretOffset === 'number' && caretOffset >= 0) {
setEditableCaretOffset(/** @type {HTMLElement} */ (element), caretOffset)
return
}
if (cursorPosition === 'start' || (cursorPosition === 'auto' && (isBlankMarker || !line.trim()))) {
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
return
}
if (cursorPosition === 'end') {
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
setEditableCaretOffset(/** @type {HTMLElement} */ (element), text.length)
return
}
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
}
defineExpose({
focusEditableAtLine
})
/**
* 인라인 편집 원문 모드 표시 상태를 갱신한다.
* @param {{ sourceLine: number, active: boolean }} payload - 줄 번호·활성 여부
@@ -690,6 +699,33 @@ const normalizeCommitPayload = (payload) => {
}
}
/**
* insert-below 이벤트 페이로드를 정규화한다.
* @param {string|Object} payload - 페이로드
* @returns {{ value: string, raw: boolean, before: string, after: string, caretAtStart: boolean, caretAtEnd: boolean }}
*/
const normalizeInsertBelowPayload = (payload) => {
if (typeof payload === 'string') {
return {
value: payload,
raw: false,
before: '',
after: '',
caretAtStart: false,
caretAtEnd: true
}
}
return {
value: String(payload?.value ?? ''),
raw: payload?.raw === true,
before: String(payload?.before ?? ''),
after: String(payload?.after ?? ''),
caretAtStart: payload?.caretAtStart === true,
caretAtEnd: payload?.caretAtEnd === true
}
}
/**
* 원본 마크다운 줄을 반환한다.
* @param {number} lineIndex - 줄 번호
@@ -770,6 +806,20 @@ const formatListLine = (value, raw, block, itemIndex) => {
return clean ? `- ${clean}` : '- '
}
/**
* 빈 목록 마커 줄을 만든다.
* @param {Object} block - 목록 블록
* @param {number} itemIndex - 항목 인덱스
* @returns {string} 마크다운 줄
*/
const createEmptyListMarkerLine = (block, itemIndex) => {
if (block.ordered) {
return `${getListMarkerNumber(block, itemIndex)}. `
}
return '- '
}
/**
* 인용 블록을 줄 단위로 분리한다.
* @param {Object} block - 인용 블록
@@ -790,6 +840,18 @@ const getQuoteLines = (block) => {
return fromText.slice(0, lineCount)
}
/**
* 코드 블록 줄 번호 목록을 만든다.
* @param {Object} block - 코드 블록
* @returns {number[]} 줄 번호(1부터)
*/
const getCodeLineNumbers = (block) => {
const text = String(block.text ?? '')
const lineCount = text.length ? text.split('\n').length : 1
return Array.from({ length: lineCount }, (_, index) => index + 1)
}
/**
* 인라인 편집 결과를 마크다운 줄로 반영한다.
* @param {Object} block - 블록
@@ -814,15 +876,81 @@ const commitInlineBlockLines = (block, replacementLines) => {
* @param {string} text - 편집된 텍스트
* @returns {void}
*/
const onParagraphInlineCommit = (block, text) => {
const value = String(text ?? '')
/**
* 코드 블록 편집 반영
* @param {Object} block - 코드 블록
* @param {string[]} replacementLines - 마크다운 줄
* @returns {void}
*/
const onCodeBlockCommit = (block, replacementLines) => {
commitInlineBlockLines(block, replacementLines)
}
if (value.includes('\n')) {
commitInlineBlockLines(block, paragraphTextToSourceLines(value))
/**
* 코드 블록 마지막 줄에서 아래로 이탈 — 본문 저장 후 다음 문단 삽입
* @param {Object} block - 코드 블록
* @param {string|Object} payload - insert-below 페이로드
* @returns {void}
*/
const onCodeBlockInsertBelow = (block, payload) => {
const { value } = normalizeInsertBelowPayload(payload)
onCodeBlockCommit(block, buildCodeBlockLines({
language: block.codeLanguage,
showLineNumbers: block.codeShowLineNumbers,
body: value
}))
onInsertBelowBlock(block, { lines: [''] })
}
/**
* 콜아웃 편집 반영
* @param {Object} block - 콜아웃 블록
* @param {string[]} replacementLines - 마크다운 줄
* @returns {void}
*/
const onCalloutBlockCommit = (block, replacementLines) => {
commitInlineBlockLines(block, replacementLines)
}
/**
* 토글 편집 반영
* @param {Object} block - 토글 블록
* @param {string[]} replacementLines - 마크다운 줄
* @returns {void}
*/
const onToggleBlockCommit = (block, replacementLines) => {
commitInlineBlockLines(block, replacementLines)
}
/**
* 토글 본문 마지막 줄에서 아래로 이탈
* @param {Object} block - 토글 블록
* @param {string|Object} payload - insert-below 페이로드
* @returns {void}
*/
const onToggleBlockInsertBelow = (block, payload) => {
const { value } = normalizeInsertBelowPayload(payload)
onToggleBlockCommit(block, buildToggleBlockLines({
title: block.title,
body: value
}))
pendingFocusPosition.value = 'start'
pendingFocusOffset.value = 0
onInsertBelowBlock(block, { lines: [''] })
}
const onParagraphInlineCommit = (block, text) => {
const value = String(text ?? '').replace(/\r/g, '')
const lines = value.split('\n').map((line) => line.trimEnd())
if (lines.length <= 1) {
commitInlineBlockLines(block, [lines[0] ?? ''])
return
}
commitInlineBlockLines(block, [value])
commitInlineBlockLines(block, lines.filter((line) => line.length > 0))
}
/**
@@ -839,6 +967,27 @@ const onSpacerInlineCommit = (block, text) => {
commitInlineBlockLines(block, [text])
}
/**
* 문단 Enter 분리 결과 줄 배열을 만든다.
* @param {string} head - 커서 앞 텍스트
* @param {string} tail - 커서 뒤 텍스트
* @returns {string[]} 분리된 줄
*/
const buildParagraphSplitLines = (head, tail) => {
const h = String(head ?? '').replace(/\n/g, ' ')
const t = String(tail ?? '').replace(/\n/g, ' ')
if (!t.length) {
return [h, '']
}
if (!h.length) {
return ['', t]
}
return [h, t]
}
/**
* 문단 Enter 분리 — 마크다운에 빈 줄을 넣어 다음 문단을 만든다.
* @param {Object} block - 블록
@@ -854,23 +1003,11 @@ const onParagraphSplit = (block, { before, after }) => {
lastParagraphSplitAt = now
const head = String(before ?? '')
const tail = String(after ?? '')
let replacementLines = []
let focusLine = block.meta.startLine
if (!tail.length) {
replacementLines = [head, '']
focusLine = block.meta.startLine + 1
} else if (!head.length) {
replacementLines = ['', tail]
focusLine = block.meta.startLine + 1
} else {
replacementLines = [head, '', tail]
focusLine = block.meta.startLine + 2
}
const replacementLines = buildParagraphSplitLines(before, after)
const focusLine = block.meta.startLine + Math.max(replacementLines.length - 1, 1)
pendingFocusLine.value = focusLine
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, replacementLines)
}
@@ -900,9 +1037,27 @@ const onInsertBelowBlock = (block, options = {}) => {
* @returns {void}
*/
const onListItemInsertBelow = (block, itemIndex, payload) => {
const { value, raw } = normalizeCommitPayload(payload)
const { value, raw, before, after, caretAtStart, caretAtEnd } = normalizeInsertBelowPayload(payload)
const nextLines = getBlockSourceLines(block)
if (caretAtStart && after.length) {
nextLines[itemIndex] = formatListLine(after, raw, block, itemIndex)
nextLines.splice(itemIndex, 0, createEmptyListMarkerLine(block, itemIndex))
pendingFocusLine.value = block.meta.startLine + itemIndex
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, nextLines)
return
}
if (before.length && after.length) {
nextLines[itemIndex] = formatListLine(before, raw, block, itemIndex)
nextLines.splice(itemIndex + 1, 0, formatListLine(after, raw, block, itemIndex + 1))
pendingFocusLine.value = block.meta.startLine + itemIndex + 1
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, nextLines)
return
}
if (itemIndex < nextLines.length) {
nextLines[itemIndex] = formatListLine(value, raw, block, itemIndex)
}
@@ -917,10 +1072,13 @@ const onListItemInsertBelow = (block, itemIndex, payload) => {
return
}
nextLines.splice(itemIndex + 1, 0, '')
pendingFocusLine.value = block.meta.startLine + itemIndex + 1
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, nextLines)
if (caretAtEnd || !before.length) {
pendingFocusLine.value = (block.meta.endLine ?? block.meta.startLine) + 1
pendingFocusPosition.value = 'start'
onInsertBelowBlock(block, { lines: [''] })
}
}
/**
@@ -991,9 +1149,27 @@ const onQuoteLineInlineCommit = (block, lineIndex, payload) => {
* @returns {void}
*/
const onQuoteLineInsertBelow = (block, lineIndex, payload) => {
const { value, raw } = normalizeCommitPayload(payload)
const { value, raw, before, after, caretAtStart } = normalizeInsertBelowPayload(payload)
const nextLines = getBlockSourceLines(block)
if (caretAtStart && after.length) {
nextLines[lineIndex] = formatQuoteLine(after, raw)
nextLines.splice(lineIndex, 0, '> ')
pendingFocusLine.value = block.meta.startLine + lineIndex
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, nextLines)
return
}
if (before.length && after.length) {
nextLines[lineIndex] = formatQuoteLine(before, raw)
nextLines.splice(lineIndex + 1, 0, formatQuoteLine(after, raw))
pendingFocusLine.value = block.meta.startLine + lineIndex + 1
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, nextLines)
return
}
if (lineIndex < nextLines.length) {
nextLines[lineIndex] = formatQuoteLine(value, raw)
}
@@ -1056,6 +1232,35 @@ const onDeleteLine = (lineIndex) => {
emit('delete-line', lineIndex)
}
/**
* 현재 줄을 이전 줄 끝에 병합한다.
* @param {number} lineIndex - 줄 번호
* @param {string|Object} payload - 편집 내용
* @returns {void}
*/
const onMergeWithPreviousLine = (lineIndex, payload) => {
if (typeof lineIndex !== 'number' || lineIndex <= 0) {
return
}
const { value, raw } = normalizeCommitPayload(payload)
const lines = String(props.content || '').split('\n')
const prevLine = lines[lineIndex - 1] ?? ''
const appendText = getAppendTextForMerge(value, prevLine, raw)
if (!appendText) {
onDeleteLine(lineIndex)
return
}
const mergedLine = appendTextToMarkdownLine(prevLine, appendText)
pendingFocusLine.value = lineIndex - 1
pendingFocusPosition.value = 'auto'
pendingFocusOffset.value = getMergeJunctionDisplayOffset(prevLine, raw)
emit('merge-with-previous-line', { lineIndex, mergedLine })
}
/**
* 줄바꿈이 포함된 인라인 마크다운을 줄 단위 세그먼트로 변환한다.
* @param {string} value - 원본 문자열
@@ -1267,12 +1472,17 @@ onBeforeUnmount(() => {
tag="p"
block-class="content-markdown-renderer__paragraph content-markdown-renderer__spacer-line text-base text-[var(--site-text)]"
enter-mode="split-paragraph"
allow-hard-break
:slash-menu-active="slashMenuActive"
:slash-command-suppressed="slashSuppressedLines.includes(block.meta.startLine)"
:source-line="block.meta.startLine"
:model-value="''"
@commit="onSpacerInlineCommit(block, $event)"
@split="onParagraphSplit(block, $event)"
@delete-line="onDeleteLine"
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
@slash-update="emit('slash-update', $event)"
@slash-end="emit('slash-end', $event)"
@slash-apply="emit('slash-apply', $event)"
/>
<div v-else-if="block.type === 'spacer'" class="content-markdown-renderer__spacer" :class="getSpacerHeightClass(block)" aria-hidden="true" />
<ContentMarkdownEditableInline
@@ -1287,6 +1497,7 @@ onBeforeUnmount(() => {
@commit="onHeadingInlineCommit(block, $event)"
@insert-below="onInsertBelowBlock(block)"
@delete-line="onDeleteLine"
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
/>
<ProseHeading v-else-if="block.type === 'heading'" :level="block.level">
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
@@ -1313,6 +1524,7 @@ onBeforeUnmount(() => {
@commit="onQuoteLineInlineCommit(block, quoteLineIndex, $event)"
@insert-below="onQuoteLineInsertBelow(block, quoteLineIndex, $event)"
@delete-line="onDeleteLine"
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine + quoteLineIndex, $event)"
@raw-mode="onInlineRawMode"
/>
</ProseBlockquote>
@@ -1352,6 +1564,7 @@ onBeforeUnmount(() => {
@commit="onListItemInlineCommit(block, itemIndex, $event)"
@insert-below="onListItemInsertBelow(block, itemIndex, $event)"
@delete-line="onDeleteLine"
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine + itemIndex, $event)"
@raw-mode="onInlineRawMode"
/>
</li>
@@ -1390,6 +1603,15 @@ onBeforeUnmount(() => {
:caption="getImageDisplayCaption(block)"
:variant="block.width"
/>
<ContentMarkdownCalloutEditor
v-else-if="block.type === 'callout' && interactive"
:callout-emoji-enabled="block.calloutEmojiEnabled"
:callout-emoji="block.calloutEmoji"
:callout-background="block.calloutBackground"
:body-source-line="block.meta.startLine + 1"
:model-value="block.text"
@commit="onCalloutBlockCommit(block, $event)"
/>
<ProseCallout
v-else-if="block.type === 'callout'"
:emoji-enabled="block.calloutEmojiEnabled"
@@ -1404,7 +1626,16 @@ onBeforeUnmount(() => {
<template v-else>{{ segment.text }}</template>
</template>
</ProseCallout>
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
<ContentMarkdownToggleEditor
v-else-if="block.type === 'toggle' && interactive"
:title="block.title"
: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>
<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>
@@ -1467,22 +1698,43 @@ onBeforeUnmount(() => {
</figcaption>
</figure>
</div>
<pre
<ContentMarkdownCodeBlockEditor
v-else-if="block.type === 'code' && interactive"
:language="block.codeLanguage"
:show-line-numbers="block.codeShowLineNumbers"
:body-source-line="block.meta.startLine + 1"
:model-value="block.text"
@commit="onCodeBlockCommit(block, $event)"
@insert-below="onCodeBlockInsertBelow(block, $event)"
/>
<ProseCodeBlock
v-else-if="block.type === 'code'"
class="content-markdown-renderer__code overflow-x-auto rounded bg-[#15171a] px-4 py-3 mb-2.5 text-sm leading-6 text-white"
><code>{{ block.text }}</code></pre>
class="content-markdown-renderer__code"
:language="block.codeLanguage"
:show-line-numbers="block.codeShowLineNumbers"
:line-numbers="getCodeLineNumbers(block)"
show-copy
:copy-text="block.text"
>
<code>{{ block.text }}</code>
</ProseCodeBlock>
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-5 border-line">
<ContentMarkdownEditableInline
v-else-if="block.type === 'paragraph' && interactive"
tag="p"
block-class="content-markdown-renderer__paragraph content-markdown-renderer__paragraph--editable mb-2.5 min-h-[1.75rem] text-base text-[var(--site-text)] last:mb-0"
enter-mode="split-paragraph"
allow-hard-break
:slash-menu-active="slashMenuActive"
:slash-command-suppressed="slashSuppressedLines.includes(block.meta.startLine)"
:source-line="block.meta.startLine"
:model-value="block.text"
@commit="onParagraphInlineCommit(block, $event)"
@split="onParagraphSplit(block, $event)"
@delete-line="onDeleteLine"
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
@slash-update="emit('slash-update', $event)"
@slash-end="emit('slash-end', $event)"
@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}`">
@@ -1593,6 +1845,10 @@ onBeforeUnmount(() => {
min-height: 1.75rem;
}
.content-markdown-renderer {
user-select: text;
}
.content-markdown-renderer__gallery-item--interactive {
cursor: grab;
}

View File

@@ -0,0 +1,154 @@
<script setup>
import { buildToggleBlockLines } from '../../lib/markdown-toggle.js'
import ProseToggle from './ProseToggle.vue'
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
const props = defineProps({
/** 토글 제목 */
title: {
type: String,
default: ''
},
/** 토글 본문 */
modelValue: {
type: String,
default: ''
},
/** 선언 줄 source-line(0-based) */
titleSourceLine: {
type: Number,
required: true
},
/** 본문 첫 줄 source-line(0-based) */
bodySourceLine: {
type: Number,
required: true
}
})
const emit = defineEmits(['commit', 'insert-below'])
const titleEditorRef = ref(null)
const bodyEditorRef = ref(null)
const titleDraft = ref(props.title)
watch(() => props.title, (value) => {
titleDraft.value = value
})
/**
* 토글 마크다운을 반영한다.
* @param {{ title?: string, body?: string }} options - 옵션
* @returns {void}
*/
const commitToggle = (options = {}) => {
emit('commit', buildToggleBlockLines({
title: options.title ?? titleDraft.value,
body: options.body ?? props.modelValue
}))
}
/**
* 제목 편집 반영
* @param {string} value - 제목
* @returns {void}
*/
const onTitleCommit = (value) => {
titleDraft.value = String(value ?? '').trim()
commitToggle({ title: titleDraft.value })
}
/**
* 제목 필드 이탈 전 로컬 초안 동기화(한글 조합·↓ 이동 시 본문 오염 방지)
* @returns {void}
*/
const syncTitleDraft = () => {
titleDraft.value = String(titleEditorRef.value?.readEditorValue?.() ?? titleDraft.value).trim()
}
/**
* 제목 Enter — 본문으로 포커스 이동
* @returns {void}
*/
const onTitleEnterAdvance = () => {
const nextTitle = String(titleEditorRef.value?.readEditorValue?.() ?? titleDraft.value).trim()
titleDraft.value = nextTitle
commitToggle({ title: titleDraft.value })
nextTick(() => {
bodyEditorRef.value?.focusEditor('start')
})
}
/**
* 본문 편집 반영
* @param {string} body - 본문
* @returns {void}
*/
const onBodyCommit = (body) => {
commitToggle({ body })
}
/**
* 토글 아래로 이탈
* @param {Object} payload - insert-below 페이로드
* @returns {void}
*/
const onExitBelow = (payload) => {
emit('insert-below', payload)
}
</script>
<template>
<ProseToggle
class="content-markdown-toggle-editor"
data-editable-scope
:title="titleDraft"
default-open
animated
>
<template #title>
<ContentMarkdownEditableInline
ref="titleEditorRef"
tag="div"
block-class="content-markdown-toggle-editor__title min-h-[1.75rem] outline-none"
enter-mode="focus-next"
navigation-scope="parent"
:source-line="titleSourceLine"
:model-value="titleDraft"
@mousedown.stop
@click.stop
@commit="onTitleCommit"
@enter-advance="onTitleEnterAdvance"
@leave-block="syncTitleDraft"
/>
</template>
<ContentMarkdownEditableInline
ref="bodyEditorRef"
tag="div"
block-class="content-markdown-toggle-editor__body min-h-[3rem] outline-none"
enter-mode="multiline"
navigation-scope="parent"
plain-text
:source-line="bodySourceLine"
:model-value="modelValue"
@commit="onBodyCommit"
@insert-below="onExitBelow"
/>
</ProseToggle>
</template>
<style scoped>
.content-markdown-toggle-editor__title:empty::before {
content: '토글 제목';
color: var(--site-muted);
pointer-events: none;
}
.content-markdown-toggle-editor__body:empty::before {
content: '펼쳤을 때 보일 내용을 입력하세요';
color: var(--site-muted);
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,122 @@
<script setup>
const props = defineProps({
/** 언어 라벨(공개 화면 표시) */
language: {
type: String,
default: ''
},
/** 줄 번호 표시 */
showLineNumbers: {
type: Boolean,
default: false
},
/** 복사 버튼 표시(공개 화면) */
showCopy: {
type: Boolean,
default: false
},
/** 복사할 코드 본문 */
copyText: {
type: String,
default: ''
},
/** 줄 번호 목록 */
lineNumbers: {
type: Array,
default: () => [1]
}
})
const copyDone = ref(false)
let copyDoneTimer = null
/**
* 코드 본문을 클립보드에 복사한다.
* @returns {Promise<void>}
*/
const copyToClipboard = async () => {
const text = String(props.copyText ?? '')
if (!import.meta.client || !text) {
return
}
try {
await navigator.clipboard.writeText(text)
copyDone.value = true
window.clearTimeout(copyDoneTimer)
copyDoneTimer = window.setTimeout(() => {
copyDone.value = false
}, 1600)
} catch {
copyDone.value = false
}
}
onBeforeUnmount(() => {
window.clearTimeout(copyDoneTimer)
})
</script>
<template>
<div
class="prose-code-block group relative mb-2.5 overflow-x-auto rounded bg-[#15171a] text-sm leading-6 text-white"
>
<div
v-if="$slots['header-tools'] || showCopy || language"
class="prose-code-block__header absolute right-3 top-2 z-10 flex items-center gap-2"
>
<slot name="header-tools" />
<span
v-if="language && !$slots['header-tools']"
class="prose-code-block__language text-xs text-white/50"
>{{ language }}</span>
<button
v-if="showCopy"
class="prose-code-block__copy rounded px-2 py-0.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/10 hover:text-white"
type="button"
@click="copyToClipboard"
>
{{ copyDone ? '복사됨' : '복사' }}
</button>
</div>
<div class="prose-code-block__body flex">
<div
v-if="showLineNumbers"
class="prose-code-block__gutter shrink-0 select-none border-r border-white/10 py-3 pl-3 pr-2 font-mono text-xs leading-6 text-white/40 tabular-nums"
aria-hidden="true"
>
<div
v-for="lineNumber in lineNumbers"
:key="`prose-code-gutter-${lineNumber}`"
class="prose-code-block__gutter-line"
>
{{ lineNumber }}
</div>
</div>
<div class="prose-code-block__content min-w-0 flex-1 px-4 py-3 font-mono text-sm leading-6">
<slot />
</div>
</div>
</div>
</template>
<style scoped>
.prose-code-block:focus-within {
outline: 2px solid rgb(255 255 255 / 0.22);
outline-offset: 0;
}
.prose-code-block__content :deep(code) {
display: block;
white-space: pre-wrap;
word-break: break-word;
background: transparent;
padding: 0;
color: inherit;
font-size: inherit;
line-height: inherit;
}
</style>

View File

@@ -1,19 +1,112 @@
<script setup>
defineProps({
const props = defineProps({
/** 접힌 상태 제목 */
title: {
type: String,
required: true
default: ''
},
/** 초기 펼침 여부 */
defaultOpen: {
type: Boolean,
default: false
},
/** 본문 열림·닫힘 애니메이션 */
animated: {
type: Boolean,
default: true
}
})
const isOpen = ref(props.defaultOpen)
watch(() => props.defaultOpen, (value) => {
isOpen.value = value
})
/**
* 토글 펼침 상태를 전환한다.
* @returns {void}
*/
const toggleOpen = () => {
isOpen.value = !isOpen.value
}
</script>
<template>
<details class="prose-toggle my-6 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
<summary class="prose-toggle__summary cursor-pointer text-[15px] font-semibold leading-7 text-[var(--site-text)]">
{{ title }}
</summary>
<div class="prose-toggle__body mt-4 whitespace-pre-line text-[15px] leading-8 text-[var(--site-muted)]">
<slot />
<div
class="prose-toggle my-6 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5"
:class="{ 'prose-toggle--open': isOpen }"
>
<div class="prose-toggle__header flex items-start gap-2">
<button
class="prose-toggle__trigger mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-[var(--site-muted)] transition-colors hover:bg-black/5 hover:text-[var(--site-text)]"
type="button"
:aria-expanded="isOpen"
aria-label="토글 펼치기·접기"
@click="toggleOpen"
>
<svg
class="prose-toggle__chevron size-4 transition-transform duration-300 ease-out"
:class="{ 'prose-toggle__chevron--open': isOpen }"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M6 4l4 4-4 4"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<div class="prose-toggle__title min-w-0 flex-1 text-[15px] font-semibold leading-7 text-[var(--site-text)]">
<slot name="title">
{{ title || '더 보기' }}
</slot>
</div>
</div>
</details>
<div
class="prose-toggle__body-shell"
:class="[
animated ? 'prose-toggle__body-shell--animated' : '',
isOpen ? 'prose-toggle__body-shell--open' : ''
]"
>
<div class="prose-toggle__body-inner min-h-0 overflow-hidden">
<div
class="prose-toggle__body mt-4 whitespace-pre-line text-[15px] leading-8 text-[var(--site-muted)]"
>
<slot />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.prose-toggle__chevron--open {
transform: rotate(90deg);
}
.prose-toggle__body-shell--animated {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.32s ease;
}
.prose-toggle__body-shell--animated.prose-toggle__body-shell--open {
grid-template-rows: 1fr;
}
.prose-toggle__body-shell:not(.prose-toggle__body-shell--animated) .prose-toggle__body-inner {
display: none;
}
.prose-toggle__body-shell:not(.prose-toggle__body-shell--animated).prose-toggle__body-shell--open .prose-toggle__body-inner {
display: block;
}
</style>