v1.2.9: 라이브 에디터·홈 피드·메인 커버 개선
라이브 모드 코드/콜아웃/토글 편집, 슬래시 명령, 홈 Latest List·Compact·Cards 보기, 사이트 설정 메인 화면 커버(720px) 및 HomeHero 반영. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user