mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
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:
@@ -495,5 +495,10 @@
|
|||||||
"Page restored successfully": "Page restored successfully",
|
"Page restored successfully": "Page restored successfully",
|
||||||
"Deleted by": "Deleted by",
|
"Deleted by": "Deleted by",
|
||||||
"Deleted at": "Deleted at",
|
"Deleted at": "Deleted at",
|
||||||
"Preview": "Preview"
|
"Preview": "Preview",
|
||||||
|
"Subpages": "Subpages",
|
||||||
|
"Failed to load subpages": "Failed to load subpages",
|
||||||
|
"No subpages": "No subpages",
|
||||||
|
"Subpages (Child pages)": "Subpages (Child pages)",
|
||||||
|
"List all subpages of the current page": "List all subpages of the current page"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
IconTable,
|
IconTable,
|
||||||
IconTypography,
|
IconTypography,
|
||||||
IconMenu4,
|
IconMenu4,
|
||||||
IconCalendar, IconAppWindow,
|
IconCalendar,
|
||||||
} from '@tabler/icons-react';
|
IconAppWindow,
|
||||||
|
IconSitemap,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
SlashMenuGroupedItemsType,
|
SlashMenuGroupedItemsType,
|
||||||
@@ -357,6 +359,15 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
.run();
|
.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",
|
title: "Iframe embed",
|
||||||
description: "Embed any Iframe",
|
description: "Embed any Iframe",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.container {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 4px;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
a {
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
Embed,
|
Embed,
|
||||||
SearchAndReplace,
|
SearchAndReplace,
|
||||||
Mention,
|
Mention,
|
||||||
|
Subpages,
|
||||||
TableDndExtension,
|
TableDndExtension,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
@@ -58,6 +59,7 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
|
|||||||
import DrawioView from "../components/drawio/drawio-view";
|
import DrawioView from "../components/drawio/drawio-view";
|
||||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||||
import EmbedView from "@/features/editor/components/embed/embed-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 plaintext from "highlight.js/lib/languages/plaintext";
|
||||||
import powershell from "highlight.js/lib/languages/powershell";
|
import powershell from "highlight.js/lib/languages/powershell";
|
||||||
import abap from "highlightjs-sap-abap";
|
import abap from "highlightjs-sap-abap";
|
||||||
@@ -214,6 +216,9 @@ export const mainExtensions = [
|
|||||||
Embed.configure({
|
Embed.configure({
|
||||||
view: EmbedView,
|
view: EmbedView,
|
||||||
}),
|
}),
|
||||||
|
Subpages.configure({
|
||||||
|
view: SubpagesView,
|
||||||
|
}),
|
||||||
MarkdownClipboard.configure({
|
MarkdownClipboard.configure({
|
||||||
transformPastedText: true,
|
transformPastedText: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||||
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
||||||
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||||
|
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
|
||||||
import {
|
import {
|
||||||
handleFileDrop,
|
handleFileDrop,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
@@ -391,6 +392,7 @@ export default function PageEditor({
|
|||||||
<ImageMenu editor={editor} />
|
<ImageMenu editor={editor} />
|
||||||
<VideoMenu editor={editor} />
|
<VideoMenu editor={editor} />
|
||||||
<CalloutMenu editor={editor} />
|
<CalloutMenu editor={editor} />
|
||||||
|
<SubpagesMenu editor={editor} />
|
||||||
<ExcalidrawMenu editor={editor} />
|
<ExcalidrawMenu editor={editor} />
|
||||||
<DrawioMenu editor={editor} />
|
<DrawioMenu editor={editor} />
|
||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
|
|||||||
@@ -6,21 +6,19 @@ import { Document } from "@tiptap/extension-document";
|
|||||||
import { Heading } from "@tiptap/extension-heading";
|
import { Heading } from "@tiptap/extension-heading";
|
||||||
import { Text } from "@tiptap/extension-text";
|
import { Text } from "@tiptap/extension-text";
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||||
import { useAtom } from "jotai/index";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
pageEditorAtom,
|
|
||||||
readOnlyEditorAtom,
|
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
title: string;
|
title: string;
|
||||||
content: any;
|
content: any;
|
||||||
|
pageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReadonlyPageEditor({
|
export default function ReadonlyPageEditor({
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
|
pageId,
|
||||||
}: PageEditorProps) {
|
}: PageEditorProps) {
|
||||||
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
||||||
|
|
||||||
@@ -56,6 +54,9 @@ export default function ReadonlyPageEditor({
|
|||||||
content={content}
|
content={content}
|
||||||
onCreate={({ editor }) => {
|
onCreate={({ editor }) => {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
|
if (pageId) {
|
||||||
|
editor.storage.pageId = pageId;
|
||||||
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
setReadOnlyEditor(editor);
|
setReadOnlyEditor(editor);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ export function useGetSidebarPagesQuery(
|
|||||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: ["sidebar-pages", data],
|
||||||
|
enabled: !!data?.pageId || !!data?.spaceId,
|
||||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
getPreviousPageParam: (firstPage) =>
|
getPreviousPageParam: (firstPage) =>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export interface ICopyPageToSpace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarPagesParams {
|
export interface SidebarPagesParams {
|
||||||
spaceId: string;
|
spaceId?: string;
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
page?: number; // pagination
|
page?: number; // pagination
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
import { ISharedPageTree } from "@/features/share/types/share.types";
|
||||||
|
import { SharedPageTreeNode } from "@/features/share/utils";
|
||||||
|
|
||||||
|
export const sharedPageTreeAtom = atom<ISharedPageTree | null>(null);
|
||||||
|
export const sharedTreeDataAtom = atom<SharedPageTreeNode[] | null>(null);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Affix,
|
Affix,
|
||||||
@@ -14,8 +14,10 @@ import SharedTree from "@/features/share/components/shared-tree.tsx";
|
|||||||
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
|
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
|
||||||
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle.tsx";
|
import { ThemeToggle } from "@/components/theme-toggle.tsx";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
import { sharedPageTreeAtom, sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
|
||||||
|
import { buildSharedPageTree } from "@/features/share/utils";
|
||||||
import {
|
import {
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
@@ -59,6 +61,20 @@ export default function ShareShell({
|
|||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
const { data } = useGetSharedPageTreeQuery(shareId);
|
const { data } = useGetSharedPageTreeQuery(shareId);
|
||||||
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
|
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
|
||||||
|
|
||||||
|
const setSharedPageTree = useSetAtom(sharedPageTreeAtom);
|
||||||
|
const setSharedTreeData = useSetAtom(sharedTreeDataAtom);
|
||||||
|
|
||||||
|
// Build and set the tree data when it changes
|
||||||
|
const treeData = useMemo(() => {
|
||||||
|
if (!data?.pageTree) return null;
|
||||||
|
return buildSharedPageTree(data.pageTree);
|
||||||
|
}, [data?.pageTree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSharedPageTree(data || null);
|
||||||
|
setSharedTreeData(treeData);
|
||||||
|
}, [data, treeData, setSharedPageTree, setSharedTreeData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
|
||||||
|
import { SharedPageTreeNode } from "@/features/share/utils";
|
||||||
|
|
||||||
|
export function useSharedPageSubpages(pageId: string | undefined) {
|
||||||
|
const treeData = useAtomValue(sharedTreeDataAtom);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!treeData || !pageId) return [];
|
||||||
|
|
||||||
|
function findSubpages(nodes: SharedPageTreeNode[]): SharedPageTreeNode[] {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.value === pageId || node.slugId === pageId) {
|
||||||
|
return node.children || [];
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
const subpages = findSubpages(node.children);
|
||||||
|
if (subpages.length > 0) {
|
||||||
|
return subpages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return findSubpages(treeData);
|
||||||
|
}, [treeData, pageId]);
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ export default function SharedPage() {
|
|||||||
key={data.page.id}
|
key={data.page.id}
|
||||||
title={data.page.title}
|
title={data.page.title}
|
||||||
content={data.page.content}
|
content={data.page.content}
|
||||||
|
pageId={data.page.id}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
Mention,
|
Mention,
|
||||||
|
Subpages,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import { generateHTML } from '../common/helpers/prosemirror/html';
|
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||||
@@ -79,6 +80,7 @@ export const tiptapExtensions = [
|
|||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
Mention,
|
Mention,
|
||||||
|
Subpages,
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
export function jsonToHtml(tiptapJson: any) {
|
export function jsonToHtml(tiptapJson: any) {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
import { SpaceIdDto } from './page.dto';
|
import { SpaceIdDto } from './page.dto';
|
||||||
|
|
||||||
export class SidebarPageDto extends SpaceIdDto {
|
export class SidebarPageDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
spaceId: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|||||||
@@ -254,21 +254,28 @@ export class PageController {
|
|||||||
@Body() pagination: PaginationOptions,
|
@Body() pagination: PaginationOptions,
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
) {
|
) {
|
||||||
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
|
if (!dto.spaceId && !dto.pageId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Either spaceId or pageId must be provided',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let spaceId = dto.spaceId;
|
||||||
|
|
||||||
|
if (dto.pageId) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceId = page.spaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
let pageId = null;
|
return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId);
|
||||||
if (dto.pageId) {
|
|
||||||
const page = await this.pageRepo.findById(dto.pageId);
|
|
||||||
if (page.spaceId !== dto.spaceId) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
pageId = page.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.pageService.getSidebarPages(dto.spaceId, pagination, pageId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ export * from "./lib/mention";
|
|||||||
export * from "./lib/markdown";
|
export * from "./lib/markdown";
|
||||||
export * from "./lib/search-and-replace";
|
export * from "./lib/search-and-replace";
|
||||||
export * from "./lib/embed-provider";
|
export * from "./lib/embed-provider";
|
||||||
|
export * from "./lib/subpages";
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { Subpages } from "./subpages";
|
||||||
|
export type { SubpagesAttributes, SubpagesOptions } from "./subpages";
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { mergeAttributes, Node } from "@tiptap/core";
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
|
||||||
|
export interface SubpagesOptions {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
view: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubpagesAttributes {}
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
subpages: {
|
||||||
|
insertSubpages: (attributes?: SubpagesAttributes) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Subpages = Node.create<SubpagesOptions>({
|
||||||
|
name: "subpages",
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
view: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
group: "block",
|
||||||
|
atom: true,
|
||||||
|
draggable: false,
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `div[data-type="${this.name}"]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"div",
|
||||||
|
mergeAttributes(
|
||||||
|
{ "data-type": this.name },
|
||||||
|
this.options.HTMLAttributes,
|
||||||
|
HTMLAttributes,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertSubpages:
|
||||||
|
(attributes) =>
|
||||||
|
({ commands }) => {
|
||||||
|
return commands.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
attrs: attributes,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user