[WIKI-511] [WIKI-572] fix: extended page root and editor body (#7661)

* fix: better refactoring of page root and editor body

* fix: editor sideeffects

* fix: add comments json field

* fix: props name

* fix: review changes

* fix: extra checks

* fix: type changes

* fix: remove editor ref current

* fix: renaming

* fix: link check
This commit is contained in:
M. Palanikannan 2025-09-01 22:17:53 +05:30 committed by GitHub
parent f42eeec2c0
commit fd5ba6c7b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 492 additions and 106 deletions

View File

@ -2,17 +2,16 @@ import React from "react";
// plane imports // plane imports
import { type EditorRefApi, type ILiteTextEditorProps, LiteTextEditorWithRef, type TFileHandler } from "@plane/editor"; import { type EditorRefApi, type ILiteTextEditorProps, LiteTextEditorWithRef, type TFileHandler } from "@plane/editor";
import type { MakeOptional } from "@plane/types"; import type { MakeOptional } from "@plane/types";
import { cn } from "@plane/utils"; import { cn, isCommentEmpty } from "@plane/utils";
// helpers // helpers
import { getEditorFileHandlers } from "@/helpers/editor.helper"; import { getEditorFileHandlers } from "@/helpers/editor.helper";
import { isCommentEmpty } from "@/helpers/string.helper";
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
// local imports // local imports
import { EditorMentionsRoot } from "./embeds/mentions"; import { EditorMentionsRoot } from "./embeds/mentions";
import { IssueCommentToolbar } from "./toolbar"; import { IssueCommentToolbar } from "./toolbar";
type LiteTextEditorWrapperProps = MakeOptional< type LiteTextEditorWrapperProps = MakeOptional<
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler">, Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
"disabledExtensions" | "flaggedExtensions" "disabledExtensions" | "flaggedExtensions"
> & { > & {
anchor: string; anchor: string;
@ -63,6 +62,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
mentionHandler={{ mentionHandler={{
renderComponent: (props) => <EditorMentionsRoot {...props} />, renderComponent: (props) => <EditorMentionsRoot {...props} />,
}} }}
extendedEditorProps={{}}
{...rest} {...rest}
// overriding the containerClassName to add relative class passed // overriding the containerClassName to add relative class passed
containerClassName={cn(containerClassName, "relative")} containerClassName={cn(containerClassName, "relative")}

View File

@ -12,7 +12,7 @@ import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { EditorMentionsRoot } from "./embeds/mentions"; import { EditorMentionsRoot } from "./embeds/mentions";
type RichTextEditorWrapperProps = MakeOptional< type RichTextEditorWrapperProps = MakeOptional<
Omit<IRichTextEditorProps, "editable" | "fileHandler" | "mentionHandler">, Omit<IRichTextEditorProps, "editable" | "fileHandler" | "mentionHandler" | "extendedEditorProps">,
"disabledExtensions" | "flaggedExtensions" "disabledExtensions" | "flaggedExtensions"
> & { > & {
anchor: string; anchor: string;
@ -56,6 +56,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
workspaceId, workspaceId,
})} })}
flaggedExtensions={richTextEditorExtensions.flagged} flaggedExtensions={richTextEditorExtensions.flagged}
extendedEditorProps={{}}
{...rest} {...rest}
containerClassName={containerClassName} containerClassName={containerClassName}
editorClassName="min-h-[100px] py-2 overflow-hidden" editorClassName="min-h-[100px] py-2 overflow-hidden"

View File

@ -57,24 +57,6 @@ export const isEmptyHtmlString = (htmlString: string, allowedHTMLTags: string[]
return cleanText.trim() === ""; return cleanText.trim() === "";
}; };
/**
* @description this function returns whether a comment is empty or not by checking for the following conditions-
* 1. If comment is undefined
* 2. If comment is an empty string
* 3. If comment is "<p></p>"
* @param {string | undefined} comment
* @returns {boolean}
*/
export const isCommentEmpty = (comment: string | undefined): boolean => {
// return true if comment is undefined
if (!comment) return true;
return (
comment?.trim() === "" ||
comment === "<p></p>" ||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component", "embed-component"])
);
};
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
@ -20,6 +20,7 @@ import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages
import { useEditorConfig } from "@/hooks/editor"; import { useEditorConfig } from "@/hooks/editor";
import { useEditorAsset } from "@/hooks/store/use-editor-asset"; import { useEditorAsset } from "@/hooks/store/use-editor-asset";
import { useWorkspace } from "@/hooks/store/use-workspace"; import { useWorkspace } from "@/hooks/store/use-workspace";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web hooks // plane web hooks
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store"; import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
// plane web services // plane web services
@ -30,13 +31,17 @@ const workspaceService = new WorkspaceService();
const projectPageService = new ProjectPageService(); const projectPageService = new ProjectPageService();
const projectPageVersionService = new ProjectPageVersionService(); const projectPageVersionService = new ProjectPageVersionService();
const storeType = EPageStoreType.PROJECT;
const PageDetailsPage = observer(() => { const PageDetailsPage = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId, pageId } = useParams(); const { workspaceSlug, projectId, pageId } = useParams();
// store hooks // store hooks
const { createPage, fetchPageDetails } = usePageStore(EPageStoreType.PROJECT); const { createPage, fetchPageDetails } = usePageStore(storeType);
const page = usePage({ const page = usePage({
pageId: pageId?.toString() ?? "", pageId: pageId?.toString() ?? "",
storeType: EPageStoreType.PROJECT, storeType,
}); });
const { getWorkspaceBySlug } = useWorkspace(); const { getWorkspaceBySlug } = useWorkspace();
const { uploadEditorAsset } = useEditorAsset(); const { uploadEditorAsset } = useEditorAsset();
@ -88,10 +93,25 @@ const PageDetailsPage = observer(() => {
versionId versionId
); );
}, },
getRedirectionLink: (pageId) => `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`, restoreVersion: async (pageId, versionId) => {
if (!workspaceSlug || !projectId) return;
await projectPageVersionService.restoreVersion(
workspaceSlug.toString(),
projectId.toString(),
pageId,
versionId
);
},
getRedirectionLink: (pageId) => {
if (pageId) {
return `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`;
} else {
return `/${workspaceSlug}/projects/${projectId}/pages`;
}
},
updateDescription: updateDescription ?? (async () => {}), updateDescription: updateDescription ?? (async () => {}),
}), }),
[createPage, fetchEntityCallback, id, projectId, updateDescription, workspaceSlug] [createPage, fetchEntityCallback, id, updateDescription, workspaceSlug, projectId]
); );
// page root config // page root config
const pageRootConfig: TPageRootConfig = useMemo( const pageRootConfig: TPageRootConfig = useMemo(
@ -115,7 +135,7 @@ const PageDetailsPage = observer(() => {
workspaceSlug: workspaceSlug?.toString() ?? "", workspaceSlug: workspaceSlug?.toString() ?? "",
}), }),
}), }),
[getEditorFileHandlers, id, projectId, uploadEditorAsset, workspaceId, workspaceSlug] [getEditorFileHandlers, id, uploadEditorAsset, projectId, workspaceId, workspaceSlug]
); );
const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo( const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo(
@ -127,6 +147,12 @@ const PageDetailsPage = observer(() => {
[projectId, workspaceSlug] [projectId, workspaceSlug]
); );
useEffect(() => {
if (page?.deleted_at && page?.id) {
router.push(pageRootHandlers.getRedirectionLink());
}
}, [page?.deleted_at, page?.id, router, pageRootHandlers]);
if ((!page || !id) && !pageDetailsError) if ((!page || !id) && !pageDetailsError)
return ( return (
<div className="size-full grid place-items-center"> <div className="size-full grid place-items-center">
@ -150,7 +176,7 @@ const PageDetailsPage = observer(() => {
</div> </div>
); );
if (!page) return null; if (!page || !workspaceSlug || !projectId) return null;
return ( return (
<> <>
@ -160,9 +186,11 @@ const PageDetailsPage = observer(() => {
<PageRoot <PageRoot
config={pageRootConfig} config={pageRootConfig}
handlers={pageRootHandlers} handlers={pageRootHandlers}
storeType={storeType}
page={page} page={page}
webhookConnectionParams={webhookConnectionParams} webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug?.toString() ?? ""} workspaceSlug={workspaceSlug.toString()}
projectId={projectId?.toString()}
/> />
<IssuePeekOverview /> <IssuePeekOverview />
</div> </div>

View File

@ -1 +1,2 @@
export * from "./move-page-modal"; export * from "./move-page-modal";
export * from "./modals";

View File

@ -0,0 +1,15 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
// components
import { EPageStoreType } from "@/plane-web/hooks/store";
// store
import { TPageInstance } from "@/store/pages/base-page";
export type TPageModalsProps = {
page: TPageInstance;
storeType: EPageStoreType;
};
export const PageModals: React.FC<TPageModalsProps> = observer((props) => null);

View File

@ -0,0 +1,2 @@
export * from "./use-pages-pane-extensions";
export * from "./use-extended-editor-extensions";

View File

@ -0,0 +1,20 @@
import type { IEditorPropsExtended } from "@plane/editor";
import type { TSearchEntityRequestPayload, TSearchResponse } from "@plane/types";
import type { TPageInstance } from "@/store/pages/base-page";
import { EPageStoreType } from "../store";
export type TExtendedEditorExtensionsHookParams = {
workspaceSlug: string;
page: TPageInstance;
storeType: EPageStoreType;
fetchEntity: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
getRedirectionLink: (pageId?: string) => string;
extensionHandlers?: Map<string, unknown>;
projectId?: string;
};
export type TExtendedEditorExtensionsConfig = IEditorPropsExtended;
export const useExtendedEditorProps = (
_params: TExtendedEditorExtensionsHookParams
): TExtendedEditorExtensionsConfig => ({});

View File

@ -0,0 +1,52 @@
import { useCallback, useMemo, type RefObject } from "react";
import { useSearchParams } from "next/navigation";
import type { EditorRefApi } from "@plane/editor";
import {
PAGE_NAVIGATION_PANE_TAB_KEYS,
PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM,
} from "@/components/pages/navigation-pane";
import { useAppRouter } from "@/hooks/use-app-router";
import { useQueryParams } from "@/hooks/use-query-params";
import type { TPageNavigationPaneTab } from "@/plane-web/components/pages/navigation-pane";
import { INavigationPaneExtension } from "@/plane-web/types/pages/pane-extensions";
import type { TPageInstance } from "@/store/pages/base-page";
export type TPageExtensionHookParams = {
page: TPageInstance;
editorRef: RefObject<EditorRefApi>;
};
export const usePagesPaneExtensions = (_params: TPageExtensionHookParams) => {
const router = useAppRouter();
const { updateQueryParams } = useQueryParams();
const searchParams = useSearchParams();
// Generic navigation pane logic - hook manages feature-specific routing
const navigationPaneQueryParam = searchParams.get(
PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM
) as TPageNavigationPaneTab | null;
const isNavigationPaneOpen =
!!navigationPaneQueryParam && PAGE_NAVIGATION_PANE_TAB_KEYS.includes(navigationPaneQueryParam);
const handleOpenNavigationPane = useCallback(() => {
const updatedRoute = updateQueryParams({
paramsToAdd: { [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM]: "outline" },
});
router.push(updatedRoute);
}, [router, updateQueryParams]);
const editorExtensionHandlers: Map<string, unknown> = useMemo(() => {
const map: Map<string, unknown> = new Map();
return map;
}, []);
const navigationPaneExtensions: INavigationPaneExtension[] = [];
return {
editorExtensionHandlers,
navigationPaneExtensions,
handleOpenNavigationPane,
isNavigationPaneOpen,
};
};

View File

@ -1,5 +1,6 @@
// editor // editor
import type { TExtensions } from "@plane/editor"; import type { TExtensions } from "@plane/editor";
import { EPageStoreType } from "@/plane-web/hooks/store";
export type TEditorFlaggingHookReturnType = { export type TEditorFlaggingHookReturnType = {
document: { document: {
@ -16,10 +17,15 @@ export type TEditorFlaggingHookReturnType = {
}; };
}; };
export type TEditorFlaggingHookProps = {
workspaceSlug: string;
storeType?: EPageStoreType;
};
/** /**
* @description extensions disabled in various editors * @description extensions disabled in various editors
*/ */
export const useEditorFlagging = (workspaceSlug: string): TEditorFlaggingHookReturnType => ({ export const useEditorFlagging = (props: TEditorFlaggingHookProps): TEditorFlaggingHookReturnType => ({
document: { document: {
disabled: ["ai", "collaboration-cursor"], disabled: ["ai", "collaboration-cursor"],
flagged: [], flagged: [],

View File

@ -0,0 +1,16 @@
import {
type INavigationPaneExtension as ICoreNavigationPaneExtension,
type INavigationPaneExtensionComponent,
} from "@/components/pages/navigation-pane";
// EE Union/map of extension data types (keyed by extension id)
export type TNavigationPaneExtensionData = Record<string, unknown>;
// EE Navigation pane extension configuration
export interface INavigationPaneExtension<
T extends keyof TNavigationPaneExtensionData = keyof TNavigationPaneExtensionData,
> extends Omit<ICoreNavigationPaneExtension<TNavigationPaneExtensionData[T]>, "id" | "data" | "component"> {
id: T;
component: INavigationPaneExtensionComponent<TNavigationPaneExtensionData[T]>;
data?: TNavigationPaneExtensionData[T];
}

View File

@ -13,7 +13,7 @@ import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
import { EditorMentionsRoot } from "../embeds/mentions"; import { EditorMentionsRoot } from "../embeds/mentions";
type DocumentEditorWrapperProps = MakeOptional< type DocumentEditorWrapperProps = MakeOptional<
Omit<IDocumentEditorProps, "fileHandler" | "mentionHandler" | "embedHandler" | "user">, Omit<IDocumentEditorProps, "fileHandler" | "mentionHandler" | "embedHandler" | "user" | "extendedEditorProps">,
"disabledExtensions" | "editable" | "flaggedExtensions" "disabledExtensions" | "editable" | "flaggedExtensions"
> & { > & {
embedHandler?: Partial<IDocumentEditorProps["embedHandler"]>; embedHandler?: Partial<IDocumentEditorProps["embedHandler"]>;
@ -45,7 +45,9 @@ export const DocumentEditor = forwardRef<EditorRefApi, DocumentEditorWrapperProp
// store hooks // store hooks
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
// editor flaggings // editor flaggings
const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug); const { document: documentEditorExtensions } = useEditorFlagging({
workspaceSlug: workspaceSlug?.toString() ?? "",
});
// use editor mention // use editor mention
const { fetchMentions } = useEditorMention({ const { fetchMentions } = useEditorMention({
searchEntity: editable ? async (payload) => await props.searchMentionCallback(payload) : async () => ({}), searchEntity: editable ? async (payload) => await props.searchMentionCallback(payload) : async () => ({}),
@ -83,6 +85,7 @@ export const DocumentEditor = forwardRef<EditorRefApi, DocumentEditorWrapperProp
issue: issueEmbedProps, issue: issueEmbedProps,
...embedHandler, ...embedHandler,
}} }}
extendedEditorProps={{}}
{...rest} {...rest}
containerClassName={cn("relative pl-3 pb-3", containerClassName)} containerClassName={cn("relative pl-3 pb-3", containerClassName)}
/> />

View File

@ -19,7 +19,7 @@ import { WorkspaceService } from "@/plane-web/services";
const workspaceService = new WorkspaceService(); const workspaceService = new WorkspaceService();
type LiteTextEditorWrapperProps = MakeOptional< type LiteTextEditorWrapperProps = MakeOptional<
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler">, Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
"disabledExtensions" | "flaggedExtensions" "disabledExtensions" | "flaggedExtensions"
> & { > & {
workspaceSlug: string; workspaceSlug: string;
@ -68,7 +68,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
// states // states
const [isFocused, setIsFocused] = useState(showToolbarInitially); const [isFocused, setIsFocused] = useState(showToolbarInitially);
// editor flaggings // editor flaggings
const { liteText: liteTextEditorExtensions } = useEditorFlagging(workspaceSlug?.toString()); const { liteText: liteTextEditorExtensions } = useEditorFlagging({
workspaceSlug: workspaceSlug?.toString() ?? "",
});
// store hooks // store hooks
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
// use editor mention // use editor mention
@ -126,6 +128,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
containerClassName={cn(containerClassName, "relative", { containerClassName={cn(containerClassName, "relative", {
"p-2": !editable, "p-2": !editable,
})} })}
extendedEditorProps={{}}
{...rest} {...rest}
/> />
{showToolbar && editable && ( {showToolbar && editable && (

View File

@ -12,7 +12,7 @@ import { useMember } from "@/hooks/store/use-member";
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
type RichTextEditorWrapperProps = MakeOptional< type RichTextEditorWrapperProps = MakeOptional<
Omit<IRichTextEditorProps, "fileHandler" | "mentionHandler">, Omit<IRichTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
"disabledExtensions" | "editable" | "flaggedExtensions" "disabledExtensions" | "editable" | "flaggedExtensions"
> & { > & {
workspaceSlug: string; workspaceSlug: string;
@ -42,7 +42,9 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
// store hooks // store hooks
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
// editor flaggings // editor flaggings
const { richText: richTextEditorExtensions } = useEditorFlagging(workspaceSlug?.toString()); const { richText: richTextEditorExtensions } = useEditorFlagging({
workspaceSlug: workspaceSlug?.toString() ?? "",
});
// use editor mention // use editor mention
const { fetchMentions } = useEditorMention({ const { fetchMentions } = useEditorMention({
searchEntity: editable ? async (payload) => await props.searchMentionCallback(payload) : async () => ({}), searchEntity: editable ? async (payload) => await props.searchMentionCallback(payload) : async () => ({}),
@ -73,6 +75,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
display_name: getUserDetails(id)?.display_name ?? "", display_name: getUserDetails(id)?.display_name ?? "",
}), }),
}} }}
extendedEditorProps={{}}
{...rest} {...rest}
containerClassName={cn("relative pl-3 pb-3", containerClassName)} containerClassName={cn("relative pl-3 pb-3", containerClassName)}
/> />

View File

@ -15,7 +15,7 @@ import { StickyEditorToolbar } from "./toolbar";
interface StickyEditorWrapperProps interface StickyEditorWrapperProps
extends Omit< extends Omit<
ILiteTextEditorProps, Omit<ILiteTextEditorProps, "extendedEditorProps">,
"disabledExtensions" | "editable" | "flaggedExtensions" | "fileHandler" | "mentionHandler" "disabledExtensions" | "editable" | "flaggedExtensions" | "fileHandler" | "mentionHandler"
> { > {
workspaceSlug: string; workspaceSlug: string;
@ -51,7 +51,9 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
// states // states
const [isFocused, setIsFocused] = useState(showToolbarInitially); const [isFocused, setIsFocused] = useState(showToolbarInitially);
// editor flaggings // editor flaggings
const { liteText: liteTextEditorExtensions } = useEditorFlagging(workspaceSlug?.toString()); const { liteText: liteTextEditorExtensions } = useEditorFlagging({
workspaceSlug: workspaceSlug?.toString() ?? "",
});
// editor config // editor config
const { getEditorFileHandlers } = useEditorConfig(); const { getEditorFileHandlers } = useEditorConfig();
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> { function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
@ -79,6 +81,7 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
mentionHandler={{ mentionHandler={{
renderComponent: () => <></>, renderComponent: () => <></>,
}} }}
extendedEditorProps={{}}
containerClassName={cn(containerClassName, "relative")} containerClassName={cn(containerClassName, "relative")}
{...rest} {...rest}
/> />

View File

@ -24,8 +24,13 @@ import { useUser } from "@/hooks/store/user";
import { usePageFilters } from "@/hooks/use-page-filters"; import { usePageFilters } from "@/hooks/use-page-filters";
// plane web components // plane web components
import { EditorAIMenu } from "@/plane-web/components/pages"; import { EditorAIMenu } from "@/plane-web/components/pages";
// plane web types
import type { TExtendedEditorExtensionsConfig } from "@/plane-web/hooks/pages";
// plane web store
import { EPageStoreType } from "@/plane-web/hooks/store";
// plane web hooks // plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed"; import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
// store // store
import type { TPageInstance } from "@/store/pages/base-page"; import type { TPageInstance } from "@/store/pages/base-page";
@ -41,6 +46,7 @@ export type TEditorBodyConfig = {
export type TEditorBodyHandlers = { export type TEditorBodyHandlers = {
fetchEntity: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>; fetchEntity: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
getRedirectionLink: (pageId?: string) => string;
}; };
type Props = { type Props = {
@ -54,7 +60,11 @@ type Props = {
isNavigationPaneOpen: boolean; isNavigationPaneOpen: boolean;
page: TPageInstance; page: TPageInstance;
webhookConnectionParams: TWebhookConnectionQueryParams; webhookConnectionParams: TWebhookConnectionQueryParams;
projectId: string;
workspaceSlug: string; workspaceSlug: string;
storeType: EPageStoreType;
extendedEditorProps: TExtendedEditorExtensionsConfig;
}; };
export const PageEditorBody: React.FC<Props> = observer((props) => { export const PageEditorBody: React.FC<Props> = observer((props) => {
@ -67,8 +77,11 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
handlers, handlers,
isNavigationPaneOpen, isNavigationPaneOpen,
page, page,
storeType,
webhookConnectionParams, webhookConnectionParams,
projectId,
workspaceSlug, workspaceSlug,
extendedEditorProps,
} = props; } = props;
// store hooks // store hooks
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
@ -93,7 +106,10 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
searchEntity: handlers.fetchEntity, searchEntity: handlers.fetchEntity,
}); });
// editor flaggings // editor flaggings
const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug); const { document: documentEditorExtensions } = useEditorFlagging({
workspaceSlug,
storeType,
});
// page filters // page filters
const { fontSize, fontStyle, isFullWidth } = usePageFilters(); const { fontSize, fontStyle, isFullWidth } = usePageFilters();
// translation // translation
@ -115,7 +131,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
workspaceId={workspaceId} workspaceId={workspaceId}
workspaceSlug={workspaceSlug?.toString() ?? ""} workspaceSlug={workspaceSlug}
/> />
), ),
[editorRef, workspaceId, workspaceSlug] [editorRef, workspaceId, workspaceSlug]
@ -202,7 +218,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
)} )}
<div className="page-header-container group/page-header"> <div className="page-header-container group/page-header">
<div className={blockWidthClassName}> <div className={blockWidthClassName}>
<PageEditorHeaderRoot page={page} /> <PageEditorHeaderRoot page={page} projectId={projectId} />
<PageEditorTitle <PageEditorTitle
editorRef={editorRef} editorRef={editorRef}
readOnly={!isContentEditable} readOnly={!isContentEditable}
@ -240,6 +256,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
menu: getAIMenu, menu: getAIMenu,
}} }}
onAssetChange={updateAssetsList} onAssetChange={updateAssetsList}
extendedEditorProps={extendedEditorProps}
/> />
</div> </div>
</Row> </Row>

View File

@ -11,6 +11,7 @@ import { PageEditorHeaderLogoPicker } from "./logo-picker";
type Props = { type Props = {
page: TPageInstance; page: TPageInstance;
projectId: string;
}; };
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => { export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {

View File

@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// plane imports // plane imports
import type { EditorRefApi } from "@plane/editor"; import type { EditorRefApi } from "@plane/editor";
import type { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types"; import type { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types";
@ -9,19 +8,20 @@ import { useAppRouter } from "@/hooks/use-app-router";
import { usePageFallback } from "@/hooks/use-page-fallback"; import { usePageFallback } from "@/hooks/use-page-fallback";
import { useQueryParams } from "@/hooks/use-query-params"; import { useQueryParams } from "@/hooks/use-query-params";
// plane web import // plane web import
import type { TPageNavigationPaneTab } from "@/plane-web/components/pages/navigation-pane"; import { PageModals } from "@/plane-web/components/pages";
import { usePagesPaneExtensions, useExtendedEditorProps } from "@/plane-web/hooks/pages";
import { EPageStoreType } from "@/plane-web/hooks/store";
// store // store
import type { TPageInstance } from "@/store/pages/base-page"; import type { TPageInstance } from "@/store/pages/base-page";
// local imports // local imports
import { import {
PAGE_NAVIGATION_PANE_TAB_KEYS,
PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM,
PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM,
PageNavigationPaneRoot, PageNavigationPaneRoot,
} from "../navigation-pane"; } from "../navigation-pane";
import { PageVersionsOverlay } from "../version"; import { PageVersionsOverlay } from "../version";
import { PagesVersionEditor } from "../version/editor"; import { PagesVersionEditor } from "../version/editor";
import { PageEditorBody, TEditorBodyConfig, TEditorBodyHandlers } from "./editor-body"; import { PageEditorBody, type TEditorBodyConfig, type TEditorBodyHandlers } from "./editor-body";
import { PageEditorToolbarRoot } from "./toolbar"; import { PageEditorToolbarRoot } from "./toolbar";
export type TPageRootHandlers = { export type TPageRootHandlers = {
@ -29,7 +29,7 @@ export type TPageRootHandlers = {
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>; fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
fetchDescriptionBinary: () => Promise<any>; fetchDescriptionBinary: () => Promise<any>;
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>; fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
getRedirectionLink: (pageId: string) => string; restoreVersion: (pageId: string, versionId: string) => Promise<void>;
updateDescription: (document: TDocumentPayload) => Promise<void>; updateDescription: (document: TDocumentPayload) => Promise<void>;
} & TEditorBodyHandlers; } & TEditorBodyHandlers;
@ -39,12 +39,14 @@ type TPageRootProps = {
config: TPageRootConfig; config: TPageRootConfig;
handlers: TPageRootHandlers; handlers: TPageRootHandlers;
page: TPageInstance; page: TPageInstance;
storeType: EPageStoreType;
webhookConnectionParams: TWebhookConnectionQueryParams; webhookConnectionParams: TWebhookConnectionQueryParams;
projectId: string;
workspaceSlug: string; workspaceSlug: string;
}; };
export const PageRoot = observer((props: TPageRootProps) => { export const PageRoot = observer((props: TPageRootProps) => {
const { config, handlers, page, webhookConnectionParams, workspaceSlug } = props; const { config, handlers, page, projectId, storeType, webhookConnectionParams, workspaceSlug } = props;
// states // states
const [editorReady, setEditorReady] = useState(false); const [editorReady, setEditorReady] = useState(false);
const [hasConnectionFailed, setHasConnectionFailed] = useState(false); const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
@ -52,8 +54,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
const editorRef = useRef<EditorRefApi>(null); const editorRef = useRef<EditorRefApi>(null);
// router // router
const router = useAppRouter(); const router = useAppRouter();
// search params
const searchParams = useSearchParams();
// derived values // derived values
const { const {
isContentEditable, isContentEditable,
@ -66,7 +66,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
hasConnectionFailed, hasConnectionFailed,
updatePageDescription: handlers.updateDescription, updatePageDescription: handlers.updateDescription,
}); });
// update query params
const { updateQueryParams } = useQueryParams(); const { updateQueryParams } = useQueryParams();
const handleEditorReady = useCallback( const handleEditorReady = useCallback(
@ -85,10 +84,31 @@ export const PageRoot = observer((props: TPageRootProps) => {
}, 0); }, 0);
}, [isContentEditable, setEditorRef]); }, [isContentEditable, setEditorRef]);
const handleRestoreVersion = useCallback(async (descriptionHTML: string) => { // Get extensions and navigation logic from hook
editorRef.current?.clearEditor(); const { editorExtensionHandlers, navigationPaneExtensions, handleOpenNavigationPane, isNavigationPaneOpen } =
editorRef.current?.setEditorValue(descriptionHTML); usePagesPaneExtensions({
}, []); page,
editorRef,
});
// Get extended editor extensions configuration
const extendedEditorProps = useExtendedEditorProps({
workspaceSlug,
page,
storeType,
fetchEntity: handlers.fetchEntity,
getRedirectionLink: handlers.getRedirectionLink,
extensionHandlers: editorExtensionHandlers,
projectId,
});
const handleRestoreVersion = useCallback(
async (descriptionHTML: string) => {
editorRef.current?.clearEditor();
editorRef.current?.setEditorValue(descriptionHTML);
},
[editorRef]
);
// reset editor ref on unmount // reset editor ref on unmount
useEffect( useEffect(
@ -98,19 +118,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
[setEditorRef] [setEditorRef]
); );
const navigationPaneQueryParam = searchParams.get(
PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM
) as TPageNavigationPaneTab | null;
const isValidNavigationPaneTab =
!!navigationPaneQueryParam && PAGE_NAVIGATION_PANE_TAB_KEYS.includes(navigationPaneQueryParam);
const handleOpenNavigationPane = useCallback(() => {
const updatedRoute = updateQueryParams({
paramsToAdd: { [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM]: "outline" },
});
router.push(updatedRoute);
}, [router, updateQueryParams]);
const handleCloseNavigationPane = useCallback(() => { const handleCloseNavigationPane = useCallback(() => {
const updatedRoute = updateQueryParams({ const updatedRoute = updateQueryParams({
paramsToRemove: [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM], paramsToRemove: [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM],
@ -127,10 +134,11 @@ export const PageRoot = observer((props: TPageRootProps) => {
handleRestore={handleRestoreVersion} handleRestore={handleRestoreVersion}
pageId={page.id ?? ""} pageId={page.id ?? ""}
restoreEnabled={isContentEditable} restoreEnabled={isContentEditable}
storeType={storeType}
/> />
<PageEditorToolbarRoot <PageEditorToolbarRoot
handleOpenNavigationPane={handleOpenNavigationPane} handleOpenNavigationPane={handleOpenNavigationPane}
isNavigationPaneOpen={isValidNavigationPaneTab} isNavigationPaneOpen={isNavigationPaneOpen}
page={page} page={page}
/> />
<PageEditorBody <PageEditorBody
@ -141,21 +149,27 @@ export const PageRoot = observer((props: TPageRootProps) => {
handleEditorReady={handleEditorReady} handleEditorReady={handleEditorReady}
handleOpenNavigationPane={handleOpenNavigationPane} handleOpenNavigationPane={handleOpenNavigationPane}
handlers={handlers} handlers={handlers}
isNavigationPaneOpen={isValidNavigationPaneTab} isNavigationPaneOpen={isNavigationPaneOpen}
page={page} page={page}
projectId={projectId}
storeType={storeType}
webhookConnectionParams={webhookConnectionParams} webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
extendedEditorProps={extendedEditorProps}
/> />
</div> </div>
<PageNavigationPaneRoot <PageNavigationPaneRoot
storeType={storeType}
handleClose={handleCloseNavigationPane} handleClose={handleCloseNavigationPane}
isNavigationPaneOpen={isValidNavigationPaneTab} isNavigationPaneOpen={isNavigationPaneOpen}
page={page} page={page}
versionHistory={{ versionHistory={{
fetchAllVersions: handlers.fetchAllVersions, fetchAllVersions: handlers.fetchAllVersions,
fetchVersionDetails: handlers.fetchVersionDetails, fetchVersionDetails: handlers.fetchVersionDetails,
}} }}
extensions={navigationPaneExtensions}
/> />
<PageModals page={page} storeType={storeType} />
</div> </div>
); );
}); });

View File

@ -2,6 +2,7 @@
import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/plane-web/components/pages/navigation-pane"; import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/plane-web/components/pages/navigation-pane";
export * from "./root"; export * from "./root";
export * from "./types";
export const PAGE_NAVIGATION_PANE_WIDTH = 294; export const PAGE_NAVIGATION_PANE_WIDTH = 294;

View File

@ -11,11 +11,14 @@ import { useQueryParams } from "@/hooks/use-query-params";
// plane web components // plane web components
import { TPageNavigationPaneTab } from "@/plane-web/components/pages/navigation-pane"; import { TPageNavigationPaneTab } from "@/plane-web/components/pages/navigation-pane";
// store // store
import { type EPageStoreType } from "@/plane-web/hooks/store";
import type { TPageInstance } from "@/store/pages/base-page"; import type { TPageInstance } from "@/store/pages/base-page";
// local imports // local imports
import { TPageRootHandlers } from "../editor/page-root"; import { TPageRootHandlers } from "../editor/page-root";
import { PageNavigationPaneTabPanelsRoot } from "./tab-panels/root"; import { PageNavigationPaneTabPanelsRoot } from "./tab-panels/root";
import { PageNavigationPaneTabsList } from "./tabs-list"; import { PageNavigationPaneTabsList } from "./tabs-list";
import { INavigationPaneExtension } from "./types/extensions";
import { import {
PAGE_NAVIGATION_PANE_TAB_KEYS, PAGE_NAVIGATION_PANE_TAB_KEYS,
PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM,
@ -28,10 +31,14 @@ type Props = {
isNavigationPaneOpen: boolean; isNavigationPaneOpen: boolean;
page: TPageInstance; page: TPageInstance;
versionHistory: Pick<TPageRootHandlers, "fetchAllVersions" | "fetchVersionDetails">; versionHistory: Pick<TPageRootHandlers, "fetchAllVersions" | "fetchVersionDetails">;
// Generic extension system for additional navigation pane content
extensions?: INavigationPaneExtension[];
storeType: EPageStoreType;
}; };
export const PageNavigationPaneRoot: React.FC<Props> = observer((props) => { export const PageNavigationPaneRoot: React.FC<Props> = observer((props) => {
const { handleClose, isNavigationPaneOpen, page, versionHistory } = props; const { handleClose, isNavigationPaneOpen, page, versionHistory, extensions = [], storeType } = props;
// navigation // navigation
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -43,6 +50,21 @@ export const PageNavigationPaneRoot: React.FC<Props> = observer((props) => {
) as TPageNavigationPaneTab | null; ) as TPageNavigationPaneTab | null;
const activeTab: TPageNavigationPaneTab = navigationPaneQueryParam || "outline"; const activeTab: TPageNavigationPaneTab = navigationPaneQueryParam || "outline";
const selectedIndex = PAGE_NAVIGATION_PANE_TAB_KEYS.indexOf(activeTab); const selectedIndex = PAGE_NAVIGATION_PANE_TAB_KEYS.indexOf(activeTab);
// Check if any extension is currently active based on query parameters
const ActiveExtension = extensions.find((extension) => {
const paneTabValue = searchParams.get(PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM);
const hasVersionParam = searchParams.get(PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM);
// Extension is active ONLY when paneTab matches AND no regular navigation params are present
return paneTabValue === extension.triggerParam && !hasVersionParam;
});
// Don't show tabs when an extension is active
const showNavigationTabs = !ActiveExtension && isNavigationPaneOpen;
// Use extension width if available, otherwise fall back to default
const paneWidth = ActiveExtension?.width ?? PAGE_NAVIGATION_PANE_WIDTH;
// translation // translation
const { t } = useTranslation(); const { t } = useTranslation();
@ -61,10 +83,10 @@ export const PageNavigationPaneRoot: React.FC<Props> = observer((props) => {
return ( return (
<aside <aside
className="flex-shrink-0 h-full flex flex-col bg-custom-background-100 pt-3.5 border-l border-custom-border-200 transition-all duration-300 ease-in-out" className="flex-shrink-0 h-full flex flex-col bg-custom-background-100 pt-3.5 border-l border-custom-border-200 transition-all duration-300 ease-out"
style={{ style={{
width: `${PAGE_NAVIGATION_PANE_WIDTH}px`, width: `${paneWidth}px`,
marginRight: isNavigationPaneOpen ? "0px" : `-${PAGE_NAVIGATION_PANE_WIDTH}px`, marginRight: isNavigationPaneOpen ? "0px" : `-${paneWidth}px`,
}} }}
> >
<div className="mb-3.5 px-3.5"> <div className="mb-3.5 px-3.5">
@ -79,10 +101,17 @@ export const PageNavigationPaneRoot: React.FC<Props> = observer((props) => {
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
<Tab.Group as={React.Fragment} selectedIndex={selectedIndex} onChange={handleTabChange}>
<PageNavigationPaneTabsList /> <div className="flex-1 flex flex-col overflow-hidden animate-slide-in-right">
<PageNavigationPaneTabPanelsRoot page={page} versionHistory={versionHistory} /> {ActiveExtension ? (
</Tab.Group> <ActiveExtension.component page={page} extensionData={ActiveExtension.data} storeType={storeType} />
) : showNavigationTabs ? (
<Tab.Group as={React.Fragment} selectedIndex={selectedIndex} onChange={handleTabChange}>
<PageNavigationPaneTabsList />
<PageNavigationPaneTabPanelsRoot page={page} versionHistory={versionHistory} />
</Tab.Group>
) : null}
</div>
</aside> </aside>
); );
}); });

View File

@ -0,0 +1,21 @@
import { ReactNode } from "react";
import { EPageStoreType } from "@/plane-web/hooks/store";
import { TPageInstance } from "@/store/pages/base-page";
export interface INavigationPaneExtensionProps<T = any> {
page: TPageInstance;
extensionData?: T;
storeType: EPageStoreType;
}
export interface INavigationPaneExtensionComponent<T = any> {
(props: INavigationPaneExtensionProps<T>): ReactNode;
}
export interface INavigationPaneExtension<T = any> {
id: string;
triggerParam: string;
component: INavigationPaneExtensionComponent<T>;
data?: T;
width?: number;
}

View File

@ -0,0 +1,6 @@
// Export generic extension system interfaces
export type {
INavigationPaneExtensionProps,
INavigationPaneExtensionComponent,
INavigationPaneExtension,
} from "./extensions";

View File

@ -9,10 +9,13 @@ import { DocumentEditor } from "@/components/editor/document/editor";
// hooks // hooks
import { useWorkspace } from "@/hooks/store/use-workspace"; import { useWorkspace } from "@/hooks/store/use-workspace";
import { usePageFilters } from "@/hooks/use-page-filters"; import { usePageFilters } from "@/hooks/use-page-filters";
// plane web hooks
import { EPageStoreType } from "@/plane-web/hooks/store";
export type TVersionEditorProps = { export type TVersionEditorProps = {
activeVersion: string | null; activeVersion: string | null;
versionDetails: TPageVersion | undefined; versionDetails: TPageVersion | undefined;
storeType: EPageStoreType;
}; };
export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props) => { export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props) => {

View File

@ -6,6 +6,8 @@ import { EyeIcon, TriangleAlert } from "lucide-react";
import { TPageVersion } from "@plane/types"; import { TPageVersion } from "@plane/types";
import { Button, setToast, TOAST_TYPE } from "@plane/ui"; import { Button, setToast, TOAST_TYPE } from "@plane/ui";
import { renderFormattedDate, renderFormattedTime } from "@plane/utils"; import { renderFormattedDate, renderFormattedTime } from "@plane/utils";
// helpers
import { EPageStoreType } from "@/plane-web/hooks/store";
// local imports // local imports
import { TVersionEditorProps } from "./editor"; import { TVersionEditorProps } from "./editor";
@ -17,11 +19,20 @@ type Props = {
handleRestore: (descriptionHTML: string) => Promise<void>; handleRestore: (descriptionHTML: string) => Promise<void>;
pageId: string; pageId: string;
restoreEnabled: boolean; restoreEnabled: boolean;
storeType: EPageStoreType;
}; };
export const PageVersionsMainContent: React.FC<Props> = observer((props) => { export const PageVersionsMainContent: React.FC<Props> = observer((props) => {
const { activeVersion, editorComponent, fetchVersionDetails, handleClose, handleRestore, pageId, restoreEnabled } = const {
props; activeVersion,
editorComponent,
fetchVersionDetails,
handleClose,
handleRestore,
pageId,
restoreEnabled,
storeType,
} = props;
// states // states
const [isRestoring, setIsRestoring] = useState(false); const [isRestoring, setIsRestoring] = useState(false);
const [isRetrying, setIsRetrying] = useState(false); const [isRetrying, setIsRetrying] = useState(false);
@ -107,7 +118,7 @@ export const PageVersionsMainContent: React.FC<Props> = observer((props) => {
)} )}
</div> </div>
<div className="pt-8 h-full overflow-y-scroll vertical-scrollbar scrollbar-sm"> <div className="pt-8 h-full overflow-y-scroll vertical-scrollbar scrollbar-sm">
<VersionEditor activeVersion={activeVersion} versionDetails={versionDetails} /> <VersionEditor activeVersion={activeVersion} storeType={storeType} versionDetails={versionDetails} />
</div> </div>
</> </>
)} )}

View File

@ -6,6 +6,8 @@ import { TPageVersion } from "@plane/types";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// hooks // hooks
import { useQueryParams } from "@/hooks/use-query-params"; import { useQueryParams } from "@/hooks/use-query-params";
// plane web imports
import { EPageStoreType } from "@/plane-web/hooks/store";
// local imports // local imports
import { PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, PAGE_NAVIGATION_PANE_WIDTH } from "../navigation-pane"; import { PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, PAGE_NAVIGATION_PANE_WIDTH } from "../navigation-pane";
import { TVersionEditorProps } from "./editor"; import { TVersionEditorProps } from "./editor";
@ -17,10 +19,11 @@ type Props = {
handleRestore: (descriptionHTML: string) => Promise<void>; handleRestore: (descriptionHTML: string) => Promise<void>;
pageId: string; pageId: string;
restoreEnabled: boolean; restoreEnabled: boolean;
storeType: EPageStoreType;
}; };
export const PageVersionsOverlay: React.FC<Props> = observer((props) => { export const PageVersionsOverlay: React.FC<Props> = observer((props) => {
const { editorComponent, fetchVersionDetails, handleRestore, pageId, restoreEnabled } = props; const { editorComponent, fetchVersionDetails, handleRestore, pageId, restoreEnabled, storeType } = props;
// navigation // navigation
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -57,6 +60,7 @@ export const PageVersionsOverlay: React.FC<Props> = observer((props) => {
handleRestore={handleRestore} handleRestore={handleRestore}
pageId={pageId} pageId={pageId}
restoreEnabled={restoreEnabled} restoreEnabled={restoreEnabled}
storeType={storeType}
/> />
</div> </div>
); );

View File

@ -30,4 +30,14 @@ export class ProjectPageVersionService extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async restoreVersion(workspaceSlug: string, projectId: string, pageId: string, versionId: string): Promise<void> {
return this.post(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/versions/${versionId}/restore/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
} }

View File

@ -93,6 +93,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
updated_by: string | undefined; updated_by: string | undefined;
created_at: Date | undefined; created_at: Date | undefined;
updated_at: Date | undefined; updated_at: Date | undefined;
deleted_at: Date | undefined;
// helpers // helpers
oldName: string = ""; oldName: string = "";
// services // services
@ -130,6 +131,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
this.created_at = page?.created_at || undefined; this.created_at = page?.created_at || undefined;
this.updated_at = page?.updated_at || undefined; this.updated_at = page?.updated_at || undefined;
this.oldName = page?.name || ""; this.oldName = page?.name || "";
this.deleted_at = page?.deleted_at || undefined;
makeObservable(this, { makeObservable(this, {
// loaders // loaders
@ -153,6 +155,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
updated_by: observable.ref, updated_by: observable.ref,
created_at: observable.ref, created_at: observable.ref,
updated_at: observable.ref, updated_at: observable.ref,
deleted_at: observable.ref,
// helpers // helpers
oldName: observable.ref, oldName: observable.ref,
setIsSubmitting: action, setIsSubmitting: action,
@ -227,6 +230,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
updated_by: this.updated_by, updated_by: this.updated_by,
created_at: this.created_at, created_at: this.created_at,
updated_at: this.updated_at, updated_at: this.updated_at,
deleted_at: this.deleted_at,
...this.asJSONExtended, ...this.asJSONExtended,
}; };
} }

View File

@ -0,0 +1,12 @@
import { type Editor } from "@tiptap/core";
import type { ReactElement } from "react";
import type { IEditorPropsExtended } from "@/types";
export type DocumentEditorSideEffectsProps = {
editor: Editor;
id: string;
updatePageProperties?: unknown;
extendedEditorProps?: IEditorPropsExtended;
};
export const DocumentEditorSideEffects = (_props: DocumentEditorSideEffectsProps): ReactElement | null => null;

View File

@ -0,0 +1,9 @@
export type IEditorExtensionOptions = unknown;
export type IEditorPropsExtended = unknown;
export type TExtendedEditorCommands = never;
export type TExtendedCommandExtraProps = unknown;
export type TExtendedEditorRefApi = unknown;

View File

@ -1 +1,2 @@
export * from "./issue-embed"; export * from "./issue-embed";
export * from "./editor-extended";

View File

@ -12,6 +12,8 @@ import { WorkItemEmbedExtension } from "@/extensions";
import { getEditorClassNames } from "@/helpers/common"; import { getEditorClassNames } from "@/helpers/common";
// hooks // hooks
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor"; import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
// constants
import { DocumentEditorSideEffects } from "@/plane-editor/components/document-editor-side-effects";
// types // types
import type { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types"; import type { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
@ -27,6 +29,7 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
editable, editable,
editorClassName = "", editorClassName = "",
editorProps, editorProps,
extendedEditorProps,
embedHandler, embedHandler,
fileHandler, fileHandler,
flaggedExtensions, flaggedExtensions,
@ -97,20 +100,23 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
if (!editor) return null; if (!editor) return null;
return ( return (
<PageRenderer <>
aiHandler={aiHandler} <DocumentEditorSideEffects editor={editor} id={id} extendedEditorProps={extendedEditorProps} />
bubbleMenuEnabled={bubbleMenuEnabled} <PageRenderer
displayConfig={displayConfig} aiHandler={aiHandler}
documentLoaderClassName={documentLoaderClassName} bubbleMenuEnabled={bubbleMenuEnabled}
editor={editor} displayConfig={displayConfig}
editorContainerClassName={cn(editorContainerClassNames, "document-editor")} documentLoaderClassName={documentLoaderClassName}
id={id} editor={editor}
isTouchDevice={!!isTouchDevice} editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
isLoading={!hasServerSynced && !hasServerConnectionFailed} id={id}
tabIndex={tabIndex} isTouchDevice={!!isTouchDevice}
flaggedExtensions={flaggedExtensions} isLoading={!hasServerSynced && !hasServerConnectionFailed}
disabledExtensions={disabledExtensions} tabIndex={tabIndex}
/> flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
/>
</>
); );
}; };

View File

@ -5,6 +5,8 @@ import type { EditorProps, EditorView } from "@tiptap/pm/view";
import type { NodeViewProps as TNodeViewProps } from "@tiptap/react"; import type { NodeViewProps as TNodeViewProps } from "@tiptap/react";
// extension types // extension types
import type { TTextAlign } from "@/extensions"; import type { TTextAlign } from "@/extensions";
// plane editor imports
import type { IEditorPropsExtended, TExtendedEditorCommands } from "@/plane-editor/types/editor-extended";
// types // types
import type { import type {
IMarking, IMarking,
@ -50,7 +52,8 @@ export type TEditorCommands =
| "callout" | "callout"
| "attachment" | "attachment"
| "emoji" | "emoji"
| "external-embed"; | "external-embed"
| TExtendedEditorCommands;
export type TCommandExtraProps = { export type TCommandExtraProps = {
image: { image: {
@ -157,6 +160,7 @@ export type IEditorProps = {
placeholder?: string | ((isFocused: boolean, value: string) => string); placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number; tabIndex?: number;
value?: string | null; value?: string | null;
extendedEditorProps: IEditorPropsExtended;
}; };
export type ILiteTextEditorProps = IEditorProps; export type ILiteTextEditorProps = IEditorProps;

View File

@ -8,5 +8,6 @@ export * from "./extensions";
export * from "./hook"; export * from "./hook";
export * from "./mention"; export * from "./mention";
export * from "./slash-commands-suggestion"; export * from "./slash-commands-suggestion";
export * from "@/plane-editor/types";
export * from "./document-collaborative-events"; export * from "./document-collaborative-events";
export * from "@/plane-editor/types";

View File

@ -0,0 +1,20 @@
/**
* Editor content types - locally defined to avoid external dependencies
*/
export type JSONContent = {
type?: string;
attrs?: Record<string, unknown>;
content?: JSONContent[];
marks?: {
type: string;
attrs?: Record<string, unknown>;
[key: string]: unknown;
}[];
text?: string;
[key: string]: unknown;
};
export type HTMLContent = string;
export type Content = HTMLContent | JSONContent | JSONContent[] | null;

View File

@ -0,0 +1 @@
export type { JSONContent, HTMLContent, Content } from "./editor-content";

View File

@ -28,6 +28,7 @@ export * from "./waitlist";
export * from "./webhook"; export * from "./webhook";
export * from "./workspace-views"; export * from "./workspace-views";
export * from "./common"; export * from "./common";
export * from "./editor";
export * from "./pragmatic"; export * from "./pragmatic";
export * from "./publish"; export * from "./publish";
export * from "./search"; export * from "./search";

View File

@ -1,3 +1,4 @@
import { JSONContent } from "../../editor";
import { EIssueCommentAccessSpecifier } from "../../enums"; import { EIssueCommentAccessSpecifier } from "../../enums";
import { TFileSignedURLResponse } from "../../file"; import { TFileSignedURLResponse } from "../../file";
import { IUserLite } from "../../users"; import { IUserLite } from "../../users";
@ -34,7 +35,7 @@ export type TIssueComment = {
comment_reactions: any[]; comment_reactions: any[];
comment_stripped: string; comment_stripped: string;
comment_html: string; comment_html: string;
comment_json: object; comment_json: JSONContent;
external_id: string | undefined; external_id: string | undefined;
external_source: string | undefined; external_source: string | undefined;
access: EIssueCommentAccessSpecifier; access: EIssueCommentAccessSpecifier;

View File

@ -21,6 +21,7 @@ export type TPage = {
updated_by: string | undefined; updated_by: string | undefined;
workspace: string | undefined; workspace: string | undefined;
logo_props: TLogoProps | undefined; logo_props: TLogoProps | undefined;
deleted_at: Date | undefined;
} & TPageExtended; } & TPageExtended;
// page filters // page filters

View File

@ -1,3 +1,4 @@
import type { Content, JSONContent } from "@plane/types";
import DOMPurify from "isomorphic-dompurify"; import DOMPurify from "isomorphic-dompurify";
/** /**
@ -161,14 +162,86 @@ export const isEmptyHtmlString = (htmlString: string, allowedHTMLTags: string[]
}; };
/** /**
* @description this function returns whether a comment is empty or not by checking for the following conditions- * @description
* 1. If comment is undefined * Check if a JSONContent object is empty
* 2. If comment is an empty string * @param {JSONContent} content
* 3. If comment is "<p></p>"
* @param {string | undefined} comment
* @returns {boolean} * @returns {boolean}
*/ */
export const isCommentEmpty = (comment: string | undefined): boolean => { const isJSONContentEmpty = (content: JSONContent): boolean => {
// If it has text, check if text is meaningful
if (content.text !== undefined) {
return !content.text || content.text.trim() === "";
}
// If it has no content array, consider it empty
if (!content.content || content.content.length === 0) {
// Special case: empty paragraph nodes should be considered empty
if (content.type === "paragraph" || content.type === "doc") {
return true;
}
// For other node types without content (like hard breaks), check if they're meaningful
return (
content.type !== "hardBreak" &&
content.type !== "image" &&
content.type !== "mention-component" &&
content.type !== "image-component"
);
}
// Check if all nested content is empty
return content.content.every(isJSONContentEmpty);
};
/**
* @description
* This function will check if the comment is empty or not.
* It returns true if comment is empty.
* Now supports TipTap Content types (HTMLContent, JSONContent, JSONContent[], null)
*
* For HTML content:
* 1. If comment is undefined/null
* 2. If comment is an empty string
* 3. If comment is "<p></p>"
* 4. If comment contains only empty HTML tags
*
* For JSON content:
* 1. If content is null/undefined
* 2. If content has no meaningful text or nested content
* 3. If all nested content is empty
*
* @param {Content} comment - TipTap Content type
* @returns {boolean}
*/
export const isCommentEmpty = (comment: Content | undefined): boolean => {
// Handle null/undefined
if (!comment) return true;
// Handle HTMLContent (string)
if (typeof comment === "string") {
return (
comment.trim() === "" ||
comment === "<p></p>" ||
isEmptyHtmlString(comment, ["img", "mention-component", "image-component"])
);
}
// Handle JSONContent[] (array)
if (Array.isArray(comment)) {
return comment.length === 0 || comment.every(isJSONContentEmpty);
}
// Handle JSONContent (object)
return isJSONContentEmpty(comment);
};
/**
* @description
* Legacy function for backward compatibility with string comments
* @param {string | undefined} comment
* @returns {boolean}
* @deprecated Use isCommentEmpty with Content type instead
*/
export const isStringCommentEmpty = (comment: string | undefined): boolean => {
// return true if comment is undefined // return true if comment is undefined
if (!comment) return true; if (!comment) return true;
return ( return (