expand subpages node functionality

This commit is contained in:
Salihu
2026-09-11 23:37:51 +01:00
parent 6205bbeb90
commit b1d3c84baa
7 changed files with 337 additions and 67 deletions
@@ -0,0 +1,160 @@
import { Collapse, Anchor, ActionIcon, Stack } from "@mantine/core";
import {
IconChevronDown,
IconChevronRight,
IconFileDescription,
} from "@tabler/icons-react";
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
import { buildPageUrl, buildPublicSpaceUrl, buildSharedPageUrl } from "@/features/page/page.utils";
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
import { useTranslation } from "react-i18next";
import styles from "../mention/mention.module.css";
import classes from "./subpages.module.css";
import {
sortSubpages,
type SubpageListItem,
type SubpagesSortBy,
} from "./subpages.utils";
interface SubpageItemProps {
page: SubpageListItem;
depth: number;
sortBy: SubpagesSortBy;
isPublicSpaceRoute: boolean;
shareId?: string;
spaceSlug?: string;
}
export default function SubpageItem({
page,
depth,
sortBy,
shareId,
spaceSlug,
isPublicSpaceRoute
}: SubpageItemProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const sharedSubpages = useSharedPageSubpages(
shareId && opened ? page.id : undefined
);
const { data, isLoading } = useGetSidebarPagesQuery(
!shareId && opened ? { pageId: page.id } : undefined
);
const children = useMemo(() => {
if (shareId) {
return sortSubpages(
sharedSubpages.map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
position: node.position,
hasChildren: node.hasChildren,
})),
sortBy
);
}
const pages = data?.pages.flatMap((result) => result.items) || [];
return sortSubpages(pages, sortBy);
}, [data, shareId, sharedSubpages, sortBy]);
const toggleOpened = () => setOpened((value) => !value);
return (
<div className={classes.item}>
<div
className={classes.row}
style={{ paddingLeft: `${depth * 20 + 4}px` }}
>
{page.hasChildren ? (
<ActionIcon
variant="subtle"
color="gray"
size="sm"
className={classes.toggle}
onClick={toggleOpened}
aria-label={opened ? t("Collapse") : t("Expand")}
>
{opened ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
)}
</ActionIcon>
) : (
<span className={classes.togglePlaceholder}></span>
)}
<Anchor
component={Link}
fw={500}
to={
shareId
? buildSharedPageUrl({
shareId,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: isPublicSpaceRoute
? buildPublicSpaceUrl({
spaceSlug,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: buildPageUrl(spaceSlug, page.slugId, page.title)
}
underline="never"
className={styles.pageMentionLink}
draggable={false}
>
{page.icon ? (
<span className={classes.icon}>{page.icon}</span>
) : (
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
<span className={styles.pageMentionText}>
{page.title || t("untitled")}
</span>
</Anchor>
</div>
{page.hasChildren && (
<Collapse expanded={opened}>
<Stack gap={5}>
{isLoading ? (
<div style={{ paddingLeft: `${(depth + 2) * 20 + 4}px` }}>
...{t("loading")}
</div>
) : (
children.map((child) => (
<SubpageItem
key={child.id}
page={child}
depth={depth + 1}
sortBy={sortBy}
shareId={shareId}
spaceSlug={spaceSlug}
isPublicSpaceRoute={isPublicSpaceRoute}
/>
))
)}
</Stack>
</Collapse>
)}
</div>
);
}
@@ -1,12 +1,13 @@
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, type JSX } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import { IconArrowsSort, IconCheck, IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { isEditorReady } from "@docmost/editor-ext";
import type { SubpagesSortBy } from "./subpages.utils";
interface SubpagesMenuProps {
editor: Editor;
@@ -57,6 +58,30 @@ export const SubpagesMenu = React.memo(
.run();
}, [editor]);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) return { sortBy: "default" };
return {
sortBy: (ctx.editor.getAttributes("subpages").sortBy ||
"default"),
};
},
});
const sortBy = editorState?.sortBy || "default";
const updateSort = useCallback(
(value: SubpagesSortBy) => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setSubpagesSortBy(value)
.run();
},
[editor],
);
return (
<BaseBubbleMenu
editor={editor}
@@ -67,6 +92,40 @@ export const SubpagesMenu = React.memo(
updateDelay={0}
shouldShow={shouldShow}
>
<Menu shadow="md" position="top-start" withinPortal>
<Menu.Target>
<Tooltip position="top" label={t("Sort")}>
<ActionIcon
variant="default"
size="lg"
aria-label={t("Sort")}
>
<IconArrowsSort size={18} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("Sort by")}</Menu.Label>
<Menu.Item
leftSection={sortBy === "default" ? <IconCheck size={14} /> : null}
onClick={() => updateSort("default")}
>
{t("default")}
</Menu.Item>
<Menu.Item
leftSection={sortBy === "title-asc" ? <IconCheck size={14} /> : null}
onClick={() => updateSort("title-asc")}
>
{t("Sort A → Z")}
</Menu.Item>
<Menu.Item
leftSection={sortBy === "title-desc" ? <IconCheck size={14} /> : null}
onClick={() => updateSort("title-desc")}
>
{t("Sort Z → A")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Tooltip position="top" label={t("Delete")}>
<ActionIcon
onClick={deleteNode}
@@ -1,23 +1,21 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { Stack, Text } from "@mantine/core";
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
import { useMemo } from "react";
import { Link, useLocation, useParams } from "react-router-dom";
import { useLocation, useParams } from "react-router-dom";
import classes from "./subpages.module.css";
import styles from "../mention/mention.module.css";
import {
buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl,
} 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 { useAtomValue } from "jotai";
import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts";
import { findSubpagesInTree } from "@/features/share/utils";
import { extractPageSlugId } from "@/lib";
import SubpageItem from "./subpage-item";
import {
sortSubpages,
type SubpageListItem,
type SubpagesSortBy,
} from "./subpages.utils";
export default function SubpagesView(props: NodeViewProps) {
const { editor } = props;
@@ -44,6 +42,7 @@ export default function SubpagesView(props: NodeViewProps) {
if (isPublicSpaceRoute) {
currentPageId = routePageId ?? publicSpaceTreeData?.[0]?.slugId;
}
const sortBy = (props.node.attrs.sortBy || "position") as SubpagesSortBy;
// Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId);
@@ -61,13 +60,14 @@ export default function SubpagesView(props: NodeViewProps) {
const subpages = useMemo(() => {
// If we're in a shared context, use the shared subpages
if (shareId && sharedSubpages) {
return sharedSubpages.map((node) => ({
return sortSubpages(sharedSubpages.map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
position: node.position,
}));
hasChildren: node.hasChildren,
})), sortBy);
}
if (isPublicSpaceRoute) {
@@ -83,14 +83,8 @@ export default function SubpagesView(props: NodeViewProps) {
// Otherwise use the API data
if (!data?.pages) return [];
const allPages = data.pages.flatMap((page) => page.items);
return sortPositionKeys(allPages);
}, [
data,
shareId,
sharedSubpages,
isPublicSpaceRoute,
publicSpaceSubpages,
]);
return sortSubpages(allPages, sortBy);
}, [data, shareId, sharedSubpages, isPublicSpaceRoute, publicSpaceSubpages, sortBy]);
if (isLoading && !isPublicView) {
return null;
@@ -122,48 +116,16 @@ export default function SubpagesView(props: NodeViewProps) {
<NodeViewWrapper data-drag-handle>
<div className={classes.container}>
<Stack gap={5} >
{subpages.map((page) => (
<Anchor
{subpages.map((page: SubpageListItem) => (
<SubpageItem
key={page.id}
component={Link}
fw={500}
to={
shareId
? buildSharedPageUrl({
shareId,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: isPublicSpaceRoute
? buildPublicSpaceUrl({
spaceSlug,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: buildPageUrl(spaceSlug, page.slugId, page.title)
}
underline="never"
className={styles.pageMentionLink}
draggable={false}
>
{page?.icon ? (
<span style={{ marginRight: "4px" }}>{page.icon}</span>
) : (
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
<span className={styles.pageMentionText}>
{page?.title || t("untitled")}
</span>
</Anchor>
page={page}
depth={0}
sortBy={sortBy}
shareId={shareId}
spaceSlug={spaceSlug}
isPublicSpaceRoute={isPublicSpaceRoute}
/>
))}
</Stack>
</div>
@@ -7,3 +7,27 @@
border: none !important;
}
}
.item {
width: 100%;
}
.row {
display: flex;
align-items: center;
min-height: 28px;
}
.toggle {
flex: 0 0 24px;
margin-right: 2px;
}
.togglePlaceholder {
display: inline-block;
flex: 0 0 26px;
}
.icon {
margin-right: 4px;
}
@@ -0,0 +1,34 @@
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
export type SubpagesSortBy = "default" | "title-asc" | "title-desc";
export type SubpageListItem = {
id: string;
slugId: string;
title: string;
icon?: string;
position: string;
hasChildren: boolean;
};
export function sortSubpages(
items: SubpageListItem[],
sortBy: SubpagesSortBy = "default"
) {
const sortedItems = [...items];
if (sortBy === "default") {
return sortPositionKeys(sortedItems);
}
return sortedItems.sort((a, b) => {
const result = (a.title || "untitled").localeCompare(
b.title || "untitled",
undefined,
{
sensitivity: "base",
}
);
return sortBy === "title-desc" ? -result : result;
});
}
@@ -1,2 +1,2 @@
export { Subpages } from "./subpages";
export type { SubpagesAttributes, SubpagesOptions } from "./subpages";
export type { SubpagesAttributes, SubpagesOptions, SubpagesSortBy } from "./subpages";
@@ -6,12 +6,17 @@ export interface SubpagesOptions {
view: any;
}
export interface SubpagesAttributes {}
export type SubpagesSortBy = "default" | "title-asc" | "title-desc";
export interface SubpagesAttributes {
sortBy?: SubpagesSortBy;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
subpages: {
insertSubpages: (attributes?: SubpagesAttributes) => ReturnType;
setSubpagesSortBy: (sortBy: SubpagesSortBy) => ReturnType;
};
}
}
@@ -26,6 +31,19 @@ export const Subpages = Node.create<SubpagesOptions>({
};
},
addAttributes() {
return {
sortBy: {
default: "default",
parseHTML: (element: HTMLElement) =>
element.getAttribute("data-sort-by") || "default",
renderHTML: (attributes: SubpagesAttributes) => ({
"data-sort-by": attributes.sortBy || "default",
}),
},
};
},
group: "block",
atom: true,
draggable: true,
@@ -60,6 +78,19 @@ export const Subpages = Node.create<SubpagesOptions>({
attrs: attributes,
});
},
setSubpagesSortBy:
(sortBy) =>
({ commands }) => {
if (
sortBy !== 'default' &&
sortBy !== 'title-asc' &&
sortBy !== 'title-desc'
) {
return;
}
return commands.updateAttributes(this.name, { sortBy });
},
};
},