Add managed plugins with lifecycle cleanup and editor extensions
This commit is contained in:
1 parent
9fd7e2a948
commit
8ce4a11b33
25 files changed
+1731
-73
No files matched your search
@@ -4,13 +4,14 @@ 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'
|
||||
import { MathWidget, CodeRendererWidget } from './widgets'
|
||||
import { codeRenderers, notePath, pluginRefresh } from '../plugins/registry'
|
||||
|
||||
// 公式块与 Mermaid 图表:未编辑时整块替换为渲染结果;光标在块内时显示源码,并在下方实时预览。
|
||||
// 跨行的块级替换只能由 StateField 提供;与表格一样只处理顶层的块
|
||||
|
||||
/** 返回块的源码内容及其相对块起点的偏移(点击渲染结果时光标落在内容开头) */
|
||||
function blockContent(state: EditorState, node: SyntaxNode): { text: string; offset: number } | null {
|
||||
function blockContent(state: EditorState, node: SyntaxNode): { text: string; offset: number; language?: string } | null {
|
||||
if (node.name === 'BlockMath') {
|
||||
const marks = node.getChildren('BlockMathMark')
|
||||
const from = state.doc.lineAt(node.from).to + 1
|
||||
@@ -19,9 +20,11 @@ function blockContent(state: EditorState, node: SyntaxNode): { text: string; off
|
||||
}
|
||||
if (node.name === 'FencedCode') {
|
||||
const info = node.getChild('CodeInfo')
|
||||
if (!info || fenceLanguage(state, info.from, info.to) !== 'mermaid') return null
|
||||
if (!info) return null
|
||||
const language = fenceLanguage(state, info.from, info.to).toLowerCase()
|
||||
if (!codeRenderers.has(language)) return null
|
||||
const { from, to } = codeContentRange(state, node)
|
||||
return { text: state.sliceDoc(from, to), offset: from - node.from }
|
||||
return { text: state.sliceDoc(from, to), offset: from - node.from, language }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -34,7 +37,7 @@ function buildBlocks(state: EditorState): DecorationSet {
|
||||
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)
|
||||
node.name === 'BlockMath' ? new MathWidget(content.text, true, editOffset) : new CodeRendererWidget(content.text, editOffset, content.language!, state.facet(notePath).path, codeRenderers.get(content.language!)!)
|
||||
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))
|
||||
}
|
||||
@@ -47,7 +50,7 @@ export const renderedBlocks = StateField.define<DecorationSet>({
|
||||
if (
|
||||
tr.docChanged ||
|
||||
tr.selection ||
|
||||
tr.effects.some((e) => e.is(setEditorFocus)) ||
|
||||
tr.effects.some((e) => e.is(setEditorFocus) || e.is(pluginRefresh)) ||
|
||||
syntaxTree(tr.startState) !== syntaxTree(tr.state)
|
||||
) {
|
||||
return buildBlocks(tr.state)
|
||||
|
||||
+17
-6
@@ -1,7 +1,7 @@
|
||||
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 { Compartment, 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'
|
||||
@@ -12,17 +12,21 @@ import { insertLink, toggleBold, toggleItalic } from './format'
|
||||
import { livePreview, resolveImageUrl } from './livepreview'
|
||||
import { tableEditing } from './table'
|
||||
import { markdownTheme } from './theme'
|
||||
import { editorExtensions, markdownSyntax, reportEditorFailure } from '../plugins/registry'
|
||||
|
||||
export { insertImageFiles, pasteImages } from './paste'
|
||||
|
||||
// 在 lang-markdown 的语法集(CommonMark + GFM + 上下标 + emoji)上加 Typora 扩展。
|
||||
// 沿用其语言数据,markdownKeymap 中的命令据此识别 Markdown(isActiveAt 按语言数据判断)
|
||||
const rawmarkLanguage = new Language(
|
||||
export const pluginCompartment = new Compartment()
|
||||
export const languageCompartment = new Compartment()
|
||||
export function pluginLanguage() { return new Language(
|
||||
markdownLanguage.data,
|
||||
(markdownLanguage.parser as MarkdownParser).configure(typoraExtensions),
|
||||
(markdownLanguage.parser as MarkdownParser).configure([typoraExtensions, ...[...markdownSyntax.values()].map((entry) => entry.value)]),
|
||||
[],
|
||||
'markdown',
|
||||
)
|
||||
) }
|
||||
export function pluginEditorExtensions(): Extension[] { return [...editorExtensions.values()].map((entry) => entry.value) }
|
||||
|
||||
export interface EditorStateOptions {
|
||||
doc: string
|
||||
@@ -40,7 +44,9 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
|
||||
EditorView.lineWrapping,
|
||||
// 不用 lang-markdown 的 markdown():它会静态引入 lang-html/css/javascript 用于嵌套 HTML 高亮,
|
||||
// 首屏包 gzip 从约 108 KB 增至约 180 KB,而 Live Preview 不需要 HTML 高亮
|
||||
rawmarkLanguage,
|
||||
languageCompartment.of(pluginLanguage()),
|
||||
pluginCompartment.of(pluginEditorExtensions()),
|
||||
EditorView.exceptionSink.of(reportEditorFailure),
|
||||
Prec.high(keymap.of(markdownKeymap)),
|
||||
// 与 Typora 一致的格式快捷键
|
||||
Prec.high(
|
||||
@@ -71,5 +77,10 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
|
||||
}
|
||||
|
||||
export function createEditorView(parent: HTMLElement, state: EditorState): EditorView {
|
||||
return new EditorView({ parent, state })
|
||||
const view = new EditorView({ parent, state })
|
||||
const dispatch = view.dispatch.bind(view)
|
||||
view.dispatch = (...args: unknown[]) => {
|
||||
try { Reflect.apply(dispatch, view, args) } catch (error) { reportEditorFailure(error) }
|
||||
}
|
||||
return view
|
||||
}
|
||||
+35
-50
@@ -1,5 +1,7 @@
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { emojiFor, renderMath, renderMermaid } from '../markdown/render'
|
||||
import { emojiFor, renderMath } from '../markdown/render'
|
||||
import { mountContent } from '../plugins/mount'
|
||||
import type { OwnedRenderer } from '../plugins/registry'
|
||||
|
||||
export class BulletWidget extends WidgetType {
|
||||
eq() {
|
||||
@@ -222,74 +224,57 @@ export class MathWidget extends WidgetType {
|
||||
}
|
||||
}
|
||||
|
||||
const mermaidSeqs = new WeakMap<HTMLElement, number>()
|
||||
const mermaidListeners = new WeakMap<HTMLElement, () => void>()
|
||||
let mermaidSeq = 0
|
||||
const rendererMounts = new WeakMap<HTMLElement, () => void>()
|
||||
|
||||
/** Mermaid 图表,editOffset 含义同 MathWidget */
|
||||
export class MermaidWidget extends WidgetType {
|
||||
/** 通用代码块预览;editOffset 与公式组件使用相同的点击编辑约定。 */
|
||||
export class CodeRendererWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
readonly editOffset: number | null,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
eq(other: MermaidWidget) {
|
||||
return other.code === this.code && other.editOffset === this.editOffset
|
||||
readonly language: string,
|
||||
readonly path: string,
|
||||
readonly renderer: OwnedRenderer,
|
||||
) { super() }
|
||||
eq(other: CodeRendererWidget) {
|
||||
return other.code === this.code && other.editOffset === this.editOffset && other.language === this.language && other.path === this.path && other.renderer === this.renderer
|
||||
}
|
||||
toDOM(view: EditorView) {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'cm-rm-mermaid'
|
||||
el.className = this.language === 'mermaid' ? 'cm-rm-mermaid' : 'cm-rm-code-render'
|
||||
if (this.editOffset === null) el.classList.add('cm-rm-preview')
|
||||
else editOnMousedown(el, view, this.editOffset)
|
||||
el.textContent = '图表渲染中…'
|
||||
this.render(el, view)
|
||||
const update = () => this.render(el, view)
|
||||
mermaidListeners.set(el, update)
|
||||
window.addEventListener('rawmark-appearance', update)
|
||||
this.mount(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)
|
||||
const old = mermaidListeners.get(dom)
|
||||
if (old) window.removeEventListener('rawmark-appearance', old)
|
||||
const update = () => this.render(dom, view)
|
||||
mermaidListeners.set(dom, update)
|
||||
window.addEventListener('rawmark-appearance', update)
|
||||
dom.className = (this.language === 'mermaid' ? 'cm-rm-mermaid' : 'cm-rm-code-render') + ' cm-rm-preview'
|
||||
this.destroy(dom)
|
||||
this.mount(dom, view)
|
||||
return true
|
||||
}
|
||||
destroy(dom: HTMLElement) {
|
||||
const update = mermaidListeners.get(dom)
|
||||
if (update) window.removeEventListener('rawmark-appearance', update)
|
||||
mermaidListeners.delete(dom)
|
||||
mermaidSeqs.delete(dom)
|
||||
rendererMounts.get(dom)?.()
|
||||
rendererMounts.delete(dom)
|
||||
}
|
||||
// 渲染是异步的,连续输入时只采用最后一次的结果,并保留旧图直到新图就绪,避免闪烁
|
||||
private render(el: HTMLElement, view: EditorView) {
|
||||
const seq = ++mermaidSeq
|
||||
mermaidSeqs.set(el, seq)
|
||||
if (!this.code.trim()) {
|
||||
el.textContent = '空图表'
|
||||
return
|
||||
private mount(el: HTMLElement, view: EditorView) {
|
||||
let stop = () => {}
|
||||
const render = () => {
|
||||
stop()
|
||||
stop = mountContent(el, (context) => Promise.resolve(this.renderer.render({
|
||||
...context, source: this.code, language: this.language, path: this.path,
|
||||
colorMode: document.documentElement.dataset.rmColorMode === 'dark' ? 'dark' : 'light',
|
||||
})).then((cleanup) => { requestAnimationFrame(() => view.requestMeasure()); return cleanup }), (error) => {
|
||||
showRenderError(el, error)
|
||||
this.renderer.fail(error)
|
||||
view.requestMeasure()
|
||||
})
|
||||
}
|
||||
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
|
||||
render()
|
||||
window.addEventListener('rawmark-appearance', render)
|
||||
rendererMounts.set(el, () => { window.removeEventListener('rawmark-appearance', render); stop() })
|
||||
}
|
||||
get estimatedHeight() { return 240 }
|
||||
}
|
||||
|
||||
export class EmojiWidget extends WidgetType {
|
||||
|
||||
Reference in new issue
Block a user