[WIKI-537] refactor: document editor (#7384)

* refactor: document editor

* chore: update user prop

* fix: type warning

* chore: update value prop name

* chore: remove unnecessary exports

* hore: update initialValue type

* chore: revert initialValue type

* refactor: unnecessary string handlers
This commit is contained in:
Aaryan Khandelwal
2025-07-25 13:57:45 +05:30
committed by GitHub
parent e20bfa55d6
commit 27f74206a3
23 changed files with 244 additions and 183 deletions

View File

@@ -0,0 +1,92 @@
import React, { forwardRef } from "react";
// plane imports
import { DocumentEditorWithRef, EditorRefApi, IDocumentEditorProps, TFileHandler } from "@plane/editor";
import { MakeOptional, TSearchEntityRequestPayload, TSearchResponse } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { EditorMentionsRoot } from "@/components/editor";
// hooks
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
import { useMember } from "@/hooks/store";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
type DocumentEditorWrapperProps = MakeOptional<
Omit<IDocumentEditorProps, "fileHandler" | "mentionHandler" | "embedHandler" | "user">,
"disabledExtensions" | "editable" | "flaggedExtensions"
> & {
embedHandler?: Partial<IDocumentEditorProps["embedHandler"]>;
workspaceSlug: string;
workspaceId: string;
projectId?: string;
} & (
| {
editable: false;
}
| {
editable: true;
searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
uploadFile: TFileHandler["upload"];
}
);
export const DocumentEditor = forwardRef<EditorRefApi, DocumentEditorWrapperProps>((props, ref) => {
const {
containerClassName,
editable,
embedHandler,
workspaceSlug,
workspaceId,
projectId,
disabledExtensions: additionalDisabledExtensions = [],
...rest
} = props;
// store hooks
const { getUserDetails } = useMember();
// editor flaggings
const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug);
// use editor mention
const { fetchMentions } = useEditorMention({
searchEntity: editable ? async (payload) => await props.searchMentionCallback(payload) : async () => ({}),
});
// editor config
const { getEditorFileHandlers } = useEditorConfig();
// issue-embed
const { issueEmbedProps } = useIssueEmbed({
projectId,
workspaceSlug,
});
return (
<DocumentEditorWithRef
ref={ref}
disabledExtensions={[...documentEditorExtensions.disabled, ...(additionalDisabledExtensions ?? [])]}
editable={editable}
flaggedExtensions={documentEditorExtensions.flagged}
fileHandler={getEditorFileHandlers({
projectId,
uploadFile: editable ? props.uploadFile : async () => "",
workspaceId,
workspaceSlug,
})}
mentionHandler={{
searchCallback: async (query) => {
const res = await fetchMentions(query);
if (!res) throw new Error("Failed in fetching mentions");
return res;
},
renderComponent: EditorMentionsRoot,
getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }),
}}
embedHandler={{
issue: issueEmbedProps,
...embedHandler,
}}
{...rest}
containerClassName={cn("relative pl-3 pb-3", containerClassName)}
/>
);
});
DocumentEditor.displayName = "DocumentEditor";

View File

@@ -1,5 +1,5 @@
export * from "./embeds";
export * from "./lite-text-editor";
export * from "./lite-text";
export * from "./pdf";
export * from "./rich-text-editor";
export * from "./rich-text";
export * from "./sticky-editor";

View File

@@ -1,3 +0,0 @@
export * from "./lite-text-editor";
export * from "./lite-text-read-only-editor";
export * from "./toolbar";

View File

@@ -0,0 +1,3 @@
export * from "./editor";
export * from "./read-only-editor";
export * from "./toolbar";

View File

@@ -1 +0,0 @@
export * from "./rich-text-editor";

View File

@@ -0,0 +1 @@
export * from "./editor";

View File

@@ -10,7 +10,7 @@ import { EFileAssetType, TIssue } from "@plane/types";
import { Loader } from "@plane/ui";
import { getDescriptionPlaceholderI18n, getTabIndex } from "@plane/utils";
// components
import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor";
import { RichTextEditor } from "@/components/editor/rich-text/editor";
// hooks
import { useEditorAsset, useProjectInbox } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";

View File

@@ -1,18 +1,14 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { DocumentReadOnlyEditorWithRef, TDisplayConfig } from "@plane/editor";
import { TDisplayConfig } from "@plane/editor";
import { TPageVersion } from "@plane/types";
import { Loader } from "@plane/ui";
// components
import { EditorMentionsRoot } from "@/components/editor";
import { DocumentEditor } from "@/components/editor/document/editor";
// hooks
import { useEditorConfig } from "@/hooks/editor";
import { useMember, useWorkspace } from "@/hooks/store";
import { useWorkspace } from "@/hooks/store";
import { usePageFilters } from "@/hooks/use-page-filters";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
export type TVersionEditorProps = {
activeVersion: string | null;
@@ -21,23 +17,12 @@ export type TVersionEditorProps = {
export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props) => {
const { activeVersion, versionDetails } = props;
// store hooks
const { getUserDetails } = useMember();
// params
const { workspaceSlug, projectId } = useParams();
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const workspaceDetails = getWorkspaceBySlug(workspaceSlug?.toString() ?? "");
// editor flaggings
const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? "");
// editor config
const { getReadOnlyEditorFileHandlers } = useEditorConfig();
// issue-embed
const { issueEmbedProps } = useIssueEmbed({
projectId: projectId?.toString() ?? "",
workspaceSlug: workspaceSlug?.toString() ?? "",
});
// page filters
const { fontSize, fontStyle } = usePageFilters();
@@ -89,32 +74,21 @@ export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props
</div>
);
const description = versionDetails?.description_html;
if (description === undefined || description?.trim() === "") return null;
const description = versionDetails?.description_json;
if (!description) return null;
return (
<DocumentReadOnlyEditorWithRef
<DocumentEditor
key={activeVersion ?? ""}
editable={false}
id={activeVersion ?? ""}
initialValue={description ?? "<p></p>"}
value={description}
containerClassName="p-0 pb-64 border-none"
disabledExtensions={documentEditorExtensions.disabled}
flaggedExtensions={documentEditorExtensions.flagged}
displayConfig={displayConfig}
editorClassName="pl-10"
fileHandler={getReadOnlyEditorFileHandlers({
projectId: projectId?.toString() ?? "",
workspaceId: workspaceDetails?.id ?? "",
workspaceSlug: workspaceSlug?.toString() ?? "",
})}
mentionHandler={{
renderComponent: (props) => <EditorMentionsRoot {...props} />,
getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }),
}}
embedHandler={{
issue: {
widgetCallback: issueEmbedProps.widgetCallback,
},
}}
projectId={projectId?.toString()}
workspaceId={workspaceDetails?.id ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
);
});

View File

@@ -11,6 +11,7 @@ export type TDocumentEditorAdditionalExtensionsProps = Pick<
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
> & {
embedConfig: TEmbedConfig | undefined;
isEditable: boolean;
provider?: HocuspocusProvider;
userDetails: TUserDetails;
};

View File

@@ -0,0 +1,109 @@
import { Extensions } from "@tiptap/core";
import { forwardRef, MutableRefObject, useMemo } from "react";
// plane imports
import { cn } from "@plane/utils";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { HeadingListExtension, WorkItemEmbedExtension, SideMenuExtension } from "@/extensions";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useEditor } from "@/hooks/use-editor";
// plane editor extensions
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { EditorRefApi, IDocumentEditorProps } from "@/types";
const DocumentEditor = (props: IDocumentEditorProps) => {
const {
bubbleMenuEnabled = false,
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
embedHandler,
fileHandler,
flaggedExtensions,
forwardedRef,
id,
handleEditorReady,
mentionHandler,
onChange,
user,
value,
} = props;
const extensions: Extensions = useMemo(() => {
const additionalExtensions: Extensions = [];
if (embedHandler?.issue) {
additionalExtensions.push(
WorkItemEmbedExtension({
widgetCallback: embedHandler.issue.widgetCallback,
})
);
}
additionalExtensions.push(
SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
dragDropEnabled: true,
}),
HeadingListExtension,
...DocumentEditorAdditionalExtensions({
disabledExtensions,
embedConfig: embedHandler,
flaggedExtensions,
isEditable: editable,
fileHandler,
userDetails: user ?? {
id: "",
name: "",
color: "",
},
})
);
return additionalExtensions;
}, []);
const editor = useEditor({
disabledExtensions,
editable,
editorClassName,
enableHistory: true,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
initialValue: value,
mentionHandler,
onChange,
});
const editorContainerClassName = getEditorClassNames({
containerClassName,
});
if (!editor) return null;
return (
<PageRenderer
bubbleMenuEnabled={bubbleMenuEnabled}
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={cn(editorContainerClassName, "document-editor")}
id={id}
/>
);
};
const DocumentEditorWithRef = forwardRef<EditorRefApi, IDocumentEditorProps>((props, ref) => (
<DocumentEditor {...props} forwardedRef={ref as MutableRefObject<EditorRefApi | null>} />
));
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
export { DocumentEditorWithRef };

View File

@@ -1,4 +1,4 @@
export * from "./collaborative-editor";
export * from "./editor";
export * from "./loader";
export * from "./page-renderer";
export * from "./read-only-editor";

View File

@@ -1,77 +0,0 @@
import { Extensions } from "@tiptap/core";
import React, { forwardRef, MutableRefObject } from "react";
// plane imports
import { cn } from "@plane/utils";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { WorkItemEmbedExtension } from "@/extensions";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import { EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps } from "@/types";
const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) => {
const {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
fileHandler,
flaggedExtensions,
id,
forwardedRef,
handleEditorReady,
initialValue,
mentionHandler,
} = props;
const extensions: Extensions = [];
if (embedHandler?.issue) {
extensions.push(
WorkItemEmbedExtension({
widgetCallback: embedHandler.issue.widgetCallback,
})
);
}
const editor = useReadOnlyEditor({
disabledExtensions,
editorClassName,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
initialValue,
mentionHandler,
});
const editorContainerClassName = getEditorClassNames({
containerClassName,
});
if (!editor) return null;
return (
<PageRenderer
bubbleMenuEnabled={false}
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={cn(editorContainerClassName, "document-editor")}
id={id}
/>
);
};
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps>((props, ref) => (
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
));
DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef";
export { DocumentReadOnlyEditorWithRef };

View File

@@ -98,6 +98,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
embedConfig: embedHandler,
fileHandler,
flaggedExtensions,
isEditable: editable,
provider,
userDetails: user,
}),

View File

@@ -1,39 +0,0 @@
import { useCallback, useState } from "react";
export interface IMarking {
type: "heading";
level: number;
text: string;
sequence: number;
}
export const useEditorMarkings = () => {
const [markings, setMarkings] = useState<IMarking[]>([]);
const updateMarkings = useCallback((html: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const headings = doc.querySelectorAll("h1, h2, h3");
const tempMarkings: IMarking[] = [];
let h1Sequence: number = 0;
let h2Sequence: number = 0;
let h3Sequence: number = 0;
headings.forEach((heading) => {
const level = parseInt(heading.tagName[1]); // Extract the number from h1, h2, h3
tempMarkings.push({
type: "heading",
level: level,
text: heading.textContent || "",
sequence: level === 1 ? ++h1Sequence : level === 2 ? ++h2Sequence : ++h3Sequence,
});
});
setMarkings(tempMarkings);
}, []);
return {
updateMarkings,
markings,
};
};

View File

@@ -70,7 +70,7 @@ export const useEditor = (props: TEditorHookProps) => {
}),
...extensions,
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
content: initialValue,
onCreate: () => handleEditorReady?.(true),
onTransaction: () => {
onTransaction?.();

View File

@@ -46,3 +46,10 @@ export type TRealtimeConfig = {
url: string;
queryParams: TWebhookConnectionQueryParams;
};
export type IMarking = {
type: "heading";
level: number;
text: string;
sequence: number;
};

View File

@@ -1,5 +1,5 @@
import { Extensions, JSONContent } from "@tiptap/core";
import { Selection } from "@tiptap/pm/state";
import type { Content, Extensions, JSONContent } from "@tiptap/core";
import type { Selection } from "@tiptap/pm/state";
// extension types
import type { TTextAlign } from "@/extensions";
// helpers
@@ -160,6 +160,14 @@ export interface ICollaborativeDocumentEditorProps
user: TUserDetails;
}
export interface IDocumentEditorProps extends Omit<IEditorProps, "initialValue" | "onEnterKeyPress" | "value"> {
aiHandler?: TAIHandler;
editable: boolean;
embedHandler: TEmbedConfig;
user?: TUserDetails;
value: Content;
}
// read only editor props
export interface IReadOnlyEditorProps
extends Pick<
@@ -181,10 +189,6 @@ export interface IReadOnlyEditorProps
export type ILiteTextReadOnlyEditorProps = IReadOnlyEditorProps;
export interface IDocumentReadOnlyEditorProps extends IReadOnlyEditorProps {
embedHandler: TEmbedConfig;
}
export interface EditorEvents {
beforeCreate: never;
create: never;

View File

@@ -1,4 +1,5 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Content } from "@tiptap/core";
import type { EditorProps } from "@tiptap/pm/view";
// local imports
import type { ICollaborativeDocumentEditorProps, IEditorProps, IReadOnlyEditorProps } from "./editor";
@@ -27,7 +28,7 @@ export type TEditorHookProps = TCoreHookProps &
> & {
editable: boolean;
enableHistory: boolean;
initialValue?: string;
initialValue?: Content;
provider?: HocuspocusProvider;
};

View File

@@ -9,30 +9,18 @@ import "./styles/drag-drop.css";
// editors
export {
CollaborativeDocumentEditorWithRef,
DocumentReadOnlyEditorWithRef,
DocumentEditorWithRef,
LiteTextEditorWithRef,
LiteTextReadOnlyEditorWithRef,
RichTextEditorWithRef,
} from "@/components/editors";
export { isCellSelection } from "@/extensions/table/table/utilities/helpers";
// constants
export * from "@/constants/common";
// helpers
export * from "@/helpers/common";
export * from "@/helpers/editor-commands";
export * from "@/helpers/yjs-utils";
export * from "@/extensions/table/table";
// components
export * from "@/components/menus";
// hooks
export { useEditor } from "@/hooks/use-editor";
export { type IMarking, useEditorMarkings } from "@/hooks/use-editor-markings";
export { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
export { CORE_EXTENSIONS } from "@/constants/extension";
export { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions";