feat(web): render editable tables and make images clickable

- Top-level GFM tables render as an editable widget: click a cell to
  edit its Markdown source, Tab/Enter/arrows to navigate, toolbar to
  insert/delete rows and columns or switch to source
- Structural table edits are isolated undo steps; focus is restored
  after the widget is rebuilt
- Clicking an image moves the cursor to its syntax to reveal the source
- Renderer escapes raw HTML and filters dangerous URL schemes by default
This commit is contained in:
eezd committed 2026-10-16 17:37:40 +08:00
1 parent a1597697e5
commit 54fecf71c8
8 files changed
+494 -11

No files matched your search

+14 -1
View File
@@ -43,11 +43,24 @@ const examples = specTests.map((t) => ({
// 用 it.fails 标记:上游修复后这些用例会转为通过并报错,提醒移出此列表。
const PARSER_DEVIATIONS = new Set([10, 171, 280, 493, 512, 523, 528, 569, 571, 621, 625, 626])
describe('安全模式', () => {
it('转义原始 HTML', () => {
expect(renderMarkdown('<img src=x onerror=alert(1)>\n\na <b>b</b>')).toBe(
'&lt;img src=x onerror=alert(1)&gt;\n<p>a &lt;b&gt;b&lt;/b&gt;</p>\n',
)
})
it('过滤危险链接协议', () => {
expect(renderMarkdown('[a](javascript:alert(1)) <javascript:x> ![i](data:image/png;base64,AA)')).toBe(
'<p><a href="#">a</a> <a href="#">javascript:x</a> <img src="data:image/png;base64,AA" alt="i" /></p>\n',
)
})
})
describe('CommonMark spec', () => {
for (const e of examples) {
const test = PARSER_DEVIATIONS.has(e.number) ? it.fails : it
test(`#${e.number} ${e.section}`, () => {
expect(normalize(renderMarkdown(e.markdown, commonmarkParser))).toBe(normalize(e.html))
expect(normalize(renderMarkdown(e.markdown, { parser: commonmarkParser, unsafe: true }))).toBe(normalize(e.html))
})
}
})
+25 -7
View File
@@ -46,8 +46,19 @@ function tabWidth(col: number): number {
return 4 - (col % 4)
}
export function renderMarkdown(src: string, parser: MarkdownParser = markdownParser): string {
export interface RenderOptions {
parser?: MarkdownParser
// 为 true 时原样输出原始 HTML、不过滤危险链接协议;仅用于可信内容(如规范测试)
unsafe?: boolean
}
const UNSAFE_PROTOCOL = /^\s*(javascript|vbscript|file|data):/i
const SAFE_DATA_IMAGE = /^\s*data:image\/(png|gif|jpeg|webp);/i
export function renderMarkdown(src: string, { parser = markdownParser, unsafe = false }: RenderOptions = {}): string {
const tree = parser.parse(src)
const safeHref = (href: string) => (unsafe || !UNSAFE_PROTOCOL.test(href) || SAFE_DATA_IMAGE.test(href) ? href : '#')
const rawHtml = (s: string) => (unsafe ? s : escapeHtml(s))
const text = (from: number, to: number) => src.slice(from, to)
const defs = new Map<string, LinkDefinition>()
@@ -261,19 +272,19 @@ export function renderMarkdown(src: string, parser: MarkdownParser = markdownPar
case 'HTMLTag':
case 'Comment':
case 'ProcessingInstruction':
return text(node.from, node.to)
return rawHtml(text(node.from, node.to))
case 'Autolink': {
const url = node.getChild('URL')
if (!url) return escapeHtml(text(node.from, node.to))
const raw = text(url.from, url.to)
const href = /^[^@\s]+@[^@\s]+$/.test(raw) && !raw.includes(':') ? 'mailto:' + raw : raw
return `<a href="${escapeAttr(normalizeUrl(href))}">${escapeHtml(raw)}</a>`
return `<a href="${escapeAttr(safeHref(normalizeUrl(href)))}">${escapeHtml(raw)}</a>`
}
case 'URL': {
// GFM 裸链接
const raw = text(node.from, node.to)
const href = raw.startsWith('www.') ? 'http://' + raw : raw.includes('@') && !raw.includes(':') ? 'mailto:' + raw : raw
return `<a href="${escapeAttr(normalizeUrl(href))}">${escapeHtml(raw)}</a>`
return `<a href="${escapeAttr(safeHref(normalizeUrl(href)))}">${escapeHtml(raw)}</a>`
}
case 'Link':
case 'Image':
@@ -305,8 +316,8 @@ export function renderMarkdown(src: string, parser: MarkdownParser = markdownPar
}
}
const title = def.title !== null ? ` title="${escapeAttr(def.title)}"` : ''
if (isImage) return `<img src="${escapeAttr(def.href)}" alt="${escapeAttr(plainText(content))}"${title} />`
return `<a href="${escapeAttr(def.href)}"${title}>${content}</a>`
if (isImage) return `<img src="${escapeAttr(safeHref(def.href))}" alt="${escapeAttr(plainText(content))}"${title} />`
return `<a href="${escapeAttr(safeHref(def.href))}"${title}>${content}</a>`
}
// ---- 块级 ----
@@ -402,7 +413,7 @@ export function renderMarkdown(src: string, parser: MarkdownParser = markdownPar
case 'HTMLBlock':
case 'CommentBlock':
case 'ProcessingInstructionBlock':
return contentLines(node, false, false).map((l) => l.rest).join('\n') + '\n'
return rawHtml(contentLines(node, false, false).map((l) => l.rest).join('\n')) + '\n'
case 'Table':
return table(node)
default:
@@ -460,3 +471,10 @@ export function renderMarkdown(src: string, parser: MarkdownParser = markdownPar
return blocks(tree.topNode)
}
// 渲染单行行内 Markdown(表格单元格等),不产生块级结构
export function renderInlineMarkdown(src: string, options?: RenderOptions): string {
const html = renderMarkdown(src, options)
const m = /^<p>([\s\S]*)<\/p>\n$/.exec(html)
return m ? m[1] : escapeHtml(src)
}