a6ff7623db
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>
90 lines
3.6 KiB
TypeScript
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;
|