feat: subpages (child pages) list node (#1462)

* feat: subpages list node

* disable user-select

* support subpages node list in public pages
This commit is contained in:
Philip Okugbe
2025-08-31 18:54:52 +01:00
committed by GitHub
parent 7d1e5bce0d
commit 73ee6ee8c3
20 changed files with 410 additions and 25 deletions

View File

@ -17,8 +17,10 @@ import {
IconTable,
IconTypography,
IconMenu4,
IconCalendar, IconAppWindow,
} from '@tabler/icons-react';
IconCalendar,
IconAppWindow,
IconSitemap,
} from "@tabler/icons-react";
import {
CommandProps,
SlashMenuGroupedItemsType,
@ -357,6 +359,15 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run();
},
},
{
title: "Subpages (Child pages)",
description: "List all subpages of the current page",
searchTerms: ["subpages", "child", "children", "nested", "hierarchy"],
icon: IconSitemap,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertSubpages().run();
},
},
{
title: "Iframe embed",
description: "Embed any Iframe",

View File

@ -0,0 +1,95 @@
import {
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} 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 { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { sticky } from "tippy.js";
interface SubpagesMenuProps {
editor: Editor;
}
interface ShouldShowProps {
state: any;
from?: number;
to?: number;
}
export const SubpagesMenu = React.memo(
({ editor }: SubpagesMenuProps): JSX.Element => {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("subpages");
},
[editor],
);
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "subpages";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
}
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const deleteNode = useCallback(() => {
const { selection } = editor.state;
editor
.chain()
.focus()
.setNodeSelection(selection.from)
.deleteSelection()
.run();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`subpages-menu}`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
<Tooltip position="top" label={t("Delete")}>
<ActionIcon
onClick={deleteNode}
variant="default"
size="lg"
color="red"
aria-label={t("Delete")}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</BaseBubbleMenu>
);
},
);
export default SubpagesMenu;

View File

@ -0,0 +1,120 @@
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 { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import classes from "./subpages.module.css";
import styles from "../mention/mention.module.css";
import {
buildPageUrl,
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";
export default function SubpagesView(props: NodeViewProps) {
const { editor } = props;
const { spaceSlug, shareId } = useParams();
const { t } = useTranslation();
const currentPageId = editor.storage.pageId;
// Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId);
const { data, isLoading, error } = useGetSidebarPagesQuery({
pageId: currentPageId,
});
const subpages = useMemo(() => {
// If we're in a shared context, use the shared subpages
if (shareId && sharedSubpages) {
return sharedSubpages.map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
position: node.position,
}));
}
// Otherwise use the API data
if (!data?.pages) return [];
const allPages = data.pages.flatMap((page) => page.items);
return sortPositionKeys(allPages);
}, [data, shareId, sharedSubpages]);
if (isLoading && !shareId) {
return null;
}
if (error && !shareId) {
return (
<NodeViewWrapper>
<Text c="dimmed" size="md" py="md">
{t("Failed to load subpages")}
</Text>
</NodeViewWrapper>
);
}
if (subpages.length === 0) {
return (
<NodeViewWrapper>
<div className={classes.container}>
<Text c="dimmed" size="md" py="md">
{t("No subpages")}
</Text>
</div>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper>
<div className={classes.container}>
<Stack gap={5}>
{subpages.map((page) => (
<Anchor
key={page.id}
component={Link}
fw={500}
to={
shareId
? buildSharedPageUrl({
shareId,
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>
))}
</Stack>
</div>
</NodeViewWrapper>
);
}

View File

@ -0,0 +1,9 @@
.container {
margin: 0;
padding-left: 4px;
user-select: none;
a {
border: none !important;
}
}

View File

@ -38,6 +38,7 @@ import {
Embed,
SearchAndReplace,
Mention,
Subpages,
TableDndExtension,
} from "@docmost/editor-ext";
import {
@ -58,6 +59,7 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell";
import abap from "highlightjs-sap-abap";
@ -214,6 +216,9 @@ export const mainExtensions = [
Embed.configure({
view: EmbedView,
}),
Subpages.configure({
view: SubpagesView,
}),
MarkdownClipboard.configure({
transformPastedText: true,
}),

View File

@ -31,6 +31,7 @@ import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import {
handleFileDrop,
handlePaste,
@ -391,6 +392,7 @@ export default function PageEditor({
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<LinkMenu editor={editor} appendTo={menuContainerRef} />

View File

@ -6,21 +6,19 @@ import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading";
import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai/index";
import {
pageEditorAtom,
readOnlyEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { Editor } from "@tiptap/core";
import { useAtom } from "jotai";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
interface PageEditorProps {
title: string;
content: any;
pageId?: string;
}
export default function ReadonlyPageEditor({
title,
content,
pageId,
}: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
@ -56,6 +54,9 @@ export default function ReadonlyPageEditor({
content={content}
onCreate={({ editor }) => {
if (editor) {
if (pageId) {
editor.storage.pageId = pageId;
}
// @ts-ignore
setReadOnlyEditor(editor);
}