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

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

@@ -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('**', '**', '굵은 글씨')