[WIKI-412] chore: improved rich text editor extensions handling (#7158)

* chore: code split for rich text editor extensions

* chore: update type

* chore: add missing prop
This commit is contained in:
Aaryan Khandelwal 2025-06-04 15:32:54 +05:30 committed by GitHub
parent b2ccca0567
commit 2792d48288
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 159 additions and 38 deletions

View file

@ -2,30 +2,31 @@ import { HocuspocusProvider } from "@hocuspocus/provider";
import { AnyExtension } from "@tiptap/core"; import { AnyExtension } from "@tiptap/core";
import { SlashCommands } from "@/extensions"; import { SlashCommands } from "@/extensions";
// plane editor types // plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types"; import { TEmbedConfig } from "@/plane-editor/types";
// types // types
import { TExtensions, TUserDetails } from "@/types"; import { TExtensions, TFileHandler, TUserDetails } from "@/types";
type Props = { export type TDocumentEditorAdditionalExtensionsProps = {
disabledExtensions?: TExtensions[]; disabledExtensions: TExtensions[];
issueEmbedConfig: TIssueEmbedConfig | undefined; embedConfig: TEmbedConfig | undefined;
provider: HocuspocusProvider; fileHandler: TFileHandler;
provider?: HocuspocusProvider;
userDetails: TUserDetails; userDetails: TUserDetails;
}; };
type ExtensionConfig = { export type TDocumentEditorAdditionalExtensionsRegistry = {
isEnabled: (disabledExtensions: TExtensions[]) => boolean; isEnabled: (disabledExtensions: TExtensions[]) => boolean;
getExtension: (props: Props) => AnyExtension; getExtension: (props: TDocumentEditorAdditionalExtensionsProps) => AnyExtension;
}; };
const extensionRegistry: ExtensionConfig[] = [ const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [
{ {
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"), isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: () => SlashCommands({}), getExtension: ({ disabledExtensions }) => SlashCommands({ disabledExtensions }),
}, },
]; ];
export const DocumentEditorAdditionalExtensions = (_props: Props) => { export const DocumentEditorAdditionalExtensions = (_props: TDocumentEditorAdditionalExtensionsProps) => {
const { disabledExtensions = [] } = _props; const { disabledExtensions = [] } = _props;
const documentExtensions = extensionRegistry const documentExtensions = extensionRegistry

View file

@ -0,0 +1,41 @@
import { AnyExtension, Extensions } from "@tiptap/core";
// extensions
import { SlashCommands } from "@/extensions/slash-commands/root";
// types
import { TExtensions, TFileHandler } from "@/types";
export type TRichTextEditorAdditionalExtensionsProps = {
disabledExtensions: TExtensions[];
fileHandler: TFileHandler;
};
/**
* Registry entry configuration for extensions
*/
export type TRichTextEditorAdditionalExtensionsRegistry = {
/** Determines if the extension should be enabled based on disabled extensions */
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
/** Returns the extension instance(s) when enabled */
getExtension: (props: TRichTextEditorAdditionalExtensionsProps) => AnyExtension | undefined;
};
const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: ({ disabledExtensions }) =>
SlashCommands({
disabledExtensions,
}),
},
];
export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => {
const { disabledExtensions } = props;
const extensions: Extensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(props))
.filter((extension): extension is AnyExtension => extension !== undefined);
return extensions;
};

View file

@ -0,0 +1,31 @@
import { AnyExtension, Extensions } from "@tiptap/core";
// types
import { TExtensions, TReadOnlyFileHandler } from "@/types";
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = {
disabledExtensions: TExtensions[];
fileHandler: TReadOnlyFileHandler;
};
/**
* Registry entry configuration for extensions
*/
export type TRichTextReadOnlyEditorAdditionalExtensionsRegistry = {
/** Determines if the extension should be enabled based on disabled extensions */
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
/** Returns the extension instance(s) when enabled */
getExtension: (props: TRichTextReadOnlyEditorAdditionalExtensionsProps) => AnyExtension | undefined;
};
const extensionRegistry: TRichTextReadOnlyEditorAdditionalExtensionsRegistry[] = [];
export const RichTextReadOnlyEditorAdditionalExtensions = (props: TRichTextReadOnlyEditorAdditionalExtensionsProps) => {
const { disabledExtensions } = props;
const extensions: Extensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(props))
.filter((extension): extension is AnyExtension => extension !== undefined);
return extensions;
};

View file

@ -15,6 +15,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
disabledExtensions, disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG, displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "", editorClassName = "",
extensions,
fileHandler, fileHandler,
forwardedRef, forwardedRef,
id, id,
@ -25,6 +26,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
const editor = useReadOnlyEditor({ const editor = useReadOnlyEditor({
disabledExtensions, disabledExtensions,
editorClassName, editorClassName,
extensions,
fileHandler, fileHandler,
forwardedRef, forwardedRef,
initialValue, initialValue,

View file

@ -3,12 +3,20 @@ import { forwardRef, useCallback } from "react";
import { EditorWrapper } from "@/components/editors"; import { EditorWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus"; import { EditorBubbleMenu } from "@/components/menus";
// extensions // extensions
import { SideMenuExtension, SlashCommands } from "@/extensions"; import { SideMenuExtension } from "@/extensions";
// plane editor imports
import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/extensions";
// types // types
import { EditorRefApi, IRichTextEditor } from "@/types"; import { EditorRefApi, IRichTextEditor } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => { const RichTextEditor = (props: IRichTextEditor) => {
const { disabledExtensions, dragDropEnabled, bubbleMenuEnabled = true, extensions: externalExtensions = [] } = props; const {
disabledExtensions,
dragDropEnabled,
fileHandler,
bubbleMenuEnabled = true,
extensions: externalExtensions = [],
} = props;
const getExtensions = useCallback(() => { const getExtensions = useCallback(() => {
const extensions = [ const extensions = [
@ -17,17 +25,14 @@ const RichTextEditor = (props: IRichTextEditor) => {
aiEnabled: false, aiEnabled: false,
dragDropEnabled: !!dragDropEnabled, dragDropEnabled: !!dragDropEnabled,
}), }),
]; ...RichTextEditorAdditionalExtensions({
if (!disabledExtensions?.includes("slash-commands")) {
extensions.push(
SlashCommands({
disabledExtensions, disabledExtensions,
}) fileHandler,
); }),
} ];
return extensions; return extensions;
}, [dragDropEnabled, disabledExtensions, externalExtensions]); }, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler]);
return ( return (
<EditorWrapper {...props} extensions={getExtensions()}> <EditorWrapper {...props} extensions={getExtensions()}>

View file

@ -1,11 +1,33 @@
import { forwardRef } from "react"; import { forwardRef, useCallback } from "react";
// plane editor extensions
import { RichTextReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/read-only-extensions";
// types // types
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types"; import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types";
// local imports
import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper"; import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper";
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => ( const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => {
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} /> const { disabledExtensions, fileHandler } = props;
));
const getExtensions = useCallback(() => {
const extensions = [
...RichTextReadOnlyEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
}),
];
return extensions;
}, [disabledExtensions, fileHandler]);
return (
<ReadOnlyEditorWrapper
{...props}
extensions={getExtensions()}
forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>}
/>
);
});
RichTextReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef"; RichTextReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef";

View file

@ -170,8 +170,9 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomTextAlignExtension, CustomTextAlignExtension,
CustomCalloutExtension, CustomCalloutExtension,
UtilityExtension({ UtilityExtension({
isEditable: editable, disabledExtensions,
fileHandler, fileHandler,
isEditable: editable,
}), }),
CustomColorExtension, CustomColorExtension,
...CoreEditorAdditionalExtensions({ ...CoreEditorAdditionalExtensions({

View file

@ -127,8 +127,9 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
CustomTextAlignExtension, CustomTextAlignExtension,
CustomCalloutReadOnlyExtension, CustomCalloutReadOnlyExtension,
UtilityExtension({ UtilityExtension({
isEditable: false, disabledExtensions,
fileHandler, fileHandler,
isEditable: false,
}), }),
...CoreReadOnlyEditorAdditionalExtensions({ ...CoreReadOnlyEditorAdditionalExtensions({
disabledExtensions, disabledExtensions,

View file

@ -8,7 +8,7 @@ import { DropHandlerPlugin } from "@/plugins/drop";
import { FilePlugins } from "@/plugins/file/root"; import { FilePlugins } from "@/plugins/file/root";
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard"; import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
// types // types
import { TFileHandler, TReadOnlyFileHandler } from "@/types"; import { TExtensions, TFileHandler, TReadOnlyFileHandler } from "@/types";
declare module "@tiptap/core" { declare module "@tiptap/core" {
interface Commands { interface Commands {
@ -24,13 +24,14 @@ export interface UtilityExtensionStorage {
} }
type Props = { type Props = {
disabledExtensions: TExtensions[];
fileHandler: TFileHandler | TReadOnlyFileHandler; fileHandler: TFileHandler | TReadOnlyFileHandler;
isEditable: boolean; isEditable: boolean;
}; };
export const UtilityExtension = (props: Props) => { export const UtilityExtension = (props: Props) => {
const { fileHandler, isEditable } = props; const { disabledExtensions, fileHandler, isEditable } = props;
const { restore: restoreImageFn } = fileHandler; const { restore } = fileHandler;
return Extension.create<Record<string, unknown>, UtilityExtensionStorage>({ return Extension.create<Record<string, unknown>, UtilityExtensionStorage>({
name: "utility", name: "utility",
@ -45,12 +46,15 @@ export const UtilityExtension = (props: Props) => {
}), }),
...codemark({ markType: this.editor.schema.marks.code }), ...codemark({ markType: this.editor.schema.marks.code }),
MarkdownClipboardPlugin(this.editor), MarkdownClipboardPlugin(this.editor),
DropHandlerPlugin(this.editor), DropHandlerPlugin({
disabledExtensions,
editor: this.editor,
}),
]; ];
}, },
onCreate() { onCreate() {
restorePublicImages(this.editor, restoreImageFn); restorePublicImages(this.editor, restore);
}, },
addStorage() { addStorage() {

View file

@ -92,7 +92,8 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
...(extensions ?? []), ...(extensions ?? []),
...DocumentEditorAdditionalExtensions({ ...DocumentEditorAdditionalExtensions({
disabledExtensions, disabledExtensions,
issueEmbedConfig: embedHandler?.issue, embedConfig: embedHandler,
fileHandler,
provider, provider,
userDetails: user, userDetails: user,
}), }),

View file

@ -3,10 +3,17 @@ import { Plugin, PluginKey } from "@tiptap/pm/state";
// constants // constants
import { ACCEPTED_ATTACHMENT_MIME_TYPES, ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config"; import { ACCEPTED_ATTACHMENT_MIME_TYPES, ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
// types // types
import { TEditorCommands } from "@/types"; import { TEditorCommands, TExtensions } from "@/types";
export const DropHandlerPlugin = (editor: Editor): Plugin => type Props = {
new Plugin({ disabledExtensions: TExtensions[];
editor: Editor;
};
export const DropHandlerPlugin = (props: Props): Plugin => {
const { disabledExtensions, editor } = props;
return new Plugin({
key: new PluginKey("drop-handler-plugin"), key: new PluginKey("drop-handler-plugin"),
props: { props: {
handlePaste: (view, event) => { handlePaste: (view, event) => {
@ -25,6 +32,7 @@ export const DropHandlerPlugin = (editor: Editor): Plugin =>
if (acceptedFiles.length) { if (acceptedFiles.length) {
const pos = view.state.selection.from; const pos = view.state.selection.from;
insertFilesSafely({ insertFilesSafely({
disabledExtensions,
editor, editor,
files: acceptedFiles, files: acceptedFiles,
initialPos: pos, initialPos: pos,
@ -58,6 +66,7 @@ export const DropHandlerPlugin = (editor: Editor): Plugin =>
if (coordinates) { if (coordinates) {
const pos = coordinates.pos; const pos = coordinates.pos;
insertFilesSafely({ insertFilesSafely({
disabledExtensions,
editor, editor,
files: acceptedFiles, files: acceptedFiles,
initialPos: pos, initialPos: pos,
@ -71,8 +80,10 @@ export const DropHandlerPlugin = (editor: Editor): Plugin =>
}, },
}, },
}); });
};
type InsertFilesSafelyArgs = { type InsertFilesSafelyArgs = {
disabledExtensions: TExtensions[];
editor: Editor; editor: Editor;
event: "insert" | "drop"; event: "insert" | "drop";
files: File[]; files: File[];
@ -81,7 +92,7 @@ type InsertFilesSafelyArgs = {
}; };
export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => { export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => {
const { editor, event, files, initialPos, type } = args; const { disabledExtensions, editor, event, files, initialPos, type } = args;
let pos = initialPos; let pos = initialPos;
for (const file of files) { for (const file of files) {
@ -100,7 +111,7 @@ export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => {
else if (ACCEPTED_ATTACHMENT_MIME_TYPES.includes(file.type)) fileType = "attachment"; else if (ACCEPTED_ATTACHMENT_MIME_TYPES.includes(file.type)) fileType = "attachment";
} }
// insert file depending on the type at the current position // insert file depending on the type at the current position
if (fileType === "image") { if (fileType === "image" && !disabledExtensions.includes("image")) {
editor.commands.insertImageComponent({ editor.commands.insertImageComponent({
file, file,
pos, pos,

View file

@ -160,6 +160,7 @@ export interface IReadOnlyEditorProps {
disabledExtensions: TExtensions[]; disabledExtensions: TExtensions[];
displayConfig?: TDisplayConfig; displayConfig?: TDisplayConfig;
editorClassName?: string; editorClassName?: string;
extensions?: Extensions;
fileHandler: TReadOnlyFileHandler; fileHandler: TReadOnlyFileHandler;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>; forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
id: string; id: string;