mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
perf: Cache decorations in editor plugins (#12030)
Avoid full document traversal on every keystroke by mapping decorations through the transaction when no relevant nodes changed. Uses changedDescendants to detect when a heading, image, or code_inline-marked text actually changes; otherwise the existing DecorationSet is mapped to new positions cheaply. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import type { NodeAnchor } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import type { Node } from "prosemirror-model";
|
||||
import type { EditorState, Transaction } from "prosemirror-state";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import { changedDescendants } from "../lib/changedDescendants";
|
||||
import { isRemoteTransaction } from "../lib/multiplayer";
|
||||
|
||||
export class AnchorPlugin extends Plugin {
|
||||
constructor() {
|
||||
@@ -11,12 +14,18 @@ export class AnchorPlugin extends Plugin {
|
||||
init: (_, state: EditorState) => ({
|
||||
decorations: this.createDecorations(state),
|
||||
}),
|
||||
apply: (tr, pluginState, oldState, newState) => {
|
||||
// Only recompute if doc changed
|
||||
if (tr.docChanged) {
|
||||
apply: (tr, pluginState, _oldState, newState) => {
|
||||
if (!tr.docChanged) {
|
||||
return pluginState;
|
||||
}
|
||||
|
||||
if (isRemoteTransaction(tr) || this.hasAnchorableChange(tr)) {
|
||||
return { decorations: this.createDecorations(newState) };
|
||||
}
|
||||
return pluginState;
|
||||
|
||||
return {
|
||||
decorations: pluginState.decorations.map(tr.mapping, tr.doc),
|
||||
};
|
||||
},
|
||||
},
|
||||
props: {
|
||||
@@ -28,8 +37,38 @@ export class AnchorPlugin extends Plugin {
|
||||
});
|
||||
}
|
||||
|
||||
private createAnchorDecoration = (anchor: NodeAnchor) =>
|
||||
Decoration.widget(
|
||||
private isAnchorable(node: Node): boolean {
|
||||
return (
|
||||
node.type.name === "heading" ||
|
||||
(Array.isArray(node.attrs.marks) &&
|
||||
node.attrs.marks.some(
|
||||
(mark: { type: string; attrs?: { id?: string } }) =>
|
||||
mark.type === "comment" && mark.attrs?.id
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the transaction changed any heading or image-with-comment-mark
|
||||
* nodes by comparing changed descendants in both directions.
|
||||
*/
|
||||
private hasAnchorableChange(tr: Transaction): boolean {
|
||||
let found = false;
|
||||
const check = (node: Node) => {
|
||||
if (!found && this.isAnchorable(node)) {
|
||||
found = true;
|
||||
}
|
||||
};
|
||||
|
||||
changedDescendants(tr.before, tr.doc, 0, check);
|
||||
if (!found) {
|
||||
changedDescendants(tr.doc, tr.before, 0, check);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private createAnchorDecoration(anchor: NodeAnchor) {
|
||||
return Decoration.widget(
|
||||
anchor.pos,
|
||||
() => {
|
||||
const anchorElement = document.createElement("a");
|
||||
@@ -39,6 +78,7 @@ export class AnchorPlugin extends Plugin {
|
||||
},
|
||||
{ side: -1, key: anchor.id }
|
||||
);
|
||||
}
|
||||
|
||||
private createDecorations(state: EditorState) {
|
||||
const anchors = ProsemirrorHelper.getAnchors(state.doc);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import type { Node } from "prosemirror-model";
|
||||
import type { EditorState, Transaction } from "prosemirror-state";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import { changedDescendants } from "../lib/changedDescendants";
|
||||
import { isRemoteTransaction } from "../lib/multiplayer";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
|
||||
interface CodeWordDecorationsConfig {
|
||||
@@ -22,13 +25,19 @@ class CodeWordDecorationsPlugin extends Plugin {
|
||||
decorations: this.createDecorations(state, finalConfig),
|
||||
}),
|
||||
apply: (tr, pluginState, _oldState, newState) => {
|
||||
// Only recompute if doc changed
|
||||
if (tr.docChanged) {
|
||||
if (!tr.docChanged) {
|
||||
return pluginState;
|
||||
}
|
||||
|
||||
if (isRemoteTransaction(tr) || this.hasCodeInlineChange(tr)) {
|
||||
return {
|
||||
decorations: this.createDecorations(newState, finalConfig),
|
||||
};
|
||||
}
|
||||
return pluginState;
|
||||
|
||||
return {
|
||||
decorations: pluginState.decorations.map(tr.mapping, tr.doc),
|
||||
};
|
||||
},
|
||||
},
|
||||
props: {
|
||||
@@ -40,6 +49,33 @@ class CodeWordDecorationsPlugin extends Plugin {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the transaction changed any text nodes with code_inline marks.
|
||||
*/
|
||||
private hasCodeInlineChange(tr: Transaction): boolean {
|
||||
const codeMarkType = tr.doc.type.schema.marks.code_inline;
|
||||
if (!codeMarkType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let found = false;
|
||||
const check = (node: Node) => {
|
||||
if (
|
||||
!found &&
|
||||
node.isText &&
|
||||
node.marks.some((m) => m.type === codeMarkType)
|
||||
) {
|
||||
found = true;
|
||||
}
|
||||
};
|
||||
|
||||
changedDescendants(tr.before, tr.doc, 0, check);
|
||||
if (!found) {
|
||||
changedDescendants(tr.doc, tr.before, 0, check);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private createDecorations(
|
||||
state: EditorState,
|
||||
config: Required<CodeWordDecorationsConfig>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import type { Node } from "prosemirror-model";
|
||||
import type { EditorState, Transaction } from "prosemirror-state";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import { changedDescendants } from "../lib/changedDescendants";
|
||||
import { isRemoteTransaction } from "../lib/multiplayer";
|
||||
|
||||
/**
|
||||
* Plugin that applies a light outline decoration to image nodes that have
|
||||
@@ -15,10 +18,17 @@ export class CommentedImagePlugin extends Plugin {
|
||||
decorations: this.createDecorations(state),
|
||||
}),
|
||||
apply: (tr, pluginState, _oldState, newState) => {
|
||||
if (tr.docChanged) {
|
||||
if (!tr.docChanged) {
|
||||
return pluginState;
|
||||
}
|
||||
|
||||
if (isRemoteTransaction(tr) || this.hasImageChange(tr)) {
|
||||
return { decorations: this.createDecorations(newState) };
|
||||
}
|
||||
return pluginState;
|
||||
|
||||
return {
|
||||
decorations: pluginState.decorations.map(tr.mapping, tr.doc),
|
||||
};
|
||||
},
|
||||
},
|
||||
props: {
|
||||
@@ -30,6 +40,24 @@ export class CommentedImagePlugin extends Plugin {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the transaction added, removed, or modified any image nodes.
|
||||
*/
|
||||
private hasImageChange(tr: Transaction): boolean {
|
||||
let found = false;
|
||||
const check = (node: Node) => {
|
||||
if (!found && node.type.name === "image") {
|
||||
found = true;
|
||||
}
|
||||
};
|
||||
|
||||
changedDescendants(tr.before, tr.doc, 0, check);
|
||||
if (!found) {
|
||||
changedDescendants(tr.doc, tr.before, 0, check);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private createDecorations(state: EditorState) {
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user