v1.2.8: 라이브 모드 인라인 편집 및 목록·인용 동작 개선
관리자 미리보기에서 문단·목록·인용을 contenteditable로 편집하고, Cmd+E 전환·사용자 지정 순서 번호·줄 삭제·화살표 줄 이동을 지원한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
311
lib/markdown-inline.js
Normal file
311
lib/markdown-inline.js
Normal file
@@ -0,0 +1,311 @@
|
||||
/** @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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')
|
||||
const parts = []
|
||||
|
||||
document.body.childNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && /** @type {HTMLElement} */ (node).tagName.toLowerCase() === 'br') {
|
||||
parts.push('\n')
|
||||
return
|
||||
}
|
||||
|
||||
const converted = convertHtmlInlineNodeToMarkdown(node)
|
||||
|
||||
if (converted) {
|
||||
parts.push(converted)
|
||||
}
|
||||
})
|
||||
|
||||
return parts.join('').replace(/\u00a0/g, ' ').trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* 문단 블록 텍스트를 소스 줄 배열로 변환한다.
|
||||
* @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')
|
||||
|
||||
if (!parts.length) {
|
||||
return ['']
|
||||
}
|
||||
|
||||
if (parts.length === 1) {
|
||||
return [parts[0]]
|
||||
}
|
||||
|
||||
return parts.map((part, index) => (index < parts.length - 1 ? `${part} ` : part))
|
||||
}
|
||||
130
lib/markdown-live-edit.js
Normal file
130
lib/markdown-live-edit.js
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 인용 접두사 제거
|
||||
* @param {string} value - 원본
|
||||
* @returns {string} 본문
|
||||
*/
|
||||
export const stripQuoteMarker = (value) => String(value ?? '').replace(/^(?:>\s*)+/, '').trim()
|
||||
|
||||
/**
|
||||
* 목록 접두사 제거
|
||||
* @param {string} value - 원본
|
||||
* @param {boolean} ordered - 순서 목록 여부
|
||||
* @returns {string} 본문
|
||||
*/
|
||||
export const stripListMarker = (value, ordered = false) => {
|
||||
const raw = String(value ?? '').trim()
|
||||
|
||||
if (ordered) {
|
||||
return raw.replace(/^\d+\.\s*/, '').trim()
|
||||
}
|
||||
|
||||
return raw.replace(/^[-*+]\s+/, '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 마커 포함 여부
|
||||
* @param {string} value - 원본
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const hasQuoteMarker = (value) => /^\s*>/.test(String(value ?? ''))
|
||||
|
||||
/**
|
||||
* 목록 마커 포함 여부
|
||||
* @param {string} value - 원본
|
||||
* @param {boolean} [ordered=false] - 순서 목록 여부
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const hasListMarker = (value, ordered = false) => {
|
||||
const raw = String(value ?? '').trim()
|
||||
|
||||
if (ordered) {
|
||||
return /^\d+\.\s*/.test(raw)
|
||||
}
|
||||
|
||||
return /^[-*+]\s/.test(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 목록 마커를 파싱한다.
|
||||
* @param {string} value - 원본 줄
|
||||
* @returns {{ number: number, body: string }|null}
|
||||
*/
|
||||
export const parseOrderedListMarker = (value) => {
|
||||
const raw = String(value ?? '').trim()
|
||||
const match = raw.match(/^(\d+)\.\s*(.*)$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
number: Number(match[1]),
|
||||
body: match[2].trim()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 목록 다음 줄 마커를 만든다.
|
||||
* @param {string} line - 현재 줄
|
||||
* @returns {string} 다음 줄 마커
|
||||
*/
|
||||
export const getNextOrderedListLine = (line) => {
|
||||
const parsed = parseOrderedListMarker(line)
|
||||
|
||||
if (!parsed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `${parsed.number + 1}. `
|
||||
}
|
||||
|
||||
/**
|
||||
* 마커만 있고 본문이 없는 목록 줄인지 확인한다.
|
||||
* @param {string} line - 마크다운 줄
|
||||
* @param {boolean} ordered - 순서 목록 여부
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isEmptyListMarkerLine = (line, ordered = false) => {
|
||||
if (!hasListMarker(line, ordered)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (ordered) {
|
||||
const parsed = parseOrderedListMarker(line)
|
||||
return parsed ? !parsed.body : false
|
||||
}
|
||||
|
||||
return !stripListMarker(line, false).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 마커만 있고 본문이 없는 인용 줄인지 확인한다.
|
||||
* @param {string} line - 마크다운 줄
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isEmptyQuoteMarkerLine = (line) => {
|
||||
if (!hasQuoteMarker(line)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !stripQuoteMarker(line).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 접두사 제거
|
||||
* @param {string} value - 원본
|
||||
* @returns {{ level: number, text: string }} 레벨·본문
|
||||
*/
|
||||
export const stripHeadingMarker = (value) => {
|
||||
const raw = String(value ?? '').trim()
|
||||
const match = raw.match(/^(#{1,6})\s+(.*)$/)
|
||||
|
||||
if (!match) {
|
||||
return { level: 0, text: raw }
|
||||
}
|
||||
|
||||
return {
|
||||
level: match[1].length,
|
||||
text: match[2].trim()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user