게시물 라이브 편집 블록 동작 개선
This commit is contained in:
@@ -316,6 +316,117 @@ const getLineIndexAtOffset = (offset) => {
|
||||
return Math.max(0, value.slice(0, safeOffset).split('\n').length - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 닫는 fenced 줄에서 대응하는 여는 줄을 찾는다.
|
||||
* @param {string[]} lines - 마크다운 줄
|
||||
* @param {number} closingLine - 닫는 줄 번호
|
||||
* @param {(line: string) => boolean} isOpeningLine - 여는 줄 판별
|
||||
* @returns {number|null} 여는 줄 번호
|
||||
*/
|
||||
const findPreviousFencedOpeningLine = (lines, closingLine, isOpeningLine) => {
|
||||
for (let index = closingLine - 1; index >= 0; index -= 1) {
|
||||
const trimmed = String(lines[index] ?? '').trim()
|
||||
|
||||
if (isOpeningLine(trimmed)) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 여는 fenced 줄에서 닫는 줄을 찾는다.
|
||||
* @param {string[]} lines - 마크다운 줄
|
||||
* @param {number} openingLine - 여는 줄 번호
|
||||
* @param {string} closingMarker - 닫는 표식
|
||||
* @returns {number|null} 닫는 줄 번호
|
||||
*/
|
||||
const findNextFencedClosingLine = (lines, openingLine, closingMarker) => {
|
||||
for (let index = openingLine + 1; index < lines.length; index += 1) {
|
||||
if (String(lines[index] ?? '').trim() === closingMarker) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 줄이 속한 코드 펜스 범위를 찾는다.
|
||||
* @param {string[]} lines - 마크다운 줄
|
||||
* @param {number} targetLine - 확인할 줄 번호
|
||||
* @returns {{ openingLine: number, closingLine: number|null }|null} 코드 펜스 범위
|
||||
*/
|
||||
const findCodeFenceRangeAtLine = (lines, targetLine) => {
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (!String(lines[index] ?? '').trim().startsWith('```')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const closingLine = findNextFencedClosingLine(lines, index, '```')
|
||||
|
||||
if (targetLine === index || (closingLine !== null && targetLine >= index && targetLine <= closingLine)) {
|
||||
return { openingLine: index, closingLine }
|
||||
}
|
||||
|
||||
if (closingLine !== null) {
|
||||
index = closingLine
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 모드에 실제로 존재하는 편집 줄로 포커스 대상을 보정한다.
|
||||
* @param {number} line - 원본 줄 번호
|
||||
* @param {number} offset - 원본 줄 내부 오프셋
|
||||
* @returns {{ line: number, offset: number }} 라이브 포커스 대상
|
||||
*/
|
||||
const resolvePreviewFocusPosition = (line, offset) => {
|
||||
const lines = (markdownValue.value ?? '').split('\n')
|
||||
const safeLine = Math.min(Math.max(0, line), Math.max(0, lines.length - 1))
|
||||
const trimmed = String(lines[safeLine] ?? '').trim()
|
||||
|
||||
if (trimmed.startsWith('```')) {
|
||||
const codeFenceRange = findCodeFenceRangeAtLine(lines, safeLine)
|
||||
|
||||
if (!codeFenceRange || codeFenceRange.closingLine === null) {
|
||||
return { line: safeLine, offset }
|
||||
}
|
||||
|
||||
const focusLine = Math.min(
|
||||
Math.max(codeFenceRange.openingLine + 1, safeLine),
|
||||
Math.max(codeFenceRange.openingLine + 1, codeFenceRange.closingLine - 1)
|
||||
)
|
||||
|
||||
return { line: focusLine, offset: safeLine === codeFenceRange.openingLine ? 0 : offset }
|
||||
}
|
||||
|
||||
if (trimmed.startsWith(':::callout') || trimmed.startsWith(':::toggle')) {
|
||||
return { line: safeLine + 1, offset: 0 }
|
||||
}
|
||||
|
||||
if (trimmed === ':::') {
|
||||
const previousFencedOpening = findPreviousFencedOpeningLine(
|
||||
lines,
|
||||
safeLine,
|
||||
(candidate) => candidate.startsWith(':::callout') || candidate.startsWith(':::toggle')
|
||||
)
|
||||
|
||||
if (previousFencedOpening !== null) {
|
||||
return { line: Math.max(previousFencedOpening + 1, safeLine - 1), offset }
|
||||
}
|
||||
}
|
||||
|
||||
if (/^>\s*(?:\[!bg=|\{bg=)/.test(trimmed) && String(lines[safeLine + 1] ?? '').trim().startsWith('>')) {
|
||||
return { line: safeLine + 1, offset: 0 }
|
||||
}
|
||||
|
||||
return { line: safeLine, offset }
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 textarea 선택 위치를 라이브 모드 포커스 대상으로 저장한다.
|
||||
* @returns {void}
|
||||
@@ -327,10 +438,12 @@ const rememberWritePositionForPreview = () => {
|
||||
? Math.min(textarea.selectionStart, value.length)
|
||||
: Math.min(lastSelectionState.value.start, value.length)
|
||||
const line = getLineIndexAtOffset(start)
|
||||
const offset = Math.max(0, start - getLineStartOffset(line))
|
||||
const focusPosition = resolvePreviewFocusPosition(line, offset)
|
||||
|
||||
pendingPreviewFocus.value = {
|
||||
line,
|
||||
offset: Math.max(0, start - getLineStartOffset(line))
|
||||
line: focusPosition.line,
|
||||
offset: focusPosition.offset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1106,6 +1219,20 @@ const wrapInline = (prefix, suffix, placeholder) => {
|
||||
setTextareaSelection(selectionStart, selectionStart + inner.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 텍스트를 링크 마크다운으로 바꾼다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertInlineLink = () => {
|
||||
const { start, end, value } = getSelectionState()
|
||||
const selected = value.slice(start, end) || '링크 텍스트'
|
||||
const replacement = `[${selected}](https://)`
|
||||
|
||||
markdownValue.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`
|
||||
const urlStart = start + selected.length + 3
|
||||
setTextareaSelection(urlStart, urlStart + 'https://'.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 줄을 변환한다.
|
||||
* @param {(line: string) => string} transformLine - 줄 변환 함수
|
||||
@@ -2819,6 +2946,10 @@ const handleKeydown = (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
deleteSelectedSourceLines()
|
||||
} else if (key === 'k') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
insertInlineLink()
|
||||
} else if (key === 'b') {
|
||||
event.preventDefault()
|
||||
wrapInline('**', '**', '굵은 글씨')
|
||||
|
||||
@@ -101,6 +101,7 @@ const onBodyCommit = (payload) => {
|
||||
block-class="content-markdown-callout-editor__body min-w-0 text-[15px] leading-8 text-[var(--site-text)]"
|
||||
enter-mode="multiline"
|
||||
plain-text
|
||||
arrow-exit-creates-line
|
||||
:source-line="bodySourceLine"
|
||||
:source-line-count="bodyLines.length"
|
||||
:model-value="modelValue"
|
||||
|
||||
@@ -156,7 +156,9 @@ const toggleLineNumbers = () => {
|
||||
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
|
||||
arrow-exit-creates-line
|
||||
:source-line="bodySourceLine"
|
||||
:source-line-count="bodyLines.length"
|
||||
:model-value="modelValue"
|
||||
@input="onBodyInput"
|
||||
@commit="onBodyCommit"
|
||||
|
||||
@@ -653,6 +653,43 @@ const buildInsertBelowPayload = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 영역의 현재 선택 텍스트를 링크 마크다운으로 바꾼다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertMarkdownLinkAtSelection = () => {
|
||||
if (!import.meta.client || !rootRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
if (!rootRef.value.contains(range.commonAncestorContainer)) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedText = selection.toString() || '링크 텍스트'
|
||||
const markdown = `[${selectedText}](https://)`
|
||||
|
||||
document.execCommand('insertText', false, markdown)
|
||||
syncSlashState()
|
||||
|
||||
nextTick(() => {
|
||||
if (!rootRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('input', readEditorValue())
|
||||
emit('commit', readEditorValue())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 커서 위치에서 문단을 분리한다.
|
||||
* @returns {void}
|
||||
@@ -792,6 +829,13 @@ const onKeydown = (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
insertMarkdownLinkAtSelection()
|
||||
return
|
||||
}
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'k') {
|
||||
if (props.sourceLine !== null) {
|
||||
const lineContext = getCaretLineContext()
|
||||
|
||||
@@ -17,9 +17,14 @@ import {
|
||||
stripListMarker,
|
||||
stripQuoteMarker
|
||||
} from '../../lib/markdown-live-edit.js'
|
||||
import { buildCodeBlockLines, parseCodeFenceLine } from '../../lib/markdown-code-block.js'
|
||||
import { buildCodeBlockLines, buildCodeFenceOpener, parseCodeFenceLine } from '../../lib/markdown-code-block.js'
|
||||
import { buildToggleBlockLines, parseToggleOpenerLine } from '../../lib/markdown-toggle.js'
|
||||
import { CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS, parseCalloutOptions } from '../../lib/markdown-callout.js'
|
||||
import {
|
||||
buildCalloutOpenerLine,
|
||||
CALLOUT_BACKGROUND_OPTIONS,
|
||||
QUOTE_BACKGROUND_OPTIONS,
|
||||
parseCalloutOptions
|
||||
} from '../../lib/markdown-callout.js'
|
||||
import { createHeadingIdFactory } from '../../lib/markdown-toc.js'
|
||||
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||
@@ -631,21 +636,29 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`, {
|
||||
codeLanguage: fenceOptions.language,
|
||||
codeShowLineNumbers: fenceOptions.showLineNumbers
|
||||
}),
|
||||
startLine,
|
||||
index
|
||||
))
|
||||
index += 1
|
||||
continue
|
||||
if (index >= lines.length) {
|
||||
index = startLine
|
||||
} else {
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`, {
|
||||
codeLanguage: fenceOptions.language,
|
||||
codeShowLineNumbers: fenceOptions.showLineNumbers
|
||||
}),
|
||||
startLine,
|
||||
index
|
||||
))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmedLine === '---') {
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(createBlock('divider', '', null, `block-${blocks.length}`), startLine, startLine))
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('divider', '', null, `block-${blocks.length}`),
|
||||
startLine,
|
||||
startLine
|
||||
))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
@@ -907,6 +920,19 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
|
||||
}
|
||||
|
||||
if (typeof caretOffset === 'number' && caretOffset >= 0) {
|
||||
if (isRangedLine && Number.isInteger(elementSourceLine)) {
|
||||
const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element))
|
||||
const textLines = text.length ? text.split('\n') : ['']
|
||||
const targetLineIndex = Math.max(0, Math.min(lineIndex - elementSourceLine, textLines.length - 1))
|
||||
const lineOffset = textLines
|
||||
.slice(0, targetLineIndex)
|
||||
.reduce((sum, textLine) => sum + textLine.length + 1, 0)
|
||||
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), lineOffset + caretOffset)
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
return
|
||||
}
|
||||
|
||||
setEditableCaretOffset(/** @type {HTMLElement} */ (element), caretOffset)
|
||||
element.scrollIntoView({ block: scrollBlock, inline: 'nearest' })
|
||||
return
|
||||
@@ -1415,6 +1441,49 @@ const buildParagraphSplitLines = (head, tail) => {
|
||||
return [h, t]
|
||||
}
|
||||
|
||||
/**
|
||||
* 문단 Enter 입력을 블록 단축 입력으로 변환할지 확인한다.
|
||||
* @param {Object} block - 문단 블록
|
||||
* @param {string} before - 커서 앞 텍스트
|
||||
* @param {string} after - 커서 뒤 텍스트
|
||||
* @returns {boolean} 변환 처리 여부
|
||||
*/
|
||||
const applyParagraphShortcutSplit = (block, before, after) => {
|
||||
const head = String(before ?? '').trim()
|
||||
const tail = String(after ?? '')
|
||||
|
||||
if (tail.trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (/^```[A-Za-z0-9_-]*$/.test(head)) {
|
||||
const opener = head === '```' ? buildCodeFenceOpener({ language: '', showLineNumbers: true }) : head
|
||||
|
||||
pendingFocusLine.value = block.meta.startLine + 1
|
||||
pendingFocusPosition.value = 'start'
|
||||
commitInlineBlockLines(block, [opener, '', '```'])
|
||||
return true
|
||||
}
|
||||
|
||||
if (head === '!!!' || head === ':::callout') {
|
||||
pendingFocusLine.value = block.meta.startLine + 1
|
||||
pendingFocusPosition.value = 'start'
|
||||
commitInlineBlockLines(block, [
|
||||
buildCalloutOpenerLine({
|
||||
calloutEmojiEnabled: false,
|
||||
calloutEmoji: '💡',
|
||||
calloutBackground: 'blue',
|
||||
title: ''
|
||||
}),
|
||||
'',
|
||||
':::'
|
||||
])
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 문단 Enter 분리 — 마크다운에 빈 줄을 넣어 다음 문단을 만든다.
|
||||
* @param {Object} block - 블록
|
||||
@@ -1430,6 +1499,10 @@ const onParagraphSplit = (block, { before, after }) => {
|
||||
|
||||
lastParagraphSplitAt = now
|
||||
|
||||
if (applyParagraphShortcutSplit(block, before, after)) {
|
||||
return
|
||||
}
|
||||
|
||||
const replacementLines = buildParagraphSplitLines(before, after)
|
||||
const focusLine = block.meta.startLine + Math.max(replacementLines.length - 1, 1)
|
||||
|
||||
|
||||
@@ -137,7 +137,9 @@ const onExitBelow = (payload) => {
|
||||
enter-mode="multiline"
|
||||
navigation-scope="parent"
|
||||
plain-text
|
||||
arrow-exit-creates-line
|
||||
:source-line="bodySourceLine"
|
||||
:source-line-count="String(modelValue ?? '').split('\n').length"
|
||||
:model-value="modelValue"
|
||||
@commit="onBodyCommit"
|
||||
@insert-below="onExitBelow"
|
||||
|
||||
@@ -41,12 +41,12 @@ const backgroundClass = computed(() => {
|
||||
|
||||
<template>
|
||||
<blockquote
|
||||
class="prose-blockquote mb-2.5 text-[15px] leading-8"
|
||||
class="prose-blockquote mb-5 text-[15px] leading-8"
|
||||
:class="variant === 'alt'
|
||||
? 'rounded-[14px] border border-[var(--site-line)] bg-[var(--site-panel)] px-6 py-5 italic text-[var(--site-text)]'
|
||||
: ['rounded-[10px] border-l-2 px-5 py-4 font-medium text-[#15171a]', backgroundClass]"
|
||||
: ['border-l-[3px] bg-transparent py-1 pl-5 pr-0 font-normal text-[#15171a]', backgroundClass]"
|
||||
>
|
||||
<span class="whitespace-pre-line">
|
||||
<span class="block whitespace-pre-line">
|
||||
<slot />
|
||||
</span>
|
||||
</blockquote>
|
||||
@@ -55,31 +55,25 @@ const backgroundClass = computed(() => {
|
||||
<style scoped>
|
||||
.prose-blockquote--gray {
|
||||
border-color: #050505;
|
||||
background: color-mix(in srgb, #050505 10%, #ffffff);
|
||||
}
|
||||
|
||||
.prose-blockquote--blue {
|
||||
border-color: #0055ff;
|
||||
background: color-mix(in srgb, #0055ff 10%, #ffffff);
|
||||
}
|
||||
|
||||
.prose-blockquote--green {
|
||||
border-color: #16ae68;
|
||||
background: color-mix(in srgb, #16ae68 10%, #ffffff);
|
||||
}
|
||||
|
||||
.prose-blockquote--yellow {
|
||||
border-color: #ffff00;
|
||||
background: color-mix(in srgb, #ffff00 10%, #ffffff);
|
||||
}
|
||||
|
||||
.prose-blockquote--red {
|
||||
border-color: #ff0000;
|
||||
background: color-mix(in srgb, #ff0000 10%, #ffffff);
|
||||
}
|
||||
|
||||
.prose-blockquote--purple {
|
||||
border-color: #8800ff;
|
||||
background: color-mix(in srgb, #8800ff 10%, #ffffff);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -63,25 +63,31 @@ const backgroundClass = computed(() => {
|
||||
<style scoped>
|
||||
.prose-callout--gray {
|
||||
background: color-mix(in srgb, #050505 10%, #ffffff);
|
||||
border: 1px solid #050505;
|
||||
}
|
||||
|
||||
.prose-callout--blue {
|
||||
background: color-mix(in srgb, #0055ff 10%, #ffffff);
|
||||
border: 1px solid #0055ff;
|
||||
}
|
||||
|
||||
.prose-callout--green {
|
||||
background: color-mix(in srgb, #16ae68 10%, #ffffff);
|
||||
border: 1px solid #16ae68;
|
||||
}
|
||||
|
||||
.prose-callout--yellow {
|
||||
background: color-mix(in srgb, #ffff00 10%, #ffffff);
|
||||
border: 1px solid #ffff00;
|
||||
}
|
||||
|
||||
.prose-callout--red {
|
||||
background: color-mix(in srgb, #ff0000 10%, #ffffff);
|
||||
border: 1px solid #ff0000;
|
||||
}
|
||||
|
||||
.prose-callout--purple {
|
||||
background: color-mix(in srgb, #8800ff 10%, #ffffff);
|
||||
border: 1px solid #8800ff;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user