라이브 편집 선택·콜아웃·인용 안정화 및 오른쪽 사이드바 여백 보정 (v1.5.70)
Selection Bridge로 블록 간 선택·삭제를 보강하고, 콜아웃·인용 멀티라인 Enter·전체 선택 삭제·한글 IME 문제를 수정했다. Obsidian식 위첨자 문법과 RightSidebar 패딩·커스텀 아이콘 색상도 함께 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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, '>')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
/**
|
||||
* 위첨자·아래첨자 토큰 본문을 읽는다.
|
||||
* @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
|
||||
|
||||
489
lib/markdown-live-selection.js
Normal file
489
lib/markdown-live-selection.js
Normal file
@@ -0,0 +1,489 @@
|
||||
import {
|
||||
getEditableCaretOffset,
|
||||
getEditableDomPointAtOffset,
|
||||
readEditableTextFromElement
|
||||
} from './markdown-inline.js'
|
||||
|
||||
/** @type {import('vue').InjectionKey<{ extendSelection: Function, selectDocument: Function, deleteSelection: Function }>} */
|
||||
export const LIVE_SELECTION_BRIDGE_KEY = Symbol('liveSelectionBridge')
|
||||
|
||||
/**
|
||||
* 컨테이너 안의 선택 가능한 contenteditable 요소 목록을 반환한다.
|
||||
* @param {HTMLElement|null} container - 탐색 루트
|
||||
* @returns {HTMLElement[]} 편집 요소 목록
|
||||
*/
|
||||
export const getSelectableEditableElements = (container) => {
|
||||
if (!container || !import.meta.client) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [...container.querySelectorAll('[data-source-line][contenteditable="true"]')]
|
||||
.filter((element) => element.getAttribute('contenteditable') === 'true')
|
||||
.sort((left, right) => Number(left.getAttribute('data-source-line')) - Number(right.getAttribute('data-source-line')))
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드가 속한 편집 요소를 반환한다.
|
||||
* @param {Node|null} node - 기준 노드
|
||||
* @param {HTMLElement[]} elements - 편집 요소 목록
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
export const getEditableElementFromNode = (node, elements) => {
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
|
||||
return elements.find((element) => element.contains(node)) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 원본 줄 번호에 해당하는 편집 요소 인덱스를 찾는다.
|
||||
* @param {HTMLElement[]} elements - 편집 요소 목록
|
||||
* @param {number} sourceLine - 원본 줄 번호(0-based)
|
||||
* @returns {number} 인덱스(없으면 -1)
|
||||
*/
|
||||
export const findEditableIndexBySourceLine = (elements, sourceLine) => {
|
||||
return elements.findIndex((element) => {
|
||||
const start = Number(element.getAttribute('data-source-line'))
|
||||
const end = Number(element.getAttribute('data-source-line-end'))
|
||||
|
||||
if (!Number.isInteger(start)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Number.isInteger(end) && end > start) {
|
||||
return sourceLine >= start && sourceLine <= end
|
||||
}
|
||||
|
||||
return start === sourceLine
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 요소의 원본 줄 범위를 반환한다.
|
||||
* @param {HTMLElement} element - contenteditable 요소
|
||||
* @returns {{ startLine: number, endLine: number }}
|
||||
*/
|
||||
export const getEditableSourceLineRange = (element) => {
|
||||
const startLine = Number(element.getAttribute('data-source-line'))
|
||||
const endLine = Number(element.getAttribute('data-source-line-end'))
|
||||
|
||||
if (!Number.isInteger(startLine)) {
|
||||
return { startLine: -1, endLine: -1 }
|
||||
}
|
||||
|
||||
return {
|
||||
startLine,
|
||||
endLine: Number.isInteger(endLine) ? endLine : startLine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 범위가 편집 요소와 겹치는지 확인한다.
|
||||
* @param {Range} range - 선택 범위
|
||||
* @param {HTMLElement} element - contenteditable 요소
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const rangeIntersectsElement = (range, element) => {
|
||||
const elementRange = document.createRange()
|
||||
elementRange.selectNodeContents(element)
|
||||
|
||||
return range.compareBoundaryPoints(Range.START_TO_END, elementRange) < 0
|
||||
&& range.compareBoundaryPoints(Range.END_TO_START, elementRange) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 요소 안에서 선택 경계의 텍스트 오프셋을 계산한다.
|
||||
* @param {HTMLElement} element - contenteditable 요소
|
||||
* @param {Range} range - 선택 범위
|
||||
* @param {'start'|'end'} edge - 경계
|
||||
* @returns {number}
|
||||
*/
|
||||
export const getSelectionOffsetInElement = (element, range, edge) => {
|
||||
const partial = document.createRange()
|
||||
partial.selectNodeContents(element)
|
||||
|
||||
if (edge === 'start') {
|
||||
partial.setEnd(range.startContainer, range.startOffset)
|
||||
} else {
|
||||
partial.setStart(range.endContainer, range.endOffset)
|
||||
}
|
||||
|
||||
return getEditableCaretOffset(element, partial)
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 줄에서 편집 본문 앞 접두사 길이를 반환한다.
|
||||
* @param {string} markdownLine - 마크다운 한 줄
|
||||
* @param {string} bodyText - 편집 본문
|
||||
* @returns {number}
|
||||
*/
|
||||
export const getMarkdownLineBodyPrefixLength = (markdownLine, bodyText) => {
|
||||
const line = String(markdownLine ?? '')
|
||||
const body = String(bodyText ?? '')
|
||||
|
||||
if (line === body) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const prefixPatterns = [
|
||||
/^(#{1,6}\s+)/,
|
||||
/^(>\s*)/,
|
||||
/^([-*+]\s+)/,
|
||||
/^(\d+\.\s+)/
|
||||
]
|
||||
|
||||
for (const pattern of prefixPatterns) {
|
||||
const match = line.match(pattern)
|
||||
|
||||
if (match && line.slice(match[0].length) === body) {
|
||||
return match[0].length
|
||||
}
|
||||
}
|
||||
|
||||
if (line.endsWith(body)) {
|
||||
return line.length - body.length
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 한 줄에 편집 본문 치환 결과를 반영한다.
|
||||
* @param {string} markdownLine - 마크다운 한 줄
|
||||
* @param {string} bodyText - 편집 본문
|
||||
* @param {string} nextBody - 치환 후 본문
|
||||
* @returns {string}
|
||||
*/
|
||||
export const rebuildMarkdownLineBody = (markdownLine, bodyText, nextBody) => {
|
||||
const prefixLength = getMarkdownLineBodyPrefixLength(markdownLine, bodyText)
|
||||
|
||||
return `${String(markdownLine ?? '').slice(0, prefixLength)}${nextBody}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 한 줄에서 본문 일부를 삭제한다.
|
||||
* @param {string} markdownLine - 마크다운 한 줄
|
||||
* @param {string} bodyText - 편집 본문
|
||||
* @param {number} startOffset - 시작 오프셋
|
||||
* @param {number} endOffset - 끝 오프셋
|
||||
* @returns {string}
|
||||
*/
|
||||
export const applyBodyEditToMarkdownLine = (markdownLine, bodyText, startOffset, endOffset) => {
|
||||
const prefixLength = getMarkdownLineBodyPrefixLength(markdownLine, bodyText)
|
||||
const line = String(markdownLine ?? '')
|
||||
const absoluteStart = prefixLength + startOffset
|
||||
const absoluteEnd = prefixLength + endOffset
|
||||
|
||||
return `${line.slice(0, absoluteStart)}${line.slice(absoluteEnd)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 인접 블록 선택 확장 대상 오프셋을 계산한다.
|
||||
* @param {string} text - 대상 편집 요소 텍스트
|
||||
* @param {number} direction - 이동 방향
|
||||
* @param {number} column - 유지할 열
|
||||
* @returns {number}
|
||||
*/
|
||||
export const getTargetSelectionOffset = (text, direction, column) => {
|
||||
const source = String(text ?? '')
|
||||
const lines = source.length ? source.split('\n') : ['']
|
||||
const safeColumn = Math.max(0, column)
|
||||
|
||||
if (direction > 0) {
|
||||
const lineText = lines[0] ?? ''
|
||||
return Math.min(safeColumn, lineText.length)
|
||||
}
|
||||
|
||||
const lineText = lines[lines.length - 1] ?? ''
|
||||
const previousLength = lines.slice(0, -1).reduce((sum, line) => sum + line.length + 1, 0)
|
||||
|
||||
return previousLength + Math.min(safeColumn, lineText.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 요소 본문 전체를 선택한다.
|
||||
* @param {HTMLElement} element - contenteditable 요소
|
||||
* @returns {void}
|
||||
*/
|
||||
export const selectEditableElementContents = (element) => {
|
||||
if (!element || !import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection) {
|
||||
return
|
||||
}
|
||||
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(element)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 요소 목록 전체를 하나의 선택 범위로 만든다.
|
||||
* @param {HTMLElement[]} elements - 편집 요소 목록
|
||||
* @returns {void}
|
||||
*/
|
||||
export const selectAllEditableElements = (elements) => {
|
||||
if (!import.meta.client || !elements.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection) {
|
||||
return
|
||||
}
|
||||
|
||||
const first = elements[0]
|
||||
const last = elements[elements.length - 1]
|
||||
const startPoint = getEditableDomPointAtOffset(first, 0)
|
||||
const endText = readEditableTextFromElement(last)
|
||||
const endPoint = getEditableDomPointAtOffset(last, endText.length)
|
||||
|
||||
if (!startPoint || !endPoint) {
|
||||
return
|
||||
}
|
||||
|
||||
selection.setBaseAndExtent(
|
||||
startPoint.node,
|
||||
startPoint.offset,
|
||||
endPoint.node,
|
||||
endPoint.offset
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 인접 편집 블록으로 선택 범위를 확장한다.
|
||||
* @param {{ container: HTMLElement|null, sourceLine: number, direction: number, column?: number }} options - 확장 옵션
|
||||
* @returns {boolean} 확장 여부
|
||||
*/
|
||||
export const extendSelectionAcrossBlocks = ({
|
||||
container,
|
||||
sourceLine,
|
||||
direction,
|
||||
column = 0
|
||||
}) => {
|
||||
if (!import.meta.client || !container || !direction) {
|
||||
return false
|
||||
}
|
||||
|
||||
const elements = getSelectableEditableElements(container)
|
||||
const currentIndex = findEditableIndexBySourceLine(elements, sourceLine)
|
||||
|
||||
if (currentIndex < 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const focusedElement = getEditableElementFromNode(selection.focusNode, elements)
|
||||
const focusedIndex = focusedElement ? elements.indexOf(focusedElement) : -1
|
||||
const resolvedCurrentIndex = focusedIndex >= 0 ? focusedIndex : currentIndex
|
||||
const targetIndex = resolvedCurrentIndex + direction
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= elements.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const target = elements[targetIndex]
|
||||
const targetText = readEditableTextFromElement(target)
|
||||
const anchorPoint = {
|
||||
node: selection.anchorNode,
|
||||
offset: selection.anchorOffset
|
||||
}
|
||||
const focusPoint = getEditableDomPointAtOffset(
|
||||
target,
|
||||
getTargetSelectionOffset(targetText, direction, column)
|
||||
)
|
||||
|
||||
if (!anchorPoint?.node || !focusPoint) {
|
||||
return false
|
||||
}
|
||||
|
||||
selection.setBaseAndExtent(
|
||||
anchorPoint.node,
|
||||
anchorPoint.offset,
|
||||
focusPoint.node,
|
||||
focusPoint.offset
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 요소가 현재 전체 선택 상태인지 확인한다.
|
||||
* @param {HTMLElement} element - contenteditable 요소
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isEditableElementFullySelected = (element) => {
|
||||
if (!element || !import.meta.client) {
|
||||
return false
|
||||
}
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
return false
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const fullText = readEditableTextFromElement(element)
|
||||
|
||||
if (!fullText.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const startOffset = getSelectionOffsetInElement(element, range, 'start')
|
||||
const endOffset = getSelectionOffsetInElement(element, range, 'end')
|
||||
|
||||
return startOffset === 0 && endOffset >= fullText.length
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 삭제·잘라내기 단축키인지 확인한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isLiveSelectionDeleteKey = (event) => {
|
||||
if (event.key === 'Backspace' || event.key === 'Delete') {
|
||||
return true
|
||||
}
|
||||
|
||||
return (event.metaKey || event.ctrlKey)
|
||||
&& !event.shiftKey
|
||||
&& !event.altKey
|
||||
&& event.key.toLowerCase() === 'x'
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 선택을 마크다운에 반영해 삭제한다.
|
||||
* @param {string} markdown - 현재 마크다운
|
||||
* @param {HTMLElement|null} container - 렌더러 루트
|
||||
* @returns {string|null} 갱신된 마크다운(처리하지 않으면 null)
|
||||
*/
|
||||
export const applyLiveSelectionDelete = (markdown, container) => {
|
||||
if (!import.meta.client || !container) {
|
||||
return null
|
||||
}
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const elements = getSelectableEditableElements(container)
|
||||
const affected = elements.filter((element) => rangeIntersectsElement(range, element))
|
||||
|
||||
if (!affected.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const lines = String(markdown ?? '').split('\n')
|
||||
|
||||
if (affected.length === 1) {
|
||||
const element = affected[0]
|
||||
const startsIn = element.contains(range.startContainer)
|
||||
const endsIn = element.contains(range.endContainer)
|
||||
|
||||
if (!startsIn || !endsIn) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bodyText = readEditableTextFromElement(element, { trimEnd: false })
|
||||
const startOffset = getSelectionOffsetInElement(element, range, 'start')
|
||||
const endOffset = getSelectionOffsetInElement(element, range, 'end')
|
||||
|
||||
if (startOffset === 0 && endOffset >= bodyText.length) {
|
||||
const { startLine, endLine } = getEditableSourceLineRange(element)
|
||||
|
||||
if (startLine < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const replacementLines = element.hasAttribute('data-empty-markdown-line')
|
||||
? [element.getAttribute('data-empty-markdown-line') ?? '']
|
||||
: []
|
||||
|
||||
return [
|
||||
...lines.slice(0, startLine),
|
||||
...replacementLines,
|
||||
...lines.slice(endLine + 1)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (startOffset < endOffset) {
|
||||
const { startLine } = getEditableSourceLineRange(element)
|
||||
const nextLine = applyBodyEditToMarkdownLine(lines[startLine] ?? '', bodyText, startOffset, endOffset)
|
||||
|
||||
return [
|
||||
...lines.slice(0, startLine),
|
||||
nextLine,
|
||||
...lines.slice(startLine + 1)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const first = affected[0]
|
||||
const last = affected[affected.length - 1]
|
||||
const { startLine: firstLine, endLine: firstEndLine } = getEditableSourceLineRange(first)
|
||||
const { startLine: lastLine, endLine: lastEndLine } = getEditableSourceLineRange(last)
|
||||
|
||||
if (firstLine < 0 || lastLine < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const firstBody = readEditableTextFromElement(first, { trimEnd: false })
|
||||
const lastBody = readEditableTextFromElement(last, { trimEnd: false })
|
||||
const startOffset = getSelectionOffsetInElement(first, range, 'start')
|
||||
const endOffset = getSelectionOffsetInElement(last, range, 'end')
|
||||
const mergedBody = `${firstBody.slice(0, startOffset)}${lastBody.slice(endOffset)}`
|
||||
|
||||
let replacementLines = []
|
||||
|
||||
if (mergedBody.length) {
|
||||
const bodySegments = mergedBody.split('\n')
|
||||
|
||||
replacementLines = bodySegments.map((segment, index) => {
|
||||
if (index === 0) {
|
||||
return rebuildMarkdownLineBody(lines[firstLine] ?? '', firstBody, segment)
|
||||
}
|
||||
|
||||
if (index === bodySegments.length - 1 && firstLine !== lastLine) {
|
||||
return rebuildMarkdownLineBody(lines[lastLine] ?? '', lastBody, segment)
|
||||
}
|
||||
|
||||
return segment
|
||||
})
|
||||
}
|
||||
|
||||
return [
|
||||
...lines.slice(0, firstLine),
|
||||
...replacementLines,
|
||||
...lines.slice(lastEndLine + 1)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 문서 선택을 접는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const collapseLiveSelection = () => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
Reference in New Issue
Block a user