220 lines
8.1 KiB
TypeScript
220 lines
8.1 KiB
TypeScript
/* =====================================================================
|
||
* richtext.ts — 结构化富文本(segments)的序列化/反序列化/渲染
|
||
*
|
||
* 数据模型:
|
||
* RichLine[] = 一组行,每行含若干 RichSegment
|
||
* RichSegment = { text, bold?, italic?, color?, highlight?, ... }
|
||
*
|
||
* 与 content (string) 的关系:
|
||
* - segments 存在时优先用于渲染
|
||
* - segmentsToPlain() 可降级为纯文本(AI context / 搜索 / 向后兼容)
|
||
* - plainToSegments() 可从纯文本升级
|
||
* ===================================================================== */
|
||
import type { RichLine, RichSegment } from './types'
|
||
|
||
/* ---------- 转义 ---------- */
|
||
function escapeHtml(s: string): string {
|
||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
}
|
||
|
||
/* ---------- segments → HTML(渲染用) ---------- */
|
||
|
||
function segmentToHtml(seg: RichSegment): string {
|
||
let html = escapeHtml(seg.text)
|
||
if (!html) return ''
|
||
|
||
// 嵌套标签(顺序:外层 → 内层)
|
||
if (seg.code) html = `<code class="rt-code">${html}</code>`
|
||
if (seg.sup) html = `<sup>${html}</sup>`
|
||
if (seg.sub) html = `<sub>${html}</sub>`
|
||
if (seg.strike) html = `<del>${html}</del>`
|
||
if (seg.underline) html = `<u>${html}</u>`
|
||
if (seg.italic) html = `<em>${html}</em>`
|
||
if (seg.bold) html = `<strong>${html}</strong>`
|
||
|
||
// 颜色 / 字号 / 高亮 / 链接 → 用 span 包裹
|
||
const styles: string[] = []
|
||
if (seg.color) styles.push(`color:${seg.color}`)
|
||
if (seg.fontSize) styles.push(`font-size:${seg.fontSize}px`)
|
||
if (seg.highlight) styles.push('background:rgba(245,158,11,.25);padding:0 .15em;border-radius:2px')
|
||
|
||
if (seg.link) {
|
||
const safeHref = /^(https?:|\/)/.test(seg.link) ? seg.link : '#'
|
||
html = `<a href="${escapeHtml(safeHref)}" target="_blank" rel="noopener"${styles.length ? ` style="${styles.join(';')}"` : ''}>${html}</a>`
|
||
} else if (styles.length) {
|
||
html = `<span style="${styles.join(';')}">${html}</span>`
|
||
}
|
||
|
||
return html
|
||
}
|
||
|
||
/** segments → 完整 HTML(多行,用 <br> 分隔) */
|
||
export function segmentsToHtml(lines: RichLine[]): string {
|
||
if (!lines || !lines.length) return ''
|
||
return lines
|
||
.map(line => (line.segments || []).map(segmentToHtml).join(''))
|
||
.join('<br>')
|
||
}
|
||
|
||
/* ---------- segments → 纯文本(AI context / 搜索 / 兼容) ---------- */
|
||
|
||
export function segmentsToPlain(lines: RichLine[]): string {
|
||
if (!lines || !lines.length) return ''
|
||
return lines
|
||
.map(line => (line.segments || []).map(s => s.text || '').join(''))
|
||
.join('\n')
|
||
}
|
||
|
||
/* ---------- 纯文本 → segments(升级) ---------- */
|
||
|
||
export function plainToSegments(text: string): RichLine[] {
|
||
if (!text) return []
|
||
return text.split('\n').map(line => ({
|
||
segments: line ? [{ text: line }] : []
|
||
}))
|
||
}
|
||
|
||
/* ---------- Markdown → segments(从方案 A 无损迁移) ---------- */
|
||
|
||
const MD_PATTERNS: Array<{ regex: RegExp; parse: (m: string, inner: string) => Partial<RichSegment> | null }> = [
|
||
{ regex: /\*\*([^*]+)\*\*/, parse: (_m, inner) => ({ text: inner, bold: true }) },
|
||
{ regex: /(?<!\*)\*([^*]+)\*(?!\*)/, parse: (_m, inner) => ({ text: inner, italic: true }) },
|
||
{ regex: /~~([^~]+)~~/, parse: (_m, inner) => ({ text: inner, strike: true }) },
|
||
{ regex: /==([^=]+)==/, parse: (_m, inner) => ({ text: inner, highlight: true }) },
|
||
{ regex: /`([^`]+)`/, parse: (_m, inner) => ({ text: inner, code: true }) },
|
||
{ regex: /\^([^\^\s][^\^]*?)\^/, parse: (_m, inner) => ({ text: inner, sup: true }) },
|
||
{ regex: /(?<!~)~([^~\s][^~]*?)~(?!~)/, parse: (_m, inner) => ({ text: inner, sub: true }) }
|
||
]
|
||
|
||
/** 把一行 Markdown 文本解析为 segments */
|
||
export function markdownLineToSegments(line: string): RichSegment[] {
|
||
if (!line) return []
|
||
const segments: RichSegment[] = []
|
||
let remaining = line
|
||
|
||
while (remaining.length > 0) {
|
||
let earliestIdx = -1
|
||
let earliestMatch: RegExpMatchArray | null = null
|
||
let earliestParser: typeof MD_PATTERNS[0]['parse'] | null = null
|
||
|
||
for (const pat of MD_PATTERNS) {
|
||
const m = remaining.match(pat.regex)
|
||
if (m && m.index !== undefined && (earliestIdx === -1 || m.index < earliestIdx)) {
|
||
earliestIdx = m.index
|
||
earliestMatch = m
|
||
earliestParser = pat.parse
|
||
}
|
||
}
|
||
|
||
if (earliestIdx === -1 || !earliestMatch || !earliestParser) {
|
||
// 无更多匹配,剩余全部为纯文本
|
||
segments.push({ text: remaining })
|
||
break
|
||
}
|
||
|
||
// 匹配前的纯文本
|
||
if (earliestIdx > 0) {
|
||
segments.push({ text: remaining.slice(0, earliestIdx) })
|
||
}
|
||
// 匹配的格式段
|
||
const parsed = earliestParser(earliestMatch[0], earliestMatch[1])
|
||
if (parsed) segments.push(parsed as RichSegment)
|
||
remaining = remaining.slice(earliestIdx + earliestMatch[0].length)
|
||
}
|
||
|
||
return segments
|
||
}
|
||
|
||
/** 把多行 Markdown 文本解析为 RichLine[] */
|
||
export function markdownToSegments(text: string): RichLine[] {
|
||
if (!text) return []
|
||
return text.split('\n').map(line => ({ segments: markdownLineToSegments(line) }))
|
||
}
|
||
|
||
/* ---------- 合并相邻相同 segment(压缩存储) ---------- */
|
||
|
||
function segEqual(a: RichSegment, b: RichSegment): boolean {
|
||
return a.bold === b.bold && a.italic === b.italic && a.underline === b.underline &&
|
||
a.strike === b.strike && a.color === b.color && a.highlight === b.highlight &&
|
||
a.code === b.code && a.sup === b.sup && a.sub === b.sub &&
|
||
a.fontSize === b.fontSize && a.link === b.link
|
||
}
|
||
|
||
export function mergeSegments(lines: RichLine[]): RichLine[] {
|
||
return lines.map(line => {
|
||
const merged: RichSegment[] = []
|
||
for (const seg of line.segments || []) {
|
||
const last = merged[merged.length - 1]
|
||
if (last && segEqual(last, seg)) {
|
||
last.text += seg.text
|
||
} else {
|
||
merged.push({ ...seg })
|
||
}
|
||
}
|
||
return { segments: merged }
|
||
})
|
||
}
|
||
|
||
/* ---------- 工具:判断是否有格式(至少一个 segment 有样式) ---------- */
|
||
|
||
export function hasFormatting(lines: RichLine[]): boolean {
|
||
if (!lines || !lines.length) return false
|
||
for (const line of lines) {
|
||
for (const seg of line.segments || []) {
|
||
if (seg.bold || seg.italic || seg.underline || seg.strike || seg.color ||
|
||
seg.highlight || seg.code || seg.sup || seg.sub || seg.fontSize || seg.link) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
/* ---------- 工具:行首是否自带列表标记(圆点/编号,用于避免渲染层双重标记) ---------- */
|
||
|
||
/** 检测行首(允许空白)是否自带列表标记:圈号①-⑳ / 阿拉伯数字+点顿 / 圆点符号 / 中文数字+点顿 */
|
||
export function hasLineMarker(line: string): boolean {
|
||
if (!line) return false
|
||
return /^[\s]*(?:[①-⑳]|[((]?\d{1,2}[)).、.]|[-•·▪●○*]|[一二三四五六七八九十]+[、..])/.test(line)
|
||
}
|
||
|
||
/* ---------- 工具:安全化(AI 输出或外部数据 → 合法 segments) ---------- */
|
||
|
||
export function normSegments(input: any): RichLine[] | undefined {
|
||
if (!input || !Array.isArray(input)) return undefined
|
||
const lines: RichLine[] = []
|
||
for (const line of input) {
|
||
if (!line || !Array.isArray(line.segments)) continue
|
||
const segs: RichSegment[] = []
|
||
for (const seg of line.segments) {
|
||
if (!seg || typeof seg !== 'object') continue
|
||
const text = String(seg.text || '')
|
||
if (!text) continue
|
||
const out: RichSegment = { text }
|
||
if (seg.bold) out.bold = true
|
||
if (seg.italic) out.italic = true
|
||
if (seg.underline) out.underline = true
|
||
if (seg.strike) out.strike = true
|
||
if (seg.highlight) out.highlight = true
|
||
if (seg.code) out.code = true
|
||
if (seg.sup) out.sup = true
|
||
if (seg.sub) out.sub = true
|
||
if (typeof seg.color === 'string') {
|
||
// 白名单:主题键或合法 hex
|
||
if (/^(primary|accent|text|muted)$/.test(seg.color) || /^#[0-9a-f]{3,8}$/i.test(seg.color)) {
|
||
out.color = seg.color
|
||
}
|
||
}
|
||
if (typeof seg.fontSize === 'number' && seg.fontSize >= 8 && seg.fontSize <= 200) {
|
||
out.fontSize = seg.fontSize
|
||
}
|
||
if (typeof seg.link === 'string' && /^(https?:|\/)/.test(seg.link)) {
|
||
out.link = seg.link
|
||
}
|
||
segs.push(out)
|
||
}
|
||
lines.push({ segments: segs })
|
||
}
|
||
return lines.length ? lines : undefined
|
||
}
|