1bcc96685e
Outside the editor the UI did background work on every tree event, socket reconnect, and navigation. Tree infra (virtualization/memo/O(N) utils) was already good — the cost was in the subscriptions and duplicates around it. Client-only; behavior 1:1. - Setter-only atom subscriptions → useSetAtom: space-tree-row, use-tree-mutation, use-tree-socket no longer subscribe every visible row to the WHOLE treeDataAtom value (a tree event re-rendered all ~20-30 rows, bypassing the DocTreeRow memo). space-tree-node-menu / mention-list read the tree imperatively (store.get) in their handlers only. breadcrumb.tsx uses a selectAtom slice (ancestor chain + field equality) instead of the whole-tree subscription. - Socket handler cleanup (BUG): use-tree-socket + use-query-subscription now socket.off() their named handlers on cleanup (were accumulating listeners on every reconnect → duplicated invalidations/tree-walks). Mirrors use-notification-socket. - Field-update tree path: invalidateOnUpdatePage does a pointwise patch of the cached embed subtrees instead of a blanket invalidatePageTree() (refetch storm); structural events keep the blanket invalidate. - usePageMetaQuery: a content-less select slice for the 13 peripheral subscribers that read only title/permissions/id, so they stop re-rendering every ~3s while typing / on every collab page.updated (page.tsx keeps the full query for content). - page.tsx: skeleton + placeholderData keepPreviousData (no blank flash on nav). - Removed refetchOnMount:true where socket/mutation invalidation already keeps the cache fresh (favorite/space/space-watcher/workspace). KEPT it on the 3 queries with NO other freshness path (trash-list, created-by, recent-changes) — the global default is refetchOnMount:false, so those overrides are load-bearing. - Small: resize mousemove/up attached only while dragging; per-row emoji-picker keydown gated on `opened`; AiChatWindow queries enabled only when the window is open. Gate: client tsc 0, client vitest page+websocket 200 passed (+editor suites), build ok. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
import { useSetAtom, useStore } from "jotai";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useParams } from "react-router-dom";
|
|
import { ActionIcon, Menu, rem } from "@mantine/core";
|
|
import { useDisclosure } from "@mantine/hooks";
|
|
import { notifications } from "@mantine/notifications";
|
|
import {
|
|
IconArrowRight,
|
|
IconClockHour4,
|
|
IconCopy,
|
|
IconDotsVertical,
|
|
IconFileExport,
|
|
IconLink,
|
|
IconStar,
|
|
IconStarFilled,
|
|
IconTemplate,
|
|
IconTrash,
|
|
} from "@tabler/icons-react";
|
|
|
|
import ExportModal from "@/components/common/export-modal";
|
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
|
import CopyPageModal from "@/features/page/components/copy-page-modal.tsx";
|
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
import { duplicatePage } from "@/features/page/services/page-service.ts";
|
|
import { useClipboard } from "@/hooks/use-clipboard";
|
|
import { getAppUrl } from "@/lib/config.ts";
|
|
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
|
import {
|
|
useFavoriteIds,
|
|
useAddFavoriteMutation,
|
|
useRemoveFavoriteMutation,
|
|
} from "@/features/favorite/queries/favorite-query";
|
|
|
|
import {
|
|
useToggleTemplateMutation,
|
|
useToggleTemporaryMutation,
|
|
} from "@/features/page-embed/queries/page-embed-query";
|
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
|
import { pageToTreeNode } from "@/features/page/tree/utils";
|
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
|
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
|
import classes from "@/features/page/tree/styles/tree.module.css";
|
|
|
|
export interface NodeMenuProps {
|
|
node: SpaceTreeNode;
|
|
canEdit: boolean;
|
|
}
|
|
|
|
export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|
const { t } = useTranslation();
|
|
const clipboard = useClipboard({ timeout: 500 });
|
|
const { spaceSlug } = useParams();
|
|
const { handleDelete } = useTreeMutation(node.spaceId);
|
|
// Setter-only: the tree value is read only imperatively inside the duplicate
|
|
// handler (via `store` below), never at render, so useSetAtom avoids
|
|
// re-rendering every row's NodeMenu on any tree event.
|
|
const setData = useSetAtom(treeDataAtom);
|
|
const store = useStore();
|
|
const emit = useQueryEmit();
|
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
|
useDisclosure(false);
|
|
const [
|
|
movePageModalOpened,
|
|
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
|
] = useDisclosure(false);
|
|
const [
|
|
copyPageModalOpened,
|
|
{ open: openCopyPageModal, close: closeCopySpaceModal },
|
|
] = useDisclosure(false);
|
|
const favoriteIds = useFavoriteIds("page", node.spaceId);
|
|
const addFavorite = useAddFavoriteMutation();
|
|
const removeFavorite = useRemoveFavoriteMutation();
|
|
const isFavorited = favoriteIds.has(node.id);
|
|
const toggleTemplate = useToggleTemplateMutation();
|
|
const isTemplate = !!node.isTemplate;
|
|
const toggleTemporary = useToggleTemporaryMutation();
|
|
const isTemporary = !!node.temporaryExpiresAt;
|
|
|
|
const handleToggleTemplate = async () => {
|
|
const next = !isTemplate;
|
|
try {
|
|
await toggleTemplate.mutateAsync({ pageId: node.id, isTemplate: next });
|
|
// Reflect the new flag locally so the menu label updates immediately.
|
|
setData((prev) =>
|
|
treeModel.update(prev, node.id, { isTemplate: next } as any),
|
|
);
|
|
notifications.show({
|
|
message: next
|
|
? t("Page marked as template")
|
|
: t("Page is no longer a template"),
|
|
});
|
|
} catch {
|
|
// mutation surfaces the error via notifications
|
|
}
|
|
};
|
|
|
|
const handleToggleTemporary = async () => {
|
|
const next = !isTemporary;
|
|
try {
|
|
const res = await toggleTemporary.mutateAsync({
|
|
pageId: node.id,
|
|
temporary: next,
|
|
});
|
|
// Reflect the new deadline locally so the icon/menu update immediately.
|
|
setData((prev) =>
|
|
treeModel.update(prev, node.id, {
|
|
temporaryExpiresAt: res.temporaryExpiresAt,
|
|
} as any),
|
|
);
|
|
notifications.show({
|
|
message: next
|
|
? t("Note will move to trash unless made permanent")
|
|
: t("Note is now permanent"),
|
|
});
|
|
} catch {
|
|
// mutation surfaces the error via notifications
|
|
}
|
|
};
|
|
|
|
const handleCopyLink = () => {
|
|
const pageUrl =
|
|
getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name);
|
|
clipboard.copy(pageUrl);
|
|
notifications.show({ message: t("Link copied") });
|
|
};
|
|
|
|
const handleDuplicatePage = async () => {
|
|
try {
|
|
const duplicatedPage = await duplicatePage({ pageId: node.id });
|
|
|
|
// figure out parent + insertion index (read the live tree imperatively)
|
|
const siblings = treeModel.siblingsOf(store.get(treeDataAtom), node.id);
|
|
const parentId = siblings?.parentId ?? null;
|
|
const currentIndex = siblings?.index ?? 0;
|
|
const newIndex = currentIndex + 1;
|
|
|
|
// Routed through the canonical mapper so the field copy stays in lockstep
|
|
// with buildTree. The server does NOT arm a death timer on duplicate (the
|
|
// copy's `temporaryExpiresAt` defaults to null = permanent), so the mapper
|
|
// carries that null through and the duplicated node correctly shows no
|
|
// clock marker — matching the server without a reload.
|
|
const treeNodeData: SpaceTreeNode = pageToTreeNode(duplicatedPage, {
|
|
canEdit: true,
|
|
});
|
|
|
|
setData((prev) =>
|
|
treeModel.insert(prev, parentId, treeNodeData, newIndex),
|
|
);
|
|
|
|
setTimeout(() => {
|
|
emit({
|
|
operation: "addTreeNode",
|
|
spaceId: node.spaceId,
|
|
payload: {
|
|
parentId,
|
|
index: newIndex,
|
|
data: treeNodeData,
|
|
},
|
|
});
|
|
}, 50);
|
|
|
|
notifications.show({ message: t("Page duplicated successfully") });
|
|
} catch (err: any) {
|
|
notifications.show({
|
|
message: err?.response?.data?.message || "An error occurred",
|
|
color: "red",
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Menu shadow="md" width={200}>
|
|
<Menu.Target>
|
|
<ActionIcon
|
|
size={20}
|
|
variant="subtle"
|
|
color="gray"
|
|
className={classes.actionIcon}
|
|
aria-label={t("Page menu for {{name}}", { name: node.name || t("Untitled") })}
|
|
tabIndex={-1}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
<IconDotsVertical
|
|
style={{ width: rem(20), height: rem(20) }}
|
|
stroke={2}
|
|
/>
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
|
|
<Menu.Dropdown>
|
|
<Menu.Item
|
|
leftSection={<IconLink size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleCopyLink();
|
|
}}
|
|
>
|
|
{t("Copy link")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={
|
|
isFavorited ? <IconStarFilled size={16} /> : <IconStar size={16} />
|
|
}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (isFavorited) {
|
|
removeFavorite.mutate({ type: "page", pageId: node.id });
|
|
} else {
|
|
addFavorite.mutate({ type: "page", pageId: node.id });
|
|
}
|
|
}}
|
|
>
|
|
{isFavorited ? t("Remove from favorites") : t("Add to favorites")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconFileExport size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openExportModal();
|
|
}}
|
|
>
|
|
{t("Export")}
|
|
</Menu.Item>
|
|
|
|
{canEdit && (
|
|
<>
|
|
<Menu.Item
|
|
leftSection={<IconCopy size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleDuplicatePage();
|
|
}}
|
|
>
|
|
{t("Duplicate")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconArrowRight size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openMovePageModal();
|
|
}}
|
|
>
|
|
{t("Move to space")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconCopy size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openCopyPageModal();
|
|
}}
|
|
>
|
|
{t("Copy to space")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconTemplate size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleToggleTemplate();
|
|
}}
|
|
>
|
|
{isTemplate ? t("Unset as template") : t("Make template")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconClockHour4 size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleToggleTemporary();
|
|
}}
|
|
>
|
|
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
|
</Menu.Item>
|
|
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
c="red"
|
|
leftSection={<IconTrash size={16} />}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleDelete(node.id);
|
|
}}
|
|
>
|
|
{t("Move to trash")}
|
|
</Menu.Item>
|
|
</>
|
|
)}
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
|
|
<MovePageModal
|
|
pageId={node.id}
|
|
slugId={node.slugId}
|
|
currentSpaceSlug={spaceSlug}
|
|
onClose={closeMoveSpaceModal}
|
|
open={movePageModalOpened}
|
|
/>
|
|
|
|
<CopyPageModal
|
|
pageId={node.id}
|
|
currentSpaceSlug={spaceSlug}
|
|
onClose={closeCopySpaceModal}
|
|
open={copyPageModalOpened}
|
|
/>
|
|
|
|
<ExportModal
|
|
type="page"
|
|
id={node.id}
|
|
open={exportOpened}
|
|
onClose={closeExportModal}
|
|
/>
|
|
</>
|
|
);
|
|
}
|