feat(editor): replace format bar with selection appearance controls

This commit is contained in:
eezd committed 2026-10-24 18:11:57 +08:00
1 parent 19aab631ec
commit 5abc99b89e
15 files changed
+634 -130

No files matched your search

+2 -1
View File
@@ -2,6 +2,7 @@
// 上标 ^x^、下标 ~x~、:emoji: 由 @lezer/markdown 自带的扩展解析
import { tags } from '@lezer/highlight'
import type { BlockContext, LeafBlockParser, Line, MarkdownConfig } from '@lezer/markdown'
import { inlineStyleSyntax } from './inlineStyles'
const PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~\xA1‐-‧]/
@@ -195,4 +196,4 @@ const FrontMatter: MarkdownConfig = {
],
}
export const typoraExtensions: MarkdownConfig[] = [FrontMatter, Highlight, InlineMath, BlockMath, Footnote, TableOfContents]
export const typoraExtensions: MarkdownConfig[] = [FrontMatter, Highlight, InlineMath, BlockMath, Footnote, TableOfContents, inlineStyleSyntax]
+8
View File
@@ -1,6 +1,7 @@
import type { SyntaxNode } from '@lezer/common'
import { Emoji, GFM, parser as commonmarkParser, Subscript, Superscript, type MarkdownParser } from '@lezer/markdown'
import { typoraExtensions } from './extensions'
import { inlineStyleText, stylesFromTag } from './inlineStyles'
// 与编辑器一致的语法集(lang-markdown 的 markdownLanguage 加 Typora 扩展)
export const markdownParser = commonmarkParser.configure([GFM, Subscript, Superscript, Emoji, ...typoraExtensions])
@@ -278,6 +279,13 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
return `<sub>${innerOf(node, 'SubscriptMark')}</sub>`
case 'Highlight':
return `<mark>${innerOf(node, 'HighlightMark')}</mark>`
case 'Underline':
return `<u>${innerOf(node, 'UnderlineMark')}</u>`
case 'InlineStyle': {
const open = node.getChild('InlineStyleMark')!
const styles = stylesFromTag(text(open.from, open.to))!
return `<span style="${escapeAttr(inlineStyleText(styles))}">${innerOf(node, 'InlineStyleMark')}</span>`
}
// 公式与 emoji 输出占位元素,由 render.ts 的 hydrate 按需加载依赖后填充
case 'InlineMath': {
const tex = text(node.from + 1, node.to - 1)
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { renderMarkdown } from './html'
import { INLINE_FONTS, parseInlineStyles } from './inlineStyles'
describe('行内外观安全渲染', () => {
it('下划线和白名单样式支持嵌套格式和表格', () => {
expect(renderMarkdown('<u>**bold**</u> <span style="color: #AABBCC; font-size: 20px">[link](https://example.com)</span>')).toBe('<p><u><strong>bold</strong></u> <span style="color: #aabbcc; font-size: 20px"><a href="https://example.com">link</a></span></p>\n')
expect(renderMarkdown('| value |\n| --- |\n| <u>text</u> |')).toContain('<td><u>text</u></td>')
expect(parseInlineStyles('font-family: ' + INLINE_FONTS.serif.css)).not.toBeNull()
})
it('拒绝事件、额外属性、外部资源及任意 CSS', () => {
for (const tag of ['<u onclick="alert(1)">', '<span style="color: #aabbcc" onclick="alert(1)">', '<span style="background: url(https://example.com)">', '<span style="position: fixed; color: #aabbcc">', '<span style="font-size: 999px">']) {
const html = renderMarkdown('text ' + tag + 'unsafe</span>')
expect(html).toContain('&lt;')
expect(html).not.toContain(tag)
}
expect(renderMarkdown('<script>alert(1)</script>')).not.toContain('<script>')
expect(renderMarkdown('<u>[x](javascript:alert(1))</u>')).toContain('href="#"')
})
})
+71
View File
@@ -0,0 +1,71 @@
import type { MarkdownConfig } from '@lezer/markdown'
import { CODE_FONTS, TEXT_FONTS } from '../themes/typography'
export const INLINE_FONTS = { ...TEXT_FONTS, mono: CODE_FONTS.jetbrains }
export const HOLLOW = '0.2px currentColor'
export const SHADOW = '1px 1px 2px currentColor'
export type InlineStyles = Partial<Record<'color' | 'background-color' | 'font-family' | 'font-size' | '-webkit-text-stroke' | '-webkit-text-fill-color' | 'text-shadow', string>>
/** 笔记是外部输入;仅允许工具栏实际生成的样式,不开放通用 HTML/CSS。 */
export function parseInlineStyles(value: string): InlineStyles | null {
const result: InlineStyles = {}
for (const declaration of value.split(';')) {
if (!declaration.trim()) continue
const colon = declaration.indexOf(':')
if (colon < 0) return null
const key = declaration.slice(0, colon).trim()
const val = declaration.slice(colon + 1).trim()
switch (key) {
case 'color': case 'background-color':
if (!/^#[\da-f]{6}$/i.test(val)) return null
result[key] = val.toLowerCase(); break
case 'font-family':
if (!Object.values(INLINE_FONTS).some(font => font.css === val)) return null
result[key] = val; break
case 'font-size': {
const match = /^(\d+(?:\.\d+)?)(px|em)$/.exec(val)
if (!match || !(match[2] === 'px' ? +match[1] >= 9 && +match[1] <= 72 : +match[1] >= .56 && +match[1] <= 4.5)) return null
result[key] = val; break
}
case '-webkit-text-stroke':
if (val !== HOLLOW) return null
result[key] = val; break
case '-webkit-text-fill-color':
if (val !== 'transparent') return null
result[key] = val; break
case 'text-shadow':
if (val !== SHADOW) return null
result[key] = val; break
default: return null
}
}
return Object.keys(result).length ? result : null
}
export function inlineStyleText(styles: InlineStyles): string {
return Object.entries(styles).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}: ${value}`).join('; ')
}
export function stylesFromTag(tag: string): InlineStyles | null {
const match = /^<span\s+style="([^"<>]*)"\s*>$/.exec(tag)
return match ? parseInlineStyles(match[1]) : null
}
const underline = { resolve: 'Underline', mark: 'UnderlineMark' }
const appearance = { resolve: 'InlineStyle', mark: 'InlineStyleMark' }
export const inlineStyleSyntax: MarkdownConfig = {
defineNodes: ['Underline', 'UnderlineMark', 'InlineStyle', 'InlineStyleMark'],
parseInline: [{
name: 'InlineAppearance', before: 'HTMLTag',
parse(cx, next, pos) {
if (next !== 60) return -1
const tag = /^<[^<>\n]*>/.exec(cx.slice(pos, cx.end))?.[0]
if (!tag) return -1
if (tag === '<u>') return cx.addDelimiter(underline, pos, pos + tag.length, true, false)
if (tag === '</u>') return cx.addDelimiter(underline, pos, pos + tag.length, false, true)
if (stylesFromTag(tag)) return cx.addDelimiter(appearance, pos, pos + tag.length, true, false)
if (tag === '</span>') return cx.addDelimiter(appearance, pos, pos + tag.length, false, true)
return -1
},
}],
}