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
@@ -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 { 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]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user