feat(editor): replace format bar with selection appearance controls
This commit is contained in:
1 parent
19aab631ec
commit
5abc99b89e
15 files changed
+634
-130
No files matched your search
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
@@ -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('<')
|
||||
expect(html).not.toContain(tag)
|
||||
}
|
||||
expect(renderMarkdown('<script>alert(1)</script>')).not.toContain('<script>')
|
||||
expect(renderMarkdown('<u>[x](javascript:alert(1))</u>')).toContain('href="#"')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}],
|
||||
}
|
||||
Reference in new issue
Block a user