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>
135 lines
4.5 KiB
TypeScript
135 lines
4.5 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { getSchema } from '@tiptap/core';
|
|
import { Document } from '@tiptap/extension-document';
|
|
import { Paragraph } from '@tiptap/extension-paragraph';
|
|
import { Text } from '@tiptap/extension-text';
|
|
import { EditorState } from '@tiptap/pm/state';
|
|
import { Node as PMNode } from '@tiptap/pm/model';
|
|
import { FootnoteReference } from './footnote-reference';
|
|
import { FootnotesList } from './footnotes-list';
|
|
import { FootnoteDefinition } from './footnote-definition';
|
|
import {
|
|
footnoteNumberingPlugin,
|
|
footnoteNumberingPluginKey,
|
|
getFootnoteNumber,
|
|
} from './footnote-numbering';
|
|
import {
|
|
FOOTNOTE_REFERENCE_NAME,
|
|
FOOTNOTES_LIST_NAME,
|
|
FOOTNOTE_DEFINITION_NAME,
|
|
} from './footnote-util';
|
|
|
|
const extensions = [
|
|
Document,
|
|
Paragraph,
|
|
Text,
|
|
FootnoteReference,
|
|
FootnotesList,
|
|
FootnoteDefinition,
|
|
];
|
|
|
|
const schema = getSchema(extensions);
|
|
|
|
function makeState(docJson: any): EditorState {
|
|
return EditorState.create({
|
|
doc: PMNode.fromJSON(schema, docJson),
|
|
plugins: [footnoteNumberingPlugin()],
|
|
});
|
|
}
|
|
|
|
const withTwoFootnotes = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [
|
|
{ type: 'text', text: 'a' },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'x' } },
|
|
{ type: 'text', text: 'b' },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'y' } },
|
|
],
|
|
},
|
|
{
|
|
type: FOOTNOTES_LIST_NAME,
|
|
content: [
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: 'x' },
|
|
content: [{ type: 'paragraph' }],
|
|
},
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: 'y' },
|
|
content: [{ type: 'paragraph' }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
describe('footnote numbering plugin — short-circuit (#343 PART 5)', () => {
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
it('does ZERO document traversals on a docChanged transaction when the doc has no footnotes', () => {
|
|
const state = makeState({
|
|
type: 'doc',
|
|
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
|
|
});
|
|
|
|
// Only count traversals caused by the transaction, not the initial build.
|
|
const descendantsSpy = vi.spyOn(PMNode.prototype, 'descendants');
|
|
|
|
const before = footnoteNumberingPluginKey.getState(state);
|
|
// A real content edit (docChanged) that introduces no footnote node.
|
|
const next = state.apply(state.tr.insertText('!', 3));
|
|
const after = footnoteNumberingPluginKey.getState(next);
|
|
|
|
// The plugin never walked the document...
|
|
expect(descendantsSpy).not.toHaveBeenCalled();
|
|
// ...and reused the exact same (empty) state object — proof it short-circuited.
|
|
expect(after).toBe(before);
|
|
expect(after?.hasFootnotes).toBe(false);
|
|
});
|
|
|
|
it('rebuilds (numbering appears) the first time a footnote is inserted into a footnote-free doc', () => {
|
|
const state = makeState({
|
|
type: 'doc',
|
|
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
|
|
});
|
|
expect(footnoteNumberingPluginKey.getState(state)?.hasFootnotes).toBe(false);
|
|
|
|
const ref = schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: 'x' });
|
|
const next = state.apply(state.tr.insert(3, ref));
|
|
|
|
const after = footnoteNumberingPluginKey.getState(next);
|
|
expect(after?.hasFootnotes).toBe(true);
|
|
expect(getFootnoteNumber(next, 'x')).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('footnote numbering plugin — numbering unchanged with footnotes (#343 PART 5)', () => {
|
|
it('numbers references in document order via the single merged walk', () => {
|
|
const state = makeState(withTwoFootnotes);
|
|
expect(getFootnoteNumber(state, 'x')).toBe(1);
|
|
expect(getFootnoteNumber(state, 'y')).toBe(2);
|
|
});
|
|
|
|
it('produces a decoration for every reference and matching definition', () => {
|
|
const state = makeState(withTwoFootnotes);
|
|
const decos = footnoteNumberingPluginKey.getState(state)?.decorations;
|
|
// 2 references + 2 definitions = 4 number decorations.
|
|
expect(decos?.find().length).toBe(4);
|
|
});
|
|
|
|
it('keeps numbering current after an edit while footnotes exist', () => {
|
|
const state = makeState(withTwoFootnotes);
|
|
// Insert a NEW reference (id "z") before the others: it must become #1 and
|
|
// shift x -> #2, y -> #3 (deterministic document-order numbering).
|
|
const ref = schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: 'z' });
|
|
const next = state.apply(state.tr.insert(1, ref));
|
|
expect(getFootnoteNumber(next, 'z')).toBe(1);
|
|
expect(getFootnoteNumber(next, 'x')).toBe(2);
|
|
expect(getFootnoteNumber(next, 'y')).toBe(3);
|
|
});
|
|
});
|