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
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<Suspense fallback={null}>
|
||||||
<Aside />
|
<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
|
||||||
|
not critical. Kept mounted after the first open so a live stream is not
|
||||||
|
aborted. */}
|
||||||
|
{aiChatEverOpened && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<AiChatWindow />
|
<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>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="60vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Outlet />
|
<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";
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,7 +45,6 @@ import {
|
|||||||
TiptapPdf,
|
TiptapPdf,
|
||||||
PageBreak,
|
PageBreak,
|
||||||
SearchAndReplace,
|
SearchAndReplace,
|
||||||
MultiCursor,
|
|
||||||
Mention,
|
Mention,
|
||||||
TableDndExtension,
|
TableDndExtension,
|
||||||
TableHandleCommandsExtension,
|
TableHandleCommandsExtension,
|
||||||
@@ -82,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";
|
||||||
@@ -91,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";
|
||||||
@@ -448,10 +447,6 @@ export const mainExtensions = [
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}).configure(),
|
}).configure(),
|
||||||
// Multi-cursor editing (MVP / Variant A): select-all-occurrences + type into
|
|
||||||
// all at once. Does not depend on collaboration, so it lives in mainExtensions
|
|
||||||
// (available in both the plain and collaborative editors).
|
|
||||||
MultiCursor,
|
|
||||||
Columns,
|
Columns,
|
||||||
Column,
|
Column,
|
||||||
AutoJoiner.configure({
|
AutoJoiner.configure({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ 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 { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
@import "./core.css";
|
@import "./core.css";
|
||||||
@import "./collaboration.css";
|
@import "./collaboration.css";
|
||||||
@import "./multi-cursor.css";
|
|
||||||
@import "./task-list.css";
|
@import "./task-list.css";
|
||||||
@import "./placeholder.css";
|
@import "./placeholder.css";
|
||||||
@import "./drag-handle.css";
|
@import "./drag-handle.css";
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
/*
|
|
||||||
* Multi-cursor (issue #196). Deliberately DISTINCT from the collaboration
|
|
||||||
* carets (collaboration.css) so a user never confuses their own multi-cursors
|
|
||||||
* with a co-author's caret: solid accent-blue carets + a translucent blue
|
|
||||||
* range highlight, versus the thin dark collaboration caret with a name label.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* A secondary caret rendered as a Decoration.widget at each cursor position. */
|
|
||||||
.multi-cursor__caret {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 0;
|
|
||||||
height: 1em;
|
|
||||||
vertical-align: text-bottom;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.multi-cursor__caret::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: -1px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 2px;
|
|
||||||
background: #2b6cb0;
|
|
||||||
animation: multi-cursor-blink 1s steps(1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optional label class reserved for future per-cursor annotations. */
|
|
||||||
.multi-cursor__label {
|
|
||||||
position: absolute;
|
|
||||||
top: -1.4em;
|
|
||||||
left: -1px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
line-height: normal;
|
|
||||||
padding: 0.05rem 0.25rem;
|
|
||||||
border-radius: 3px 3px 3px 0;
|
|
||||||
background: #2b6cb0;
|
|
||||||
color: #fff;
|
|
||||||
white-space: nowrap;
|
|
||||||
user-select: none;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline highlight for a multi-cursor RANGE (from < to). */
|
|
||||||
.multi-cursor__selection {
|
|
||||||
background: rgba(43, 108, 176, 0.28);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes multi-cursor-blink {
|
|
||||||
0%,
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
51%,
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="60vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Outlet />
|
<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;
|
||||||
|
}
|
||||||
+46
-13
@@ -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,6 +41,7 @@ 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);
|
||||||
|
|
||||||
|
function renderApp() {
|
||||||
root.render(
|
root.render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
@@ -58,12 +49,54 @@ root.render(
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
<PostHogProvider client={posthog}>
|
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
||||||
|
404 after a deploy is caught and recovered here instead of
|
||||||
|
blanking the whole app. */}
|
||||||
|
<ChunkLoadErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
</PostHogProvider>
|
</ChunkLoadErrorBoundary>
|
||||||
</HelmetProvider>
|
</HelmetProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</BrowserRouter>,
|
</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[\\/]/,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export * from "./lib/html-embed/html-embed";
|
|||||||
export * from "./lib/mention";
|
export * from "./lib/mention";
|
||||||
export * from "./lib/markdown";
|
export * from "./lib/markdown";
|
||||||
export * from "./lib/search-and-replace";
|
export * from "./lib/search-and-replace";
|
||||||
export * from "./lib/multi-cursor";
|
|
||||||
export * from "./lib/embed-provider";
|
export * from "./lib/embed-provider";
|
||||||
export * from "./lib/subpages";
|
export * from "./lib/subpages";
|
||||||
export * from "./lib/transclusion";
|
export * from "./lib/transclusion";
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { MultiCursor } from "./multi-cursor";
|
|
||||||
export * from "./multi-cursor";
|
|
||||||
export default MultiCursor;
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
import { Document } from "@tiptap/extension-document";
|
|
||||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
|
||||||
import { Text } from "@tiptap/extension-text";
|
|
||||||
import { Bold } from "@tiptap/extension-bold";
|
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
|
||||||
import { MultiCursor, multiCursorPluginKey, MAX_CURSORS } from "./multi-cursor";
|
|
||||||
import { findOccurrences } from "../search-and-replace/find-occurrences";
|
|
||||||
|
|
||||||
const extensions = [Document, Paragraph, Text, Bold, MultiCursor];
|
|
||||||
|
|
||||||
function makeEditor(content?: any) {
|
|
||||||
return new Editor({
|
|
||||||
extensions,
|
|
||||||
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function doc(...paragraphs: string[]) {
|
|
||||||
return {
|
|
||||||
type: "doc",
|
|
||||||
content: paragraphs.map((text) => ({
|
|
||||||
type: "paragraph",
|
|
||||||
content: text ? [{ type: "text", text }] : [],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function paraTexts(d: PMNode): string[] {
|
|
||||||
const out: string[] = [];
|
|
||||||
d.forEach((node) => {
|
|
||||||
if (node.type.name === "paragraph") out.push(node.textContent);
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cursors(editor: Editor) {
|
|
||||||
return multiCursorPluginKey.getState(editor.state)!.cursors;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate typing a character through the real handleTextInput routing (the
|
|
||||||
// browser path). someMethod-equivalent: dispatch a DOM-ish text input by calling
|
|
||||||
// the view's input handler directly.
|
|
||||||
function typeText(editor: Editor, text: string) {
|
|
||||||
const { from, to } = editor.state.selection;
|
|
||||||
// props.handleTextInput is what ProseMirror calls on beforeinput/keypress.
|
|
||||||
const handled = editor.view.someProp(
|
|
||||||
"handleTextInput",
|
|
||||||
(fn) => fn(editor.view, from, to, text) || false,
|
|
||||||
);
|
|
||||||
if (!handled) {
|
|
||||||
// Fall back to a normal insertion (no active multi-cursor set).
|
|
||||||
editor.view.dispatch(editor.state.tr.insertText(text, from, to));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pressKey(editor: Editor, key: string) {
|
|
||||||
editor.view.someProp("handleKeyDown", (fn) =>
|
|
||||||
fn(editor.view, new KeyboardEvent("keydown", { key })),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("multi-cursor: selectAllOccurrences", () => {
|
|
||||||
it("finds EVERY occurrence of a repeated word under the cursor", () => {
|
|
||||||
const editor = makeEditor(doc("foo bar foo baz foo"));
|
|
||||||
// Cursor inside the first "foo".
|
|
||||||
editor.commands.setTextSelection(2);
|
|
||||||
expect(editor.commands.selectAllOccurrences()).toBe(true);
|
|
||||||
|
|
||||||
const cs = cursors(editor);
|
|
||||||
expect(cs.length).toBe(3);
|
|
||||||
// Every cursor spans a "foo".
|
|
||||||
for (const c of cs) {
|
|
||||||
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("foo");
|
|
||||||
}
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the current non-empty selection as the term", () => {
|
|
||||||
const editor = makeEditor(doc("ab abc ab abcd ab"));
|
|
||||||
// Select the first "ab".
|
|
||||||
editor.commands.setTextSelection({ from: 1, to: 3 });
|
|
||||||
expect(editor.state.doc.textBetween(1, 3)).toBe("ab");
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
// Literal substring match (selection is not whole-word), so every "ab"
|
|
||||||
// including those inside "abc"/"abcd" is matched: 5 total.
|
|
||||||
const cs = cursors(editor);
|
|
||||||
expect(cs.length).toBe(5);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("whole-word matching from a word cursor does not match substrings", () => {
|
|
||||||
const editor = makeEditor(doc("cat category cat scatter cat"));
|
|
||||||
editor.commands.setTextSelection(2); // inside first "cat"
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
// Only the three standalone "cat" words, not "category"/"scatter".
|
|
||||||
expect(cursors(editor).length).toBe(3);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("multi-cursor: mass typing (single transaction)", () => {
|
|
||||||
it("types text into N carets at once", () => {
|
|
||||||
const editor = makeEditor(doc("foo foo foo"));
|
|
||||||
editor.commands.setTextSelection(2);
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
expect(cursors(editor).length).toBe(3);
|
|
||||||
|
|
||||||
// Typing replaces each selected "foo" with "X".
|
|
||||||
typeText(editor, "X");
|
|
||||||
expect(paraTexts(editor.state.doc)).toEqual(["X X X"]);
|
|
||||||
|
|
||||||
// The cursors are now carets right after each inserted "X".
|
|
||||||
const cs = cursors(editor);
|
|
||||||
expect(cs.length).toBe(3);
|
|
||||||
for (const c of cs) expect(c.from).toBe(c.to);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("continues typing at the resulting carets (append semantics)", () => {
|
|
||||||
const editor = makeEditor(doc("a a a"));
|
|
||||||
editor.commands.setTextSelection(1);
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
typeText(editor, "b"); // each "a" -> "b"
|
|
||||||
typeText(editor, "c"); // append at each caret -> "bc"
|
|
||||||
expect(paraTexts(editor.state.doc)).toEqual(["bc bc bc"]);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies the whole multi-edit in a SINGLE transaction (one undo step)", () => {
|
|
||||||
// "One Cmd/Ctrl+Z undoes the whole multi-edit" holds iff the N edits land in
|
|
||||||
// ONE transaction (history groups by transaction). @tiptap/extension-history
|
|
||||||
// is not a dependency here, so rather than exercise undo we assert the
|
|
||||||
// property that guarantees it: typing into N cursors is exactly ONE dispatch.
|
|
||||||
const editor = makeEditor(doc("foo foo foo"));
|
|
||||||
editor.commands.setTextSelection(2);
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
expect(cursors(editor).length).toBe(3);
|
|
||||||
|
|
||||||
const orig = editor.view.dispatch.bind(editor.view);
|
|
||||||
let dispatches = 0;
|
|
||||||
editor.view.dispatch = (tr) => {
|
|
||||||
dispatches += 1;
|
|
||||||
return orig(tr);
|
|
||||||
};
|
|
||||||
typeText(editor, "Z");
|
|
||||||
editor.view.dispatch = orig;
|
|
||||||
|
|
||||||
expect(dispatches).toBe(1); // all three edits share one transaction
|
|
||||||
expect(paraTexts(editor.state.doc)).toEqual(["Z Z Z"]);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("off-by-one guard: reverse-order iteration keeps every position valid", () => {
|
|
||||||
// If the mass edit iterated FORWARD, inserting at an earlier cursor would
|
|
||||||
// shift every later cursor and corrupt the result. Different-length
|
|
||||||
// replacement makes such a bug visible.
|
|
||||||
const editor = makeEditor(doc("x x x x"));
|
|
||||||
editor.commands.setTextSelection(1);
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
expect(cursors(editor).length).toBe(4);
|
|
||||||
typeText(editor, "LONG");
|
|
||||||
expect(paraTexts(editor.state.doc)).toEqual(["LONG LONG LONG LONG"]);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("multi-cursor: mass Backspace / Delete", () => {
|
|
||||||
it("Backspace removes one char before each caret", () => {
|
|
||||||
const editor = makeEditor(doc("foo foo foo"));
|
|
||||||
editor.commands.setTextSelection(2);
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
// Collapse selections to carets at the END of each "foo" by typing then
|
|
||||||
// removing is complex; instead type to convert ranges into carets first.
|
|
||||||
typeText(editor, "ab"); // each "foo" -> "ab", carets after "ab"
|
|
||||||
expect(paraTexts(editor.state.doc)).toEqual(["ab ab ab"]);
|
|
||||||
pressKey(editor, "Backspace"); // remove the trailing "b" at each caret
|
|
||||||
expect(paraTexts(editor.state.doc)).toEqual(["a a a"]);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Delete removes one char after each caret", () => {
|
|
||||||
const editor = makeEditor(doc("fooX fooX"));
|
|
||||||
// Literal (selection) match of "foo" -> both occurrences inside "fooX".
|
|
||||||
editor.commands.setTextSelection({ from: 1, to: 4 }); // first "foo"
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
expect(cursors(editor).length).toBe(2);
|
|
||||||
typeText(editor, "foo"); // rewrite "foo", carets now sit before each "X"
|
|
||||||
expect(paraTexts(editor.state.doc)).toEqual(["fooX fooX"]);
|
|
||||||
pressKey(editor, "Delete"); // remove the "X" after each caret
|
|
||||||
expect(paraTexts(editor.state.doc)).toEqual(["foo foo"]);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Backspace at a block-start caret is a no-op for that cursor", () => {
|
|
||||||
const editor = makeEditor(doc("ab", "ab"));
|
|
||||||
// Select both "ab" then convert to carets at start by replacing with "".
|
|
||||||
editor.commands.setTextSelection({ from: 1, to: 3 }); // first "ab"
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
// Move carets to block start: type "" is not possible; instead delete range.
|
|
||||||
pressKey(editor, "Backspace"); // deletes each selected "ab"
|
|
||||||
expect(paraTexts(editor.state.doc)).toEqual(["", ""]);
|
|
||||||
// Carets are now at each block start; another Backspace must not throw and
|
|
||||||
// must not merge blocks (still two empty paragraphs).
|
|
||||||
pressKey(editor, "Backspace");
|
|
||||||
expect(paraTexts(editor.state.doc)).toEqual(["", ""]);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("multi-cursor: addNextOccurrence (Cmd/Ctrl+D)", () => {
|
|
||||||
it("first press selects the current word, next press adds the next", () => {
|
|
||||||
const editor = makeEditor(doc("go go go"));
|
|
||||||
editor.commands.setTextSelection(2); // inside first "go"
|
|
||||||
editor.commands.addNextOccurrence();
|
|
||||||
expect(cursors(editor).length).toBe(1);
|
|
||||||
editor.commands.addNextOccurrence();
|
|
||||||
expect(cursors(editor).length).toBe(2);
|
|
||||||
editor.commands.addNextOccurrence();
|
|
||||||
expect(cursors(editor).length).toBe(3);
|
|
||||||
// Nothing left to add — stays at 3.
|
|
||||||
editor.commands.addNextOccurrence();
|
|
||||||
expect(cursors(editor).length).toBe(3);
|
|
||||||
for (const c of cursors(editor)) {
|
|
||||||
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("go");
|
|
||||||
}
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("multi-cursor: position remapping", () => {
|
|
||||||
it("remaps cursors after a LOCAL edit before them", () => {
|
|
||||||
const editor = makeEditor(doc("foo foo"));
|
|
||||||
editor.commands.setTextSelection(2);
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
const before = cursors(editor).map((c) => ({ ...c }));
|
|
||||||
|
|
||||||
// Insert unrelated text at the very start (pos 1), shifting everything +5.
|
|
||||||
editor.view.dispatch(editor.state.tr.insertText("HELLO", 1));
|
|
||||||
|
|
||||||
const after = cursors(editor);
|
|
||||||
expect(after.length).toBe(before.length);
|
|
||||||
for (let i = 0; i < after.length; i += 1) {
|
|
||||||
expect(after[i].from).toBe(before[i].from + 5);
|
|
||||||
expect(after[i].to).toBe(before[i].to + 5);
|
|
||||||
// And they still point at "foo".
|
|
||||||
expect(editor.state.doc.textBetween(after[i].from, after[i].to)).toBe(
|
|
||||||
"foo",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("remaps cursors after a simulated REMOTE edit (ordinary transaction)", () => {
|
|
||||||
const editor = makeEditor(doc("foo bar foo"));
|
|
||||||
editor.commands.setTextSelection(2);
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
const before = cursors(editor).map((c) => ({ ...c }));
|
|
||||||
expect(before.length).toBe(2);
|
|
||||||
|
|
||||||
// y-prosemirror applies remote changes as ordinary transactions. Emulate a
|
|
||||||
// remote insertion between the two "foo"s (inside "bar", pos 6) with a tr
|
|
||||||
// that carries NO multi-cursor meta — exactly like a collaborator's edit.
|
|
||||||
const tr = editor.state.tr.insertText("ZZ", 6);
|
|
||||||
editor.view.dispatch(tr);
|
|
||||||
|
|
||||||
const after = cursors(editor);
|
|
||||||
// The first "foo" (before the insertion) is unchanged; the second shifts +2.
|
|
||||||
expect(after[0].from).toBe(before[0].from);
|
|
||||||
expect(after[1].from).toBe(before[1].from + 2);
|
|
||||||
for (const c of after) {
|
|
||||||
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("foo");
|
|
||||||
}
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a REMOTE delete UNDER a cursor collapses it to a caret (not drop), leaving others intact", () => {
|
|
||||||
// The riskiest remap path: a collaborator deletes the very text one cursor
|
|
||||||
// spans. Both edges map with assoc +1 and there is no drop logic, so the
|
|
||||||
// deleted-over cursor CONTRACT is: it collapses to a zero-width caret at the
|
|
||||||
// deletion point (from === to) and STAYS in the set — it is not removed.
|
|
||||||
// Untouched cursors keep spanning their occurrence. Pinning this makes the
|
|
||||||
// collapse-not-drop choice explicit (review #372 F2).
|
|
||||||
const editor = makeEditor(doc("foo bar foo"));
|
|
||||||
editor.commands.setTextSelection(2);
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
const before = cursors(editor).map((c) => ({ ...c }));
|
|
||||||
expect(before.length).toBe(2);
|
|
||||||
|
|
||||||
// Remote (no multi-cursor meta) delete of the FIRST "foo" range.
|
|
||||||
const tr = editor.state.tr.delete(before[0].from, before[0].to);
|
|
||||||
editor.view.dispatch(tr);
|
|
||||||
|
|
||||||
const after = cursors(editor);
|
|
||||||
// Still two cursors — the deleted-over one is NOT dropped.
|
|
||||||
expect(after.length).toBe(2);
|
|
||||||
// The first collapsed to a caret at the deletion point.
|
|
||||||
expect(after[0].from).toBe(after[0].to);
|
|
||||||
expect(after[0].from).toBe(before[0].from);
|
|
||||||
// The second still spans "foo" (shifted left by the 3 removed chars).
|
|
||||||
expect(after[1].from).toBe(before[1].from - 3);
|
|
||||||
expect(editor.state.doc.textBetween(after[1].from, after[1].to)).toBe("foo");
|
|
||||||
// Sanity: the document now reads " bar foo".
|
|
||||||
expect(paraTexts(editor.state.doc)).toEqual([" bar foo"]);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("multi-cursor: collapse / exit", () => {
|
|
||||||
it("exitMultiCursor clears the set", () => {
|
|
||||||
const editor = makeEditor(doc("foo foo"));
|
|
||||||
editor.commands.setTextSelection(2);
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
expect(cursors(editor).length).toBe(2);
|
|
||||||
editor.commands.exitMultiCursor();
|
|
||||||
expect(cursors(editor).length).toBe(0);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an arrow key collapses the set", () => {
|
|
||||||
const editor = makeEditor(doc("foo foo"));
|
|
||||||
editor.commands.setTextSelection(2);
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
expect(cursors(editor).length).toBe(2);
|
|
||||||
pressKey(editor, "ArrowRight");
|
|
||||||
expect(cursors(editor).length).toBe(0);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("multi-cursor: collapse on composition / mousedown", () => {
|
|
||||||
// Invoke a plugin handleDOMEvents handler through the real prop plumbing.
|
|
||||||
function fireDOM(editor: Editor, name: string): void {
|
|
||||||
editor.view.someProp("handleDOMEvents", (handlers: any) => {
|
|
||||||
const h = handlers && handlers[name];
|
|
||||||
if (h) h(editor.view, new Event(name));
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it("collapses the set on compositionstart (IME) — MVP does not multi-IME", () => {
|
|
||||||
const editor = makeEditor(doc("foo foo"));
|
|
||||||
editor.commands.setTextSelection(2);
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
expect(cursors(editor).length).toBe(2);
|
|
||||||
fireDOM(editor, "compositionstart");
|
|
||||||
expect(cursors(editor).length).toBe(0);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collapses the set on a plain mousedown (VS Code behaviour)", () => {
|
|
||||||
const editor = makeEditor(doc("foo foo"));
|
|
||||||
editor.commands.setTextSelection(2);
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
expect(cursors(editor).length).toBe(2);
|
|
||||||
fireDOM(editor, "mousedown");
|
|
||||||
expect(cursors(editor).length).toBe(0);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("multi-cursor: hard cap", () => {
|
|
||||||
it("never activates more than MAX_CURSORS cursors", () => {
|
|
||||||
const many = new Array(MAX_CURSORS + 20).fill("w").join(" ");
|
|
||||||
const editor = makeEditor(doc(many));
|
|
||||||
editor.commands.setTextSelection(2);
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
expect(cursors(editor).length).toBe(MAX_CURSORS);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("multi-cursor: marks are carried across a mass edit", () => {
|
|
||||||
it("preserves marks spanning each replaced range", () => {
|
|
||||||
const editor = makeEditor({
|
|
||||||
type: "doc",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: "a " },
|
|
||||||
{ type: "text", marks: [{ type: "bold" }], text: "key" },
|
|
||||||
{ type: "text", text: " b " },
|
|
||||||
{ type: "text", marks: [{ type: "bold" }], text: "key" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
editor.commands.setTextSelection(3); // inside first bold "key"
|
|
||||||
editor.commands.selectAllOccurrences();
|
|
||||||
expect(cursors(editor).length).toBe(2);
|
|
||||||
typeText(editor, "NEW");
|
|
||||||
|
|
||||||
// Both replacements keep the bold mark.
|
|
||||||
let boldRuns = 0;
|
|
||||||
editor.state.doc.descendants((node) => {
|
|
||||||
if (
|
|
||||||
node.isText &&
|
|
||||||
node.text === "NEW" &&
|
|
||||||
node.marks.some((m) => m.type.name === "bold")
|
|
||||||
) {
|
|
||||||
boldRuns += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(boldRuns).toBe(2);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The extracted find-occurrences util must return the SAME occurrences that the
|
|
||||||
// old inline walk produced (and that search-and-replace still relies on).
|
|
||||||
describe("find-occurrences util", () => {
|
|
||||||
it("finds all matches of a literal regex across text nodes", () => {
|
|
||||||
const editor = makeEditor(doc("foo foofoo foo"));
|
|
||||||
const results = findOccurrences(editor.state.doc, /foo/gu);
|
|
||||||
// 4 occurrences: two standalone + two inside "foofoo".
|
|
||||||
expect(results.length).toBe(4);
|
|
||||||
for (const r of results) {
|
|
||||||
expect(editor.state.doc.textBetween(r.from, r.to)).toBe("foo");
|
|
||||||
}
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores whitespace-only matches and empty regex", () => {
|
|
||||||
const editor = makeEditor(doc("a b c"));
|
|
||||||
expect(findOccurrences(editor.state.doc, null as any).length).toBe(0);
|
|
||||||
// A whitespace regex yields no results (matches are trimmed away).
|
|
||||||
expect(findOccurrences(editor.state.doc, /\s/gu).length).toBe(0);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finds a match spanning two differently-marked contiguous text nodes", () => {
|
|
||||||
const editor = makeEditor({
|
|
||||||
type: "doc",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: "wo" },
|
|
||||||
{ type: "text", marks: [{ type: "bold" }], text: "rd" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const results = findOccurrences(editor.state.doc, /word/gu);
|
|
||||||
expect(results.length).toBe(1);
|
|
||||||
expect(editor.state.doc.textBetween(results[0].from, results[0].to)).toBe(
|
|
||||||
"word",
|
|
||||||
);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,545 +0,0 @@
|
|||||||
import { Extension, Range } from "@tiptap/core";
|
|
||||||
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
|
|
||||||
import {
|
|
||||||
Plugin,
|
|
||||||
PluginKey,
|
|
||||||
TextSelection,
|
|
||||||
type EditorState,
|
|
||||||
} from "@tiptap/pm/state";
|
|
||||||
import { Mark } from "@tiptap/pm/model";
|
|
||||||
import { findOccurrences } from "../search-and-replace/find-occurrences";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Multi-cursor editing — MVP (issue #196, "Variant A").
|
|
||||||
*
|
|
||||||
* VS Code-style multi-cursor limited to "select all occurrences of a word (or
|
|
||||||
* the current selection) and type into all of them at once", built ON TOP OF
|
|
||||||
* the search-and-replace mass-transaction machinery:
|
|
||||||
*
|
|
||||||
* - Cmd/Ctrl+Shift+L (selectAllOccurrences): the word under the cursor (or the
|
|
||||||
* current non-empty selection) -> ALL its occurrences become active cursors.
|
|
||||||
* - Cmd/Ctrl+D (addNextOccurrence): add the NEXT occurrence of the term.
|
|
||||||
* - Typing / Backspace / Delete apply to EVERY active cursor in ONE
|
|
||||||
* transaction (so a single Cmd/Ctrl+Z undoes the whole multi-edit).
|
|
||||||
* - Esc (exitMultiCursor): collapse back to a single cursor.
|
|
||||||
*
|
|
||||||
* The single-transaction, reverse-order edit mechanic mirrors `replaceAll` in
|
|
||||||
* search-and-replace.ts: we iterate cursors from the END of the document to the
|
|
||||||
* START so an earlier edit never invalidates a later position, carrying the
|
|
||||||
* marks that span each range.
|
|
||||||
*
|
|
||||||
* CONSCIOUS v1 OUT-OF-SCOPE BOUNDARIES (these are "Variant B", deliberately NOT
|
|
||||||
* built here):
|
|
||||||
* - Alt+Click arbitrary carets and Alt+drag column selection.
|
|
||||||
* - Cmd/Ctrl+Alt+Up/Down "add cursor on the adjacent line".
|
|
||||||
* - Simultaneous IME / composition input into multiple positions — on
|
|
||||||
* `compositionstart` we collapse back to a single cursor.
|
|
||||||
* - Cursors spanning different schema nodes in one edit.
|
|
||||||
*
|
|
||||||
* NOT out of scope, but worth stating precisely: there is NO schema-aware or
|
|
||||||
* structural cursor. Occurrences are found by a plain text-node walk
|
|
||||||
* (`findOccurrences`), so a term that appears inside a table cell, code block or
|
|
||||||
* callout DOES get a cursor there and IS edited — as plain text, exactly like
|
|
||||||
* `replaceAll`. There is no special table/code handling; the per-cursor try/catch
|
|
||||||
* only SKIPS a cursor whose edit would violate the schema (never applied
|
|
||||||
* half-way), it does not exclude those node types from matching.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface MultiCursorState {
|
|
||||||
// Each active cursor: a caret when from === to, a range when from < to.
|
|
||||||
cursors: Range[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const multiCursorPluginKey = new PluginKey<MultiCursorState>(
|
|
||||||
"multiCursor",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Hard safety cap on simultaneously-active cursors — stop adding past it.
|
|
||||||
export const MAX_CURSORS = 100;
|
|
||||||
|
|
||||||
export interface MultiCursorStorage {
|
|
||||||
// Whether the active term matches whole words only. Set to true when the set
|
|
||||||
// was seeded from a bare cursor (word under caret), false when seeded from an
|
|
||||||
// explicit selection (literal substring match, like VS Code). Remembered so
|
|
||||||
// addNextOccurrence keeps matching the same way as selectAllOccurrences.
|
|
||||||
wholeWord: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
|
||||||
interface Storage {
|
|
||||||
multiCursor: MultiCursorStorage;
|
|
||||||
}
|
|
||||||
interface Commands<ReturnType> {
|
|
||||||
multiCursor: {
|
|
||||||
/** Select all occurrences of the word/selection as active cursors. */
|
|
||||||
selectAllOccurrences: () => ReturnType;
|
|
||||||
/** Add the next occurrence of the current term to the cursor set. */
|
|
||||||
addNextOccurrence: () => ReturnType;
|
|
||||||
/** Collapse the multi-cursor set back to a single cursor. */
|
|
||||||
exitMultiCursor: () => ReturnType;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Term helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function escapeRegExp(s: string): string {
|
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
// A "word" is a run of letters/numbers/underscore; those get whole-word
|
|
||||||
// matching (\b…\b) so a term never matches inside a larger word. Anything else
|
|
||||||
// (punctuation, phrases) is matched literally. Case-sensitive, like VS Code.
|
|
||||||
function isWordTerm(s: string): boolean {
|
|
||||||
return /^[\p{L}\p{N}_]+$/u.test(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
// wholeWord uses \b…\b so the term never matches inside a larger word; it only
|
|
||||||
// applies to word-like terms (a term containing punctuation cannot be
|
|
||||||
// whole-word-bounded meaningfully). Otherwise the term is matched literally.
|
|
||||||
function buildTermRegex(term: string, wholeWord: boolean): RegExp {
|
|
||||||
const esc = escapeRegExp(term);
|
|
||||||
return wholeWord && isWordTerm(term)
|
|
||||||
? new RegExp(`\\b${esc}\\b`, "gu")
|
|
||||||
: new RegExp(esc, "gu");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Word under a position: returns the exact { from, to } range and its text, or
|
|
||||||
// null if the position is not inside a word in a textblock.
|
|
||||||
function getWordAt(
|
|
||||||
state: EditorState,
|
|
||||||
pos: number,
|
|
||||||
): { from: number; to: number; text: string } | null {
|
|
||||||
const $pos = state.doc.resolve(pos);
|
|
||||||
const parent = $pos.parent;
|
|
||||||
if (!parent.isTextblock) return null;
|
|
||||||
|
|
||||||
const text = parent.textContent;
|
|
||||||
const offset = $pos.parentOffset;
|
|
||||||
const start = $pos.start();
|
|
||||||
const wordRe = /[\p{L}\p{N}_]+/gu;
|
|
||||||
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
while ((m = wordRe.exec(text)) !== null) {
|
|
||||||
const s = m.index;
|
|
||||||
const e = m.index + m[0].length;
|
|
||||||
if (offset >= s && offset <= e) {
|
|
||||||
return { from: start + s, to: start + e, text: m[0] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Plugin-state access
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function getCursors(state: EditorState): Range[] {
|
|
||||||
const st = multiCursorPluginKey.getState(state);
|
|
||||||
return st ? st.cursors : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCursors(view: EditorView, cursors: Range[]): void {
|
|
||||||
view.dispatch(view.state.tr.setMeta(multiCursorPluginKey, cursors));
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapse(view: EditorView): void {
|
|
||||||
setCursors(view, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// The single-transaction, reverse-order mass edit (mirrors replaceAll)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface EditOp {
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
// Text to insert at `from` after deleting [from, to); "" for a pure delete.
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply one edit per cursor in ONE transaction. Ops are processed from the END
|
|
||||||
* of the document to the START so an earlier edit never shifts a later position
|
|
||||||
* (mirrors `replaceAll`). Each cursor is wrapped independently: a schema
|
|
||||||
* violation SKIPS that one cursor instead of throwing away the whole
|
|
||||||
* transaction, so the document is never left half-applied.
|
|
||||||
*
|
|
||||||
* After building the transaction the new cursor positions are recomputed by
|
|
||||||
* mapping each op's original anchor through `tr.mapping` (which also remaps any
|
|
||||||
* concurrent changes), so carets land right after their inserted text.
|
|
||||||
*/
|
|
||||||
function dispatchMassEdit(view: EditorView, ops: EditOp[]): boolean {
|
|
||||||
if (!ops.length) return false;
|
|
||||||
|
|
||||||
const { state } = view;
|
|
||||||
const tr = state.tr;
|
|
||||||
const schema = state.schema;
|
|
||||||
|
|
||||||
// Ascending by `from`; iterate reverse so earlier positions stay valid.
|
|
||||||
const sorted = [...ops].sort((a, b) => a.from - b.from);
|
|
||||||
const appliedLen: number[] = new Array(sorted.length).fill(0);
|
|
||||||
|
|
||||||
for (let i = sorted.length - 1; i >= 0; i -= 1) {
|
|
||||||
const { from, to, text } = sorted[i];
|
|
||||||
try {
|
|
||||||
let marks: readonly Mark[] = [];
|
|
||||||
if (text) {
|
|
||||||
if (to > from) {
|
|
||||||
// Carry all marks spanning the replaced range.
|
|
||||||
const set = new Set<Mark>();
|
|
||||||
tr.doc.nodesBetween(from, to, (node) => {
|
|
||||||
if (node.isText && node.marks) {
|
|
||||||
node.marks.forEach((mk) => set.add(mk));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
marks = Array.from(set);
|
|
||||||
} else {
|
|
||||||
// Caret: continue the marks active at the insertion point.
|
|
||||||
marks = state.storedMarks || state.doc.resolve(from).marks();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ONE atomic step per cursor: replaceWith covers both insert (from === to)
|
|
||||||
// and replace (to > from); a pure delete (empty text) uses delete. This
|
|
||||||
// can never leave a cursor half-applied (deleted but not re-inserted) the
|
|
||||||
// way a separate delete-then-insert pair could if the insert step threw.
|
|
||||||
if (text) {
|
|
||||||
tr.replaceWith(from, to, schema.text(text, marks as Mark[]));
|
|
||||||
} else if (to > from) {
|
|
||||||
tr.delete(from, to);
|
|
||||||
}
|
|
||||||
|
|
||||||
appliedLen[i] = text.length;
|
|
||||||
} catch {
|
|
||||||
// Per-cursor backstop (text-only MVP): drop this cursor's edit, keep the
|
|
||||||
// rest of the transaction intact.
|
|
||||||
appliedLen[i] = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tr.docChanged) return false;
|
|
||||||
|
|
||||||
// Recompute cursor carets from the ORIGINAL op anchors through the full map.
|
|
||||||
const newCursors: Range[] = sorted.map((op, i) => {
|
|
||||||
const start = tr.mapping.map(op.from, -1);
|
|
||||||
const caret = start + appliedLen[i];
|
|
||||||
return { from: caret, to: caret };
|
|
||||||
});
|
|
||||||
|
|
||||||
tr.setMeta(multiCursorPluginKey, newCursors);
|
|
||||||
|
|
||||||
// Park the native selection on the last caret so the browser draws exactly
|
|
||||||
// one real caret; the rest are our decoration widgets.
|
|
||||||
const last = newCursors[newCursors.length - 1];
|
|
||||||
tr.setSelection(TextSelection.create(tr.doc, last.from));
|
|
||||||
|
|
||||||
view.dispatch(tr);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDeleteOps(
|
|
||||||
state: EditorState,
|
|
||||||
cursors: Range[],
|
|
||||||
forward: boolean,
|
|
||||||
): EditOp[] {
|
|
||||||
return cursors.map((c) => {
|
|
||||||
// A selected range: Backspace/Delete removes the whole range.
|
|
||||||
if (c.to > c.from) return { from: c.from, to: c.to, text: "" };
|
|
||||||
|
|
||||||
const $pos = state.doc.resolve(c.from);
|
|
||||||
if (forward) {
|
|
||||||
// Delete: at the end of a textblock there is nothing to remove (a no-op;
|
|
||||||
// MVP does not merge blocks across a multi-cursor set).
|
|
||||||
if ($pos.parentOffset >= $pos.parent.content.size) {
|
|
||||||
return { from: c.from, to: c.from, text: "" };
|
|
||||||
}
|
|
||||||
return { from: c.from, to: c.from + 1, text: "" };
|
|
||||||
}
|
|
||||||
// Backspace: at the start of a textblock there is nothing to remove.
|
|
||||||
if ($pos.parentOffset <= 0) {
|
|
||||||
return { from: c.from, to: c.from, text: "" };
|
|
||||||
}
|
|
||||||
return { from: c.from - 1, to: c.from, text: "" };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Extension
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const MultiCursor = Extension.create<unknown, MultiCursorStorage>({
|
|
||||||
name: "multiCursor",
|
|
||||||
|
|
||||||
addStorage() {
|
|
||||||
return { wholeWord: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
addCommands() {
|
|
||||||
return {
|
|
||||||
selectAllOccurrences:
|
|
||||||
() =>
|
|
||||||
({ editor, state, tr, dispatch }) => {
|
|
||||||
let term: string;
|
|
||||||
// A bare cursor expands to the whole word; an explicit selection is
|
|
||||||
// matched literally (VS Code semantics).
|
|
||||||
const wholeWord = state.selection.empty;
|
|
||||||
if (wholeWord) {
|
|
||||||
const word = getWordAt(state, state.selection.from);
|
|
||||||
if (!word) return false;
|
|
||||||
term = word.text;
|
|
||||||
} else {
|
|
||||||
term = state.doc.textBetween(
|
|
||||||
state.selection.from,
|
|
||||||
state.selection.to,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!term.trim()) return false;
|
|
||||||
editor.storage.multiCursor.wholeWord = wholeWord;
|
|
||||||
|
|
||||||
const results = findOccurrences(
|
|
||||||
state.doc,
|
|
||||||
buildTermRegex(term, wholeWord),
|
|
||||||
).slice(0, MAX_CURSORS);
|
|
||||||
if (!results.length) return false;
|
|
||||||
|
|
||||||
if (dispatch) {
|
|
||||||
tr.setMeta(multiCursorPluginKey, results);
|
|
||||||
const last = results[results.length - 1];
|
|
||||||
tr.setSelection(TextSelection.create(tr.doc, last.from, last.to));
|
|
||||||
dispatch(tr);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
addNextOccurrence:
|
|
||||||
() =>
|
|
||||||
({ editor, state, tr, dispatch }) => {
|
|
||||||
const existing = getCursors(state);
|
|
||||||
let cursors: Range[];
|
|
||||||
|
|
||||||
if (!existing.length) {
|
|
||||||
// First press: turn the current word/selection into the one cursor.
|
|
||||||
let range: Range;
|
|
||||||
const wholeWord = state.selection.empty;
|
|
||||||
if (wholeWord) {
|
|
||||||
const word = getWordAt(state, state.selection.from);
|
|
||||||
if (!word) return false;
|
|
||||||
range = { from: word.from, to: word.to };
|
|
||||||
} else {
|
|
||||||
range = { from: state.selection.from, to: state.selection.to };
|
|
||||||
}
|
|
||||||
editor.storage.multiCursor.wholeWord = wholeWord;
|
|
||||||
cursors = [range];
|
|
||||||
} else {
|
|
||||||
// Subsequent press: add the next unselected occurrence of the term,
|
|
||||||
// matched the SAME way (whole-word vs literal) the set was seeded.
|
|
||||||
if (existing.length >= MAX_CURSORS) return true;
|
|
||||||
|
|
||||||
const first = existing[0];
|
|
||||||
const term = state.doc.textBetween(first.from, first.to);
|
|
||||||
if (!term.trim()) return false;
|
|
||||||
|
|
||||||
const results = findOccurrences(
|
|
||||||
state.doc,
|
|
||||||
buildTermRegex(term, editor.storage.multiCursor.wholeWord),
|
|
||||||
);
|
|
||||||
const keys = new Set(existing.map((c) => `${c.from}:${c.to}`));
|
|
||||||
const notSelected = results.filter(
|
|
||||||
(r) => !keys.has(`${r.from}:${r.to}`),
|
|
||||||
);
|
|
||||||
if (!notSelected.length) return true; // all occurrences selected
|
|
||||||
|
|
||||||
const maxTo = Math.max(...existing.map((c) => c.to));
|
|
||||||
const next =
|
|
||||||
notSelected.find((r) => r.from >= maxTo) || notSelected[0];
|
|
||||||
cursors = [...existing, next];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dispatch) {
|
|
||||||
tr.setMeta(multiCursorPluginKey, cursors);
|
|
||||||
const last = cursors[cursors.length - 1];
|
|
||||||
tr.setSelection(TextSelection.create(tr.doc, last.from, last.to));
|
|
||||||
dispatch(tr);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
exitMultiCursor:
|
|
||||||
() =>
|
|
||||||
({ tr, dispatch }) => {
|
|
||||||
if (dispatch) {
|
|
||||||
tr.setMeta(multiCursorPluginKey, []);
|
|
||||||
dispatch(tr);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addKeyboardShortcuts() {
|
|
||||||
return {
|
|
||||||
"Mod-Shift-l": () => {
|
|
||||||
this.editor.commands.selectAllOccurrences();
|
|
||||||
// Always consume so the browser's default is prevented.
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
"Mod-d": () => {
|
|
||||||
this.editor.commands.addNextOccurrence();
|
|
||||||
// Consume unconditionally to prevent the browser's Cmd/Ctrl+D bookmark.
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
Escape: () => {
|
|
||||||
// Only swallow Escape while a multi-cursor set is active; otherwise let
|
|
||||||
// Escape keep its other behaviours (e.g. closing dialogs).
|
|
||||||
if (!getCursors(this.editor.state).length) return false;
|
|
||||||
return this.editor.commands.exitMultiCursor();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [
|
|
||||||
new Plugin<MultiCursorState>({
|
|
||||||
key: multiCursorPluginKey,
|
|
||||||
|
|
||||||
state: {
|
|
||||||
init: () => ({ cursors: [] }),
|
|
||||||
apply(tr, value): MultiCursorState {
|
|
||||||
// A command (or a mass edit) can set/clear the cursor set directly.
|
|
||||||
// Its cursors are already in the post-transaction coordinate space,
|
|
||||||
// so they take priority over remapping.
|
|
||||||
const meta = tr.getMeta(multiCursorPluginKey) as
|
|
||||||
| Range[]
|
|
||||||
| undefined;
|
|
||||||
if (meta !== undefined) {
|
|
||||||
return { cursors: meta.slice(0, MAX_CURSORS) };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value.cursors.length) return value;
|
|
||||||
|
|
||||||
// Remap surviving cursors across ANY doc change — this covers both
|
|
||||||
// local edits and REMOTE Yjs edits (y-prosemirror applies remote
|
|
||||||
// changes as ordinary transactions, so mapping them here keeps every
|
|
||||||
// multi-cursor correctly positioned without special-casing collab).
|
|
||||||
if (tr.docChanged) {
|
|
||||||
// Map both edges with the SAME association (+1) so content
|
|
||||||
// inserted at a boundary shifts the whole cursor right and a caret
|
|
||||||
// (from === to) can never invert into a range.
|
|
||||||
const cursors = value.cursors.map((c) => ({
|
|
||||||
from: tr.mapping.map(c.from, 1),
|
|
||||||
to: tr.mapping.map(c.to, 1),
|
|
||||||
}));
|
|
||||||
return { cursors };
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
|
||||||
decorations(state) {
|
|
||||||
const st = multiCursorPluginKey.getState(state);
|
|
||||||
if (!st || !st.cursors.length) return DecorationSet.empty;
|
|
||||||
|
|
||||||
const decorations: Decoration[] = [];
|
|
||||||
st.cursors.forEach((c, i) => {
|
|
||||||
if (c.from === c.to) {
|
|
||||||
decorations.push(
|
|
||||||
Decoration.widget(
|
|
||||||
c.from,
|
|
||||||
() => {
|
|
||||||
const el = document.createElement("span");
|
|
||||||
el.className = "multi-cursor__caret";
|
|
||||||
return el;
|
|
||||||
},
|
|
||||||
{ side: 0, key: `mc-caret-${i}` },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
decorations.push(
|
|
||||||
Decoration.inline(c.from, c.to, {
|
|
||||||
class: "multi-cursor__selection",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return DecorationSet.create(state.doc, decorations);
|
|
||||||
},
|
|
||||||
|
|
||||||
handleTextInput(view, _from, _to, text) {
|
|
||||||
const cursors = getCursors(view.state);
|
|
||||||
if (!cursors.length) return false;
|
|
||||||
|
|
||||||
// Insert `text` at EVERY cursor in one transaction. Returning true
|
|
||||||
// prevents ProseMirror's own single-position insert at the native
|
|
||||||
// selection, so there is no double-insert there.
|
|
||||||
const ops = cursors.map((c) => ({
|
|
||||||
from: c.from,
|
|
||||||
to: c.to,
|
|
||||||
text,
|
|
||||||
}));
|
|
||||||
return dispatchMassEdit(view, ops);
|
|
||||||
},
|
|
||||||
|
|
||||||
handleKeyDown(view, event) {
|
|
||||||
const cursors = getCursors(view.state);
|
|
||||||
if (!cursors.length) return false;
|
|
||||||
|
|
||||||
if (event.key === "Backspace") {
|
|
||||||
dispatchMassEdit(view, buildDeleteOps(view.state, cursors, false));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (event.key === "Delete") {
|
|
||||||
dispatchMassEdit(view, buildDeleteOps(view.state, cursors, true));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let modifier combinations (our own shortcuts, copy, etc.) through
|
|
||||||
// WITHOUT collapsing the set.
|
|
||||||
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
|
||||||
|
|
||||||
// Navigation / block keys collapse back to a single cursor, then let
|
|
||||||
// ProseMirror handle the movement on the native selection.
|
|
||||||
const COLLAPSE_KEYS = [
|
|
||||||
"ArrowLeft",
|
|
||||||
"ArrowRight",
|
|
||||||
"ArrowUp",
|
|
||||||
"ArrowDown",
|
|
||||||
"Home",
|
|
||||||
"End",
|
|
||||||
"PageUp",
|
|
||||||
"PageDown",
|
|
||||||
"Enter",
|
|
||||||
"Tab",
|
|
||||||
];
|
|
||||||
if (COLLAPSE_KEYS.includes(event.key)) {
|
|
||||||
collapse(view);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
|
|
||||||
handleDOMEvents: {
|
|
||||||
// A plain click exits multi-cursor (VS Code behaviour).
|
|
||||||
mousedown: (view) => {
|
|
||||||
if (getCursors(view.state).length) collapse(view);
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
// MVP does not drive multi-position IME — collapse on composition.
|
|
||||||
compositionstart: (view) => {
|
|
||||||
if (getCursors(view.state).length) collapse(view);
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default MultiCursor;
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { Range } from "@tiptap/core";
|
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
|
||||||
|
|
||||||
interface TextNodesWithPosition {
|
|
||||||
text: string;
|
|
||||||
pos: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared "find all occurrences of a term in the doc" primitive.
|
|
||||||
*
|
|
||||||
* Walks every text node of the document and returns each regex match as a
|
|
||||||
* `{ from, to }` range. Contiguous text nodes (which may differ only by marks)
|
|
||||||
* are concatenated into a single run, so a match that spans e.g. "wo" + bold
|
|
||||||
* "rd" is still found; runs are split by any non-text node, so a match never
|
|
||||||
* crosses a node boundary. Whitespace-only matches are ignored.
|
|
||||||
*
|
|
||||||
* This is used by BOTH search-and-replace (highlight/replace) and multi-cursor
|
|
||||||
* (turn occurrences into active cursors) so the two stay behaviourally in sync.
|
|
||||||
* Extracted verbatim from the original `processSearches` walk.
|
|
||||||
*/
|
|
||||||
export function findOccurrences(doc: PMNode, searchTerm: RegExp): Range[] {
|
|
||||||
const results: Range[] = [];
|
|
||||||
|
|
||||||
if (!searchTerm) return results;
|
|
||||||
|
|
||||||
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
doc?.descendants((node, pos) => {
|
|
||||||
if (node.isText) {
|
|
||||||
if (textNodesWithPosition[index]) {
|
|
||||||
textNodesWithPosition[index] = {
|
|
||||||
text: textNodesWithPosition[index].text + node.text,
|
|
||||||
pos: textNodesWithPosition[index].pos,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
textNodesWithPosition[index] = {
|
|
||||||
text: `${node.text}`,
|
|
||||||
pos,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
|
|
||||||
|
|
||||||
for (const element of textNodesWithPosition) {
|
|
||||||
const { text, pos } = element;
|
|
||||||
const matches = Array.from(text.matchAll(searchTerm)).filter(
|
|
||||||
([matchText]) => matchText.trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const m of matches) {
|
|
||||||
if (m[0] === "") break;
|
|
||||||
|
|
||||||
if (m.index !== undefined) {
|
|
||||||
results.push({
|
|
||||||
from: pos + m.index,
|
|
||||||
to: pos + m.index + m[0].length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { SearchAndReplace } from './search-and-replace'
|
import { SearchAndReplace } from './search-and-replace'
|
||||||
export * from './search-and-replace'
|
export * from './search-and-replace'
|
||||||
export * from './find-occurrences'
|
|
||||||
export default SearchAndReplace
|
export default SearchAndReplace
|
||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
type Transaction,
|
type Transaction,
|
||||||
} from "@tiptap/pm/state";
|
} from "@tiptap/pm/state";
|
||||||
import { Node as PMNode, Mark } from "@tiptap/pm/model";
|
import { Node as PMNode, Mark } from "@tiptap/pm/model";
|
||||||
import { findOccurrences } from "./find-occurrences";
|
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Storage {
|
interface Storage {
|
||||||
@@ -77,6 +76,11 @@ declare module "@tiptap/core" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TextNodesWithPosition {
|
||||||
|
text: string;
|
||||||
|
pos: number;
|
||||||
|
}
|
||||||
|
|
||||||
const getRegex = (
|
const getRegex = (
|
||||||
s: string,
|
s: string,
|
||||||
disableRegex: boolean,
|
disableRegex: boolean,
|
||||||
@@ -100,6 +104,10 @@ function processSearches(
|
|||||||
resultIndex: number,
|
resultIndex: number,
|
||||||
): ProcessedSearches {
|
): ProcessedSearches {
|
||||||
const decorations: Decoration[] = [];
|
const decorations: Decoration[] = [];
|
||||||
|
const results: Range[] = [];
|
||||||
|
|
||||||
|
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
if (!searchTerm) {
|
if (!searchTerm) {
|
||||||
return {
|
return {
|
||||||
@@ -108,8 +116,43 @@ function processSearches(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared find-all-occurrences primitive (also used by multi-cursor).
|
doc?.descendants((node, pos) => {
|
||||||
const results: Range[] = findOccurrences(doc, searchTerm);
|
if (node.isText) {
|
||||||
|
if (textNodesWithPosition[index]) {
|
||||||
|
textNodesWithPosition[index] = {
|
||||||
|
text: textNodesWithPosition[index].text + node.text,
|
||||||
|
pos: textNodesWithPosition[index].pos,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
textNodesWithPosition[index] = {
|
||||||
|
text: `${node.text}`,
|
||||||
|
pos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
|
||||||
|
|
||||||
|
for (const element of textNodesWithPosition) {
|
||||||
|
const { text, pos } = element;
|
||||||
|
const matches = Array.from(text.matchAll(searchTerm)).filter(
|
||||||
|
([matchText]) => matchText.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const m of matches) {
|
||||||
|
if (m[0] === "") break;
|
||||||
|
|
||||||
|
if (m.index !== undefined) {
|
||||||
|
results.push({
|
||||||
|
from: pos + m.index,
|
||||||
|
to: pos + m.index + m[0].length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < results.length; i += 1) {
|
for (let i = 0; i < results.length; i += 1) {
|
||||||
const r = results[i];
|
const r = results[i];
|
||||||
|
|||||||
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