Skip to content

Commit 5acc6bc

Browse files
Optimize editor per-keystroke performance
Major bottlenecks identified via profiling and addressed: Tables: skip full-document rebuild when the change doesn't touch any Table node — map existing decorations through position changes instead. Lists: consolidate 5 separate StateFields into a single ViewPlugin scoped to visible ranges. The continuation-line check used a full-tree scan per line — replaced with O(depth) resolveInner lookup. Toolbar: getBlockType scanned every line for code fences — now checks heading first (line-local) and only scans up to the cursor line. UpdateListener: deduplicate doc.toString() calls, skip countSearchMatches when no active query. Inline images: skip collectSearchMatches + doc.toString() when there is no active search query. onChange: defer store update off the critical paint path with setTimeout so the React re-render cascade doesn't block the keystroke.
1 parent 6466309 commit 5acc6bc

5 files changed

Lines changed: 456 additions & 406 deletions

File tree

app/src/features/editor/extensions/inline-images/index.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,26 @@ export function findInlineImageBeforeCursor(
285285
state: EditorState,
286286
cursor: number,
287287
): InlineImageMatch | null {
288-
for (const match of findInlineImages(state)) {
289-
if (match.to === cursor) {
290-
return match;
291-
}
288+
// Only check the current line instead of scanning the entire document.
289+
// Image syntax is always single-line so a line-scoped search is correct.
290+
const line = state.doc.lineAt(cursor);
291+
const tree = syntaxTree(state);
292+
const regex = new RegExp(INLINE_IMAGE_REGEX.source, "g");
293+
const slice = state.doc.sliceString(line.from, line.to);
294+
295+
let m;
296+
while ((m = regex.exec(slice)) !== null) {
297+
const from = line.from + m.index;
298+
const to = from + m[0].length;
299+
300+
if (to !== cursor) continue;
301+
if (isInsideCodeBlock(tree, from)) continue;
302+
303+
const altText = m[1] ?? "";
304+
const src = m[2] ?? "";
305+
if (!src) continue;
306+
307+
return { altText, from, src, to };
292308
}
293309

294310
return null;
@@ -364,10 +380,9 @@ function inlineImagePlugin(searchQuery = "") {
364380
searchMatches: SearchMatch[];
365381

366382
constructor(view: EditorView) {
367-
this.searchMatches = collectSearchMatches(
368-
view.state.doc.toString(),
369-
searchQuery,
370-
);
383+
this.searchMatches = searchQuery
384+
? collectSearchMatches(view.state.doc.toString(), searchQuery)
385+
: [];
371386
this.decorations = buildInlineImageDecorations(
372387
view,
373388
this.searchMatches,
@@ -381,10 +396,9 @@ function inlineImagePlugin(searchQuery = "") {
381396
syntaxTree(update.state) !== syntaxTree(update.startState)
382397
) {
383398
if (update.docChanged) {
384-
this.searchMatches = collectSearchMatches(
385-
update.state.doc.toString(),
386-
searchQuery,
387-
);
399+
this.searchMatches = searchQuery
400+
? collectSearchMatches(update.state.doc.toString(), searchQuery)
401+
: [];
388402
}
389403
this.decorations = buildInlineImageDecorations(
390404
update.view,

app/src/features/editor/extensions/markdown-decorations/builders/tables.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,6 +1070,48 @@ function transactionTouchesOnlyActiveCell(
10701070
return touchesOnlyCell;
10711071
}
10721072

1073+
/**
1074+
* Check whether any changed range in a transaction intersects with a
1075+
* Table node in either the old or new syntax tree.
1076+
*/
1077+
function changesAffectTables(transaction: Transaction): boolean {
1078+
let found = false;
1079+
1080+
transaction.changes.iterChangedRanges((fromA, toA, fromB, toB) => {
1081+
if (found) return;
1082+
1083+
const oldTree = syntaxTree(transaction.startState);
1084+
const oldDoc = transaction.startState.doc;
1085+
oldTree.iterate({
1086+
from: oldDoc.lineAt(fromA).from,
1087+
to: oldDoc.lineAt(Math.min(toA, oldDoc.length)).to,
1088+
enter(node) {
1089+
if (node.name === "Table") {
1090+
found = true;
1091+
return false;
1092+
}
1093+
},
1094+
});
1095+
1096+
if (found) return;
1097+
1098+
const newTree = syntaxTree(transaction.state);
1099+
const newDoc = transaction.state.doc;
1100+
newTree.iterate({
1101+
from: newDoc.lineAt(fromB).from,
1102+
to: newDoc.lineAt(Math.min(toB, newDoc.length)).to,
1103+
enter(node) {
1104+
if (node.name === "Table") {
1105+
found = true;
1106+
return false;
1107+
}
1108+
},
1109+
});
1110+
});
1111+
1112+
return found;
1113+
}
1114+
10731115
const tableDecorationField = StateField.define<DecorationSet>({
10741116
create(state) {
10751117
return buildTableDecorations(state);
@@ -1084,6 +1126,11 @@ const tableDecorationField = StateField.define<DecorationSet>({
10841126
}
10851127

10861128
if (!transaction.docChanged) {
1129+
if (
1130+
syntaxTree(transaction.state) !== syntaxTree(transaction.startState)
1131+
) {
1132+
return buildTableDecorations(transaction.state);
1133+
}
10871134
return decorations;
10881135
}
10891136

@@ -1095,6 +1142,13 @@ const tableDecorationField = StateField.define<DecorationSet>({
10951142
return decorations.map(transaction.changes);
10961143
}
10971144

1145+
// When the change doesn't touch any Table node, map existing
1146+
// decorations through position changes. This avoids rebuilding
1147+
// all table widgets on every keystroke outside of tables.
1148+
if (!changesAffectTables(transaction)) {
1149+
return decorations.map(transaction.changes);
1150+
}
1151+
10981152
return buildTableDecorations(transaction.state);
10991153
},
11001154
provide(field) {

0 commit comments

Comments
 (0)