Merge pull request 'feat(editor): recursive tree mode for the subpages node (#150)' (#155) from feat/subpages-recursive-tree into develop

This commit was merged in pull request #155.
This commit is contained in:
claude_code
2026-06-24 14:35:17 +03:00
11 changed files with 532 additions and 19 deletions

View File

@@ -1300,5 +1300,12 @@
"Go to login page": "Go to login page",
"Move to space": "Move to space",
"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"
}

View File

@@ -1152,5 +1152,13 @@
"Auto-detect": "Автоопределение",
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
"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}} подстраниц"
}

View File

@@ -524,6 +524,29 @@ const CommandGroups: SlashMenuGroupedItemsType = {
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",
description: "Create a block that stays in sync across pages.",

View File

@@ -1,9 +1,9 @@
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 React, { useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { ActionIcon, Group, Tooltip } from "@mantine/core";
import { IconTrash, IconList, IconSitemap } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { isEditorReady } from "@docmost/editor-ext";
@@ -47,6 +47,13 @@ export const SubpagesMenu = React.memo(
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const toggleRecursive = useCallback(() => {
const current = editor.getAttributes("subpages")?.recursive ?? false;
editor.commands.updateAttributes("subpages", {
recursive: !current,
});
}, [editor]);
const deleteNode = useCallback(() => {
const { selection } = editor.state;
editor
@@ -57,6 +64,15 @@ export const SubpagesMenu = React.memo(
.run();
}, [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 (
<BaseBubbleMenu
editor={editor}
@@ -64,17 +80,41 @@ export const SubpagesMenu = React.memo(
updateDelay={0}
shouldShow={shouldShow}
>
<Tooltip position="top" label={t("Delete")}>
<ActionIcon
onClick={deleteNode}
variant="default"
size="lg"
color="red"
aria-label={t("Delete")}
<Group gap={4} wrap="nowrap">
<Tooltip
position="top"
label={
isRecursive
? t("Switch to flat list")
: t("Switch to tree")
}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
<ActionIcon
onClick={toggleRecursive}
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>
);
}

View File

@@ -1,7 +1,10 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
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 { Link, useParams } from "react-router-dom";
import classes from "./subpages.module.css";
@@ -12,16 +15,130 @@ import {
} from "@/features/page/page.utils.ts";
import { useTranslation } from "react-i18next";
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) {
const { editor } = props;
const { spaceSlug, shareId } = useParams();
const { t } = useTranslation();
const recursive: boolean = props.node.attrs.recursive ?? false;
//@ts-ignore
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
const sharedSubpages = useSharedPageSubpages(currentPageId);
@@ -119,3 +236,78 @@ export default function SubpagesView(props: NodeViewProps) {
</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>
);
}

View File

@@ -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" });
});
});

View File

@@ -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);
}

View File

@@ -21,6 +21,7 @@ import {
getAllSidebarPages,
getDeletedPages,
restorePage,
getSpaceTree,
} from "@/features/page/services/page-service";
import {
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(
pageId: string,
): 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>) {
invalidatePageTree();
const newPage: Partial<IPage> = {
creatorId: data.creatorId,
hasChildren: data.hasChildren,
@@ -478,6 +499,7 @@ export function invalidateOnUpdatePage(
title: string,
icon: string,
) {
invalidatePageTree();
let queryKey: QueryKey = null;
if (parentPageId === null) {
queryKey = ["root-sidebar-pages", spaceId];
@@ -516,6 +538,7 @@ export function updateCacheOnMovePage(
newParentId: string | null,
pageData: Partial<IPage>,
) {
invalidatePageTree();
// Remove page from old parent's cache
const oldQueryKey =
oldParentId === null
@@ -633,6 +656,7 @@ export function updateCacheOnMovePage(
}
export function invalidateOnDeletePage(pageId: string) {
invalidatePageTree();
//update all sidebar pages
const allSideBarMatches = queryClient.getQueriesData({
predicate: (query) =>

View File

@@ -93,7 +93,7 @@ export async function getAllSidebarPages(
}
export async function getSpaceTree(params: {
spaceId: string;
spaceId?: string;
pageId?: string;
}): Promise<IPage[]> {
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);

View File

@@ -27,3 +27,11 @@ export function useSharedPageSubpages(pageId: string | undefined) {
return findSubpages(treeData);
}, [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;

View File

@@ -6,7 +6,9 @@ export interface SubpagesOptions {
view: any;
}
export interface SubpagesAttributes {}
export interface SubpagesAttributes {
recursive?: boolean;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@@ -31,6 +33,18 @@ export const Subpages = Node.create<SubpagesOptions>({
draggable: 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() {
return [
{