- 라이브 모드 blur 시 인라인 마크다운(**·*)이 사라지던 문제 수정 - 인용 블록에 > [!bg=색상] 옵션으로 콜아웃과 동일한 배경 프리셋 지정 - 소스 모드에서 라이브 전환 시 현재 커서 줄을 화면 중앙에 가깝게 스크롤
532 lines
14 KiB
JavaScript
532 lines
14 KiB
JavaScript
/** @type {RegExp} 인라인 마크다운 패턴 */
|
|
const INLINE_MARKDOWN_RE = /(\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
|
|
|
|
/**
|
|
* HTML 특수문자 이스케이프
|
|
* @param {string} value - 원본 문자열
|
|
* @returns {string} 이스케이프된 문자열
|
|
*/
|
|
export const escapeHtml = (value) => String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
|
|
/**
|
|
* 인라인 마크다운을 표시 세그먼트로 변환한다.
|
|
* @param {string} value - 원본 문자열
|
|
* @returns {Array<{ type: string, text: string, href?: string }>} 인라인 세그먼트
|
|
*/
|
|
export const parseInlineSegments = (value) => {
|
|
const source = String(value || '')
|
|
const segments = []
|
|
let lastIndex = 0
|
|
INLINE_MARKDOWN_RE.lastIndex = 0
|
|
let match = INLINE_MARKDOWN_RE.exec(source)
|
|
|
|
while (match) {
|
|
if (match.index > lastIndex) {
|
|
segments.push({
|
|
type: 'text',
|
|
text: source.slice(lastIndex, match.index)
|
|
})
|
|
}
|
|
|
|
if (match[2] && match[3]) {
|
|
segments.push({
|
|
type: 'link',
|
|
text: match[2],
|
|
href: match[3]
|
|
})
|
|
} else if (match[4]) {
|
|
segments.push({
|
|
type: 'strong',
|
|
text: match[4]
|
|
})
|
|
} else if (match[5]) {
|
|
segments.push({
|
|
type: 'code',
|
|
text: match[5]
|
|
})
|
|
} else if (match[6]) {
|
|
segments.push({
|
|
type: 'em',
|
|
text: match[6]
|
|
})
|
|
}
|
|
|
|
lastIndex = INLINE_MARKDOWN_RE.lastIndex
|
|
match = INLINE_MARKDOWN_RE.exec(source)
|
|
}
|
|
|
|
if (lastIndex < source.length) {
|
|
segments.push({
|
|
type: 'text',
|
|
text: source.slice(lastIndex)
|
|
})
|
|
}
|
|
|
|
return segments.length ? segments : [{ type: 'text', text: source }]
|
|
}
|
|
|
|
/**
|
|
* 인라인 세그먼트를 HTML 문자열로 변환한다.
|
|
* @param {Array<{ type: string, text: string, href?: string }>} segments - 세그먼트
|
|
* @returns {string} HTML
|
|
*/
|
|
export const segmentsToInlineHtml = (segments) => segments.map((segment) => {
|
|
if (segment.type === 'strong') {
|
|
return `<strong>${escapeHtml(segment.text)}</strong>`
|
|
}
|
|
|
|
if (segment.type === 'em') {
|
|
return `<em>${escapeHtml(segment.text)}</em>`
|
|
}
|
|
|
|
if (segment.type === 'code') {
|
|
return `<code>${escapeHtml(segment.text)}</code>`
|
|
}
|
|
|
|
if (segment.type === 'link') {
|
|
return `<a href="${escapeHtml(segment.href || '')}">${escapeHtml(segment.text)}</a>`
|
|
}
|
|
|
|
return escapeHtml(segment.text)
|
|
}).join('')
|
|
|
|
/**
|
|
* 인라인 마크다운(단일 줄)을 HTML로 변환한다.
|
|
* @param {string} value - 마크다운 문자열
|
|
* @returns {string} HTML
|
|
*/
|
|
export const markdownInlineToHtml = (value) => segmentsToInlineHtml(parseInlineSegments(value))
|
|
|
|
/**
|
|
* 여러 줄 인라인 마크다운을 HTML로 변환한다.
|
|
* @param {string} value - 줄바꿈 포함 마크다운
|
|
* @returns {string} HTML
|
|
*/
|
|
export const markdownMultilineInlineToHtml = (value) => String(value || '')
|
|
.split('\n')
|
|
.map((line) => markdownInlineToHtml(line))
|
|
.join('<br>')
|
|
|
|
/**
|
|
* 인라인 HTML 노드를 마크다운 문자열로 변환한다.
|
|
* @param {Node} node - HTML 노드
|
|
* @returns {string} 마크다운 문자열
|
|
*/
|
|
export const convertHtmlInlineNodeToMarkdown = (node) => {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
return node.textContent || ''
|
|
}
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
return ''
|
|
}
|
|
|
|
const element = /** @type {HTMLElement} */ (node)
|
|
const tagName = element.tagName.toLowerCase()
|
|
const childText = Array.from(element.childNodes).map(convertHtmlInlineNodeToMarkdown).join('')
|
|
|
|
if (tagName === 'strong' || tagName === 'b') {
|
|
return `**${childText}**`
|
|
}
|
|
|
|
if (tagName === 'em' || tagName === 'i') {
|
|
return `*${childText}*`
|
|
}
|
|
|
|
if (tagName === 'code') {
|
|
return `\`${childText}\``
|
|
}
|
|
|
|
if (tagName === 'a') {
|
|
const href = element.getAttribute('href')
|
|
return href ? `[${childText || href}](${href})` : childText
|
|
}
|
|
|
|
if (tagName === 'img') {
|
|
const src = element.getAttribute('src')
|
|
const alt = element.getAttribute('alt') || ''
|
|
return src ? `` : ''
|
|
}
|
|
|
|
if (tagName === 'br') {
|
|
return '\n'
|
|
}
|
|
|
|
if (tagName === 'div' || tagName === 'p' || tagName === 'span') {
|
|
return childText
|
|
}
|
|
|
|
return childText
|
|
}
|
|
|
|
/**
|
|
* Range에 포함된 HTML 조각을 반환한다.
|
|
* @param {Range} range - DOM Range
|
|
* @returns {string} HTML
|
|
*/
|
|
export const getRangeInnerHtml = (range) => {
|
|
if (!range) {
|
|
return ''
|
|
}
|
|
|
|
const fragment = range.cloneContents()
|
|
const container = document.createElement('div')
|
|
container.appendChild(fragment)
|
|
|
|
return container.innerHTML
|
|
}
|
|
|
|
/** @type {Set<string>} contenteditable 줄 구분 블록 태그 */
|
|
const EDITABLE_BLOCK_TAGS = new Set(['div', 'p'])
|
|
|
|
/**
|
|
* 루트 직계 자식이 줄 구분 블록인지 확인한다.
|
|
* @param {HTMLElement} element - 요소
|
|
* @param {HTMLElement} root - contenteditable 루트
|
|
* @returns {boolean}
|
|
*/
|
|
const isEditableBlockBreak = (element, root) => {
|
|
if (!element || element === root) {
|
|
return false
|
|
}
|
|
|
|
return EDITABLE_BLOCK_TAGS.has(element.tagName.toLowerCase())
|
|
&& element.parentElement === root
|
|
}
|
|
|
|
/**
|
|
* contenteditable 텍스트 단위를 순회한다.
|
|
* @param {HTMLElement} root - contenteditable 루트
|
|
* @yields {{ kind: 'text'|'break'|'block-break', node: Node|null, length: number }}
|
|
*/
|
|
function* iterateEditableTextUnits(root) {
|
|
/**
|
|
* @param {Node} node - 순회 노드
|
|
* @param {boolean} parentIsRoot - 루트 직계 여부
|
|
* @param {number} indexInParent - 형제 인덱스
|
|
* @returns {Generator<{ kind: string, node: Node|null, length: number }>}
|
|
*/
|
|
const visit = function* (node, parentIsRoot, indexInParent) {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
yield { kind: 'text', node, length: node.textContent?.length ?? 0 }
|
|
return
|
|
}
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
return
|
|
}
|
|
|
|
const element = /** @type {HTMLElement} */ (node)
|
|
const tag = element.tagName.toLowerCase()
|
|
|
|
if (tag === 'br') {
|
|
yield { kind: 'break', node, length: 1 }
|
|
return
|
|
}
|
|
|
|
if (isEditableBlockBreak(element, root)) {
|
|
if (parentIsRoot && indexInParent > 0) {
|
|
yield { kind: 'block-break', node: null, length: 1 }
|
|
}
|
|
|
|
const children = [...element.childNodes]
|
|
|
|
for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
|
|
yield* visit(children[childIndex], false, childIndex)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
const children = [...element.childNodes]
|
|
|
|
for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
|
|
yield* visit(children[childIndex], parentIsRoot, childIndex)
|
|
}
|
|
}
|
|
|
|
const children = [...root.childNodes]
|
|
|
|
for (let index = 0; index < children.length; index += 1) {
|
|
yield* visit(children[index], true, index)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* contenteditable 루트의 단일 자식 노드를 마크다운 인라인 문자열로 직렬화한다.
|
|
* @param {Node} node - 자식 노드
|
|
* @returns {string} 마크다운 조각
|
|
*/
|
|
const readEditableChildNodeToMarkdown = (node) => {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
return node.textContent || ''
|
|
}
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
return ''
|
|
}
|
|
|
|
const element = /** @type {HTMLElement} */ (node)
|
|
const tagName = element.tagName.toLowerCase()
|
|
|
|
if (tagName === 'br') {
|
|
return '\n'
|
|
}
|
|
|
|
return convertHtmlInlineNodeToMarkdown(element)
|
|
}
|
|
|
|
/**
|
|
* contenteditable 루트에서 텍스트를 읽는다.
|
|
* @param {HTMLElement} root - contenteditable 루트
|
|
* @returns {string} 마크다운 인라인 텍스트
|
|
*/
|
|
export const readEditableTextFromElement = (root) => {
|
|
if (!root) {
|
|
return ''
|
|
}
|
|
|
|
const parts = []
|
|
|
|
for (let index = 0; index < root.childNodes.length; index += 1) {
|
|
const node = root.childNodes[index]
|
|
|
|
if (node.nodeType === Node.ELEMENT_NODE && isEditableBlockBreak(/** @type {HTMLElement} */ (node), root) && index > 0) {
|
|
parts.push('\n')
|
|
}
|
|
|
|
parts.push(readEditableChildNodeToMarkdown(node))
|
|
}
|
|
|
|
return parts.join('').replace(/\u00a0/g, ' ').trimEnd()
|
|
}
|
|
|
|
/**
|
|
* contenteditable 루트에서 커서 오프셋을 계산한다.
|
|
* @param {HTMLElement} root - contenteditable 루트
|
|
* @param {Range} range - 선택 범위
|
|
* @returns {number} 텍스트 오프셋
|
|
*/
|
|
export const getEditableCaretOffset = (root, range) => {
|
|
if (!root || !range) {
|
|
return 0
|
|
}
|
|
|
|
if (range.startContainer === root) {
|
|
let offset = 0
|
|
const children = [...root.childNodes]
|
|
const measureRoot = document.createElement('div')
|
|
|
|
for (let index = 0; index < Math.min(range.startOffset, children.length); index += 1) {
|
|
measureRoot.appendChild(children[index].cloneNode(true))
|
|
}
|
|
|
|
for (const unit of iterateEditableTextUnits(/** @type {HTMLElement} */ (measureRoot))) {
|
|
offset += unit.length
|
|
}
|
|
|
|
return offset
|
|
}
|
|
|
|
let offset = 0
|
|
let found = false
|
|
|
|
for (const unit of iterateEditableTextUnits(root)) {
|
|
if (found) {
|
|
break
|
|
}
|
|
|
|
if (unit.kind === 'text' && unit.node === range.startContainer) {
|
|
offset += range.startOffset
|
|
found = true
|
|
break
|
|
}
|
|
|
|
if (unit.node === range.startContainer) {
|
|
found = true
|
|
break
|
|
}
|
|
|
|
offset += unit.length
|
|
}
|
|
|
|
return offset
|
|
}
|
|
|
|
/**
|
|
* contenteditable 루트에 커서를 텍스트 오프셋으로 둔다.
|
|
* @param {HTMLElement} root - contenteditable 루트
|
|
* @param {number} targetOffset - 텍스트 오프셋
|
|
* @returns {void}
|
|
*/
|
|
export const setEditableCaretOffset = (root, targetOffset) => {
|
|
if (!root || !import.meta.client) {
|
|
return
|
|
}
|
|
|
|
const safeOffset = Math.max(0, targetOffset)
|
|
let walked = 0
|
|
let placed = false
|
|
|
|
for (const unit of iterateEditableTextUnits(root)) {
|
|
if (placed) {
|
|
break
|
|
}
|
|
|
|
if (unit.kind === 'text' && unit.node?.nodeType === Node.TEXT_NODE) {
|
|
if (walked + unit.length >= safeOffset) {
|
|
const range = document.createRange()
|
|
const charOffset = Math.min(safeOffset - walked, unit.length)
|
|
range.setStart(unit.node, charOffset)
|
|
range.collapse(true)
|
|
const selection = window.getSelection()
|
|
selection?.removeAllRanges()
|
|
selection?.addRange(range)
|
|
placed = true
|
|
break
|
|
}
|
|
|
|
walked += unit.length
|
|
continue
|
|
}
|
|
|
|
if (walked + unit.length >= safeOffset) {
|
|
const range = document.createRange()
|
|
range.selectNodeContents(root)
|
|
range.collapse(false)
|
|
const selection = window.getSelection()
|
|
selection?.removeAllRanges()
|
|
selection?.addRange(range)
|
|
placed = true
|
|
break
|
|
}
|
|
|
|
walked += unit.length
|
|
}
|
|
|
|
if (!placed) {
|
|
const range = document.createRange()
|
|
range.selectNodeContents(root)
|
|
range.collapse(false)
|
|
const selection = window.getSelection()
|
|
selection?.removeAllRanges()
|
|
selection?.addRange(range)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* contenteditable 내부 HTML을 인라인 마크다운으로 변환한다.
|
|
* @param {string} html - innerHTML
|
|
* @returns {string} 마크다운
|
|
*/
|
|
export const convertEditableHtmlToMarkdown = (html) => {
|
|
if (!html?.trim()) {
|
|
return ''
|
|
}
|
|
|
|
const document = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
|
|
|
|
return readEditableTextFromElement(/** @type {HTMLElement} */ (document.body))
|
|
}
|
|
|
|
/**
|
|
* 문단 블록 텍스트를 소스 줄 배열로 변환한다.
|
|
* @param {string} text - 문단 텍스트
|
|
* @returns {string[]} 마크다운 줄
|
|
*/
|
|
/**
|
|
* HTML 블록 노드를 마크다운 문자열로 변환한다.
|
|
* @param {Node} node - HTML 노드
|
|
* @param {number} listIndex - 순서 목록 번호
|
|
* @returns {string} 마크다운 문자열
|
|
*/
|
|
export const convertHtmlBlockNodeToMarkdown = (node, listIndex = 1) => {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
return (node.textContent || '').trim()
|
|
}
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
return ''
|
|
}
|
|
|
|
const element = /** @type {HTMLElement} */ (node)
|
|
const tagName = element.tagName.toLowerCase()
|
|
const inlineText = Array.from(element.childNodes).map(convertHtmlInlineNodeToMarkdown).join('').trim()
|
|
|
|
if (/^h[1-6]$/.test(tagName)) {
|
|
return `${'#'.repeat(Number(tagName.slice(1)))} ${inlineText}`
|
|
}
|
|
|
|
if (tagName === 'p') {
|
|
return inlineText
|
|
}
|
|
|
|
if (tagName === 'blockquote') {
|
|
return inlineText.split('\n').map((line) => `> ${line}`).join('\n')
|
|
}
|
|
|
|
if (tagName === 'pre') {
|
|
return `\`\`\`\n${element.textContent?.trim() || ''}\n\`\`\``
|
|
}
|
|
|
|
if (tagName === 'li') {
|
|
return `${listIndex}. ${inlineText}`
|
|
}
|
|
|
|
if (tagName === 'ul' || tagName === 'ol') {
|
|
return Array.from(element.children)
|
|
.filter((child) => child.tagName.toLowerCase() === 'li')
|
|
.map((child, index) => {
|
|
const marker = tagName === 'ol' ? `${index + 1}.` : '-'
|
|
const text = Array.from(child.childNodes).map(convertHtmlInlineNodeToMarkdown).join('').trim()
|
|
return `${marker} ${text}`
|
|
})
|
|
.join('\n')
|
|
}
|
|
|
|
if (tagName === 'div' || tagName === 'section' || tagName === 'article') {
|
|
const childBlocks = Array.from(element.childNodes)
|
|
.map(convertHtmlBlockNodeToMarkdown)
|
|
.filter(Boolean)
|
|
|
|
return childBlocks.length ? childBlocks.join('\n\n') : inlineText
|
|
}
|
|
|
|
return inlineText
|
|
}
|
|
|
|
/**
|
|
* 클립보드 HTML을 마크다운 문서 조각으로 변환한다.
|
|
* @param {string} html - HTML 문자열
|
|
* @returns {string} 마크다운 문자열
|
|
*/
|
|
export const convertHtmlToMarkdown = (html) => {
|
|
const document = new DOMParser().parseFromString(html, 'text/html')
|
|
|
|
return Array.from(document.body.childNodes)
|
|
.map(convertHtmlBlockNodeToMarkdown)
|
|
.filter(Boolean)
|
|
.join('\n\n')
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.trim()
|
|
}
|
|
|
|
/**
|
|
* 문단 블록 텍스트를 소스 줄 배열로 변환한다.
|
|
* @param {string} text - 문단 텍스트
|
|
* @returns {string[]} 마크다운 줄
|
|
*/
|
|
export const paragraphTextToSourceLines = (text) => {
|
|
const parts = String(text || '').split('\n').map((part) => part.trimEnd())
|
|
|
|
if (!parts.length) {
|
|
return ['']
|
|
}
|
|
|
|
return parts
|
|
}
|