feat(editor): recursive tree mode for the subpages node (#150) #155
@@ -1300,5 +1300,12 @@
|
|||||||
"Go to login page": "Go to login page",
|
"Go to login page": "Go to login page",
|
||||||
"Move to space": "Move to space",
|
"Move to space": "Move to space",
|
||||||
"Float left (wrap text)": "Float left (wrap text)",
|
"Float left (wrap text)": "Float left (wrap text)",
|
||||||
"Float right (wrap text)": "Float right (wrap text)"
|
"Float right (wrap text)": "Float right (wrap text)",
|
||||||
|
"Switch to tree": "Switch to tree",
|
||||||
|
"Switch to flat list": "Switch to flat list",
|
||||||
|
"Toggle subpages display mode": "Toggle subpages display mode",
|
||||||
|
"Page tree (child pages, recursive)": "Page tree (child pages, recursive)",
|
||||||
|
"Render the full nested tree of all descendant pages": "Render the full nested tree of all descendant pages",
|
||||||
|
"Showing {{count}} subpages_one": "Showing {{count}} subpage",
|
||||||
|
"Showing {{count}} subpages_other": "Showing {{count}} subpages"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1152,5 +1152,13 @@
|
|||||||
"Auto-detect": "Автоопределение",
|
"Auto-detect": "Автоопределение",
|
||||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||||
"Float left (wrap text)": "Обтекание слева",
|
"Float left (wrap text)": "Обтекание слева",
|
||||||
"Float right (wrap text)": "Обтекание справа"
|
"Float right (wrap text)": "Обтекание справа",
|
||||||
|
"Switch to tree": "Переключить на дерево",
|
||||||
|
"Switch to flat list": "Переключить на плоский список",
|
||||||
|
"Toggle subpages display mode": "Переключить режим отображения подстраниц",
|
||||||
|
"Page tree (child pages, recursive)": "Дерево страниц (дочерние, рекурсивно)",
|
||||||
|
"Render the full nested tree of all descendant pages": "Показать полное вложенное дерево всех дочерних страниц",
|
||||||
|
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
||||||
|
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
||||||
|
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -524,6 +524,29 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Page tree (child pages, recursive)",
|
||||||
|
description: "Render the full nested tree of all descendant pages",
|
||||||
|
searchTerms: [
|
||||||
|
"subpages",
|
||||||
|
"child",
|
||||||
|
"children",
|
||||||
|
"nested",
|
||||||
|
"hierarchy",
|
||||||
|
"tree",
|
||||||
|
"recursive",
|
||||||
|
"toc",
|
||||||
|
],
|
||||||
|
icon: IconSitemap,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertSubpages({ recursive: true })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Synced block",
|
title: "Synced block",
|
||||||
description: "Create a block that stays in sync across pages.",
|
description: "Create a block that stays in sync across pages.",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
import { posToDOMRect, findParentNode, useEditorState } from "@tiptap/react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Group, Tooltip } from "@mantine/core";
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash, IconList, IconSitemap } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { isEditorReady } from "@docmost/editor-ext";
|
import { isEditorReady } from "@docmost/editor-ext";
|
||||||
@@ -47,6 +47,13 @@ export const SubpagesMenu = React.memo(
|
|||||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
const toggleRecursive = useCallback(() => {
|
||||||
|
const current = editor.getAttributes("subpages")?.recursive ?? false;
|
||||||
|
editor.commands.updateAttributes("subpages", {
|
||||||
|
recursive: !current,
|
||||||
|
});
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
const deleteNode = useCallback(() => {
|
const deleteNode = useCallback(() => {
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
editor
|
editor
|
||||||
@@ -57,6 +64,15 @@ export const SubpagesMenu = React.memo(
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
// Subscribe to the live `recursive` attribute the standard way (as the
|
||||||
|
// sibling bubble menus do): useEditorState re-renders only when the selected
|
||||||
|
// value actually changes, so the mode icon/tooltip stay current after a
|
||||||
|
// toggle without re-rendering on every keystroke.
|
||||||
|
const isRecursive = useEditorState({
|
||||||
|
editor,
|
||||||
|
selector: (ctx) => ctx.editor?.getAttributes("subpages")?.recursive ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@@ -64,17 +80,41 @@ export const SubpagesMenu = React.memo(
|
|||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<Tooltip position="top" label={t("Delete")}>
|
<Group gap={4} wrap="nowrap">
|
||||||
<ActionIcon
|
<Tooltip
|
||||||
onClick={deleteNode}
|
position="top"
|
||||||
variant="default"
|
label={
|
||||||
size="lg"
|
isRecursive
|
||||||
color="red"
|
? t("Switch to flat list")
|
||||||
aria-label={t("Delete")}
|
: t("Switch to tree")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<IconTrash size={18} />
|
<ActionIcon
|
||||||
</ActionIcon>
|
onClick={toggleRecursive}
|
||||||
</Tooltip>
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Toggle subpages display mode")}
|
||||||
|
>
|
||||||
|
{isRecursive ? (
|
||||||
|
<IconList size={18} />
|
||||||
|
) : (
|
||||||
|
<IconSitemap size={18} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={deleteNode}
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
color="red"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
|
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
|
||||||
import { IconFileDescription } from "@tabler/icons-react";
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
|
import {
|
||||||
|
useGetSidebarPagesQuery,
|
||||||
|
useGetPageTreeQuery,
|
||||||
|
} from "@/features/page/queries/page-query";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import classes from "./subpages.module.css";
|
import classes from "./subpages.module.css";
|
||||||
@@ -12,16 +15,130 @@ import {
|
|||||||
} from "@/features/page/page.utils.ts";
|
} from "@/features/page/page.utils.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
|
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
|
||||||
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
|
import {
|
||||||
|
useSharedPageSubpages,
|
||||||
|
useSharedPageSubtree,
|
||||||
|
} from "@/features/share/hooks/use-shared-page-subpages";
|
||||||
|
import {
|
||||||
|
SubpageNode,
|
||||||
|
buildSubtree,
|
||||||
|
mapSharedNodes,
|
||||||
|
countNodes,
|
||||||
|
} from "./subpages-view.utils";
|
||||||
|
|
||||||
|
// Threshold above which the recursive tree shows a small count note. We never
|
||||||
|
// cap the data — this is only an informational hint for very large trees.
|
||||||
|
const LARGE_TREE_THRESHOLD = 300;
|
||||||
|
|
||||||
|
interface TreeNodeProps {
|
||||||
|
node: SubpageNode;
|
||||||
|
depth: number;
|
||||||
|
shareId?: string;
|
||||||
|
spaceSlug?: string;
|
||||||
|
// Threaded down from the variant component so a large tree does not create one
|
||||||
|
// i18n subscription (useTranslation) per rendered node.
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive renderer for a single node and its descendants. Indents each level
|
||||||
|
// by depth * 16px and reuses the same link/icon markup as the flat list.
|
||||||
|
function TreeNode({ node, depth, shareId, spaceSlug, t }: TreeNodeProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
fw={500}
|
||||||
|
to={
|
||||||
|
shareId
|
||||||
|
? buildSharedPageUrl({
|
||||||
|
shareId,
|
||||||
|
pageSlugId: node.slugId,
|
||||||
|
pageTitle: node.title,
|
||||||
|
})
|
||||||
|
: buildPageUrl(spaceSlug, node.slugId, node.title)
|
||||||
|
}
|
||||||
|
underline="never"
|
||||||
|
className={styles.pageMentionLink}
|
||||||
|
draggable={false}
|
||||||
|
style={{ paddingLeft: depth * 16 }}
|
||||||
|
>
|
||||||
|
{node?.icon ? (
|
||||||
|
<span style={{ marginRight: "4px" }}>{node.icon}</span>
|
||||||
|
) : (
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
color="gray"
|
||||||
|
component="span"
|
||||||
|
size={18}
|
||||||
|
style={{ verticalAlign: "text-bottom" }}
|
||||||
|
>
|
||||||
|
<IconFileDescription size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className={styles.pageMentionText}>
|
||||||
|
{node?.title || t("untitled")}
|
||||||
|
</span>
|
||||||
|
</Anchor>
|
||||||
|
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<TreeNode
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
shareId={shareId}
|
||||||
|
spaceSlug={spaceSlug}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SubpagesView(props: NodeViewProps) {
|
export default function SubpagesView(props: NodeViewProps) {
|
||||||
const { editor } = props;
|
const { editor } = props;
|
||||||
const { spaceSlug, shareId } = useParams();
|
const { spaceSlug, shareId } = useParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const recursive: boolean = props.node.attrs.recursive ?? false;
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const currentPageId = editor.storage.pageId;
|
const currentPageId = editor.storage.pageId;
|
||||||
|
|
||||||
|
if (recursive) {
|
||||||
|
return (
|
||||||
|
<RecursiveSubpages
|
||||||
|
currentPageId={currentPageId}
|
||||||
|
shareId={shareId}
|
||||||
|
spaceSlug={spaceSlug}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatSubpages
|
||||||
|
currentPageId={currentPageId}
|
||||||
|
shareId={shareId}
|
||||||
|
spaceSlug={spaceSlug}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubpagesVariantProps {
|
||||||
|
currentPageId: string;
|
||||||
|
shareId?: string;
|
||||||
|
spaceSlug?: string;
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlatSubpages({
|
||||||
|
currentPageId,
|
||||||
|
shareId,
|
||||||
|
spaceSlug,
|
||||||
|
t,
|
||||||
|
}: SubpagesVariantProps) {
|
||||||
// Get subpages from shared tree if we're in a shared context
|
// Get subpages from shared tree if we're in a shared context
|
||||||
const sharedSubpages = useSharedPageSubpages(currentPageId);
|
const sharedSubpages = useSharedPageSubpages(currentPageId);
|
||||||
|
|
||||||
@@ -119,3 +236,78 @@ export default function SubpagesView(props: NodeViewProps) {
|
|||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RecursiveSubpages({
|
||||||
|
currentPageId,
|
||||||
|
shareId,
|
||||||
|
spaceSlug,
|
||||||
|
t,
|
||||||
|
}: SubpagesVariantProps) {
|
||||||
|
// In a shared/public context reuse the already-loaded nested shared tree
|
||||||
|
// instead of issuing a /pages/tree request.
|
||||||
|
const sharedSubtree = useSharedPageSubtree(currentPageId);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useGetPageTreeQuery(
|
||||||
|
shareId ? "" : currentPageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tree = useMemo<SubpageNode[]>(() => {
|
||||||
|
if (shareId) {
|
||||||
|
return mapSharedNodes(sharedSubtree);
|
||||||
|
}
|
||||||
|
if (!data) return [];
|
||||||
|
return buildSubtree(data, currentPageId);
|
||||||
|
}, [data, shareId, sharedSubtree, currentPageId]);
|
||||||
|
|
||||||
|
const total = useMemo(() => countNodes(tree), [tree]);
|
||||||
|
|
||||||
|
if (isLoading && !shareId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !shareId) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper data-drag-handle>
|
||||||
|
<Text c="dimmed" size="md" py="md">
|
||||||
|
{t("Failed to load subpages")}
|
||||||
|
</Text>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tree.length === 0) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper data-drag-handle>
|
||||||
|
<div className={classes.container}>
|
||||||
|
<Text c="dimmed" size="md" py="md">
|
||||||
|
{t("No subpages")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper data-drag-handle>
|
||||||
|
<div className={classes.container}>
|
||||||
|
<Stack gap={5}>
|
||||||
|
{tree.map((node) => (
|
||||||
|
<TreeNode
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
depth={0}
|
||||||
|
shareId={shareId}
|
||||||
|
spaceSlug={spaceSlug}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{total > LARGE_TREE_THRESHOLD && (
|
||||||
|
<Text c="dimmed" size="xs" pt="xs">
|
||||||
|
{t("Showing {{count}} subpages", { count: total })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
buildSubtree,
|
||||||
|
countNodes,
|
||||||
|
mapSharedNodes,
|
||||||
|
SubpageNode,
|
||||||
|
} from "./subpages-view.utils";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
|
||||||
|
// Minimal IPage fixture — buildSubtree only reads id/slugId/title/icon/position/
|
||||||
|
// parentPageId. `position` keys are fractional-indexing strings (lexicographic).
|
||||||
|
const page = (p: Partial<IPage> & { id: string }): IPage =>
|
||||||
|
({
|
||||||
|
slugId: `slug-${p.id}`,
|
||||||
|
title: `Title ${p.id}`,
|
||||||
|
icon: undefined,
|
||||||
|
position: "a0",
|
||||||
|
parentPageId: null,
|
||||||
|
...p,
|
||||||
|
}) as IPage;
|
||||||
|
|
||||||
|
const ids = (nodes: SubpageNode[]): string[] => nodes.map((n) => n.id);
|
||||||
|
|
||||||
|
describe("buildSubtree", () => {
|
||||||
|
it("nests children under the root and excludes the root itself", () => {
|
||||||
|
const pages = [
|
||||||
|
page({ id: "root" }),
|
||||||
|
page({ id: "a", parentPageId: "root", position: "a0" }),
|
||||||
|
page({ id: "b", parentPageId: "root", position: "a1" }),
|
||||||
|
page({ id: "a1", parentPageId: "a", position: "a0" }),
|
||||||
|
];
|
||||||
|
const tree = buildSubtree(pages, "root");
|
||||||
|
// Root is not rendered; only its descendants.
|
||||||
|
expect(ids(tree)).toEqual(["a", "b"]);
|
||||||
|
expect(ids(tree[0].children)).toEqual(["a1"]);
|
||||||
|
expect(tree[1].children).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts each level by position", () => {
|
||||||
|
const pages = [
|
||||||
|
page({ id: "root" }),
|
||||||
|
page({ id: "z", parentPageId: "root", position: "a2" }),
|
||||||
|
page({ id: "x", parentPageId: "root", position: "a0" }),
|
||||||
|
page({ id: "y", parentPageId: "root", position: "a1" }),
|
||||||
|
];
|
||||||
|
expect(ids(buildSubtree(pages, "root"))).toEqual(["x", "y", "z"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] when the root is absent from the page set", () => {
|
||||||
|
const pages = [page({ id: "a", parentPageId: "missing-root" })];
|
||||||
|
expect(buildSubtree(pages, "missing-root")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("silently drops a node whose parent is absent (unreachable parent)", () => {
|
||||||
|
const pages = [
|
||||||
|
page({ id: "root" }),
|
||||||
|
page({ id: "ok", parentPageId: "root" }),
|
||||||
|
page({ id: "orphan", parentPageId: "ghost" }), // parent not in the set
|
||||||
|
];
|
||||||
|
expect(ids(buildSubtree(pages, "root"))).toEqual(["ok"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards against self-parenting / attaching the root", () => {
|
||||||
|
const pages = [
|
||||||
|
// A (defensive) self-parented root must not attach to itself.
|
||||||
|
page({ id: "root", parentPageId: "root" }),
|
||||||
|
page({ id: "a", parentPageId: "root" }),
|
||||||
|
];
|
||||||
|
const tree = buildSubtree(pages, "root");
|
||||||
|
expect(ids(tree)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] for empty input", () => {
|
||||||
|
expect(buildSubtree([], "root")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("countNodes", () => {
|
||||||
|
it("counts every descendant across all levels", () => {
|
||||||
|
const tree: SubpageNode[] = [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
slugId: "s",
|
||||||
|
title: "A",
|
||||||
|
children: [
|
||||||
|
{ id: "a1", slugId: "s", title: "A1", children: [] },
|
||||||
|
{ id: "a2", slugId: "s", title: "A2", children: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: "b", slugId: "s", title: "B", children: [] },
|
||||||
|
];
|
||||||
|
expect(countNodes(tree)).toBe(4);
|
||||||
|
expect(countNodes([])).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapSharedNodes", () => {
|
||||||
|
it("remaps value->id / name->title and keeps nested children", () => {
|
||||||
|
const shared = [
|
||||||
|
{
|
||||||
|
value: "p1",
|
||||||
|
slugId: "s1",
|
||||||
|
name: "Parent",
|
||||||
|
icon: "📁",
|
||||||
|
children: [
|
||||||
|
{ value: "c1", slugId: "sc1", name: "Child", children: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any;
|
||||||
|
const mapped = mapSharedNodes(shared);
|
||||||
|
expect(mapped[0]).toMatchObject({ id: "p1", slugId: "s1", title: "Parent", icon: "📁" });
|
||||||
|
expect(mapped[0].children[0]).toMatchObject({ id: "c1", title: "Child" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
import { SharedPageTreeNode } from "@/features/share/utils";
|
||||||
|
|
||||||
|
// Normalized node shared by the flat and recursive subpages renderers so the
|
||||||
|
// same link/icon markup works for both API pages and shared-tree nodes.
|
||||||
|
export interface SubpageNode {
|
||||||
|
id: string;
|
||||||
|
slugId: string;
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
children: SubpageNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subpage node carrying `position` so each level can be sorted in place.
|
||||||
|
export type SubpageNodeWithPos = SubpageNode & {
|
||||||
|
position: string;
|
||||||
|
children: SubpageNodeWithPos[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a nested subtree (the current page's descendants) from the flat `IPage[]`
|
||||||
|
* the `/pages/tree` endpoint returns. Attaches each node to its parent by
|
||||||
|
* `parentPageId`, drops the root itself, and sorts every level by `position`.
|
||||||
|
*
|
||||||
|
* Guards only against SELF-PARENTING and attaching the root (`p.id !== rootId`) —
|
||||||
|
* NOT against multi-node `parentPageId` cycles. Those cannot occur here: the
|
||||||
|
* server rejects cyclic moves, and the recursive `getPageAndDescendants` CTE that
|
||||||
|
* produces this list would itself loop before reaching the client, so the flat
|
||||||
|
* input is acyclic by construction. A node whose `parentPageId` points outside
|
||||||
|
* the result set (an unreachable parent) is silently dropped — it is, by
|
||||||
|
* definition, not a descendant of the root being rendered.
|
||||||
|
*/
|
||||||
|
export function buildSubtree(pages: IPage[], rootId: string): SubpageNode[] {
|
||||||
|
const byId = new Map<string, SubpageNodeWithPos>(
|
||||||
|
pages.map((p) => [
|
||||||
|
p.id,
|
||||||
|
{
|
||||||
|
id: p.id,
|
||||||
|
slugId: p.slugId,
|
||||||
|
title: p.title,
|
||||||
|
icon: p.icon,
|
||||||
|
position: p.position,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const p of pages) {
|
||||||
|
const node = byId.get(p.id);
|
||||||
|
const parent = p.parentPageId ? byId.get(p.parentPageId) : undefined;
|
||||||
|
if (node && parent && p.id !== rootId) {
|
||||||
|
parent.children.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortRecursive = (
|
||||||
|
nodes: SubpageNodeWithPos[],
|
||||||
|
): SubpageNodeWithPos[] => {
|
||||||
|
const sorted = sortPositionKeys(nodes) as SubpageNodeWithPos[];
|
||||||
|
sorted.forEach((n) => sortRecursive(n.children));
|
||||||
|
return sorted;
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = byId.get(rootId);
|
||||||
|
return root ? sortRecursive(root.children) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map shared-tree nodes (already nested) onto the normalized SubpageNode shape.
|
||||||
|
export function mapSharedNodes(nodes: SharedPageTreeNode[]): SubpageNode[] {
|
||||||
|
return nodes.map((node) => ({
|
||||||
|
id: node.value,
|
||||||
|
slugId: node.slugId,
|
||||||
|
title: node.name,
|
||||||
|
icon: node.icon,
|
||||||
|
children: node.children ? mapSharedNodes(node.children) : [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count every descendant in a normalized subtree.
|
||||||
|
export function countNodes(nodes: SubpageNode[]): number {
|
||||||
|
return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children), 0);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getAllSidebarPages,
|
getAllSidebarPages,
|
||||||
getDeletedPages,
|
getDeletedPages,
|
||||||
restorePage,
|
restorePage,
|
||||||
|
getSpaceTree,
|
||||||
} from "@/features/page/services/page-service";
|
} from "@/features/page/services/page-service";
|
||||||
import {
|
import {
|
||||||
IMovePage,
|
IMovePage,
|
||||||
@@ -303,6 +304,15 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useGetPageTreeQuery(pageId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["page-tree", pageId],
|
||||||
|
queryFn: () => getSpaceTree({ pageId }),
|
||||||
|
enabled: !!pageId,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function usePageBreadcrumbsQuery(
|
export function usePageBreadcrumbsQuery(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
): UseQueryResult<Partial<IPage[]>, Error> {
|
): UseQueryResult<Partial<IPage[]>, Error> {
|
||||||
@@ -363,7 +373,18 @@ export function useDeletedPagesQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate every cached page-subtree (the recursive `subpages` node, issue
|
||||||
|
* #150). Called from each tree-structure cache helper below so a create / move /
|
||||||
|
* rename / delete (local OR websocket-echoed) refreshes any open recursive tree.
|
||||||
|
* Keyed loosely (`["page-tree"]` prefix) so all subtrees are caught.
|
||||||
|
*/
|
||||||
|
function invalidatePageTree() {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["page-tree"] });
|
||||||
|
}
|
||||||
|
|
||||||
export function invalidateOnCreatePage(data: Partial<IPage>) {
|
export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||||
|
invalidatePageTree();
|
||||||
const newPage: Partial<IPage> = {
|
const newPage: Partial<IPage> = {
|
||||||
creatorId: data.creatorId,
|
creatorId: data.creatorId,
|
||||||
hasChildren: data.hasChildren,
|
hasChildren: data.hasChildren,
|
||||||
@@ -478,6 +499,7 @@ export function invalidateOnUpdatePage(
|
|||||||
title: string,
|
title: string,
|
||||||
icon: string,
|
icon: string,
|
||||||
) {
|
) {
|
||||||
|
invalidatePageTree();
|
||||||
let queryKey: QueryKey = null;
|
let queryKey: QueryKey = null;
|
||||||
if (parentPageId === null) {
|
if (parentPageId === null) {
|
||||||
queryKey = ["root-sidebar-pages", spaceId];
|
queryKey = ["root-sidebar-pages", spaceId];
|
||||||
@@ -516,6 +538,7 @@ export function updateCacheOnMovePage(
|
|||||||
newParentId: string | null,
|
newParentId: string | null,
|
||||||
pageData: Partial<IPage>,
|
pageData: Partial<IPage>,
|
||||||
) {
|
) {
|
||||||
|
invalidatePageTree();
|
||||||
// Remove page from old parent's cache
|
// Remove page from old parent's cache
|
||||||
const oldQueryKey =
|
const oldQueryKey =
|
||||||
oldParentId === null
|
oldParentId === null
|
||||||
@@ -633,6 +656,7 @@ export function updateCacheOnMovePage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateOnDeletePage(pageId: string) {
|
export function invalidateOnDeletePage(pageId: string) {
|
||||||
|
invalidatePageTree();
|
||||||
//update all sidebar pages
|
//update all sidebar pages
|
||||||
const allSideBarMatches = queryClient.getQueriesData({
|
const allSideBarMatches = queryClient.getQueriesData({
|
||||||
predicate: (query) =>
|
predicate: (query) =>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export async function getAllSidebarPages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getSpaceTree(params: {
|
export async function getSpaceTree(params: {
|
||||||
spaceId: string;
|
spaceId?: string;
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
}): Promise<IPage[]> {
|
}): Promise<IPage[]> {
|
||||||
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);
|
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);
|
||||||
|
|||||||
@@ -27,3 +27,11 @@ export function useSharedPageSubpages(pageId: string | undefined) {
|
|||||||
return findSubpages(treeData);
|
return findSubpages(treeData);
|
||||||
}, [treeData, pageId]);
|
}, [treeData, pageId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recursive variant for the subpages node in a shared/public context. The shared
|
||||||
|
// tree (`sharedTreeDataAtom`) is ALREADY fully nested, so a page's `children`
|
||||||
|
// each carry their own nested `children` — exactly what the recursive renderer
|
||||||
|
// needs. The data is therefore identical to the flat hook; only the rendering
|
||||||
|
// differs (the recursive view walks `children` instead of showing one level).
|
||||||
|
// Thin alias to avoid duplicating the lookup. No `/pages/tree` request here.
|
||||||
|
export const useSharedPageSubtree = useSharedPageSubpages;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ export interface SubpagesOptions {
|
|||||||
view: any;
|
view: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubpagesAttributes {}
|
export interface SubpagesAttributes {
|
||||||
|
recursive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
@@ -31,6 +33,18 @@ export const Subpages = Node.create<SubpagesOptions>({
|
|||||||
draggable: true,
|
draggable: true,
|
||||||
isolating: true,
|
isolating: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
recursive: {
|
||||||
|
// Existing nodes stay flat -> backward compatible.
|
||||||
|
default: false,
|
||||||
|
parseHTML: (el) => el.getAttribute("data-recursive") === "true",
|
||||||
|
renderHTML: (attrs) =>
|
||||||
|
attrs.recursive ? { "data-recursive": "true" } : {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user