Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9e4c1028d | |||
| 382e5196da | |||
| 76e0c08cec |
+16
-2
@@ -5,6 +5,13 @@ RUN npm install -g pnpm@10.4.0
|
|||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
|
# re2 (packages/mcp) always compiles from source under pnpm (the prebuilt-binary
|
||||||
|
# download cannot identify the GitHub repo), so node-gyp needs python3/make/g++.
|
||||||
|
# This stage is discarded, so the toolchain can stay installed.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -57,9 +64,16 @@ COPY --from=builder /app/patches /app/patches
|
|||||||
|
|
||||||
RUN chown -R node:node /app
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
USER node
|
# Toolchain is needed transiently to compile re2 during the prod install; install
|
||||||
|
# and purge it in one layer to keep the final image slim. The install itself runs
|
||||||
|
# as the node user via su to keep node_modules ownership without a costly chown layer.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& su node -c "pnpm install --frozen-lockfile --prod" \
|
||||||
|
&& apt-get purge -y --auto-remove python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
USER node
|
||||||
|
|
||||||
RUN mkdir -p /app/data/storage
|
RUN mkdir -p /app/data/storage
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ export function AudioMenu({ editor }: EditorMenuProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #343 PART 1: skip getAttributes unless an audio node is active. The menu
|
||||||
|
// only shows for an active audio node (shouldShow), so the null state while
|
||||||
|
// inactive is never rendered — behavior unchanged.
|
||||||
|
if (!ctx.editor.isActive("audio")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const audioAttrs = ctx.editor.getAttributes("audio");
|
const audioAttrs = ctx.editor.getAttributes("audio");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -43,8 +43,15 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #343 PART 1: skip the per-type isActive() probes unless a callout is
|
||||||
|
// active. The menu only shows for an active callout (shouldShow), so the
|
||||||
|
// null state while inactive is never rendered — behavior unchanged.
|
||||||
|
if (!ctx.editor.isActive("callout")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isCallout: ctx.editor.isActive("callout"),
|
isCallout: true,
|
||||||
isInfo: ctx.editor.isActive("callout", { type: "info" }),
|
isInfo: ctx.editor.isActive("callout", { type: "info" }),
|
||||||
isNote: ctx.editor.isActive("callout", { type: "note" }),
|
isNote: ctx.editor.isActive("callout", { type: "note" }),
|
||||||
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
|
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export default function CodeBlockView(props: NodeViewProps) {
|
|||||||
const [isSelected, setIsSelected] = useState(false);
|
const [isSelected, setIsSelected] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// #343 PART 6: `isSelected` only drives the mermaid source's visibility (the
|
||||||
|
// `hidden` prop below). For every non-mermaid code block it is never read,
|
||||||
|
// so skip the per-block `selectionUpdate` listener entirely — otherwise N
|
||||||
|
// code blocks each add a global listener + a setState on every caret move.
|
||||||
|
if (language !== "mermaid") return;
|
||||||
|
|
||||||
const updateSelection = () => {
|
const updateSelection = () => {
|
||||||
const { state } = editor;
|
const { state } = editor;
|
||||||
const { from, to } = state.selection;
|
const { from, to } = state.selection;
|
||||||
@@ -32,11 +38,14 @@ export default function CodeBlockView(props: NodeViewProps) {
|
|||||||
setIsSelected(isNodeSelected);
|
setIsSelected(isNodeSelected);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialize on attach so switching a block's language to "mermaid" reflects
|
||||||
|
// the current selection immediately (the listener was not running before).
|
||||||
|
updateSelection();
|
||||||
editor.on("selectionUpdate", updateSelection);
|
editor.on("selectionUpdate", updateSelection);
|
||||||
return () => {
|
return () => {
|
||||||
editor.off("selectionUpdate", updateSelection);
|
editor.off("selectionUpdate", updateSelection);
|
||||||
};
|
};
|
||||||
}, [editor, getPos(), node.nodeSize]);
|
}, [editor, getPos(), node.nodeSize, language]);
|
||||||
|
|
||||||
function changeLanguage(language: string) {
|
function changeLanguage(language: string) {
|
||||||
setLanguageValue(language);
|
setLanguageValue(language);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
import { useEditorState } from "@tiptap/react";
|
import { useEditorState } from "@tiptap/react";
|
||||||
|
import { undoDepth, redoDepth } from "@tiptap/pm/history";
|
||||||
|
import { yUndoPluginKey } from "@tiptap/y-tiptap";
|
||||||
|
|
||||||
export interface ToolbarState {
|
export interface ToolbarState {
|
||||||
isBold: boolean;
|
isBold: boolean;
|
||||||
@@ -16,14 +18,45 @@ export interface ToolbarState {
|
|||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Undo/redo come from either StarterKit's history or the Yjs collaboration
|
// Undo/redo availability, computed WITHOUT `editor.can().undo()/.redo()`.
|
||||||
// history extension. During the brief moment a page is rendered with the
|
//
|
||||||
// static editor (mainExtensions only, undoRedo disabled), neither is loaded
|
// `editor.can()` runs the command as a dry-run (building a throwaway state +
|
||||||
// and editor.can().undo/redo is undefined.
|
// transaction) — the most expensive work in this selector, and it ran on every
|
||||||
function safeCan(editor: Editor, command: "undo" | "redo"): boolean {
|
// keystroke (and every REMOTE keystroke under collaboration). Instead we read
|
||||||
const can = editor.can() as Record<string, unknown>;
|
// the history stack depth directly, which is a cheap plugin-state lookup and
|
||||||
const fn = can[command];
|
// mirrors exactly what the undo/redo commands themselves check:
|
||||||
return typeof fn === "function" ? (fn as () => boolean)() : false;
|
//
|
||||||
|
// - Collaboration (Yjs): the yjs UndoManager's undo/redo stack lengths — the
|
||||||
|
// same `undoStack.length === 0` / `redoStack.length === 0` guard the
|
||||||
|
// Collaboration extension's undo/redo commands use.
|
||||||
|
// - Plain history (templates / non-collab): prosemirror-history's undoDepth /
|
||||||
|
// redoDepth, which back the UndoRedo extension.
|
||||||
|
//
|
||||||
|
// When neither history backend is installed (the pre-sync static editor —
|
||||||
|
// mainExtensions only, undoRedo disabled), both fall through to 0 -> false,
|
||||||
|
// matching the previous `safeCan` behavior.
|
||||||
|
function historyAvailability(editor: Editor): {
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
} {
|
||||||
|
const state = editor.state;
|
||||||
|
|
||||||
|
// Collaboration history (Yjs) takes precedence when present.
|
||||||
|
const yState = yUndoPluginKey.getState(state) as
|
||||||
|
| { undoManager?: { undoStack: unknown[]; redoStack: unknown[] } }
|
||||||
|
| undefined;
|
||||||
|
if (yState?.undoManager) {
|
||||||
|
return {
|
||||||
|
canUndo: yState.undoManager.undoStack.length > 0,
|
||||||
|
canRedo: yState.undoManager.redoStack.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain prosemirror-history (returns 0 when the history plugin is absent).
|
||||||
|
return {
|
||||||
|
canUndo: undoDepth(state) > 0,
|
||||||
|
canRedo: redoDepth(state) > 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useToolbarState(editor: Editor | null): ToolbarState | null {
|
export function useToolbarState(editor: Editor | null): ToolbarState | null {
|
||||||
@@ -31,6 +64,7 @@ export function useToolbarState(editor: Editor | null): ToolbarState | null {
|
|||||||
editor,
|
editor,
|
||||||
selector: (ctx) => {
|
selector: (ctx) => {
|
||||||
if (!ctx.editor) return null;
|
if (!ctx.editor) return null;
|
||||||
|
const { canUndo, canRedo } = historyAvailability(ctx.editor);
|
||||||
return {
|
return {
|
||||||
isBold: ctx.editor.isActive("bold"),
|
isBold: ctx.editor.isActive("bold"),
|
||||||
isItalic: ctx.editor.isActive("italic"),
|
isItalic: ctx.editor.isActive("italic"),
|
||||||
@@ -42,8 +76,8 @@ export function useToolbarState(editor: Editor | null): ToolbarState | null {
|
|||||||
isBulletList: ctx.editor.isActive("bulletList"),
|
isBulletList: ctx.editor.isActive("bulletList"),
|
||||||
isOrderedList: ctx.editor.isActive("orderedList"),
|
isOrderedList: ctx.editor.isActive("orderedList"),
|
||||||
isTaskList: ctx.editor.isActive("taskList"),
|
isTaskList: ctx.editor.isActive("taskList"),
|
||||||
canUndo: safeCan(ctx.editor, "undo"),
|
canUndo,
|
||||||
canRedo: safeCan(ctx.editor, "redo"),
|
canRedo,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #343 PART 1: skip the expensive per-keystroke work (getAttributes + the
|
||||||
|
// alignment isActive() probes) unless an image is actually active. The
|
||||||
|
// menu is only shown when an image is active (see shouldShow), so a null
|
||||||
|
// state while inactive is never rendered — behavior is unchanged.
|
||||||
|
if (!ctx.editor.isActive("image")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const imageAttrs = ctx.editor.getAttributes("image");
|
const imageAttrs = ctx.editor.getAttributes("image");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ export function PdfMenu({ editor }: EditorMenuProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #343 PART 1: skip getAttributes unless a pdf node is active. The menu
|
||||||
|
// only shows for an active pdf node (shouldShow), so the null state while
|
||||||
|
// inactive is never rendered — behavior unchanged.
|
||||||
|
if (!ctx.editor.isActive("pdf")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const pdfAttrs = ctx.editor.getAttributes("pdf");
|
const pdfAttrs = ctx.editor.getAttributes("pdf");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -70,7 +70,14 @@ export const SubpagesMenu = React.memo(
|
|||||||
// toggle without re-rendering on every keystroke.
|
// toggle without re-rendering on every keystroke.
|
||||||
const isRecursive = useEditorState({
|
const isRecursive = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
selector: (ctx) => ctx.editor?.getAttributes("subpages")?.recursive ?? false,
|
// #343 PART 1: skip getAttributes unless a subpages node is active. The
|
||||||
|
// menu only shows for an active subpages node (shouldShow), so the value
|
||||||
|
// is only read then; getAttributes on an inactive node returns the default
|
||||||
|
// (recursive === false) anyway, so this is behavior-preserving.
|
||||||
|
selector: (ctx) =>
|
||||||
|
ctx.editor?.isActive("subpages")
|
||||||
|
? (ctx.editor.getAttributes("subpages")?.recursive ?? false)
|
||||||
|
: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { FC, useEffect, useRef, useState } from "react";
|
|||||||
import classes from "./table-of-contents.module.css";
|
import classes from "./table-of-contents.module.css";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Box, Text, Title } from "@mantine/core";
|
import { Box, Text, Title } from "@mantine/core";
|
||||||
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type TableOfContentsProps = {
|
type TableOfContentsProps = {
|
||||||
@@ -79,13 +80,21 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
|||||||
setHeadingDOMNodes(result.nodes);
|
setHeadingDOMNodes(result.nodes);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Debounce the update-driven rescan: `$nodes("heading")` scans every heading
|
||||||
|
// in the document, and it previously ran on EVERY keystroke while the TOC
|
||||||
|
// panel was open. The panel is derived UI, so recomputing ~300ms after typing
|
||||||
|
// settles keeps it correct without doing an all-headings scan per keystroke
|
||||||
|
// (#343, PART 7). `useDebouncedCallback` returns a stable reference and always
|
||||||
|
// invokes the latest `handleUpdate`.
|
||||||
|
const debouncedHandleUpdate = useDebouncedCallback(handleUpdate, 300);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
props.editor?.on("update", handleUpdate);
|
props.editor?.on("update", debouncedHandleUpdate);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
props.editor?.off("update", handleUpdate);
|
props.editor?.off("update", debouncedHandleUpdate);
|
||||||
};
|
};
|
||||||
}, [props.editor]);
|
}, [props.editor, debouncedHandleUpdate]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #343 PART 1: skip getAttributes + alignment isActive() probes unless a
|
||||||
|
// video is active. The menu only shows for an active video (shouldShow),
|
||||||
|
// so the null state while inactive is never rendered — behavior unchanged.
|
||||||
|
if (!ctx.editor.isActive("video")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const videoAttrs = ctx.editor.getAttributes("video");
|
const videoAttrs = ctx.editor.getAttributes("video");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ import getSuggestionItems from '@/features/editor/components/slash-menu/menu-ite
|
|||||||
|
|
||||||
export const slashMenuPluginKey = new PluginKey('slash-command');
|
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
|
// @ts-ignore
|
||||||
const Command = Extension.create({
|
const Command = Extension.create({
|
||||||
name: 'slash-command',
|
name: 'slash-command',
|
||||||
@@ -38,7 +55,7 @@ const Command = Extension.create({
|
|||||||
// non-matching queries while keeping multi-word matches (e.g.
|
// non-matching queries while keeping multi-word matches (e.g.
|
||||||
// "/Heading 1") working.
|
// "/Heading 1") working.
|
||||||
const query = state.doc.textBetween(range.from + 1, range.to);
|
const query = state.doc.textBetween(range.from + 1, range.to);
|
||||||
const groups = getSuggestionItems({ query });
|
const groups = suggestionItemsForQuery(query);
|
||||||
const hasMatches = Object.values(groups).some(
|
const hasMatches = Object.values(groups).some(
|
||||||
(items) => items.length > 0,
|
(items) => items.length > 0,
|
||||||
);
|
);
|
||||||
@@ -61,7 +78,9 @@ const Command = Extension.create({
|
|||||||
|
|
||||||
const SlashCommand = Command.configure({
|
const SlashCommand = Command.configure({
|
||||||
suggestion: {
|
suggestion: {
|
||||||
items: getSuggestionItems,
|
// 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,
|
render: renderItems,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import type { MutableRefObject } from "react";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
|
||||||
|
// Mock the app entry so importing the hook doesn't boot the whole app; the hook
|
||||||
|
// only needs queryClient's cache read/write, which we stub here. Declared via
|
||||||
|
// vi.hoisted so the spies exist before the hoisted vi.mock factory runs.
|
||||||
|
const { getQueryData, setQueryData } = vi.hoisted(() => ({
|
||||||
|
getQueryData: vi.fn(() => undefined as unknown),
|
||||||
|
setQueryData: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/main.tsx", () => ({
|
||||||
|
queryClient: { getQueryData, setQueryData },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { usePageContentCache } from "./use-page-content-cache";
|
||||||
|
|
||||||
|
const SNAPSHOT = { type: "doc", content: [] };
|
||||||
|
|
||||||
|
function makeFakeEditor(overrides: Partial<Editor> = {}): Editor {
|
||||||
|
return {
|
||||||
|
isEmpty: false,
|
||||||
|
isDestroyed: false,
|
||||||
|
getJSON: vi.fn(() => SNAPSHOT),
|
||||||
|
...overrides,
|
||||||
|
} as unknown as Editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("usePageContentCache (#343 PART 3) — getJSON off the keystroke path", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// A cached page exists so the write path runs.
|
||||||
|
getQueryData.mockReturnValue({ id: "p1", content: {} });
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onUpdate (calling the debounced fn) does NOT call getJSON synchronously", () => {
|
||||||
|
const editor = makeFakeEditor();
|
||||||
|
const editorRef = { current: editor } as MutableRefObject<Editor | null>;
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePageContentCache(editorRef, "slug-1", 3000),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate a keystroke's onUpdate -> only schedules the debounce.
|
||||||
|
act(() => {
|
||||||
|
result.current();
|
||||||
|
result.current();
|
||||||
|
result.current();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The whole-doc serialization must NOT have happened yet.
|
||||||
|
expect(editor.getJSON).not.toHaveBeenCalled();
|
||||||
|
expect(setQueryData).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Once the debounce window elapses, getJSON runs exactly once (not per call).
|
||||||
|
act(() => vi.advanceTimersByTime(3000));
|
||||||
|
expect(editor.getJSON).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setQueryData).toHaveBeenCalledWith(["pages", "slug-1"], {
|
||||||
|
id: "p1",
|
||||||
|
content: SNAPSHOT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flushes the pending snapshot on unmount so the last edit isn't lost", () => {
|
||||||
|
const editor = makeFakeEditor();
|
||||||
|
const editorRef = { current: editor } as MutableRefObject<Editor | null>;
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() =>
|
||||||
|
usePageContentCache(editorRef, "slug-1", 3000),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => result.current());
|
||||||
|
expect(editor.getJSON).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Navigation/unmount must flush (not drop) the pending write.
|
||||||
|
act(() => unmount());
|
||||||
|
expect(editor.getJSON).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setQueryData).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the write when the editor is destroyed (flush racing teardown)", () => {
|
||||||
|
const editor = makeFakeEditor({ isDestroyed: true });
|
||||||
|
const editorRef = { current: editor } as MutableRefObject<Editor | null>;
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePageContentCache(editorRef, "slug-1", 3000),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => result.current());
|
||||||
|
act(() => vi.advanceTimersByTime(3000));
|
||||||
|
|
||||||
|
expect(editor.getJSON).not.toHaveBeenCalled();
|
||||||
|
expect(setQueryData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { MutableRefObject } from "react";
|
||||||
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Off-keystroke local page-cache updater (issue #343, PART 3).
|
||||||
|
*
|
||||||
|
* The editor's `onUpdate` fires on every keystroke — and, under collaboration,
|
||||||
|
* on every REMOTE keystroke too. Serializing the WHOLE document with
|
||||||
|
* `editor.getJSON()` on that hot path is expensive, and the previous 3s debounce
|
||||||
|
* only guarded the cache WRITE, not the serialization: `getJSON()` still ran per
|
||||||
|
* keystroke.
|
||||||
|
*
|
||||||
|
* This hook moves the serialization INSIDE the debounced callback, so the
|
||||||
|
* full-doc traversal happens at most once per `delay`, not per keystroke. Call
|
||||||
|
* the returned function from `onUpdate` (it only schedules the debounce); the
|
||||||
|
* `getJSON()` snapshot is taken when the debounce fires.
|
||||||
|
*
|
||||||
|
* On unmount/navigation the pending snapshot is FLUSHED (via `flushOnUnmount`)
|
||||||
|
* so the last edits within the debounce window aren't lost from the local cache.
|
||||||
|
* The source of truth is collab/Yjs, but the cache must not go stale.
|
||||||
|
*
|
||||||
|
* IMPORTANT: call this hook BEFORE `useEditor`. React runs effect cleanups in
|
||||||
|
* declaration order on unmount, so the debounce's flush cleanup must be declared
|
||||||
|
* before `useEditor`'s teardown to run while the editor is still alive; the
|
||||||
|
* `isDestroyed` guard keeps a flush that still races teardown safe (it skips).
|
||||||
|
*/
|
||||||
|
export function usePageContentCache(
|
||||||
|
editorRef: MutableRefObject<Editor | null>,
|
||||||
|
slugId: string | undefined,
|
||||||
|
delay = 3000,
|
||||||
|
) {
|
||||||
|
return useDebouncedCallback(
|
||||||
|
() => {
|
||||||
|
const e = editorRef.current;
|
||||||
|
if (!e || e.isDestroyed || e.isEmpty) return;
|
||||||
|
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||||
|
if (pageData) {
|
||||||
|
// getJSON() (full-doc serialization) runs HERE, off the keystroke path.
|
||||||
|
queryClient.setQueryData(["pages", slugId], {
|
||||||
|
...pageData,
|
||||||
|
content: e.getJSON(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ delay, flushOnUnmount: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@ import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
|||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
import { useDocumentVisibility } from "@mantine/hooks";
|
||||||
import { useIdle } from "@/hooks/use-idle.ts";
|
import { useIdle } from "@/hooks/use-idle.ts";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
@@ -79,6 +79,7 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||||
|
import { usePageContentCache } from "./hooks/use-page-content-cache";
|
||||||
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
||||||
import { useSwapHeightReservation } from "./hooks/use-swap-height-reservation";
|
import { useSwapHeightReservation } from "./hooks/use-swap-height-reservation";
|
||||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||||
@@ -267,8 +268,13 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||||
|
|
||||||
// Attach here, to make sure the connection gets properly established
|
// Attach the remote provider once it's ready (and again after a pageId swap
|
||||||
providersRef.current?.remote.attach();
|
// recreates it) to make sure the connection gets properly established. This
|
||||||
|
// used to run in the render body — a side effect during render (#343, PART 7).
|
||||||
|
// `attach()` is idempotent, so re-running it on these deps is safe.
|
||||||
|
useEffect(() => {
|
||||||
|
providersRef.current?.remote.attach();
|
||||||
|
}, [providersReady, pageId]);
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
||||||
@@ -283,6 +289,12 @@ export default function PageEditor({
|
|||||||
];
|
];
|
||||||
}, [providersReady, currentUser?.user]);
|
}, [providersReady, currentUser?.user]);
|
||||||
|
|
||||||
|
// getJSON() serialization + cache write live in the hook, off the keystroke
|
||||||
|
// path, and flush on unmount so the last snapshot survives navigation (#343).
|
||||||
|
// MUST be declared before useEditor: React runs effect cleanups in declaration
|
||||||
|
// order on unmount, so the flush must run before the editor is torn down.
|
||||||
|
const debouncedUpdateContent = usePageContentCache(editorRef, slugId);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
extensions,
|
extensions,
|
||||||
@@ -353,11 +365,11 @@ export default function PageEditor({
|
|||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUpdate({ editor }) {
|
onUpdate() {
|
||||||
if (editor.isEmpty) return;
|
// Only schedule the debounce here — the whole-doc getJSON() serialization
|
||||||
const editorJson = editor.getJSON();
|
// happens INSIDE the debounced callback (see usePageContentCache), so it
|
||||||
//update local page cache to reduce flickers
|
// no longer runs synchronously on every (local or remote) keystroke.
|
||||||
debouncedUpdateContent(editorJson);
|
debouncedUpdateContent();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[pageId, editable, extensions],
|
[pageId, editable, extensions],
|
||||||
@@ -403,17 +415,6 @@ export default function PageEditor({
|
|||||||
};
|
};
|
||||||
}, [editor, pageId, editorIsEditable]);
|
}, [editor, pageId, editorIsEditable]);
|
||||||
|
|
||||||
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
|
|
||||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
|
||||||
|
|
||||||
if (pageData) {
|
|
||||||
queryClient.setQueryData(["pages", slugId], {
|
|
||||||
...pageData,
|
|
||||||
content: newContent,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
const handleActiveCommentEvent = (event) => {
|
const handleActiveCommentEvent = (event) => {
|
||||||
const { commentId, resolved } = event.detail;
|
const { commentId, resolved } = event.detail;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state';
|
import { EditorState, Plugin, PluginKey, Transaction } from '@tiptap/pm/state';
|
||||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||||
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
import { Node as ProseMirrorNode, Slice } from '@tiptap/pm/model';
|
||||||
import {
|
import {
|
||||||
FOOTNOTE_DEFINITION_NAME,
|
FOOTNOTE_DEFINITION_NAME,
|
||||||
FOOTNOTE_REFERENCE_NAME,
|
FOOTNOTE_REFERENCE_NAME,
|
||||||
computeFootnoteNumbers,
|
|
||||||
computeFootnoteRefCounts,
|
|
||||||
} from './footnote-util';
|
} from './footnote-util';
|
||||||
|
|
||||||
export const footnoteNumberingPluginKey = new PluginKey<FootnoteNumberingState>(
|
export const footnoteNumberingPluginKey = new PluginKey<FootnoteNumberingState>(
|
||||||
@@ -27,8 +25,22 @@ interface FootnoteNumberingState {
|
|||||||
refCounts: Map<string, number>;
|
refCounts: Map<string, number>;
|
||||||
/** Decorations rendering those numbers (refs + definitions). */
|
/** Decorations rendering those numbers (refs + definitions). */
|
||||||
decorations: DecorationSet;
|
decorations: DecorationSet;
|
||||||
|
/** Whether the document contains ANY footnote reference/definition node.
|
||||||
|
* Cached so `apply` can skip the whole-doc walk on every keystroke in the
|
||||||
|
* common case (documents with no footnotes), recomputing only once a
|
||||||
|
* transaction actually inserts a footnote node (#343, PART 5). */
|
||||||
|
hasFootnotes: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reusable empty state for footnote-free documents — avoids reallocating an
|
||||||
|
* empty map/decoration set on every keystroke while there are no footnotes. */
|
||||||
|
const EMPTY_STATE: FootnoteNumberingState = {
|
||||||
|
numbers: new Map(),
|
||||||
|
refCounts: new Map(),
|
||||||
|
decorations: DecorationSet.empty,
|
||||||
|
hasFootnotes: false,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the decoration set for footnote numbers. Pure function of the document:
|
* Build the decoration set for footnote numbers. Pure function of the document:
|
||||||
* walk references in document order, assign 1-based numbers, then attach a
|
* walk references in document order, assign 1-based numbers, then attach a
|
||||||
@@ -41,50 +53,101 @@ export function buildFootnoteDecorations(doc: ProseMirrorNode): DecorationSet {
|
|||||||
return buildFootnoteNumberingState(doc).decorations;
|
return buildFootnoteNumberingState(doc).decorations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function numberDecoration(pos: number, nodeSize: number, num: number): Decoration {
|
||||||
|
return Decoration.node(pos, pos + nodeSize, {
|
||||||
|
'data-footnote-number': String(num),
|
||||||
|
style: `--footnote-number: "${num}";`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute both the number map AND the decorations for `doc` in a single walk.
|
* Compute the number map, reference counts AND the decorations for `doc` in a
|
||||||
* The plugin caches the result so NodeViews can read numbers without
|
* SINGLE document walk (previously three separate O(n) traversals per
|
||||||
* recomputing.
|
* docChanged — computeFootnoteNumbers + computeFootnoteRefCounts + a decoration
|
||||||
|
* pass, #343 PART 5). The plugin caches the result so NodeViews can read numbers
|
||||||
|
* without recomputing.
|
||||||
|
*
|
||||||
|
* References are numbered and decorated as they are encountered (document
|
||||||
|
* order). Definition positions are collected during the same walk and decorated
|
||||||
|
* afterwards from the completed number map — so a definition that appears before
|
||||||
|
* its reference in document order still resolves to the correct number, and the
|
||||||
|
* output is identical to the previous three-pass implementation. (Decoration
|
||||||
|
* insertion order does not matter: DecorationSet.create indexes by position.)
|
||||||
*/
|
*/
|
||||||
function buildFootnoteNumberingState(
|
function buildFootnoteNumberingState(
|
||||||
doc: ProseMirrorNode,
|
doc: ProseMirrorNode,
|
||||||
): FootnoteNumberingState {
|
): FootnoteNumberingState {
|
||||||
const numbers = computeFootnoteNumbers(doc);
|
const numbers = new Map<string, number>();
|
||||||
const refCounts = computeFootnoteRefCounts(doc);
|
const refCounts = new Map<string, number>();
|
||||||
const decorations: Decoration[] = [];
|
const decorations: Decoration[] = [];
|
||||||
|
const definitions: { id: string; pos: number; nodeSize: number }[] = [];
|
||||||
|
let n = 0;
|
||||||
|
let hasFootnotes = false;
|
||||||
|
|
||||||
doc.descendants((node, pos) => {
|
doc.descendants((node, pos) => {
|
||||||
if (node.type.name === FOOTNOTE_REFERENCE_NAME) {
|
const typeName = node.type.name;
|
||||||
const num = numbers.get(node.attrs.id);
|
if (typeName === FOOTNOTE_REFERENCE_NAME) {
|
||||||
if (num != null) {
|
hasFootnotes = true;
|
||||||
decorations.push(
|
const id = node.attrs.id;
|
||||||
Decoration.node(pos, pos + node.nodeSize, {
|
if (id) {
|
||||||
'data-footnote-number': String(num),
|
if (!numbers.has(id)) numbers.set(id, ++n);
|
||||||
style: `--footnote-number: "${num}";`,
|
refCounts.set(id, (refCounts.get(id) ?? 0) + 1);
|
||||||
}),
|
decorations.push(numberDecoration(pos, node.nodeSize, numbers.get(id)!));
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME) {
|
|
||||||
const num = numbers.get(node.attrs.id);
|
|
||||||
if (num != null) {
|
|
||||||
decorations.push(
|
|
||||||
Decoration.node(pos, pos + node.nodeSize, {
|
|
||||||
'data-footnote-number': String(num),
|
|
||||||
style: `--footnote-number: "${num}";`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
} else if (typeName === FOOTNOTE_DEFINITION_NAME) {
|
||||||
|
hasFootnotes = true;
|
||||||
|
const id = node.attrs.id;
|
||||||
|
if (id != null) definitions.push({ id, pos, nodeSize: node.nodeSize });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!hasFootnotes) return EMPTY_STATE;
|
||||||
|
|
||||||
|
for (const def of definitions) {
|
||||||
|
const num = numbers.get(def.id);
|
||||||
|
if (num != null) {
|
||||||
|
decorations.push(numberDecoration(def.pos, def.nodeSize, num));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
numbers,
|
numbers,
|
||||||
refCounts,
|
refCounts,
|
||||||
decorations: DecorationSet.create(doc, decorations),
|
decorations: DecorationSet.create(doc, decorations),
|
||||||
|
hasFootnotes: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap check: does any of a transaction's inserted content contain a footnote
|
||||||
|
* reference/definition node? Footnote nodes can only ENTER the document through
|
||||||
|
* replace steps (ReplaceStep / ReplaceAroundStep both expose a `.slice`), so
|
||||||
|
* scanning only the inserted slices — O(change size), not O(doc) — is sufficient
|
||||||
|
* to detect a newly-added footnote. Mark/attr steps never introduce nodes.
|
||||||
|
* Lets `apply` keep skipping the whole-doc walk until a footnote first appears.
|
||||||
|
*/
|
||||||
|
function transactionInsertsFootnote(tr: Transaction): boolean {
|
||||||
|
for (const step of tr.steps) {
|
||||||
|
const slice = (step as unknown as { slice?: Slice }).slice;
|
||||||
|
if (!slice || slice.content.size === 0) continue;
|
||||||
|
let found = false;
|
||||||
|
slice.content.descendants((node) => {
|
||||||
|
if (found) return false;
|
||||||
|
const typeName = node.type.name;
|
||||||
|
if (
|
||||||
|
typeName === FOOTNOTE_REFERENCE_NAME ||
|
||||||
|
typeName === FOOTNOTE_DEFINITION_NAME
|
||||||
|
) {
|
||||||
|
found = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (found) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the cached footnote number for `id` from the numbering plugin's state.
|
* Read the cached footnote number for `id` from the numbering plugin's state.
|
||||||
* This is the source NodeViews should use instead of calling
|
* This is the source NodeViews should use instead of calling
|
||||||
@@ -126,6 +189,13 @@ export function footnoteNumberingPlugin(): Plugin {
|
|||||||
// the number map NodeViews read stays current on every edit while
|
// the number map NodeViews read stays current on every edit while
|
||||||
// non-doc transactions (selection, etc.) reuse the cache for free.
|
// non-doc transactions (selection, etc.) reuse the cache for free.
|
||||||
if (!tr.docChanged) return old;
|
if (!tr.docChanged) return old;
|
||||||
|
// Short-circuit the whole-doc walk while the document has no footnotes:
|
||||||
|
// if there were none and this transaction did not INSERT one, there is
|
||||||
|
// still nothing to number, so reuse the empty state (#343, PART 5). Once
|
||||||
|
// a footnote exists we always rebuild (covers renumbering/deletion).
|
||||||
|
if (!old.hasFootnotes && !transactionInsertsFootnote(tr)) {
|
||||||
|
return old;
|
||||||
|
}
|
||||||
return buildFootnoteNumberingState(tr.doc);
|
return buildFootnoteNumberingState(tr.doc);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user