Files
gitmost/apps/client/src/features/editor/extensions/slash-command.ts
T
agent_coder a6ff7623db perf(editor): cut per-keystroke work on the typing hot path (#343)
The editor lagged while typing (worse with doc size, and under collaboration the
same cost is paid for every REMOTE keystroke). ProseMirror itself was fine — the
overhead was the surrounding work done on every transaction. Behavior is 1:1;
only WHEN work runs changed.

- getJSON() off the keystroke path: `onUpdate` no longer serializes the whole doc
  synchronously — the serialization now runs inside a 3s debounce (new hook
  use-page-content-cache.ts), flushed on unmount so the last snapshot isn't lost.
- footnote numbering: merged 3 per-docChanged O(n) doc walks into one, and
  short-circuit the whole-doc renumber when the doc has no footnotes and the
  transaction didn't insert one (step-slice scan — covers typing/paste/collab).
- toolbar: replaced per-keystroke `editor.can().undo()/.redo()` dry-runs with
  cheap history-depth reads (Yjs undoManager stack length / pm-history depth).
- render side-effect bug: `remote.attach()` moved out of the render body into a
  useEffect.
- debounced the TOC all-headings rescan and memoized the slash-command suggestion
  build (was rebuilt twice per keystroke).
- node menus (image/video/audio/pdf/callout/subpages): the per-transaction
  selectors early-return a cheap isActive check instead of running getAttributes +
  multiple alignment probes while their node type is inactive (shouldShow still
  controls display — appears exactly when it did).
- code blocks: the global selectionUpdate listener is now added only for mermaid
  blocks (the only consumer of the selected state), eliminating N listeners +
  N setStates per caret move for normal code blocks.

Deferred (documented, collab hot-path risk): full conditional menu MOUNTING
(menu-less-frame risk on same-tx context switch) and code-block re-tokenization
debounce / language-persist (self-dispatching meta tx + node-attr writes interact
with collab/undo). The route split from #342 already keeps lowlight off startup.

Gate: editor-ext build + 252/252 tests, client editor tests pass, tsc --noEmit 0,
client build ok. New tests: footnote no-footnote-doc → 0 traversals + numbering
unchanged; page-content-cache onUpdate-no-sync-getJSON + flush-on-unmount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 04:10:04 +03:00

90 lines
3.6 KiB
TypeScript

import { Extension } from '@tiptap/core';
import { PluginKey } from '@tiptap/pm/state';
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion';
import renderItems from '@/features/editor/components/slash-menu/render-items';
import getSuggestionItems from '@/features/editor/components/slash-menu/menu-items';
export const slashMenuPluginKey = new PluginKey('slash-command');
// getSuggestionItems fuzzy-matches EVERY command against the query (plus its
// wrong-keyboard-layout remaps) and, while the slash menu is open, is invoked
// TWICE per keystroke: once by the synchronous `allow` gate below and once by
// the popup's `items` builder. A synchronous gating predicate can't be
// debounced without breaking the suggestion decoration/activation, so instead we
// memoize the LAST query's result: the two same-query calls in one keystroke
// build the list only once, and the cache invalidates the moment the query
// changes — so there is no stale-state risk (#343, PART 7).
let lastQuery: string | null = null;
let lastResult: ReturnType<typeof getSuggestionItems> | null = null;
function suggestionItemsForQuery(query: string) {
if (query === lastQuery && lastResult) return lastResult;
lastQuery = query;
lastResult = getSuggestionItems({ query });
return lastResult;
}
// @ts-ignore
const Command = Extension.create({
name: 'slash-command',
addOptions() {
return {
suggestion: {
char: '/',
// Keep the query alive through spaces so multi-word item labels
// (e.g. "Heading 1", "Math block") match instead of terminating the
// query and leaving literal "/Heading 1" text in the document.
allowSpaces: true,
command: ({ editor, range, props }) => {
props.command({ editor, range, props });
},
allow: ({ state, range }) => {
const $from = state.doc.resolve(range.from);
// Disable slash menu inside code blocks
if ($from.parent.type.name === 'codeBlock') {
return false;
}
// With `allowSpaces: true` a query that contains a space no longer
// terminates the suggestion on its own, so a space-bearing query that
// matches nothing (e.g. "/todo abc") would otherwise keep an empty
// popup logically active and leave the literal "/todo abc" text in the
// document, only dismissable via Escape. Deactivate the suggestion when
// no item matches the current query: returning false here removes the
// decoration, fires the popup's `onExit`, and lets subsequent keystrokes
// pass through normally — restoring the pre-`allowSpaces` behavior for
// non-matching queries while keeping multi-word matches (e.g.
// "/Heading 1") working.
const query = state.doc.textBetween(range.from + 1, range.to);
const groups = suggestionItemsForQuery(query);
const hasMatches = Object.values(groups).some(
(items) => items.length > 0,
);
return hasMatches;
},
} as Partial<SuggestionOptions>,
};
},
addProseMirrorPlugins() {
return [
Suggestion({
pluginKey: slashMenuPluginKey,
...this.options.suggestion,
editor: this.editor,
}),
];
},
});
const SlashCommand = Command.configure({
suggestion: {
// Share the per-query memo with `allow` so the pair of same-query calls in a
// single keystroke rebuilds the list once (#343, PART 7).
items: ({ query }: { query: string }) => suggestionItemsForQuery(query),
render: renderItems,
},
});
export { Command as SlashCommandExtension };
export default SlashCommand;