라이브 편집 선택·콜아웃·인용 안정화 및 오른쪽 사이드바 여백 보정 (v1.5.70)

Selection Bridge로 블록 간 선택·삭제를 보강하고, 콜아웃·인용 멀티라인 Enter·전체 선택 삭제·한글 IME 문제를 수정했다. Obsidian식 위첨자 문법과 RightSidebar 패딩·커스텀 아이콘 색상도 함께 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-05 15:27:06 +09:00
parent 4c0875446b
commit 928b8446b4
13 changed files with 1458 additions and 117 deletions

View File

@@ -1,5 +1,5 @@
/** @type {RegExp} 인라인 마크다운 패턴 */
const INLINE_MARKDOWN_RE = /(\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
const INLINE_MARKDOWN_RE = /(\$([^$\n]+?)\$|\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
/**
* HTML 특수문자 이스케이프
@@ -12,6 +12,120 @@ export const escapeHtml = (value) => String(value || '')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
/**
* 위첨자·아래첨자 토큰 본문을 읽는다.
* @param {string} source - 수식 본문
* @param {number} startIndex - 토큰 시작 위치
* @returns {{ text: string, nextIndex: number }}
*/
const readScriptToken = (source, startIndex) => {
const first = source[startIndex]
if (!first) {
return { text: '', nextIndex: startIndex }
}
if (first === '{') {
const closeIndex = source.indexOf('}', startIndex + 1)
if (closeIndex > startIndex) {
return {
text: source.slice(startIndex + 1, closeIndex),
nextIndex: closeIndex + 1
}
}
}
if (/\d/.test(first)) {
let index = startIndex
while (index < source.length && /\d/.test(source[index])) {
index += 1
}
return {
text: source.slice(startIndex, index),
nextIndex: index
}
}
if (/[A-Za-z가-힣]/.test(first)) {
let index = startIndex
while (index < source.length && source[index] !== '_' && source[index] !== '^') {
index += 1
}
return {
text: source.slice(startIndex, index),
nextIndex: index
}
}
return {
text: first,
nextIndex: startIndex + 1
}
}
/**
* Obsidian식 `$H_2O$` 인라인 수식을 세그먼트로 변환한다.
* @param {string} value - 수식 본문
* @returns {Array<{ type: string, text: string }>} 인라인 세그먼트
*/
export const parseObsidianMathSegments = (value) => {
const source = String(value || '')
const segments = []
let textBuffer = ''
let index = 0
const flushText = () => {
if (!textBuffer) {
return
}
segments.push({ type: 'text', text: textBuffer })
textBuffer = ''
}
while (index < source.length) {
const marker = source[index]
if ((marker === '_' || marker === '^') && index < source.length - 1) {
const token = readScriptToken(source, index + 1)
if (token.text) {
flushText()
segments.push({
type: marker === '_' ? 'subscript' : 'superscript',
text: token.text
})
index = token.nextIndex
continue
}
}
textBuffer += marker
index += 1
}
flushText()
return segments
}
/**
* 위첨자·아래첨자를 Obsidian식 `$...$` 토큰으로 직렬화한다.
* @param {'_'|'^'} marker - 첨자 기호
* @param {string} value - 첨자 본문
* @returns {string}
*/
const formatScriptMarkdown = (marker, value) => {
const text = String(value || '')
const body = /^[A-Za-z0-9가-힣]+$/.test(text) ? text : `{${text}}`
return `$${marker}${body}$`
}
/**
* 인라인 마크다운을 표시 세그먼트로 변환한다.
* @param {string} value - 원본 문자열
@@ -32,27 +146,29 @@ export const parseInlineSegments = (value) => {
})
}
if (match[2] && match[3]) {
if (match[2]) {
segments.push(...parseObsidianMathSegments(match[2]))
} else if (match[3] && match[4]) {
segments.push({
type: 'link',
text: match[2],
href: match[3]
})
} else if (match[4]) {
segments.push({
type: 'strong',
text: match[4]
text: match[3],
href: match[4]
})
} else if (match[5]) {
segments.push({
type: 'code',
type: 'strong',
text: match[5]
})
} else if (match[6]) {
segments.push({
type: 'em',
type: 'code',
text: match[6]
})
} else if (match[7]) {
segments.push({
type: 'em',
text: match[7]
})
}
lastIndex = INLINE_MARKDOWN_RE.lastIndex
@@ -83,6 +199,14 @@ export const segmentsToInlineHtml = (segments) => segments.map((segment) => {
return `<em>${escapeHtml(segment.text)}</em>`
}
if (segment.type === 'subscript') {
return `<sub>${escapeHtml(segment.text)}</sub>`
}
if (segment.type === 'superscript') {
return `<sup>${escapeHtml(segment.text)}</sup>`
}
if (segment.type === 'code') {
return `<code>${escapeHtml(segment.text)}</code>`
}
@@ -137,6 +261,14 @@ export const convertHtmlInlineNodeToMarkdown = (node) => {
return `*${childText}*`
}
if (tagName === 'sub') {
return formatScriptMarkdown('_', childText)
}
if (tagName === 'sup') {
return formatScriptMarkdown('^', childText)
}
if (tagName === 'code') {
return `\`${childText}\``
}
@@ -283,9 +415,10 @@ const readEditableChildNodeToMarkdown = (node) => {
/**
* contenteditable 루트에서 텍스트를 읽는다.
* @param {HTMLElement} root - contenteditable 루트
* @param {{ trimEnd?: boolean }} [options] - 읽기 옵션
* @returns {string} 마크다운 인라인 텍스트
*/
export const readEditableTextFromElement = (root) => {
export const readEditableTextFromElement = (root, options = {}) => {
if (!root) {
return ''
}
@@ -302,7 +435,8 @@ export const readEditableTextFromElement = (root) => {
parts.push(readEditableChildNodeToMarkdown(node))
}
return parts.join('').replace(/\u00a0/g, ' ').trimEnd()
const value = parts.join('').replace(/\u00a0/g, ' ')
return options.trimEnd === false ? value : value.trimEnd()
}
/**
@@ -418,6 +552,49 @@ export const setEditableCaretOffset = (root, targetOffset) => {
}
}
/**
* contenteditable 루트의 텍스트 오프셋에 해당하는 DOM 위치를 반환한다.
* @param {HTMLElement} root - contenteditable 루트
* @param {number} targetOffset - 텍스트 오프셋
* @returns {{ node: Node, offset: number }|null} DOM 위치
*/
export const getEditableDomPointAtOffset = (root, targetOffset) => {
if (!root || !import.meta.client) {
return null
}
const safeOffset = Math.max(0, targetOffset)
let walked = 0
for (const unit of iterateEditableTextUnits(root)) {
if (unit.kind === 'text' && unit.node?.nodeType === Node.TEXT_NODE) {
if (walked + unit.length >= safeOffset) {
return {
node: unit.node,
offset: Math.min(safeOffset - walked, unit.length)
}
}
walked += unit.length
continue
}
if (walked + unit.length >= safeOffset) {
return {
node: root,
offset: root.childNodes.length
}
}
walked += unit.length
}
return {
node: root,
offset: root.childNodes.length
}
}
/**
* contenteditable 내부 HTML을 인라인 마크다운으로 변환한다.
* @param {string} html - innerHTML