新增: u-ppt Vue 3 在线演示工具初始版本
- 核心架构: Vue 3 + Vite + TypeScript,零运行时依赖(仅 Vue) - 编辑器: 幻灯片增删排序、元素拖拽八向缩放、双击编辑、12 种元素类型 (标题/正文/列表/数据/金句/图片/形状/图表/卡片/表格/代码/公式) - 图表: 8 种 SVG 自绘(柱状/条形/折线/面积/饼图/环形/雷达/进度) - 富文本: 结构化 segments(加粗/斜体/颜色/高亮/上下标/代码/链接) - AI 能力: 对话编辑、生成整套、润色本页、大纲→逐页生成、一键美化、AI 配图 - 会话绑定: 每份 PPT 独立会话,切换 PPT 自动切换对话历史 - 模板系统: 7 个内置版式 + 用户自存模板 - 演示模式: 全屏播放、键盘/鼠标/滚轮导航、入场动画 - 持久化: localStorage 存 deck/文库/会话/模板/配置 - 支持多服务商: 智谱/DeepSeek/通义/Kimi/豆包/OpenAI/Anthropic/Gemini/Groq/Ollama
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
/* =====================================================================
|
||||
* 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
|
||||
}
|
||||
|
||||
/* ---------- 工具:安全化(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
|
||||
}
|
||||
Reference in New Issue
Block a user