Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51ded06fde | |||
| 456a91d289 | |||
| 515c08afed |
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "^3.0.208",
|
"@ai-sdk/react": "^3.0.208",
|
||||||
|
"@braintree/sanitize-url": "7.1.2",
|
||||||
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
|
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
||||||
|
|||||||
+58
-24
@@ -1,38 +1,72 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
|
import Layout from "@/components/layouts/global/layout.tsx";
|
||||||
|
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||||
|
|
||||||
|
// ShareLayout is route-split: its ShareShell chrome pulls in the table of
|
||||||
|
// contents (and thus TipTap), so keeping it out of the eager graph removes the
|
||||||
|
// editor engine from startup for authenticated users too.
|
||||||
|
const ShareLayout = lazy(
|
||||||
|
() => import("@/features/share/components/share-layout.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth / entry pages stay eager: they are the first paint for an unauthenticated
|
||||||
|
// visitor (e.g. /login) and are already small, so code-splitting them would only
|
||||||
|
// add a cold-chunk round trip to the most common cold-start path.
|
||||||
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
|
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
|
||||||
import LoginPage from "@/pages/auth/login";
|
import LoginPage from "@/pages/auth/login";
|
||||||
import Home from "@/pages/dashboard/home";
|
|
||||||
import Page from "@/pages/page/page";
|
|
||||||
import AccountSettings from "@/pages/settings/account/account-settings";
|
|
||||||
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
|
|
||||||
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
|
|
||||||
import AiSettings from "@/pages/settings/workspace/ai-settings";
|
|
||||||
import Groups from "@/pages/settings/group/groups";
|
|
||||||
import GroupInfo from "./pages/settings/group/group-info";
|
|
||||||
import Spaces from "@/pages/settings/space/spaces.tsx";
|
|
||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
|
||||||
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
|
|
||||||
import SpaceHome from "@/pages/space/space-home.tsx";
|
|
||||||
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
|
||||||
import Layout from "@/components/layouts/global/layout.tsx";
|
|
||||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||||
import PasswordReset from "./pages/auth/password-reset";
|
import PasswordReset from "./pages/auth/password-reset";
|
||||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
|
||||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
|
||||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
|
||||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
// Heavy / leaf pages are route-split with React.lazy so their code (most
|
||||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
// importantly the whole TipTap editor + KaTeX + lowlight grammars + drawio that
|
||||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
// the page editor and the readonly share editor pull in) is fetched only when
|
||||||
import LabelPage from "@/pages/label/label-page";
|
// the matching route is actually visited. The <Suspense> boundaries live inside
|
||||||
|
// each Layout (around its <Outlet/>), so the app shell stays mounted while a
|
||||||
|
// route chunk loads.
|
||||||
|
const Home = lazy(() => import("@/pages/dashboard/home"));
|
||||||
|
const Page = lazy(() => import("@/pages/page/page"));
|
||||||
|
const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx"));
|
||||||
|
const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx"));
|
||||||
|
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
|
||||||
|
const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page"));
|
||||||
|
const LabelPage = lazy(() => import("@/pages/label/label-page"));
|
||||||
|
const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx"));
|
||||||
|
|
||||||
|
const AccountSettings = lazy(
|
||||||
|
() => import("@/pages/settings/account/account-settings"),
|
||||||
|
);
|
||||||
|
const AccountPreferences = lazy(
|
||||||
|
() => import("@/pages/settings/account/account-preferences.tsx"),
|
||||||
|
);
|
||||||
|
const WorkspaceSettings = lazy(
|
||||||
|
() => import("@/pages/settings/workspace/workspace-settings"),
|
||||||
|
);
|
||||||
|
const AiSettings = lazy(() => import("@/pages/settings/workspace/ai-settings"));
|
||||||
|
const WorkspaceMembers = lazy(
|
||||||
|
() => import("@/pages/settings/workspace/workspace-members"),
|
||||||
|
);
|
||||||
|
const Groups = lazy(() => import("@/pages/settings/group/groups"));
|
||||||
|
const GroupInfo = lazy(() => import("./pages/settings/group/group-info"));
|
||||||
|
const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx"));
|
||||||
|
const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx"));
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useTrackOrigin();
|
useTrackOrigin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="100vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to="/home" />} />
|
<Route index element={<Navigate to="/home" />} />
|
||||||
<Route path={"/login"} element={<LoginPage />} />
|
<Route path={"/login"} element={<LoginPage />} />
|
||||||
@@ -83,6 +117,6 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="*" element={<Error404 />} />
|
<Route path="*" element={<Error404 />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { isChunkLoadError } from "./chunk-load-error-boundary";
|
||||||
|
|
||||||
|
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
||||||
|
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
||||||
|
// recovery UI, no reload). A false negative on a real chunk failure re-blanks the
|
||||||
|
// app; a false positive would auto-reload on an ordinary error. Pin both sides.
|
||||||
|
describe("isChunkLoadError", () => {
|
||||||
|
it("detects the ChunkLoadError name", () => {
|
||||||
|
expect(isChunkLoadError({ name: "ChunkLoadError", message: "x" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"Failed to fetch dynamically imported module: https://x/assets/index-abc.js",
|
||||||
|
"error loading dynamically imported module",
|
||||||
|
"Importing a module script failed.",
|
||||||
|
])("detects the dynamic-import failure message %#", (message) => {
|
||||||
|
expect(isChunkLoadError({ name: "TypeError", message })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive on the message", () => {
|
||||||
|
expect(
|
||||||
|
isChunkLoadError({ message: "FAILED TO FETCH DYNAMICALLY IMPORTED MODULE" }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
{ name: "TypeError", message: "Cannot read properties of undefined" },
|
||||||
|
{ message: "Network request failed" },
|
||||||
|
new Error("some ordinary render error"),
|
||||||
|
])("returns false for a non-chunk error %#", (err) => {
|
||||||
|
expect(isChunkLoadError(err)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
const RELOAD_FLAG = "chunk-reload-attempted";
|
||||||
|
|
||||||
|
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||||
|
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||||
|
// replaces the hashed chunks, a tab left open on the old index.html requests a
|
||||||
|
// chunk URL that now 404s, and React.lazy rejects. Browsers / Vite surface these
|
||||||
|
// with a ChunkLoadError name or one of these messages.
|
||||||
|
export function isChunkLoadError(error: unknown): boolean {
|
||||||
|
if (!error) return false;
|
||||||
|
const name = (error as { name?: string }).name ?? "";
|
||||||
|
const message = (error as { message?: string }).message ?? "";
|
||||||
|
return (
|
||||||
|
name === "ChunkLoadError" ||
|
||||||
|
/Failed to fetch dynamically imported module/i.test(message) ||
|
||||||
|
/error loading dynamically imported module/i.test(message) ||
|
||||||
|
/Importing a module script failed/i.test(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(error: unknown) {
|
||||||
|
if (!isChunkLoadError(error)) return;
|
||||||
|
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||||
|
// the new chunk manifest. Auto-reload once, guarding against a reload loop
|
||||||
|
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
|
||||||
|
// flag is already set we fall through to the manual recovery UI below.
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(RELOAD_FLAG)) return;
|
||||||
|
sessionStorage.setItem(RELOAD_FLAG, "1");
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
||||||
|
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root-level boundary that sits ABOVE every route-level Suspense boundary so a
|
||||||
|
// lazy route/component chunk failure is caught here instead of unmounting the
|
||||||
|
// whole tree into a blank white screen. Per-feature ErrorBoundaries (page.tsx,
|
||||||
|
// transclusion, page-embed) remain in place underneath for their local errors.
|
||||||
|
export function ChunkLoadErrorBoundary({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
onError={handleError}
|
||||||
|
fallbackRender={({ error }) => {
|
||||||
|
const chunk = isChunkLoadError(error);
|
||||||
|
return (
|
||||||
|
<Center h="100vh" p="md">
|
||||||
|
<Stack align="center" gap="sm" maw={420}>
|
||||||
|
<Text fw={600}>
|
||||||
|
{chunk ? "A new version is available" : "Something went wrong"}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{chunk
|
||||||
|
? "Please reload the page to load the latest version."
|
||||||
|
: "An unexpected error occurred. Reloading the page may help."}
|
||||||
|
</Text>
|
||||||
|
<Button onClick={() => window.location.reload()}>Reload</Button>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { AppShell, Container } from "@mantine/core";
|
import { AppShell, Container } from "@mantine/core";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { Suspense, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||||
import {
|
import {
|
||||||
APP_NAVBAR_ID,
|
APP_NAVBAR_ID,
|
||||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
NAVBAR_COLLAPSE_BREAKPOINT,
|
||||||
@@ -14,8 +15,6 @@ import {
|
|||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||||
import Aside from "@/components/layouts/global/aside.tsx";
|
|
||||||
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
|
|
||||||
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
||||||
import classes from "./app-shell.module.css";
|
import classes from "./app-shell.module.css";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
@@ -23,6 +22,21 @@ import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
|||||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
||||||
|
|
||||||
|
// Lazily load the AI chat window so the AI SDK runtime it pulls in is fetched
|
||||||
|
// only after the user first opens the chat, instead of for every authenticated
|
||||||
|
// user on load. The window itself renders null while closed, so there is no
|
||||||
|
// behavior difference — it simply is not mounted until first opened.
|
||||||
|
const AiChatWindow = React.lazy(
|
||||||
|
() => import("@/features/ai-chat/components/ai-chat-window.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The right aside hosts the comment panel and table of contents, both of which
|
||||||
|
// pull in TipTap. It only ever renders on page routes, so lazy-loading it keeps
|
||||||
|
// the whole editor engine out of the eager global-shell startup graph.
|
||||||
|
const Aside = React.lazy(
|
||||||
|
() => import("@/components/layouts/global/aside.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
export default function GlobalAppShell({
|
export default function GlobalAppShell({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -37,6 +51,15 @@ export default function GlobalAppShell({
|
|||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const sidebarRef = useRef(null);
|
const sidebarRef = useRef(null);
|
||||||
|
|
||||||
|
// Latch: once the AI chat window has been opened, keep it mounted so an
|
||||||
|
// in-flight stream is never torn down. Before the first open the AI chat chunk
|
||||||
|
// is never fetched.
|
||||||
|
const aiChatOpen = useAtomValue(aiChatWindowOpenAtom);
|
||||||
|
const [aiChatEverOpened, setAiChatEverOpened] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (aiChatOpen) setAiChatEverOpened(true);
|
||||||
|
}, [aiChatOpen]);
|
||||||
|
|
||||||
const startResizing = React.useCallback((mouseDownEvent) => {
|
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||||
mouseDownEvent.preventDefault();
|
mouseDownEvent.preventDefault();
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
@@ -160,13 +183,21 @@ export default function GlobalAppShell({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Aside />
|
<Suspense fallback={null}>
|
||||||
|
<Aside />
|
||||||
|
</Suspense>
|
||||||
</AppShell.Aside>
|
</AppShell.Aside>
|
||||||
)}
|
)}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
{/* Floating AI chat window. Mounted once globally; it is position: fixed
|
{/* Floating AI chat window. Mounted once globally on first open; it is
|
||||||
and self-hides when closed, so its place in the tree is not critical. */}
|
position: fixed and self-hides when closed, so its place in the tree is
|
||||||
<AiChatWindow />
|
not critical. Kept mounted after the first open so a live stream is not
|
||||||
|
aborted. */}
|
||||||
|
{aiChatEverOpened && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<AiChatWindow />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
||||||
createPageWithRecording on window.gitmost so the native host can
|
createPageWithRecording on window.gitmost so the native host can
|
||||||
create a page with a recording even when no page editor is open. */}
|
create a page with a recording even when no page editor is open. */}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { Suspense, useEffect } from "react";
|
||||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||||
import { Outlet, useParams } from "react-router-dom";
|
import { Outlet, useParams } from "react-router-dom";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||||
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
@@ -8,10 +10,39 @@ export default function Layout() {
|
|||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
|
|
||||||
|
// Warm the (now route-split) editor chunk during idle time on authenticated
|
||||||
|
// routes, so the first navigation to a page renders from cache instead of a
|
||||||
|
// cold chunk fetch. Best-effort: gated on requestIdleCallback and never blocks
|
||||||
|
// startup — the dynamic import mirrors the App.tsx route lazy loader so both
|
||||||
|
// resolve to the same chunk.
|
||||||
|
useEffect(() => {
|
||||||
|
const ric =
|
||||||
|
typeof window !== "undefined" && (window as any).requestIdleCallback;
|
||||||
|
const warm = () => {
|
||||||
|
// Best-effort prefetch: a failed warm-up (offline, stale 404) is harmless
|
||||||
|
// and must not surface as an unhandledrejection.
|
||||||
|
void import("@/pages/page/page").catch(() => {});
|
||||||
|
};
|
||||||
|
if (ric) {
|
||||||
|
const id = ric(warm);
|
||||||
|
return () => (window as any).cancelIdleCallback?.(id);
|
||||||
|
}
|
||||||
|
const timer = setTimeout(warm, 2000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<GlobalAppShell>
|
<GlobalAppShell>
|
||||||
<Outlet />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="60vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
<SearchSpotlight spaceId={space?.id} />
|
<SearchSpotlight spaceId={space?.id} />
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { Editor } from "@tiptap/core";
|
// Type-only: these atoms only hold an Editor reference for typing. A value
|
||||||
|
// import would drag the whole @tiptap/core engine into the eager graph of every
|
||||||
|
// shell component that reads one of these atoms.
|
||||||
|
import type { Editor } from "@tiptap/core";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
|
|||||||
@@ -46,13 +46,6 @@ 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,15 +43,8 @@ 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: true,
|
isCallout: ctx.editor.isActive("callout"),
|
||||||
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,12 +22,6 @@ 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;
|
||||||
@@ -38,14 +32,11 @@ 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, language]);
|
}, [editor, getPos(), node.nodeSize]);
|
||||||
|
|
||||||
function changeLanguage(language: string) {
|
function changeLanguage(language: string) {
|
||||||
setLanguageValue(language);
|
setLanguageValue(language);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||||
|
|
||||||
|
// Lazily load the drawio bubble menu so it is split out of the editor chunk and
|
||||||
|
// fetched only when an editable editor is mounted (mirrors excalidraw-menu-lazy).
|
||||||
|
const DrawioMenu = lazy(
|
||||||
|
() => import("@/features/editor/components/drawio/drawio-menu.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function DrawioMenuLazy(props: EditorMenuProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DrawioMenu {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
// Lazily load the drawio node view so the heavy react-drawio embed runtime is
|
||||||
|
// split into its own chunk and fetched only when a drawio diagram is actually
|
||||||
|
// rendered (mirrors excalidraw-view-lazy).
|
||||||
|
const DrawioView = lazy(
|
||||||
|
() => import("@/features/editor/components/drawio/drawio-view.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function DrawioViewLazy(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DrawioView {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
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;
|
||||||
@@ -18,45 +16,14 @@ export interface ToolbarState {
|
|||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Undo/redo availability, computed WITHOUT `editor.can().undo()/.redo()`.
|
// Undo/redo come from either StarterKit's history or the Yjs collaboration
|
||||||
//
|
// history extension. During the brief moment a page is rendered with the
|
||||||
// `editor.can()` runs the command as a dry-run (building a throwaway state +
|
// static editor (mainExtensions only, undoRedo disabled), neither is loaded
|
||||||
// transaction) — the most expensive work in this selector, and it ran on every
|
// and editor.can().undo/redo is undefined.
|
||||||
// keystroke (and every REMOTE keystroke under collaboration). Instead we read
|
function safeCan(editor: Editor, command: "undo" | "redo"): boolean {
|
||||||
// the history stack depth directly, which is a cheap plugin-state lookup and
|
const can = editor.can() as Record<string, unknown>;
|
||||||
// mirrors exactly what the undo/redo commands themselves check:
|
const fn = can[command];
|
||||||
//
|
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 {
|
||||||
@@ -64,7 +31,6 @@ 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"),
|
||||||
@@ -76,8 +42,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,
|
canUndo: safeCan(ctx.editor, "undo"),
|
||||||
canRedo,
|
canRedo: safeCan(ctx.editor, "redo"),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,14 +38,6 @@ 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 {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
// Lazily load the KaTeX-backed block math view so the katex chunk is fetched
|
||||||
|
// only when a document actually contains a math node (mirrors the mermaid/
|
||||||
|
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
|
||||||
|
// crashing or blocking the whole editor: while it loads we render the raw
|
||||||
|
// LaTeX source as a node-sized placeholder.
|
||||||
|
const MathBlockView = lazy(
|
||||||
|
() => import("@/features/editor/components/math/math-block.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function MathBlockViewLazy(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div data-katex="true">{props.node.attrs.text}</div>}>
|
||||||
|
<MathBlockView {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
// Lazily load the KaTeX-backed inline math view so the katex chunk is fetched
|
||||||
|
// only when a document actually contains a math node (mirrors the mermaid/
|
||||||
|
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
|
||||||
|
// crashing or blocking the whole editor: while it loads we render the raw
|
||||||
|
// LaTeX source as a node-sized placeholder.
|
||||||
|
const MathInlineView = lazy(
|
||||||
|
() => import("@/features/editor/components/math/math-inline.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function MathInlineViewLazy(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<span data-katex="true">{props.node.attrs.text}</span>}>
|
||||||
|
<MathInlineView {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,13 +25,6 @@ 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,14 +70,7 @@ 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,
|
||||||
// #343 PART 1: skip getAttributes unless a subpages node is active. The
|
selector: (ctx) => ctx.editor?.getAttributes("subpages")?.recursive ?? false,
|
||||||
// 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,7 +4,6 @@ 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 = {
|
||||||
@@ -80,21 +79,13 @@ 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", debouncedHandleUpdate);
|
props.editor?.on("update", handleUpdate);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
props.editor?.off("update", debouncedHandleUpdate);
|
props.editor?.off("update", handleUpdate);
|
||||||
};
|
};
|
||||||
}, [props.editor, debouncedHandleUpdate]);
|
}, [props.editor]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -31,13 +31,6 @@ 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 {
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ import {
|
|||||||
createResizeHandle,
|
createResizeHandle,
|
||||||
buildResizeClasses,
|
buildResizeClasses,
|
||||||
} from "@/features/editor/components/common/node-resize-handles.ts";
|
} from "@/features/editor/components/common/node-resize-handles.ts";
|
||||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
import MathInlineView from "@/features/editor/components/math/math-inline-lazy.tsx";
|
||||||
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
import MathBlockView from "@/features/editor/components/math/math-block-lazy.tsx";
|
||||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||||
import StatusView from "@/features/editor/components/status/status-view.tsx";
|
import StatusView from "@/features/editor/components/status/status-view.tsx";
|
||||||
@@ -90,7 +90,7 @@ import VideoView from "@/features/editor/components/video/video-view.tsx";
|
|||||||
import AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
import AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
||||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||||
import DrawioView from "../components/drawio/drawio-view";
|
import DrawioView from "../components/drawio/drawio-view-lazy.tsx";
|
||||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
|
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
|
||||||
|
|||||||
@@ -6,23 +6,6 @@ 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',
|
||||||
@@ -55,7 +38,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 = suggestionItemsForQuery(query);
|
const groups = getSuggestionItems({ query });
|
||||||
const hasMatches = Object.values(groups).some(
|
const hasMatches = Object.values(groups).some(
|
||||||
(items) => items.length > 0,
|
(items) => items.length > 0,
|
||||||
);
|
);
|
||||||
@@ -78,9 +61,7 @@ const Command = Extension.create({
|
|||||||
|
|
||||||
const SlashCommand = Command.configure({
|
const SlashCommand = Command.configure({
|
||||||
suggestion: {
|
suggestion: {
|
||||||
// Share the per-query memo with `allow` so the pair of same-query calls in a
|
items: getSuggestionItems,
|
||||||
// single keystroke rebuilds the list once (#343, PART 7).
|
|
||||||
items: ({ query }: { query: string }) => suggestionItemsForQuery(query),
|
|
||||||
render: renderItems,
|
render: renderItems,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { getDefaultStore } from "jotai";
|
import { getDefaultStore } from "jotai";
|
||||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
|
||||||
import { Editor } from "@tiptap/core";
|
// Literal value of WebSocketStatus.Connected from @hocuspocus/provider. Inlined
|
||||||
|
// so this always-mounted global bridge does not statically import
|
||||||
|
// @hocuspocus/provider — that import pulls Yjs (and, through a shared chunk, the
|
||||||
|
// whole TipTap engine) into the eager startup graph. yjsConnectionStatusAtom
|
||||||
|
// already stores these raw status strings.
|
||||||
|
const YJS_STATUS_CONNECTED = "connected";
|
||||||
|
// Type-only: importing Editor as a type keeps @tiptap/core (the whole editor
|
||||||
|
// engine) out of the eager global-shell graph — the bridge only uses it for
|
||||||
|
// annotations/casts, never as a runtime value.
|
||||||
|
import type { Editor } from "@tiptap/core";
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
@@ -16,16 +25,19 @@ import {
|
|||||||
getSidebarPages,
|
getSidebarPages,
|
||||||
} from "@/features/page/services/page-service.ts";
|
} from "@/features/page/services/page-service.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import {
|
// Types are erased at build time, so importing them does not pull the module's
|
||||||
|
// runtime (which drags in @tiptap + the editor-ext barrel). The actual recording
|
||||||
|
// helpers are dynamically imported at call time inside createPageWithRecording,
|
||||||
|
// keeping the editor engine out of the eager global-shell startup graph — the
|
||||||
|
// bridge is mounted for every authenticated user but recording is a rare,
|
||||||
|
// native-host-driven action.
|
||||||
|
import type {
|
||||||
GitmostBridge,
|
GitmostBridge,
|
||||||
GitmostCreatePagePayload,
|
GitmostCreatePagePayload,
|
||||||
GitmostCreatePageResult,
|
GitmostCreatePageResult,
|
||||||
GitmostListPagesPayload,
|
GitmostListPagesPayload,
|
||||||
GitmostListPagesResult,
|
GitmostListPagesResult,
|
||||||
GitmostListSpacesResult,
|
GitmostListSpacesResult,
|
||||||
gitmostDecodePayloadToFile,
|
|
||||||
gitmostInsertTranscriptIntoEditor,
|
|
||||||
gitmostUploadFileToEditor,
|
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
|
|
||||||
// How long to wait for a freshly-navigated page's editor to mount, become
|
// How long to wait for a freshly-navigated page's editor to mount, become
|
||||||
@@ -58,7 +70,7 @@ function gitmostWaitForEditor(
|
|||||||
!editor.isDestroyed &&
|
!editor.isDestroyed &&
|
||||||
editor.isEditable &&
|
editor.isEditable &&
|
||||||
editorPageId === pageId &&
|
editorPageId === pageId &&
|
||||||
yjsStatus === WebSocketStatus.Connected;
|
yjsStatus === YJS_STATUS_CONNECTED;
|
||||||
if (ready) {
|
if (ready) {
|
||||||
resolve(editor);
|
resolve(editor);
|
||||||
return;
|
return;
|
||||||
@@ -172,6 +184,15 @@ export default function GitmostGlobalBridge() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the recording helpers on demand (see the import note above). This
|
||||||
|
// is the only place they are needed, so the @tiptap/editor-ext code they
|
||||||
|
// pull in stays out of the eager startup graph.
|
||||||
|
const {
|
||||||
|
gitmostDecodePayloadToFile,
|
||||||
|
gitmostUploadFileToEditor,
|
||||||
|
gitmostInsertTranscriptIntoEditor,
|
||||||
|
} = await import("@/features/editor/gitmost/gitmost-recording.ts");
|
||||||
|
|
||||||
// Validate/decode the recording BEFORE creating the page so a bad
|
// Validate/decode the recording BEFORE creating the page so a bad
|
||||||
// payload never leaves an empty junk page behind. Per the createPage
|
// payload never leaves an empty junk page behind. Per the createPage
|
||||||
// error contract, any decode failure collapses to "insert-failed" (the
|
// error contract, any decode failure collapses to "insert-failed" (the
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -59,10 +59,10 @@ import {
|
|||||||
handlePaste,
|
handlePaste,
|
||||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu-lazy";
|
||||||
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 { useDocumentVisibility } from "@mantine/hooks";
|
import { useDebouncedCallback, 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,7 +79,6 @@ 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";
|
||||||
@@ -273,13 +272,8 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||||
|
|
||||||
// Attach the remote provider once it's ready (and again after a pageId swap
|
// Attach here, to make sure the connection gets properly established
|
||||||
// recreates it) to make sure the connection gets properly established. This
|
providersRef.current?.remote.attach();
|
||||||
// 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) {
|
||||||
@@ -294,12 +288,6 @@ 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,
|
||||||
@@ -404,11 +392,11 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUpdate() {
|
onUpdate({ editor }) {
|
||||||
// Only schedule the debounce here — the whole-doc getJSON() serialization
|
if (editor.isEmpty) return;
|
||||||
// happens INSIDE the debounced callback (see usePageContentCache), so it
|
const editorJson = editor.getJSON();
|
||||||
// no longer runs synchronously on every (local or remote) keystroke.
|
//update local page cache to reduce flickers
|
||||||
debouncedUpdateContent();
|
debouncedUpdateContent(editorJson);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[pageId, editable, extensions],
|
[pageId, editable, extensions],
|
||||||
@@ -454,6 +442,17 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
import ShareShell from "@/features/share/components/share-shell.tsx";
|
import ShareShell from "@/features/share/components/share-shell.tsx";
|
||||||
|
|
||||||
export default function ShareLayout() {
|
export default function ShareLayout() {
|
||||||
return (
|
return (
|
||||||
<ShareShell>
|
<ShareShell>
|
||||||
<Outlet />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="60vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</ShareShell>
|
</ShareShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
||||||
// polyfilled to support execCommand fallback
|
// polyfilled to support execCommand fallback
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { execCommandCopy } from "@docmost/editor-ext";
|
import { execCommandCopy } from "@/lib/copy-to-clipboard.ts";
|
||||||
|
|
||||||
export type UseClipboardOptions = {
|
export type UseClipboardOptions = {
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import bytes from "bytes";
|
import bytes from "bytes";
|
||||||
import { castToBoolean } from "@/lib/utils.tsx";
|
import { castToBoolean } from "@/lib/utils.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
import { sanitizeUrl } from "@docmost/editor-ext";
|
import { sanitizeUrl } from "@/lib/sanitize-url.ts";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Client-local execCommand copy fallback (previously imported from
|
||||||
|
// @docmost/editor-ext). It lives here so the ubiquitous useClipboard / CopyButton
|
||||||
|
// path does not pull in the editor-ext barrel — and with it the whole TipTap
|
||||||
|
// engine — through the eager startup graph. Behavior is identical to the
|
||||||
|
// editor-ext helper it replaces.
|
||||||
|
export function execCommandCopy(text: string): void {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
textarea.style.top = "-9999px";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { sanitizeUrl } from "./sanitize-url";
|
||||||
|
|
||||||
|
// `sanitizeUrl` is a byte-identical client-local copy of editor-ext's wrapper
|
||||||
|
// around @braintree/sanitize-url: it maps the sanitizer's "about:blank" XSS
|
||||||
|
// sentinel to "". These assertions mirror editor-ext's own security-contract
|
||||||
|
// test so the extracted copy keeps the same guarantees.
|
||||||
|
describe("sanitizeUrl", () => {
|
||||||
|
it("blocks dangerous schemes (returns empty string)", () => {
|
||||||
|
expect(sanitizeUrl("javascript:alert(1)")).toBe("");
|
||||||
|
expect(sanitizeUrl("data:text/html,<script>alert(1)</script>")).toBe("");
|
||||||
|
expect(sanitizeUrl("vbscript:msgbox(1)")).toBe("");
|
||||||
|
// Case / whitespace obfuscation must not slip past the sanitizer.
|
||||||
|
expect(sanitizeUrl(" JaVaScRiPt:alert(1)")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for empty / undefined input", () => {
|
||||||
|
expect(sanitizeUrl(undefined)).toBe("");
|
||||||
|
expect(sanitizeUrl("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows safe https, relative file and mailto URLs", () => {
|
||||||
|
expect(sanitizeUrl("https://example.com/page")).toMatch(
|
||||||
|
/^https:\/\/example\.com\/page/,
|
||||||
|
);
|
||||||
|
expect(sanitizeUrl("/api/files/abc-123")).toBe("/api/files/abc-123");
|
||||||
|
expect(sanitizeUrl("mailto:user@example.com")).toBe(
|
||||||
|
"mailto:user@example.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
|
||||||
|
|
||||||
|
// Client-local copy of editor-ext's sanitizeUrl wrapper. Importing it from the
|
||||||
|
// editor-ext barrel dragged the whole TipTap engine into the eager startup graph
|
||||||
|
// via the app-wide config module (getFileUrl). This keeps the exact same
|
||||||
|
// behavior (braintree sanitize + normalize "about:blank" -> "") without that
|
||||||
|
// dependency.
|
||||||
|
export function sanitizeUrl(url: string | undefined): string {
|
||||||
|
if (!url) return "";
|
||||||
|
|
||||||
|
const sanitized = braintreeSanitizeUrl(url);
|
||||||
|
|
||||||
|
// Return an empty string instead of "about:blank".
|
||||||
|
return sanitized === "about:blank" ? "" : sanitized;
|
||||||
|
}
|
||||||
+60
-27
@@ -13,15 +13,14 @@ import { ModalsProvider } from "@mantine/modals";
|
|||||||
import { Notifications } from "@mantine/notifications";
|
import { Notifications } from "@mantine/notifications";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { HelmetProvider } from "react-helmet-async";
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
|
import { ChunkLoadErrorBoundary } from "@/components/chunk-load-error-boundary.tsx";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import { PostHogProvider } from "posthog-js/react";
|
|
||||||
import {
|
import {
|
||||||
getPostHogHost,
|
getPostHogHost,
|
||||||
getPostHogKey,
|
getPostHogKey,
|
||||||
isCloud,
|
isCloud,
|
||||||
isPostHogEnabled,
|
isPostHogEnabled,
|
||||||
} from "@/lib/config.ts";
|
} from "@/lib/config.ts";
|
||||||
import posthog from "posthog-js";
|
|
||||||
import { initVitals } from "@/lib/telemetry/vitals";
|
import { initVitals } from "@/lib/telemetry/vitals";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
@@ -35,15 +34,6 @@ export const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isCloud() && isPostHogEnabled) {
|
|
||||||
posthog.init(getPostHogKey(), {
|
|
||||||
api_host: getPostHogHost(),
|
|
||||||
defaults: "2025-05-24",
|
|
||||||
disable_session_recording: true,
|
|
||||||
capture_pageleave: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// #355 — client perf-telemetry. Decides sampling ONCE (25%/session) before
|
// #355 — client perf-telemetry. Decides sampling ONCE (25%/session) before
|
||||||
// subscribing to any observer; non-sampled sessions send nothing.
|
// subscribing to any observer; non-sampled sessions send nothing.
|
||||||
initVitals();
|
initVitals();
|
||||||
@@ -51,19 +41,62 @@ initVitals();
|
|||||||
const container = document.getElementById("root") as HTMLElement;
|
const container = document.getElementById("root") as HTMLElement;
|
||||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||||
|
|
||||||
root.render(
|
function renderApp() {
|
||||||
<BrowserRouter>
|
root.render(
|
||||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
<BrowserRouter>
|
||||||
<ModalsProvider>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<ModalsProvider>
|
||||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
<QueryClientProvider client={queryClient}>
|
||||||
<HelmetProvider>
|
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||||
<PostHogProvider client={posthog}>
|
<HelmetProvider>
|
||||||
<App />
|
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
||||||
</PostHogProvider>
|
404 after a deploy is caught and recovered here instead of
|
||||||
</HelmetProvider>
|
blanking the whole app. */}
|
||||||
</QueryClientProvider>
|
<ChunkLoadErrorBoundary>
|
||||||
</ModalsProvider>
|
<App />
|
||||||
</MantineProvider>
|
</ChunkLoadErrorBoundary>
|
||||||
</BrowserRouter>,
|
</HelmetProvider>
|
||||||
);
|
</QueryClientProvider>
|
||||||
|
</ModalsProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</BrowserRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initAnalytics() {
|
||||||
|
// posthog-js is only pulled in for cloud deployments with analytics enabled, so
|
||||||
|
// self-hosted builds never download it. The gate is kept identical to the
|
||||||
|
// previous eager code so cloud analytics behavior is unchanged; the import is
|
||||||
|
// simply deferred behind it.
|
||||||
|
//
|
||||||
|
// Crucially this runs AFTER the immediate first render below, so first paint is
|
||||||
|
// never gated on the analytics chunk. Any failure (network, stale 404, or an
|
||||||
|
// ad-blocker blocking a chunk named "posthog") is swallowed so the user keeps a
|
||||||
|
// working app without analytics instead of a permanently blank page.
|
||||||
|
//
|
||||||
|
// NOTE: we init the posthog SINGLETON only and do NOT wrap the tree in
|
||||||
|
// <PostHogProvider>. The app has zero consumers of the PostHog React context
|
||||||
|
// (no usePostHog / useFeatureFlag* / PostHogFeature), and PostHogProvider given
|
||||||
|
// an already-initialized `client` is a no-op — all capture goes through the
|
||||||
|
// singleton. Re-rendering to attach the provider would only REMOUNT the whole
|
||||||
|
// App (running every mount effect twice and dropping local state / focus /
|
||||||
|
// in-progress input on cloud cold-load) for no functional gain.
|
||||||
|
if (!(isCloud() && isPostHogEnabled)) return;
|
||||||
|
try {
|
||||||
|
const { default: posthog } = await import("posthog-js");
|
||||||
|
posthog.init(getPostHogKey(), {
|
||||||
|
api_host: getPostHogHost(),
|
||||||
|
defaults: "2025-05-24",
|
||||||
|
disable_session_recording: true,
|
||||||
|
capture_pageleave: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Analytics failed to load — degrade gracefully; the app already rendered.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paint immediately for everyone (self-hosted stays exactly as instant as before,
|
||||||
|
// cloud no longer blocks on the analytics import). The posthog singleton is
|
||||||
|
// initialized after, without re-rendering the tree.
|
||||||
|
renderApp();
|
||||||
|
void initAnalytics();
|
||||||
|
|||||||
@@ -63,6 +63,20 @@ export default defineConfig(({ mode }) => {
|
|||||||
name: "vendor-mantine",
|
name: "vendor-mantine",
|
||||||
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
||||||
},
|
},
|
||||||
|
// NOTE: TipTap/ProseMirror/Yjs are intentionally NOT force-grouped
|
||||||
|
// into a single vendor chunk. Doing so backfires: rolldown co-locates
|
||||||
|
// a small module shared with the (eager) react-i18next runtime into
|
||||||
|
// that group chunk, which then drags the whole ~590KB editor engine
|
||||||
|
// into the eager modulepreload graph. Left to the default splitting,
|
||||||
|
// the editor engine stays in lazily-loaded chunks pulled only by the
|
||||||
|
// route-split editor/share pages. KaTeX is safe to group (nothing
|
||||||
|
// eager references it).
|
||||||
|
// KaTeX in its own stable chunk; loaded on demand by the lazy math
|
||||||
|
// node views (never in the startup path).
|
||||||
|
{
|
||||||
|
name: "vendor-katex",
|
||||||
|
test: /[\\/]node_modules[\\/]katex[\\/]/,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
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,9 +1,11 @@
|
|||||||
import { EditorState, Plugin, PluginKey, Transaction } from '@tiptap/pm/state';
|
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state';
|
||||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||||
import { Node as ProseMirrorNode, Slice } from '@tiptap/pm/model';
|
import { Node as ProseMirrorNode } 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>(
|
||||||
@@ -25,22 +27,8 @@ 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
|
||||||
@@ -53,101 +41,50 @@ 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 the number map, reference counts AND the decorations for `doc` in a
|
* Compute both the number map AND the decorations for `doc` in a single walk.
|
||||||
* SINGLE document walk (previously three separate O(n) traversals per
|
* The plugin caches the result so NodeViews can read numbers without
|
||||||
* docChanged — computeFootnoteNumbers + computeFootnoteRefCounts + a decoration
|
* recomputing.
|
||||||
* 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 = new Map<string, number>();
|
const numbers = computeFootnoteNumbers(doc);
|
||||||
const refCounts = new Map<string, number>();
|
const refCounts = computeFootnoteRefCounts(doc);
|
||||||
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) => {
|
||||||
const typeName = node.type.name;
|
if (node.type.name === FOOTNOTE_REFERENCE_NAME) {
|
||||||
if (typeName === FOOTNOTE_REFERENCE_NAME) {
|
const num = numbers.get(node.attrs.id);
|
||||||
hasFootnotes = true;
|
if (num != null) {
|
||||||
const id = node.attrs.id;
|
decorations.push(
|
||||||
if (id) {
|
Decoration.node(pos, pos + node.nodeSize, {
|
||||||
if (!numbers.has(id)) numbers.set(id, ++n);
|
'data-footnote-number': String(num),
|
||||||
refCounts.set(id, (refCounts.get(id) ?? 0) + 1);
|
style: `--footnote-number: "${num}";`,
|
||||||
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
|
||||||
@@ -189,13 +126,6 @@ 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);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+3
@@ -272,6 +272,9 @@ importers:
|
|||||||
'@atlaskit/pragmatic-drag-and-drop-live-region':
|
'@atlaskit/pragmatic-drag-and-drop-live-region':
|
||||||
specifier: 1.3.4
|
specifier: 1.3.4
|
||||||
version: 1.3.4
|
version: 1.3.4
|
||||||
|
'@braintree/sanitize-url':
|
||||||
|
specifier: 7.1.2
|
||||||
|
version: 7.1.2
|
||||||
'@casl/react':
|
'@casl/react':
|
||||||
specifier: 5.0.1
|
specifier: 5.0.1
|
||||||
version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1)
|
version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1)
|
||||||
|
|||||||
Reference in New Issue
Block a user