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:
eezd committed 2026-10-17 13:38:53 +08:00
1 parent 919575f3e7
commit 75000fc571
15 files changed
+1991 -47

No files matched your search

+58
View File
@@ -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),
})
+37
View File
@@ -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
View File
@@ -1,14 +1,28 @@
import { defaultKeymap, history, historyKeymap, indentWithTab, redo } from '@codemirror/commands'
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
import { Language } from '@codemirror/language'
import { EditorState, Prec, type Extension } from '@codemirror/state'
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 { editorFocus } from './focus'
import { livePreview, resolveImageUrl } from './livepreview'
import { tableEditing } from './table'
import { markdownTheme } from './theme'
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 {
doc: string
resolveImage?: (src: string) => string
@@ -25,12 +39,14 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
EditorView.lineWrapping,
// 不用 lang-markdown 的 markdown():它会静态引入 lang-html/css/javascript 用于嵌套 HTML 高亮,
// 首屏包 gzip 从约 108 KB 增至约 180 KB,而 Live Preview 不需要 HTML 高亮
markdownLanguage,
rawmarkLanguage,
Prec.high(keymap.of(markdownKeymap)),
markdownTheme,
livePreview,
codeBlockHighlight,
editorFocus,
tableEditing,
renderedBlocks,
// CodeMirror 只在 Linux/Mac 上把 Ctrl/Cmd+Shift+Z 绑定为重做,Windows 上会退化成撤销;
// 与 Typora、VS Code 保持一致,所有平台都支持
keymap.of([indentWithTab, { key: 'Mod-Shift-z', run: redo, preventDefault: true }, ...defaultKeymap, ...historyKeymap]),
+85 -1
View File
@@ -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 type { SyntaxNode } from '@lezer/common'
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(相对路径需结合笔记所在目录)
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 inlineCode = Decoration.mark({ class: 'cm-rm-inline-code' })
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>()
function lineDeco(cls: string): Decoration {
@@ -66,6 +81,32 @@ function buildDecorations(view: EditorView): DecorationSet {
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) {
syntaxTree(state).iterate({
from,
@@ -90,8 +131,51 @@ function buildDecorations(view: EditorView): DecorationSet {
case 'StrikethroughMark':
case 'SuperscriptMark':
case 'SubscriptMark':
case 'HighlightMark':
if (!touches(node.parent!)) hide(node.from, node.to)
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':
out.push(inlineCode.range(node.from, node.to))
return
+2 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
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正文'
+14 -40
View File
@@ -1,9 +1,11 @@
import { isolateHistory, redo, undo } from '@codemirror/commands'
import { syntaxTree } from '@codemirror/language'
import { StateEffect, StateField, type EditorState, type Range } from '@codemirror/state'
import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from '@codemirror/view'
import { StateField, type EditorState, type Range } from '@codemirror/state'
import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import { renderInlineMarkdown } from '../markdown/html'
import { hydrate } from '../markdown/render'
import { editorFocused, setEditorFocus } from './focus'
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 {
model: TableModel
// 最近编辑的单元格,工具栏操作以它为准
@@ -164,7 +171,7 @@ class TableWidget extends WidgetType {
cell.textContent = raw
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
}
@@ -184,7 +191,7 @@ class TableWidget extends WidgetType {
cell.dataset.r = String(r)
cell.dataset.c = String(c)
if (this.model.aligns[c]) cell.style.textAlign = this.model.aligns[c]
cell.innerHTML = renderInlineMarkdown(raw)
renderCell(cell, raw)
bindCell(view, wrap, cell)
tr.append(cell)
})
@@ -356,7 +363,7 @@ function bindCell(view: EditorView, wrap: HTMLElement, cell: HTMLElement) {
cell.addEventListener('blur', () => {
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) => {
@@ -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。
// 与其他语法一致:编辑器获得焦点且光标在表格内时才显示源码(打开笔记时光标默认在文首,不应因此展开开头的表格)
// 与其他语法一致:编辑器获得焦点且光标在表格内时才显示源码(见 focus.ts)
function buildTables(state: EditorState): DecorationSet {
const out: Range<Decoration>[] = []
const sel = state.field(editorFocused) ? state.selection.ranges : []
@@ -465,6 +441,4 @@ export const tableField = StateField.define<DecorationSet>({
provide: (f) => EditorView.decorations.from(f),
})
export { setEditorFocus }
export const tableEditing = [editorFocused, tableField, focusTracker]
export const tableEditing = tableField
+201 -1
View File
@@ -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 {
eq() {
@@ -156,3 +157,202 @@ export class ImageWidget extends WidgetType {
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
}
}