게시물 라이브 편집 블록 동작 개선

This commit is contained in:
2026-06-05 10:38:00 +09:00
parent 264f551cb4
commit 09b6c51048
14 changed files with 306 additions and 35 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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()

View File

@@ -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)

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>