Add Typora extension syntax
- Parser extensions shared by the editor and the HTML renderer: ==highlight==, $inline math$ (Pandoc rules, so amounts like $5 are not math), $$ math blocks, footnote references and definitions, [TOC], and YAML front matter - Live preview: highlight, superscript/subscript, emoji names (gemoji), footnote references with the definition as tooltip, a clickable table of contents, styled front matter - Math (KaTeX) and Mermaid render in place; while editing a block the source is shown with a live preview below. KaTeX, Mermaid and the emoji table are loaded only when a note uses them - KaTeX is pinned to 0.18 so Mermaid shares the same copy - Move editor focus tracking out of table.ts so block widgets and tables use the same state
This commit is contained in:
1 parent
919575f3e7
commit
75000fc571
15 files changed
+1991
-47
No files matched your search
Generated
+1161
-1
File diff suppressed because it is too large.
Load diff
@@ -17,6 +17,9 @@
|
|||||||
"@lezer/common": "^1.5.3",
|
"@lezer/common": "^1.5.3",
|
||||||
"@lezer/highlight": "^1.2.5",
|
"@lezer/highlight": "^1.2.5",
|
||||||
"@lezer/markdown": "^1.8.0",
|
"@lezer/markdown": "^1.8.0",
|
||||||
|
"gemoji": "^8.1.0",
|
||||||
|
"katex": "^0.18.11",
|
||||||
|
"mermaid": "^12.2.0",
|
||||||
"shiki": "^4.5.0",
|
"shiki": "^4.5.0",
|
||||||
"vue": "^3.5.44"
|
"vue": "^3.5.44"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { syntaxTree } from '@codemirror/language'
|
||||||
|
import { StateField, type EditorState, type Range } from '@codemirror/state'
|
||||||
|
import { Decoration, EditorView, type DecorationSet } from '@codemirror/view'
|
||||||
|
import type { SyntaxNode } from '@lezer/common'
|
||||||
|
import { codeContentRange, fenceLanguage } from './codeblocks'
|
||||||
|
import { editorFocused, setEditorFocus } from './focus'
|
||||||
|
import { MathWidget, MermaidWidget } from './widgets'
|
||||||
|
|
||||||
|
// 公式块与 Mermaid 图表:未编辑时整块替换为渲染结果;光标在块内时显示源码,并在下方实时预览。
|
||||||
|
// 跨行的块级替换只能由 StateField 提供;与表格一样只处理顶层的块
|
||||||
|
|
||||||
|
/** 返回块的源码内容及其相对块起点的偏移(点击渲染结果时光标落在内容开头) */
|
||||||
|
function blockContent(state: EditorState, node: SyntaxNode): { text: string; offset: number } | null {
|
||||||
|
if (node.name === 'BlockMath') {
|
||||||
|
const marks = node.getChildren('BlockMathMark')
|
||||||
|
const from = state.doc.lineAt(node.from).to + 1
|
||||||
|
const to = marks.length > 1 ? state.doc.lineAt(marks[marks.length - 1].from).from - 1 : node.to
|
||||||
|
return { text: from <= to ? state.sliceDoc(from, to) : '', offset: Math.min(from, node.to) - node.from }
|
||||||
|
}
|
||||||
|
if (node.name === 'FencedCode') {
|
||||||
|
const info = node.getChild('CodeInfo')
|
||||||
|
if (!info || fenceLanguage(state, info.from, info.to) !== 'mermaid') return null
|
||||||
|
const { from, to } = codeContentRange(state, node)
|
||||||
|
return { text: state.sliceDoc(from, to), offset: from - node.from }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBlocks(state: EditorState): DecorationSet {
|
||||||
|
const out: Range<Decoration>[] = []
|
||||||
|
const sel = state.field(editorFocused) ? state.selection.ranges : []
|
||||||
|
for (let node = syntaxTree(state).topNode.firstChild; node; node = node.nextSibling) {
|
||||||
|
const content = blockContent(state, node)
|
||||||
|
if (!content) continue
|
||||||
|
const editing = sel.some((r) => r.from <= node.to && r.to >= node.from)
|
||||||
|
const widget = (editOffset: number | null) =>
|
||||||
|
node.name === 'BlockMath' ? new MathWidget(content.text, true, editOffset) : new MermaidWidget(content.text, editOffset)
|
||||||
|
if (editing) out.push(Decoration.widget({ widget: widget(null), block: true, side: 1 }).range(node.to))
|
||||||
|
else out.push(Decoration.replace({ widget: widget(content.offset), block: true }).range(node.from, node.to))
|
||||||
|
}
|
||||||
|
return Decoration.set(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renderedBlocks = StateField.define<DecorationSet>({
|
||||||
|
create: buildBlocks,
|
||||||
|
update(value, tr) {
|
||||||
|
if (
|
||||||
|
tr.docChanged ||
|
||||||
|
tr.selection ||
|
||||||
|
tr.effects.some((e) => e.is(setEditorFocus)) ||
|
||||||
|
syntaxTree(tr.startState) !== syntaxTree(tr.state)
|
||||||
|
) {
|
||||||
|
return buildBlocks(tr.state)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
provide: (f) => EditorView.decorations.from(f),
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { StateEffect, StateField } from '@codemirror/state'
|
||||||
|
import { ViewPlugin, type EditorView, type ViewUpdate } from '@codemirror/view'
|
||||||
|
|
||||||
|
// 编辑器焦点状态。块级渲染组件(表格、公式块、Mermaid)由 StateField 提供,拿不到视图的焦点,
|
||||||
|
// 由视图插件把焦点变化同步进状态:获得焦点且光标在块内时显示源码,否则渲染
|
||||||
|
// (打开笔记时光标默认在文首,不应因此展开开头的块)
|
||||||
|
export const setEditorFocus = StateEffect.define<boolean>()
|
||||||
|
|
||||||
|
export const editorFocused = StateField.define<boolean>({
|
||||||
|
create: () => false,
|
||||||
|
update(value, tr) {
|
||||||
|
for (const e of tr.effects) if (e.is(setEditorFocus)) value = e.value
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const focusTracker = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
constructor(readonly view: EditorView) {
|
||||||
|
this.sync()
|
||||||
|
}
|
||||||
|
update(u: ViewUpdate) {
|
||||||
|
if (u.focusChanged) this.sync()
|
||||||
|
}
|
||||||
|
// 不能在 update 中同步 dispatch,推迟到微任务
|
||||||
|
sync() {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const focused = this.view.hasFocus
|
||||||
|
if (this.view.state.field(editorFocused, false) !== undefined && this.view.state.field(editorFocused) !== focused) {
|
||||||
|
this.view.dispatch({ effects: setEditorFocus.of(focused) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const editorFocus = [editorFocused, focusTracker]
|
||||||
+17
-1
@@ -1,14 +1,28 @@
|
|||||||
import { defaultKeymap, history, historyKeymap, indentWithTab, redo } from '@codemirror/commands'
|
import { defaultKeymap, history, historyKeymap, indentWithTab, redo } from '@codemirror/commands'
|
||||||
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
|
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
|
||||||
|
import { Language } from '@codemirror/language'
|
||||||
import { EditorState, Prec, type Extension } from '@codemirror/state'
|
import { EditorState, Prec, type Extension } from '@codemirror/state'
|
||||||
import { drawSelection, EditorView, keymap, type ViewUpdate } from '@codemirror/view'
|
import { drawSelection, EditorView, keymap, type ViewUpdate } from '@codemirror/view'
|
||||||
|
import type { MarkdownParser } from '@lezer/markdown'
|
||||||
|
import { typoraExtensions } from '../markdown/extensions'
|
||||||
|
import { renderedBlocks } from './blocks'
|
||||||
import { codeBlockHighlight } from './codeblocks'
|
import { codeBlockHighlight } from './codeblocks'
|
||||||
|
import { editorFocus } from './focus'
|
||||||
import { livePreview, resolveImageUrl } from './livepreview'
|
import { livePreview, resolveImageUrl } from './livepreview'
|
||||||
import { tableEditing } from './table'
|
import { tableEditing } from './table'
|
||||||
import { markdownTheme } from './theme'
|
import { markdownTheme } from './theme'
|
||||||
|
|
||||||
export { pasteImages } from './paste'
|
export { pasteImages } from './paste'
|
||||||
|
|
||||||
|
// 在 lang-markdown 的语法集(CommonMark + GFM + 上下标 + emoji)上加 Typora 扩展。
|
||||||
|
// 沿用其语言数据,markdownKeymap 中的命令据此识别 Markdown(isActiveAt 按语言数据判断)
|
||||||
|
const rawmarkLanguage = new Language(
|
||||||
|
markdownLanguage.data,
|
||||||
|
(markdownLanguage.parser as MarkdownParser).configure(typoraExtensions),
|
||||||
|
[],
|
||||||
|
'markdown',
|
||||||
|
)
|
||||||
|
|
||||||
export interface EditorStateOptions {
|
export interface EditorStateOptions {
|
||||||
doc: string
|
doc: string
|
||||||
resolveImage?: (src: string) => string
|
resolveImage?: (src: string) => string
|
||||||
@@ -25,12 +39,14 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
|
|||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
// 不用 lang-markdown 的 markdown():它会静态引入 lang-html/css/javascript 用于嵌套 HTML 高亮,
|
// 不用 lang-markdown 的 markdown():它会静态引入 lang-html/css/javascript 用于嵌套 HTML 高亮,
|
||||||
// 首屏包 gzip 从约 108 KB 增至约 180 KB,而 Live Preview 不需要 HTML 高亮
|
// 首屏包 gzip 从约 108 KB 增至约 180 KB,而 Live Preview 不需要 HTML 高亮
|
||||||
markdownLanguage,
|
rawmarkLanguage,
|
||||||
Prec.high(keymap.of(markdownKeymap)),
|
Prec.high(keymap.of(markdownKeymap)),
|
||||||
markdownTheme,
|
markdownTheme,
|
||||||
livePreview,
|
livePreview,
|
||||||
codeBlockHighlight,
|
codeBlockHighlight,
|
||||||
|
editorFocus,
|
||||||
tableEditing,
|
tableEditing,
|
||||||
|
renderedBlocks,
|
||||||
// CodeMirror 只在 Linux/Mac 上把 Ctrl/Cmd+Shift+Z 绑定为重做,Windows 上会退化成撤销;
|
// CodeMirror 只在 Linux/Mac 上把 Ctrl/Cmd+Shift+Z 绑定为重做,Windows 上会退化成撤销;
|
||||||
// 与 Typora、VS Code 保持一致,所有平台都支持
|
// 与 Typora、VS Code 保持一致,所有平台都支持
|
||||||
keymap.of([indentWithTab, { key: 'Mod-Shift-z', run: redo, preventDefault: true }, ...defaultKeymap, ...historyKeymap]),
|
keymap.of([indentWithTab, { key: 'Mod-Shift-z', run: redo, preventDefault: true }, ...defaultKeymap, ...historyKeymap]),
|
||||||
|
|||||||
@@ -3,7 +3,18 @@ import { Facet, type EditorState, type Range } from '@codemirror/state'
|
|||||||
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
|
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
|
||||||
import type { SyntaxNode } from '@lezer/common'
|
import type { SyntaxNode } from '@lezer/common'
|
||||||
import { codeContentRange, fenceLanguage, fencedCodeAt } from './codeblocks'
|
import { codeContentRange, fenceLanguage, fencedCodeAt } from './codeblocks'
|
||||||
import { BulletWidget, CheckboxWidget, CodeHeaderWidget, HorizontalRuleWidget, ImageWidget } from './widgets'
|
import {
|
||||||
|
BulletWidget,
|
||||||
|
CheckboxWidget,
|
||||||
|
CodeHeaderWidget,
|
||||||
|
EmojiWidget,
|
||||||
|
FootnoteRefWidget,
|
||||||
|
HorizontalRuleWidget,
|
||||||
|
ImageWidget,
|
||||||
|
MathWidget,
|
||||||
|
TocWidget,
|
||||||
|
type TocEntry,
|
||||||
|
} from './widgets'
|
||||||
|
|
||||||
// 把笔记中书写的图片地址解析为浏览器可加载的 URL(相对路径需结合笔记所在目录)
|
// 把笔记中书写的图片地址解析为浏览器可加载的 URL(相对路径需结合笔记所在目录)
|
||||||
export const resolveImageUrl = Facet.define<(src: string) => string, (src: string) => string>({
|
export const resolveImageUrl = Facet.define<(src: string) => string, (src: string) => string>({
|
||||||
@@ -13,6 +24,10 @@ export const resolveImageUrl = Facet.define<(src: string) => string, (src: strin
|
|||||||
const hidden = Decoration.replace({})
|
const hidden = Decoration.replace({})
|
||||||
const inlineCode = Decoration.mark({ class: 'cm-rm-inline-code' })
|
const inlineCode = Decoration.mark({ class: 'cm-rm-inline-code' })
|
||||||
const linkText = Decoration.mark({ class: 'cm-rm-link' })
|
const linkText = Decoration.mark({ class: 'cm-rm-link' })
|
||||||
|
const highlightText = Decoration.mark({ class: 'cm-rm-highlight' })
|
||||||
|
const superscriptText = Decoration.mark({ class: 'cm-rm-sup' })
|
||||||
|
const subscriptText = Decoration.mark({ class: 'cm-rm-sub' })
|
||||||
|
const footnoteLabel = Decoration.mark({ class: 'cm-rm-footnote-label' })
|
||||||
const lineDecos = new Map<string, Decoration>()
|
const lineDecos = new Map<string, Decoration>()
|
||||||
|
|
||||||
function lineDeco(cls: string): Decoration {
|
function lineDeco(cls: string): Decoration {
|
||||||
@@ -66,6 +81,32 @@ function buildDecorations(view: EditorView): DecorationSet {
|
|||||||
return end
|
return end
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 脚注定义(标签 → 内容)与目录条目,只在可见区域出现相应语法时才计算
|
||||||
|
let footnoteMap: Map<string, string> | undefined
|
||||||
|
function footnotes() {
|
||||||
|
if (!footnoteMap) {
|
||||||
|
footnoteMap = new Map()
|
||||||
|
for (let n = syntaxTree(state).topNode.firstChild; n; n = n.nextSibling) {
|
||||||
|
if (n.name !== 'FootnoteDefinition') continue
|
||||||
|
const label = n.getChild('FootnoteLabel')!
|
||||||
|
footnoteMap.set(state.sliceDoc(label.from + 2, label.to - 2), state.sliceDoc(label.to, n.to).trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return footnoteMap
|
||||||
|
}
|
||||||
|
|
||||||
|
function tocEntries(): TocEntry[] {
|
||||||
|
const entries: TocEntry[] = []
|
||||||
|
for (let n = syntaxTree(state).topNode.firstChild; n; n = n.nextSibling) {
|
||||||
|
const m = /^(ATX|Setext)Heading(\d)$/.exec(n.name)
|
||||||
|
if (!m) continue
|
||||||
|
const raw = m[1] === 'ATX' ? state.sliceDoc(n.from, n.to).replace(/^#+\s*/, '').replace(/\s+#+\s*$/, '') : doc.lineAt(n.from).text
|
||||||
|
const text = raw.replace(/\*\*|__|==|~~|`/g, '').trim()
|
||||||
|
if (text) entries.push({ level: Number(m[2]), text, pos: n.from })
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
for (const { from, to } of view.visibleRanges) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
syntaxTree(state).iterate({
|
syntaxTree(state).iterate({
|
||||||
from,
|
from,
|
||||||
@@ -90,8 +131,51 @@ function buildDecorations(view: EditorView): DecorationSet {
|
|||||||
case 'StrikethroughMark':
|
case 'StrikethroughMark':
|
||||||
case 'SuperscriptMark':
|
case 'SuperscriptMark':
|
||||||
case 'SubscriptMark':
|
case 'SubscriptMark':
|
||||||
|
case 'HighlightMark':
|
||||||
if (!touches(node.parent!)) hide(node.from, node.to)
|
if (!touches(node.parent!)) hide(node.from, node.to)
|
||||||
return
|
return
|
||||||
|
case 'Highlight':
|
||||||
|
out.push(highlightText.range(node.from, node.to))
|
||||||
|
return
|
||||||
|
case 'Superscript':
|
||||||
|
out.push(superscriptText.range(node.from, node.to))
|
||||||
|
return
|
||||||
|
case 'Subscript':
|
||||||
|
out.push(subscriptText.range(node.from, node.to))
|
||||||
|
return
|
||||||
|
case 'InlineMath':
|
||||||
|
if (!touches(node) && lineOf(node.from) === lineOf(node.to)) {
|
||||||
|
const widget = new MathWidget(state.sliceDoc(node.from + 1, node.to - 1), false, 1)
|
||||||
|
out.push(Decoration.replace({ widget }).range(node.from, node.to))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case 'Emoji':
|
||||||
|
if (!touches(node)) out.push(Decoration.replace({ widget: new EmojiWidget(state.sliceDoc(node.from + 1, node.to - 1)) }).range(node.from, node.to))
|
||||||
|
return
|
||||||
|
case 'FootnoteReference':
|
||||||
|
if (!touches(node)) {
|
||||||
|
const label = state.sliceDoc(node.from + 2, node.to - 1)
|
||||||
|
out.push(Decoration.replace({ widget: new FootnoteRefWidget(label, footnotes().get(label)) }).range(node.from, node.to))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case 'FootnoteDefinition':
|
||||||
|
classLines(node.from, node.to, () => 'cm-rm-footnote')
|
||||||
|
return
|
||||||
|
case 'FootnoteLabel':
|
||||||
|
if (!isActive(node.from)) out.push(footnoteLabel.range(node.from, node.to))
|
||||||
|
return
|
||||||
|
case 'TableOfContents':
|
||||||
|
if (!isActive(node.from)) out.push(Decoration.replace({ widget: new TocWidget(tocEntries()) }).range(node.from, node.to))
|
||||||
|
return
|
||||||
|
case 'FrontMatter':
|
||||||
|
classLines(node.from, node.to, (n, first, last) =>
|
||||||
|
n === first ? 'cm-rm-frontmatter cm-rm-frontmatter-begin' : n === last ? 'cm-rm-frontmatter cm-rm-frontmatter-end' : 'cm-rm-frontmatter',
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
case 'BlockMath':
|
||||||
|
// 未编辑时由 blocks.ts 整块替换为渲染结果,这里只负责编辑时的源码样式
|
||||||
|
classLines(node.from, node.to, () => 'cm-rm-math-source')
|
||||||
|
return false
|
||||||
case 'InlineCode':
|
case 'InlineCode':
|
||||||
out.push(inlineCode.range(node.from, node.to))
|
out.push(inlineCode.range(node.from, node.to))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { createEditorState } from './index'
|
import { createEditorState } from './index'
|
||||||
import { setEditorFocus, tableField } from './table'
|
import { setEditorFocus } from './focus'
|
||||||
|
import { tableField } from './table'
|
||||||
|
|
||||||
const doc = '| a | b |\n| --- | --- |\n| 1 | 2 |\n\n正文'
|
const doc = '| a | b |\n| --- | --- |\n| 1 | 2 |\n\n正文'
|
||||||
|
|
||||||
|
|||||||
+14
-40
@@ -1,9 +1,11 @@
|
|||||||
import { isolateHistory, redo, undo } from '@codemirror/commands'
|
import { isolateHistory, redo, undo } from '@codemirror/commands'
|
||||||
import { syntaxTree } from '@codemirror/language'
|
import { syntaxTree } from '@codemirror/language'
|
||||||
import { StateEffect, StateField, type EditorState, type Range } from '@codemirror/state'
|
import { StateField, type EditorState, type Range } from '@codemirror/state'
|
||||||
import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from '@codemirror/view'
|
import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view'
|
||||||
import type { SyntaxNode } from '@lezer/common'
|
import type { SyntaxNode } from '@lezer/common'
|
||||||
import { renderInlineMarkdown } from '../markdown/html'
|
import { renderInlineMarkdown } from '../markdown/html'
|
||||||
|
import { hydrate } from '../markdown/render'
|
||||||
|
import { editorFocused, setEditorFocus } from './focus'
|
||||||
|
|
||||||
type Align = '' | 'left' | 'center' | 'right'
|
type Align = '' | 'left' | 'center' | 'right'
|
||||||
|
|
||||||
@@ -94,6 +96,11 @@ function findTable(state: EditorState, pos: number): SyntaxNode | null {
|
|||||||
|
|
||||||
// ---- 渲染组件 ----
|
// ---- 渲染组件 ----
|
||||||
|
|
||||||
|
function renderCell(cell: HTMLElement, raw: string) {
|
||||||
|
cell.innerHTML = renderInlineMarkdown(raw)
|
||||||
|
hydrate(cell)
|
||||||
|
}
|
||||||
|
|
||||||
interface WidgetState {
|
interface WidgetState {
|
||||||
model: TableModel
|
model: TableModel
|
||||||
// 最近编辑的单元格,工具栏操作以它为准
|
// 最近编辑的单元格,工具栏操作以它为准
|
||||||
@@ -164,7 +171,7 @@ class TableWidget extends WidgetType {
|
|||||||
cell.textContent = raw
|
cell.textContent = raw
|
||||||
placeCaretAtEnd(cell)
|
placeCaretAtEnd(cell)
|
||||||
}
|
}
|
||||||
} else if (old.rows[r].cells[c] !== raw) cell.innerHTML = renderInlineMarkdown(raw)
|
} else if (old.rows[r].cells[c] !== raw) renderCell(cell, raw)
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -184,7 +191,7 @@ class TableWidget extends WidgetType {
|
|||||||
cell.dataset.r = String(r)
|
cell.dataset.r = String(r)
|
||||||
cell.dataset.c = String(c)
|
cell.dataset.c = String(c)
|
||||||
if (this.model.aligns[c]) cell.style.textAlign = this.model.aligns[c]
|
if (this.model.aligns[c]) cell.style.textAlign = this.model.aligns[c]
|
||||||
cell.innerHTML = renderInlineMarkdown(raw)
|
renderCell(cell, raw)
|
||||||
bindCell(view, wrap, cell)
|
bindCell(view, wrap, cell)
|
||||||
tr.append(cell)
|
tr.append(cell)
|
||||||
})
|
})
|
||||||
@@ -356,7 +363,7 @@ function bindCell(view: EditorView, wrap: HTMLElement, cell: HTMLElement) {
|
|||||||
|
|
||||||
cell.addEventListener('blur', () => {
|
cell.addEventListener('blur', () => {
|
||||||
cell.removeAttribute('contenteditable')
|
cell.removeAttribute('contenteditable')
|
||||||
cell.innerHTML = renderInlineMarkdown(st().model.rows[r()]?.cells[c()] ?? '')
|
renderCell(cell, st().model.rows[r()]?.cells[c()] ?? '')
|
||||||
})
|
})
|
||||||
|
|
||||||
const moveTo = (nr: number, nc: number) => {
|
const moveTo = (nr: number, nc: number) => {
|
||||||
@@ -402,39 +409,8 @@ function bindCell(view: EditorView, wrap: HTMLElement, cell: HTMLElement) {
|
|||||||
|
|
||||||
// ---- 装饰 ----
|
// ---- 装饰 ----
|
||||||
|
|
||||||
// StateField 拿不到视图的焦点状态,由视图插件把焦点变化同步进状态
|
|
||||||
const setEditorFocus = StateEffect.define<boolean>()
|
|
||||||
|
|
||||||
const editorFocused = StateField.define<boolean>({
|
|
||||||
create: () => false,
|
|
||||||
update(value, tr) {
|
|
||||||
for (const e of tr.effects) if (e.is(setEditorFocus)) value = e.value
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const focusTracker = ViewPlugin.fromClass(
|
|
||||||
class {
|
|
||||||
constructor(readonly view: EditorView) {
|
|
||||||
this.sync()
|
|
||||||
}
|
|
||||||
update(u: ViewUpdate) {
|
|
||||||
if (u.focusChanged) this.sync()
|
|
||||||
}
|
|
||||||
// 不能在 update 中同步 dispatch,推迟到微任务
|
|
||||||
sync() {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
const focused = this.view.hasFocus
|
|
||||||
if (this.view.state.field(editorFocused, false) !== undefined && this.view.state.field(editorFocused) !== focused) {
|
|
||||||
this.view.dispatch({ effects: setEditorFocus.of(focused) })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// 块级替换装饰跨越多行,只能由 StateField 提供,不能来自 ViewPlugin。
|
// 块级替换装饰跨越多行,只能由 StateField 提供,不能来自 ViewPlugin。
|
||||||
// 与其他语法一致:编辑器获得焦点且光标在表格内时才显示源码(打开笔记时光标默认在文首,不应因此展开开头的表格)
|
// 与其他语法一致:编辑器获得焦点且光标在表格内时才显示源码(见 focus.ts)
|
||||||
function buildTables(state: EditorState): DecorationSet {
|
function buildTables(state: EditorState): DecorationSet {
|
||||||
const out: Range<Decoration>[] = []
|
const out: Range<Decoration>[] = []
|
||||||
const sel = state.field(editorFocused) ? state.selection.ranges : []
|
const sel = state.field(editorFocused) ? state.selection.ranges : []
|
||||||
@@ -465,6 +441,4 @@ export const tableField = StateField.define<DecorationSet>({
|
|||||||
provide: (f) => EditorView.decorations.from(f),
|
provide: (f) => EditorView.decorations.from(f),
|
||||||
})
|
})
|
||||||
|
|
||||||
export { setEditorFocus }
|
export const tableEditing = tableField
|
||||||
|
|
||||||
export const tableEditing = [editorFocused, tableField, focusTracker]
|
|
||||||
+201
-1
@@ -1,4 +1,5 @@
|
|||||||
import { WidgetType, type EditorView } from '@codemirror/view'
|
import { EditorView, WidgetType } from '@codemirror/view'
|
||||||
|
import { emojiFor, renderMath, renderMermaid } from '../markdown/render'
|
||||||
|
|
||||||
export class BulletWidget extends WidgetType {
|
export class BulletWidget extends WidgetType {
|
||||||
eq() {
|
eq() {
|
||||||
@@ -156,3 +157,202 @@ export class ImageWidget extends WidgetType {
|
|||||||
return 200
|
return 200
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 扩展语法(公式、Mermaid、emoji、脚注、目录) ----
|
||||||
|
|
||||||
|
// 点击渲染结果时把光标移到源码中(offset 为相对组件起点的位置)以展开编辑,做法同图片
|
||||||
|
function editOnMousedown(el: HTMLElement, view: EditorView, offset: number) {
|
||||||
|
el.addEventListener('mousedown', (e) => {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
view.dispatch({ selection: { anchor: Math.min(view.posAtDOM(el) + offset, view.state.doc.length) } })
|
||||||
|
view.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRenderError(el: HTMLElement, err: unknown) {
|
||||||
|
const pre = document.createElement('pre')
|
||||||
|
pre.className = 'cm-rm-render-error'
|
||||||
|
pre.textContent = err instanceof Error ? err.message : String(err)
|
||||||
|
el.replaceChildren(pre)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公式。editOffset 为 null 时是编辑中显示在源码下方的预览(不响应点击,内容变化时原地更新),
|
||||||
|
* 否则是替换源码的渲染结果,点击后光标移到源码 editOffset 处
|
||||||
|
*/
|
||||||
|
export class MathWidget extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly tex: string,
|
||||||
|
readonly display: boolean,
|
||||||
|
readonly editOffset: number | null,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
eq(other: MathWidget) {
|
||||||
|
return other.tex === this.tex && other.display === this.display && other.editOffset === this.editOffset
|
||||||
|
}
|
||||||
|
toDOM(view: EditorView) {
|
||||||
|
const el = document.createElement(this.display ? 'div' : 'span')
|
||||||
|
el.className = this.display ? 'cm-rm-math-block' : 'cm-rm-math'
|
||||||
|
if (this.editOffset === null) el.classList.add('cm-rm-preview')
|
||||||
|
else editOnMousedown(el, view, this.editOffset)
|
||||||
|
this.render(el, view)
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
// 只复用预览元素:从渲染结果切到编辑时,编辑器会尝试用新组件更新旧元素(旧元素带点击编辑的监听)
|
||||||
|
updateDOM(dom: HTMLElement, view: EditorView) {
|
||||||
|
if (this.editOffset !== null || !dom.classList.contains('cm-rm-preview')) return false
|
||||||
|
this.render(dom, view)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
private render(el: HTMLElement, view: EditorView) {
|
||||||
|
if (!this.tex.trim()) {
|
||||||
|
el.textContent = '空公式'
|
||||||
|
el.classList.add('cm-rm-placeholder')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
el.classList.remove('cm-rm-placeholder')
|
||||||
|
// 加载 KaTeX 前先显示源码
|
||||||
|
if (!el.firstChild) el.textContent = this.tex
|
||||||
|
void renderMath(el, this.tex, this.display).then(() => view.requestMeasure())
|
||||||
|
}
|
||||||
|
get estimatedHeight() {
|
||||||
|
return this.display ? 60 : -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mermaidSeqs = new WeakMap<HTMLElement, number>()
|
||||||
|
let mermaidSeq = 0
|
||||||
|
|
||||||
|
/** Mermaid 图表,editOffset 含义同 MathWidget */
|
||||||
|
export class MermaidWidget extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly code: string,
|
||||||
|
readonly editOffset: number | null,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
eq(other: MermaidWidget) {
|
||||||
|
return other.code === this.code && other.editOffset === this.editOffset
|
||||||
|
}
|
||||||
|
toDOM(view: EditorView) {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = 'cm-rm-mermaid'
|
||||||
|
if (this.editOffset === null) el.classList.add('cm-rm-preview')
|
||||||
|
else editOnMousedown(el, view, this.editOffset)
|
||||||
|
el.textContent = '图表渲染中…'
|
||||||
|
this.render(el, view)
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
// 只复用预览元素:从渲染结果切到编辑时,编辑器会尝试用新组件更新旧元素(旧元素带点击编辑的监听)
|
||||||
|
updateDOM(dom: HTMLElement, view: EditorView) {
|
||||||
|
if (this.editOffset !== null || !dom.classList.contains('cm-rm-preview')) return false
|
||||||
|
this.render(dom, view)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 渲染是异步的,连续输入时只采用最后一次的结果,并保留旧图直到新图就绪,避免闪烁
|
||||||
|
private render(el: HTMLElement, view: EditorView) {
|
||||||
|
const seq = ++mermaidSeq
|
||||||
|
mermaidSeqs.set(el, seq)
|
||||||
|
if (!this.code.trim()) {
|
||||||
|
el.textContent = '空图表'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderMermaid(this.code).then(
|
||||||
|
(svg) => {
|
||||||
|
if (mermaidSeqs.get(el) !== seq) return
|
||||||
|
el.innerHTML = svg
|
||||||
|
view.requestMeasure()
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (mermaidSeqs.get(el) !== seq) return
|
||||||
|
showRenderError(el, err)
|
||||||
|
view.requestMeasure()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
get estimatedHeight() {
|
||||||
|
return 240
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmojiWidget extends WidgetType {
|
||||||
|
constructor(readonly name: string) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
eq(other: EmojiWidget) {
|
||||||
|
return other.name === this.name
|
||||||
|
}
|
||||||
|
toDOM(view: EditorView) {
|
||||||
|
const el = document.createElement('span')
|
||||||
|
el.className = 'cm-rm-emoji'
|
||||||
|
// 名称表加载前或名称未知时显示原文
|
||||||
|
el.textContent = `:${this.name}:`
|
||||||
|
void emojiFor(this.name).then((e) => {
|
||||||
|
if (e) el.textContent = e
|
||||||
|
})
|
||||||
|
editOnMousedown(el, view, 1)
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FootnoteRefWidget extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly label: string,
|
||||||
|
readonly definition: string | undefined,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
eq(other: FootnoteRefWidget) {
|
||||||
|
return other.label === this.label && other.definition === this.definition
|
||||||
|
}
|
||||||
|
toDOM(view: EditorView) {
|
||||||
|
const el = document.createElement('sup')
|
||||||
|
el.className = 'cm-rm-footnote-ref'
|
||||||
|
el.textContent = this.label
|
||||||
|
el.title = this.definition ?? '未找到脚注定义'
|
||||||
|
editOnMousedown(el, view, 2)
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TocEntry {
|
||||||
|
level: number
|
||||||
|
text: string
|
||||||
|
pos: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TocWidget extends WidgetType {
|
||||||
|
constructor(readonly entries: TocEntry[]) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
eq(other: TocWidget) {
|
||||||
|
return JSON.stringify(other.entries) === JSON.stringify(this.entries)
|
||||||
|
}
|
||||||
|
toDOM(view: EditorView) {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = 'cm-rm-toc'
|
||||||
|
if (!this.entries.length) {
|
||||||
|
el.textContent = '目录(还没有标题)'
|
||||||
|
el.classList.add('cm-rm-placeholder')
|
||||||
|
}
|
||||||
|
const minLevel = Math.min(...this.entries.map((e) => e.level))
|
||||||
|
for (const entry of this.entries) {
|
||||||
|
const item = document.createElement('a')
|
||||||
|
item.className = 'cm-rm-toc-item'
|
||||||
|
item.style.paddingLeft = `${(entry.level - minLevel) * 1.2}em`
|
||||||
|
item.textContent = entry.text
|
||||||
|
item.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
view.dispatch({ selection: { anchor: entry.pos }, effects: EditorView.scrollIntoView(entry.pos, { y: 'start', yMargin: 24 }) })
|
||||||
|
view.focus()
|
||||||
|
})
|
||||||
|
el.append(item)
|
||||||
|
}
|
||||||
|
// 点击目录空白处展开 [TOC] 源码
|
||||||
|
editOnMousedown(el, view, 0)
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { parser as base } from '@lezer/markdown'
|
||||||
|
import { GFM } from '@lezer/markdown'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { typoraExtensions } from './extensions'
|
||||||
|
|
||||||
|
const parser = base.configure([GFM, ...typoraExtensions])
|
||||||
|
|
||||||
|
/** 列出指定类型节点覆盖的原文 */
|
||||||
|
function nodes(src: string, name: string): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
parser.parse(src).iterate({
|
||||||
|
enter(n) {
|
||||||
|
if (n.name === name) out.push(src.slice(n.from, n.to))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Typora 扩展语法', () => {
|
||||||
|
it('==高亮==', () => {
|
||||||
|
expect(nodes('a ==b c== d', 'Highlight')).toEqual(['==b c=='])
|
||||||
|
expect(nodes('a == b ==', 'Highlight')).toEqual([])
|
||||||
|
expect(nodes('x === y', 'Highlight')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('行内公式:金额与转义不误判', () => {
|
||||||
|
expect(nodes('设 $x^2 + y$ 为', 'InlineMath')).toEqual(['$x^2 + y$'])
|
||||||
|
expect(nodes('价格 $5 和 $10', 'InlineMath')).toEqual([])
|
||||||
|
expect(nodes('$ x$ 与 $x $', 'InlineMath')).toEqual([])
|
||||||
|
expect(nodes('\\$x$ 不是公式', 'InlineMath')).toEqual([])
|
||||||
|
expect(nodes('$a\\$b$', 'InlineMath')).toEqual(['$a\\$b$'])
|
||||||
|
expect(nodes('`$x$`', 'InlineMath')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('公式块:可打断段落,只在顶层识别,未闭合延续到文末', () => {
|
||||||
|
expect(nodes('段落\n$$\na^2\n$$\n后文', 'BlockMath')).toEqual(['$$\na^2\n$$'])
|
||||||
|
expect(nodes('> $$\n> x\n> $$\n\n外面', 'BlockMath')).toEqual([])
|
||||||
|
expect(nodes('$$\nx\n', 'BlockMath')).toEqual(['$$\nx\n'])
|
||||||
|
expect(nodes('$$x$$', 'BlockMath')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('脚注引用与定义', () => {
|
||||||
|
expect(nodes('正文[^1]与[^note]。', 'FootnoteReference')).toEqual(['[^1]', '[^note]'])
|
||||||
|
expect(nodes('[^ 1] 不是脚注', 'FootnoteReference')).toEqual([])
|
||||||
|
expect(nodes('[^1]: 第一行\n继续\n\n下一段', 'FootnoteDefinition')).toEqual(['[^1]: 第一行\n继续'])
|
||||||
|
expect(nodes('[^1]: 有 **粗体**', 'StrongEmphasis')).toEqual(['**粗体**'])
|
||||||
|
expect(nodes('[^1]: x', 'LinkReference')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('[TOC] 独占一行', () => {
|
||||||
|
expect(nodes('[TOC]\n\n# 标题', 'TableOfContents')).toEqual(['[TOC]'])
|
||||||
|
expect(nodes('[toc] ', 'TableOfContents')).toEqual(['[toc] '])
|
||||||
|
expect(nodes('见 [TOC] 处', 'TableOfContents')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('YAML front matter 只在文档首行', () => {
|
||||||
|
expect(nodes('---\ntitle: a\n---\n# 正文', 'FrontMatter')).toEqual(['---\ntitle: a\n---'])
|
||||||
|
expect(nodes('---\ntitle: a\n...\n正文', 'FrontMatter')).toEqual(['---\ntitle: a\n...'])
|
||||||
|
expect(nodes('正文\n\n---\na: b\n---', 'FrontMatter')).toEqual([])
|
||||||
|
expect(nodes('正文\n\n---\n', 'HorizontalRule')).toEqual(['---'])
|
||||||
|
// 闭合后的 --- 照常解析为分隔线
|
||||||
|
expect(nodes('---\na: 1\n---\n\n---\n', 'HorizontalRule')).toEqual(['---'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// Typora 扩展语法:==高亮==、$行内公式$、$$公式块$$、脚注、[TOC]、YAML front matter。
|
||||||
|
// 上标 ^x^、下标 ~x~、:emoji: 由 @lezer/markdown 自带的扩展解析
|
||||||
|
import { tags } from '@lezer/highlight'
|
||||||
|
import type { BlockContext, LeafBlockParser, Line, MarkdownConfig } from '@lezer/markdown'
|
||||||
|
|
||||||
|
const PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~\xA1‐-‧]/
|
||||||
|
|
||||||
|
const highlightDelim = { resolve: 'Highlight', mark: 'HighlightMark' }
|
||||||
|
|
||||||
|
/** ==高亮==,定界规则与 GFM 删除线相同 */
|
||||||
|
const Highlight: MarkdownConfig = {
|
||||||
|
defineNodes: [
|
||||||
|
{ name: 'Highlight', style: { 'Highlight/...': tags.special(tags.content) } },
|
||||||
|
{ name: 'HighlightMark', style: tags.processingInstruction },
|
||||||
|
],
|
||||||
|
parseInline: [
|
||||||
|
{
|
||||||
|
name: 'Highlight',
|
||||||
|
parse(cx, next, pos) {
|
||||||
|
if (next !== 61 /* = */ || cx.char(pos + 1) !== 61 || cx.char(pos + 2) === 61) return -1
|
||||||
|
const before = cx.slice(pos - 1, pos)
|
||||||
|
const after = cx.slice(pos + 2, pos + 3)
|
||||||
|
const sBefore = /\s|^$/.test(before)
|
||||||
|
const sAfter = /\s|^$/.test(after)
|
||||||
|
const pBefore = PUNCTUATION.test(before)
|
||||||
|
const pAfter = PUNCTUATION.test(after)
|
||||||
|
return cx.addDelimiter(
|
||||||
|
highlightDelim,
|
||||||
|
pos,
|
||||||
|
pos + 2,
|
||||||
|
!sAfter && (!pAfter || sBefore || pBefore),
|
||||||
|
!sBefore && (!pBefore || sAfter || pAfter),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
after: 'Emphasis',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* $行内公式$(Pandoc 规则):开头 $ 后不能是空白,结尾 $ 前不能是空白、后面不能紧跟数字,
|
||||||
|
* 因此"$5 和 $10"这类金额不会被当成公式;\$ 为转义
|
||||||
|
*/
|
||||||
|
const InlineMath: MarkdownConfig = {
|
||||||
|
defineNodes: [
|
||||||
|
{ name: 'InlineMath', style: tags.monospace },
|
||||||
|
{ name: 'InlineMathMark', style: tags.processingInstruction },
|
||||||
|
],
|
||||||
|
parseInline: [
|
||||||
|
{
|
||||||
|
name: 'InlineMath',
|
||||||
|
parse(cx, next, pos) {
|
||||||
|
if (next !== 36 /* $ */ || cx.char(pos + 1) === 36 || /\s/.test(cx.slice(pos + 1, pos + 2))) return -1
|
||||||
|
for (let i = pos + 1; i < cx.end; i++) {
|
||||||
|
const ch = cx.char(i)
|
||||||
|
if (ch === 92 /* \ */) i++
|
||||||
|
else if (ch === 36 && i > pos + 1 && !/\s/.test(cx.slice(i - 1, i)) && !/\d/.test(cx.slice(i + 1, i + 2))) {
|
||||||
|
return cx.addElement(cx.elt('InlineMath', pos, i + 1, [cx.elt('InlineMathMark', pos, pos + 1), cx.elt('InlineMathMark', i, i + 1)]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
},
|
||||||
|
before: 'Emphasis',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMathFence = (line: Line) => /^\$\$\s*$/.test(line.text.slice(line.pos))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 独占一行的 $$ 之间为公式块,未闭合时延续到文档末尾。只在顶层识别:解析器的公开接口无法判断
|
||||||
|
* 下一行是否仍属于引用、列表等容器,与表格一样,容器内的公式块以源码显示
|
||||||
|
*/
|
||||||
|
const BlockMath: MarkdownConfig = {
|
||||||
|
defineNodes: [
|
||||||
|
{ name: 'BlockMath', block: true, style: tags.monospace },
|
||||||
|
{ name: 'BlockMathMark', style: tags.processingInstruction },
|
||||||
|
],
|
||||||
|
parseBlock: [
|
||||||
|
{
|
||||||
|
name: 'BlockMath',
|
||||||
|
parse(cx, line) {
|
||||||
|
if (cx.parentType().name !== 'Document' || !isMathFence(line)) return false
|
||||||
|
const from = cx.lineStart + line.pos
|
||||||
|
const marks = [cx.elt('BlockMathMark', from, from + 2)]
|
||||||
|
while (cx.nextLine()) {
|
||||||
|
if (isMathFence(line)) {
|
||||||
|
marks.push(cx.elt('BlockMathMark', cx.lineStart + line.pos, cx.lineStart + line.pos + 2))
|
||||||
|
cx.nextLine()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cx.addElement(cx.elt('BlockMath', from, cx.prevLineEnd(), marks))
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
endLeaf: (cx, line) => cx.parentType().name === 'Document' && isMathFence(line),
|
||||||
|
before: 'FencedCode',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOOTNOTE_LABEL = /^\[\^([^\]\s]+)\]/
|
||||||
|
|
||||||
|
/** 脚注引用 [^label] 与脚注定义 [^label]: 内容(定义可跨行,直到空行) */
|
||||||
|
const Footnote: MarkdownConfig = {
|
||||||
|
defineNodes: [
|
||||||
|
{ name: 'FootnoteReference', style: tags.labelName },
|
||||||
|
{ name: 'FootnoteDefinition', block: true },
|
||||||
|
{ name: 'FootnoteLabel', style: tags.labelName },
|
||||||
|
],
|
||||||
|
parseInline: [
|
||||||
|
{
|
||||||
|
name: 'FootnoteReference',
|
||||||
|
parse(cx, next, pos) {
|
||||||
|
if (next !== 91 /* [ */ || cx.char(pos + 1) !== 94 /* ^ */) return -1
|
||||||
|
const m = FOOTNOTE_LABEL.exec(cx.slice(pos, cx.end))
|
||||||
|
return m ? cx.addElement(cx.elt('FootnoteReference', pos, pos + m[0].length)) : -1
|
||||||
|
},
|
||||||
|
before: 'Link',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
parseBlock: [
|
||||||
|
{
|
||||||
|
name: 'FootnoteDefinition',
|
||||||
|
leaf(_cx, leaf) {
|
||||||
|
const m = /^\[\^[^\]\s]+\]:/.exec(leaf.content)
|
||||||
|
if (!m) return null
|
||||||
|
const self: LeafBlockParser = {
|
||||||
|
// "[^1]: 文字"同时也是合法的链接引用定义;本解析器排在前面,续行时去掉其他候选,整块归为脚注定义。
|
||||||
|
// 须原地修改:调用方正在遍历这个数组
|
||||||
|
nextLine(_cx, _line, leaf) {
|
||||||
|
leaf.parsers.splice(0, leaf.parsers.length, self)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
finish(cx, leaf) {
|
||||||
|
const label = cx.elt('FootnoteLabel', leaf.start, leaf.start + m[0].length)
|
||||||
|
const body = cx.parser.parseInline(leaf.content.slice(m[0].length), leaf.start + m[0].length)
|
||||||
|
cx.addLeafElement(leaf, cx.elt('FootnoteDefinition', leaf.start, leaf.start + leaf.content.length, [label, ...body]))
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
},
|
||||||
|
before: 'LinkReference',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 独占一行的 [TOC](不区分大小写)显示为目录 */
|
||||||
|
const TableOfContents: MarkdownConfig = {
|
||||||
|
defineNodes: [{ name: 'TableOfContents', block: true, style: tags.meta }],
|
||||||
|
parseBlock: [
|
||||||
|
{
|
||||||
|
name: 'TableOfContents',
|
||||||
|
parse(cx, line) {
|
||||||
|
if (!/^\[toc\]\s*$/i.test(line.text.slice(line.pos))) return false
|
||||||
|
cx.addElement(cx.elt('TableOfContents', cx.lineStart + line.pos, cx.lineStart + line.text.length))
|
||||||
|
cx.nextLine()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
before: 'LinkReference',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFrontMatterEnd = (line: Line) => /^(---|\.\.\.)\s*$/.test(line.text)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档首行为 --- 时,到下一个 --- 或 ... 为 YAML front matter。解析器无法向后预读整个文档,
|
||||||
|
* 未闭合时延续到文末——与 Typora(首行输入 --- 即创建 front matter)及 GitHub、Hugo 的识别方式一致
|
||||||
|
*/
|
||||||
|
const FrontMatter: MarkdownConfig = {
|
||||||
|
defineNodes: [
|
||||||
|
{ name: 'FrontMatter', block: true, style: tags.monospace },
|
||||||
|
{ name: 'FrontMatterMark', style: tags.processingInstruction },
|
||||||
|
],
|
||||||
|
parseBlock: [
|
||||||
|
{
|
||||||
|
name: 'FrontMatter',
|
||||||
|
parse(cx: BlockContext, line: Line) {
|
||||||
|
if (cx.lineStart !== 0 || cx.parentType().name !== 'Document' || !/^---\s*$/.test(line.text)) return false
|
||||||
|
const marks = [cx.elt('FrontMatterMark', 0, 3)]
|
||||||
|
while (cx.nextLine()) {
|
||||||
|
if (isFrontMatterEnd(line)) {
|
||||||
|
marks.push(cx.elt('FrontMatterMark', cx.lineStart, cx.lineStart + 3))
|
||||||
|
cx.nextLine()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cx.addElement(cx.elt('FrontMatter', 0, cx.prevLineEnd(), marks))
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
before: 'HorizontalRule',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const typoraExtensions: MarkdownConfig[] = [FrontMatter, Highlight, InlineMath, BlockMath, Footnote, TableOfContents]
|
||||||
@@ -56,6 +56,21 @@ describe('安全模式', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Typora 扩展', () => {
|
||||||
|
it('高亮、上下标、脚注引用', () => {
|
||||||
|
expect(renderMarkdown('==a== x^2^ H~2~O[^1]')).toBe('<p><mark>a</mark> x<sup>2</sup> H<sub>2</sub>O<sup class="footnote-ref">1</sup></p>\n')
|
||||||
|
})
|
||||||
|
it('公式与 emoji 输出待填充的占位元素,内容被转义', () => {
|
||||||
|
expect(renderMarkdown('$a<b$ :smile:')).toBe('<p><span data-math="a<b">a<b</span> <span data-emoji="smile">:smile:</span></p>\n')
|
||||||
|
expect(renderMarkdown('$$\n"><img src=x onerror=alert(1)>\n$$')).toBe(
|
||||||
|
'<div data-math=""><img src=x onerror=alert(1)>">"><img src=x onerror=alert(1)></div>\n',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('脚注定义、front matter 与 [TOC]', () => {
|
||||||
|
expect(renderMarkdown('---\ntitle: x\n---\n[TOC]\n\n[^1]: 说明 **重点**')).toBe('<div class="footnote"><sup>1</sup> 说明 <strong>重点</strong></div>\n')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('CommonMark spec', () => {
|
describe('CommonMark spec', () => {
|
||||||
for (const e of examples) {
|
for (const e of examples) {
|
||||||
const test = PARSER_DEVIATIONS.has(e.number) ? it.fails : it
|
const test = PARSER_DEVIATIONS.has(e.number) ? it.fails : it
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { SyntaxNode } from '@lezer/common'
|
import type { SyntaxNode } from '@lezer/common'
|
||||||
import { Emoji, GFM, parser as commonmarkParser, Subscript, Superscript, type MarkdownParser } from '@lezer/markdown'
|
import { Emoji, GFM, parser as commonmarkParser, Subscript, Superscript, type MarkdownParser } from '@lezer/markdown'
|
||||||
|
import { typoraExtensions } from './extensions'
|
||||||
|
|
||||||
// 与编辑器(lang-markdown 的 markdownLanguage)一致的语法集
|
// 与编辑器一致的语法集(lang-markdown 的 markdownLanguage 加 Typora 扩展)
|
||||||
export const markdownParser = commonmarkParser.configure([GFM, Subscript, Superscript, Emoji])
|
export const markdownParser = commonmarkParser.configure([GFM, Subscript, Superscript, Emoji, ...typoraExtensions])
|
||||||
|
|
||||||
export { commonmarkParser }
|
export { commonmarkParser }
|
||||||
|
|
||||||
@@ -263,6 +264,19 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
|||||||
return `<sup>${innerOf(node, 'SuperscriptMark')}</sup>`
|
return `<sup>${innerOf(node, 'SuperscriptMark')}</sup>`
|
||||||
case 'Subscript':
|
case 'Subscript':
|
||||||
return `<sub>${innerOf(node, 'SubscriptMark')}</sub>`
|
return `<sub>${innerOf(node, 'SubscriptMark')}</sub>`
|
||||||
|
case 'Highlight':
|
||||||
|
return `<mark>${innerOf(node, 'HighlightMark')}</mark>`
|
||||||
|
// 公式与 emoji 输出占位元素,由 render.ts 的 hydrate 按需加载依赖后填充
|
||||||
|
case 'InlineMath': {
|
||||||
|
const tex = text(node.from + 1, node.to - 1)
|
||||||
|
return `<span data-math="${escapeHtml(tex)}">${escapeHtml(tex)}</span>`
|
||||||
|
}
|
||||||
|
case 'Emoji': {
|
||||||
|
const raw = text(node.from, node.to)
|
||||||
|
return `<span data-emoji="${escapeHtml(raw.slice(1, -1))}">${escapeHtml(raw)}</span>`
|
||||||
|
}
|
||||||
|
case 'FootnoteReference':
|
||||||
|
return `<sup class="footnote-ref">${escapeHtml(text(node.from + 2, node.to - 1))}</sup>`
|
||||||
case 'InlineCode': {
|
case 'InlineCode': {
|
||||||
const marks = node.getChildren('CodeMark')
|
const marks = node.getChildren('CodeMark')
|
||||||
let code = text(marks[0].to, marks[marks.length - 1].from).replace(/\n/g, ' ')
|
let code = text(marks[0].to, marks[marks.length - 1].from).replace(/\n/g, ' ')
|
||||||
@@ -416,6 +430,18 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
|||||||
return rawHtml(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':
|
case 'Table':
|
||||||
return table(node)
|
return table(node)
|
||||||
|
case 'BlockMath': {
|
||||||
|
const marks = node.getChildren('BlockMathMark')
|
||||||
|
const from = Math.min(lineEnd(node.from) + 1, node.to)
|
||||||
|
const to = marks.length > 1 ? Math.max(from, lineStart(marks[marks.length - 1].from) - 1) : node.to
|
||||||
|
const tex = text(from, to)
|
||||||
|
return `<div data-math="${escapeHtml(tex)}">${escapeHtml(tex)}</div>\n`
|
||||||
|
}
|
||||||
|
case 'FootnoteDefinition': {
|
||||||
|
const label = node.getChild('FootnoteLabel')!
|
||||||
|
return `<div class="footnote"><sup>${escapeHtml(text(label.from + 2, label.to - 2))}</sup> ${inline(node, label.to, node.to).trim()}</div>\n`
|
||||||
|
}
|
||||||
|
// front matter 是元数据,不输出;[TOC] 在只读分享页面(M4)中生成
|
||||||
default:
|
default:
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// 数学公式、Mermaid 图表、emoji 的渲染。依赖体积较大,首次用到时才加载(各自打成独立的包)
|
||||||
|
import type { MermaidConfig } from 'mermaid'
|
||||||
|
|
||||||
|
let katexLoad: Promise<typeof import('katex').default> | undefined
|
||||||
|
function loadKatex() {
|
||||||
|
katexLoad ??= Promise.all([import('katex'), import('katex/dist/katex.min.css')]).then(([m]) => m.default)
|
||||||
|
return katexLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染公式;语法错误以红色显示错误信息而不是抛出 */
|
||||||
|
export async function renderMath(el: HTMLElement, tex: string, displayMode: boolean): Promise<void> {
|
||||||
|
const katex = await loadKatex()
|
||||||
|
katex.render(tex, el, { displayMode, throwOnError: false, output: 'htmlAndMathml' })
|
||||||
|
}
|
||||||
|
|
||||||
|
let mermaidLoad: Promise<typeof import('mermaid').default> | undefined
|
||||||
|
let mermaidSeq = 0
|
||||||
|
function loadMermaid() {
|
||||||
|
mermaidLoad ??= import('mermaid').then(({ default: mermaid }) => {
|
||||||
|
const dark = matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
// strict:图表中的 HTML 会被清理,点击事件等交互被禁用
|
||||||
|
const config: MermaidConfig = { startOnLoad: false, securityLevel: 'strict', theme: dark ? 'dark' : 'default' }
|
||||||
|
mermaid.initialize(config)
|
||||||
|
return mermaid
|
||||||
|
})
|
||||||
|
return mermaidLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染 Mermaid 图表,返回 SVG 标记;语法错误时抛出带说明的 Error */
|
||||||
|
export async function renderMermaid(code: string): Promise<string> {
|
||||||
|
const mermaid = await loadMermaid()
|
||||||
|
const id = `rm-mermaid-${++mermaidSeq}`
|
||||||
|
try {
|
||||||
|
return (await mermaid.render(id, code)).svg
|
||||||
|
} catch (err) {
|
||||||
|
// 出错时 mermaid 会把临时节点留在 body 中
|
||||||
|
document.getElementById('d' + id)?.remove()
|
||||||
|
throw err instanceof Error ? err : new Error(String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let emojiLoad: Promise<Map<string, string>> | undefined
|
||||||
|
|
||||||
|
/** 按 GitHub/Typora 的名称查找 emoji,如 smile → 😄;未知名称返回 undefined */
|
||||||
|
export async function emojiFor(name: string): Promise<string | undefined> {
|
||||||
|
emojiLoad ??= import('gemoji').then(({ gemoji }) => {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const g of gemoji) for (const n of g.names) map.set(n, g.emoji)
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
return (await emojiLoad).get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为渲染器输出的占位元素(表格单元格等处)填充公式与 emoji */
|
||||||
|
export function hydrate(root: HTMLElement) {
|
||||||
|
for (const el of root.querySelectorAll<HTMLElement>('[data-math]')) {
|
||||||
|
void renderMath(el, el.dataset.math!, el.tagName === 'DIV')
|
||||||
|
}
|
||||||
|
for (const el of root.querySelectorAll<HTMLElement>('[data-emoji]')) {
|
||||||
|
void emojiFor(el.dataset.emoji!).then((e) => {
|
||||||
|
if (e) el.textContent = e
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
--rm-code-bg: #292d3e;
|
--rm-code-bg: #292d3e;
|
||||||
--rm-code-text: #e1e4e8;
|
--rm-code-text: #e1e4e8;
|
||||||
--rm-code-font-size: 14px;
|
--rm-code-font-size: 14px;
|
||||||
|
--rm-highlight-bg: rgba(255, 213, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
--rm-inline-code-bg: #2f2f2f;
|
--rm-inline-code-bg: #2f2f2f;
|
||||||
--rm-inline-code-text: #aac8e4;
|
--rm-inline-code-text: #aac8e4;
|
||||||
--rm-code-bg: #242424;
|
--rm-code-bg: #242424;
|
||||||
|
--rm-highlight-bg: rgba(255, 213, 0, 0.28);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,3 +233,45 @@ body {
|
|||||||
.cm-rm-code-copy { width: 36px; height: 36px; }
|
.cm-rm-code-copy { width: 36px; height: 36px; }
|
||||||
.cm-line.cm-rm-code { padding: 0 12px; }
|
.cm-line.cm-rm-code { padding: 0 12px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Typora 扩展语法 ---- */
|
||||||
|
.cm-rm-highlight,
|
||||||
|
.cm-rm-table-widget mark { background: var(--rm-highlight-bg); color: inherit; border-radius: 2px; padding: 0 1px; }
|
||||||
|
.cm-rm-sup { vertical-align: super; font-size: 0.75em; line-height: 0; }
|
||||||
|
.cm-rm-sub { vertical-align: sub; font-size: 0.75em; line-height: 0; }
|
||||||
|
.cm-rm-placeholder { color: var(--rm-text-muted); font-style: italic; }
|
||||||
|
|
||||||
|
.cm-rm-math { cursor: pointer; }
|
||||||
|
.cm-rm-math-block { display: block; padding: 8px 0; text-align: center; overflow-x: auto; overflow-y: hidden; cursor: pointer; }
|
||||||
|
.cm-rm-math-block .katex-display { margin: 0; }
|
||||||
|
.cm-line.cm-rm-math-source { font-family: var(--rm-font-mono); font-size: 0.9em; color: var(--rm-text-muted); }
|
||||||
|
/* 编辑时显示在源码下方的预览 */
|
||||||
|
.cm-rm-preview { margin: 4px 0 8px; padding: 8px 12px; border: 1px dashed var(--rm-divider); border-radius: 6px; cursor: default; }
|
||||||
|
|
||||||
|
.cm-rm-mermaid { display: block; padding: 8px 0; text-align: center; overflow-x: auto; cursor: pointer; color: var(--rm-text-muted); }
|
||||||
|
.cm-rm-mermaid svg { max-width: 100%; height: auto; }
|
||||||
|
.cm-rm-render-error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(229, 72, 77, 0.08);
|
||||||
|
color: #e5484d;
|
||||||
|
font-family: var(--rm-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-rm-emoji { cursor: text; }
|
||||||
|
|
||||||
|
.cm-rm-footnote-ref { color: var(--rm-accent-strong); font-size: 0.75em; cursor: pointer; padding: 0 1px; }
|
||||||
|
.cm-line.cm-rm-footnote { font-size: 0.9em; color: var(--rm-text-muted); }
|
||||||
|
.cm-rm-footnote-label { color: var(--rm-accent-strong); }
|
||||||
|
|
||||||
|
.cm-rm-toc { display: block; padding: 8px 12px; border-left: 3px solid var(--rm-divider); cursor: pointer; }
|
||||||
|
.cm-rm-toc-item { display: block; color: var(--rm-accent-strong); line-height: 1.8; cursor: pointer; text-decoration: none; }
|
||||||
|
.cm-rm-toc-item:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.cm-line.cm-rm-frontmatter { font-family: var(--rm-font-mono); font-size: 0.85em; color: var(--rm-text-muted); background: var(--rm-inline-code-bg); padding: 0 12px; }
|
||||||
|
.cm-line.cm-rm-frontmatter-begin { border-radius: 6px 6px 0 0; padding-top: 4px; }
|
||||||
|
.cm-line.cm-rm-frontmatter-end { border-radius: 0 0 6px 6px; padding-bottom: 4px; }
|
||||||
Reference in new issue
Block a user