mirror of
https://github.com/docmost/docmost.git
synced 2026-09-12 08:21:32 +08:00
expand subpages node functionality
This commit is contained in:
@@ -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 });
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user