From 28edf03a94e36ca5b3ce97beab23a8ca11dca867 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Thu, 19 Jun 2025 17:22:24 +0530 Subject: [PATCH] [WIKI-181] feat: editor attachments (#2238) * feat: attachment extension * chore: slash command option added for attachments * chore: add upload logic * chore: add missing plugins to attachments extension * refactor: file handling * fix: attachment upload * fix: attachment upload * restore unnecessary files * chore: added more types for work item and pages * refactor: attachment extension * chore: added conditions for custom entities * chore: add upload status * refactor: onCreate and upload logic * fix: extension without props * fix: restore deleted assets logic * refactor: attachment extension types and utils * chore: add editor extensions and translations * chore: add disabled state and translations * chore: update container elements * fix: pageEmbedComponent correct node name * chore: add missing translations * refactor: remove read only extension * chore: handle api call error * fix: lint error * fix: drop handlerr * chore: extension in the read only document editor * chore: add support for initiative and project overview * fix: liver server runtime error * refactor: editor props structure * chore: add missing prop * chore: update editor flag hook * fix: space app build * fix: customers attachment extension * fix: customer requests description * refactor: additional extension types * chore: remove unnecessary comments --------- Co-authored-by: NarayanBavisetti Co-authored-by: Palanikannan M --- apiserver/plane/app/views/asset/v2.py | 50 ++++- packages/constants/src/feature-flag.ts | 19 +- .../src/ce/extensions/core/extensions.ts | 14 +- .../extensions/core/read-only-extensions.ts | 15 +- .../src/ce/extensions/document-extensions.tsx | 25 ++- .../ce/extensions/rich-text/extensions.tsx | 19 +- .../rich-text/read-only-extensions.tsx | 10 +- .../src/ce/extensions/slash-commands.tsx | 8 +- .../editors/document/collaborative-editor.tsx | 14 +- .../components/editors/document/editor.tsx | 7 +- .../editors/document/page-renderer.tsx | 4 +- .../editors/document/read-only-editor.tsx | 42 ++-- .../components/editors/editor-wrapper.tsx | 2 + .../components/editors/lite-text/editor.tsx | 22 +- .../editors/lite-text/read-only-editor.tsx | 4 +- .../editors/read-only-editor-wrapper.tsx | 2 + .../components/editors/rich-text/editor.tsx | 14 +- .../editors/rich-text/read-only-editor.tsx | 19 +- .../editor/src/core/extensions/extensions.ts | 25 ++- .../core/extensions/read-only-extensions.ts | 11 +- .../slash-commands/command-items-list.tsx | 3 +- .../core/extensions/slash-commands/root.tsx | 7 +- .../editor/src/core/extensions/utility.ts | 5 +- packages/editor/src/core/helpers/file.ts | 1 + .../core/hooks/use-collaborative-editor.ts | 28 +-- packages/editor/src/core/hooks/use-editor.ts | 54 +---- .../src/core/hooks/use-read-only-editor.ts | 38 ++-- .../editor/src/core/hooks/use-title-editor.ts | 4 +- .../editor/src/core/plugins/drag-handle.ts | 1 + packages/editor/src/core/plugins/drop.ts | 7 +- .../editor/src/core/types/collaboration.ts | 51 ----- packages/editor/src/core/types/config.ts | 15 ++ packages/editor/src/core/types/editor.ts | 90 +++----- packages/editor/src/core/types/extensions.ts | 3 +- packages/editor/src/core/types/hook.ts | 63 ++++++ packages/editor/src/core/types/index.ts | 1 + .../editor/src/ee/constants/extensions.ts | 5 +- packages/editor/src/ee/constants/utility.ts | 17 +- .../attachments/components/block.tsx | 56 +++++ .../attachments/components/flagged-state.tsx | 19 ++ .../attachments/components/node-view.tsx | 54 +++++ .../attachments/components/upload-status.tsx | 60 +++++ .../components/uploader-details.tsx | 100 +++++++++ .../attachments/components/uploader.tsx | 207 ++++++++++++++++++ .../attachments/extension-config.ts | 44 ++++ .../ee/extensions/attachments/extension.ts | 113 ++++++++++ .../src/ee/extensions/attachments/types.ts | 42 ++++ .../src/ee/extensions/attachments/utils.ts | 21 ++ .../src/ee/extensions/core/extensions.ts | 11 +- .../extensions/core/read-only-extensions.ts | 12 +- .../src/ee/extensions/core/without-props.ts | 3 +- .../src/ee/extensions/document-extensions.tsx | 57 +++-- .../ee/extensions/rich-text/extensions.tsx | 79 ++++++- .../rich-text/read-only-extensions.tsx | 32 ++- .../editor/src/ee/helpers/editor-commands.ts | 24 ++ packages/editor/src/ee/types/storage.ts | 15 ++ packages/editor/src/index.ts | 1 - .../i18n/src/locales/cs/editor-extended.json | 28 ++- .../i18n/src/locales/de/editor-extended.json | 28 ++- .../i18n/src/locales/en/editor-extended.json | 28 ++- .../i18n/src/locales/es/editor-extended.json | 28 ++- .../i18n/src/locales/fr/editor-extended.json | 28 ++- .../i18n/src/locales/id/editor-extended.json | 28 ++- .../i18n/src/locales/it/editor-extended.json | 28 ++- .../i18n/src/locales/ja/editor-extended.json | 28 ++- .../i18n/src/locales/ko/editor-extended.json | 28 ++- .../i18n/src/locales/pl/editor-extended.json | 28 ++- .../src/locales/pt-BR/editor-extended.json | 28 ++- .../i18n/src/locales/ro/editor-extended.json | 28 ++- .../i18n/src/locales/ru/editor-extended.json | 28 ++- .../i18n/src/locales/sk/editor-extended.json | 28 ++- .../src/locales/tr-TR/editor-extended.json | 28 ++- .../i18n/src/locales/ua/editor-extended.json | 28 ++- .../src/locales/vi-VN/editor-extended.json | 28 ++- .../src/locales/zh-CN/editor-extended.json | 28 ++- .../src/locales/zh-TW/editor-extended.json | 28 ++- .../components/editor/lite-text-editor.tsx | 8 +- .../editor/lite-text-read-only-editor.tsx | 9 +- .../components/editor/rich-text-editor.tsx | 9 +- .../editor/rich-text-read-only-editor.tsx | 9 +- space/ee/components/pages/main-content.tsx | 1 + web/ce/hooks/use-editor-flagging.ts | 38 +++- .../lite-text-editor/lite-text-editor.tsx | 13 +- .../lite-text-read-only-editor.tsx | 11 +- .../rich-text-editor/rich-text-editor.tsx | 11 +- .../rich-text-read-only-editor.tsx | 11 +- .../editor/sticky-editor/editor.tsx | 12 +- .../components/pages/editor/editor-body.tsx | 5 +- web/core/components/pages/version/editor.tsx | 5 +- .../common/input/description-input.tsx | 8 +- .../customer-modal/default-properties.tsx | 1 + web/ee/components/customers/detail/main.tsx | 1 + web/ee/components/customers/requests/form.tsx | 3 +- web/ee/components/pages/embed/preview.tsx | 5 +- web/ee/components/pages/version/editor.tsx | 5 +- .../templates/settings/page/form/editor.tsx | 5 +- web/ee/hooks/use-editor-flagging.ts | 60 +++-- 97 files changed, 1947 insertions(+), 484 deletions(-) create mode 100644 packages/editor/src/core/types/hook.ts create mode 100644 packages/editor/src/ee/extensions/attachments/components/block.tsx create mode 100644 packages/editor/src/ee/extensions/attachments/components/flagged-state.tsx create mode 100644 packages/editor/src/ee/extensions/attachments/components/node-view.tsx create mode 100644 packages/editor/src/ee/extensions/attachments/components/upload-status.tsx create mode 100644 packages/editor/src/ee/extensions/attachments/components/uploader-details.tsx create mode 100644 packages/editor/src/ee/extensions/attachments/components/uploader.tsx create mode 100644 packages/editor/src/ee/extensions/attachments/extension-config.ts create mode 100644 packages/editor/src/ee/extensions/attachments/extension.ts create mode 100644 packages/editor/src/ee/extensions/attachments/types.ts create mode 100644 packages/editor/src/ee/extensions/attachments/utils.ts create mode 100644 packages/editor/src/ee/helpers/editor-commands.ts create mode 100644 packages/editor/src/ee/types/storage.ts diff --git a/apiserver/plane/app/views/asset/v2.py b/apiserver/plane/app/views/asset/v2.py index 6979cec6df..b42653c80d 100644 --- a/apiserver/plane/app/views/asset/v2.py +++ b/apiserver/plane/app/views/asset/v2.py @@ -385,12 +385,26 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): "image/jpg", "image/gif", ] - if type not in allowed_types: + + # Define the set of entity types that use settings.ATTACHMENT_MIME_TYPES + special_entity_types = { + FileAsset.EntityTypeContext.PAGE_DESCRIPTION, + FileAsset.EntityTypeContext.WORKITEM_TEMPLATE_DESCRIPTION, + FileAsset.EntityTypeContext.PAGE_TEMPLATE_DESCRIPTION, + FileAsset.EntityTypeContext.INITIATIVE_DESCRIPTION, + } + + # Map entity type category to allowed types and error message + if entity_type in special_entity_types: + valid_types = settings.ATTACHMENT_MIME_TYPES + error_message = "Invalid file type." + else: + valid_types = allowed_types + error_message = "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed." + + if type not in valid_types: return Response( - { - "error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.", - "status": False, - }, + {"error": error_message, "status": False}, status=status.HTTP_400_BAD_REQUEST, ) @@ -608,12 +622,28 @@ class ProjectAssetEndpoint(BaseAPIView): "image/jpg", "image/gif", ] - if type not in allowed_types: + + # Define the set of entity types that use settings.ATTACHMENT_MIME_TYPES + special_entity_types = { + FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, + FileAsset.EntityTypeContext.PAGE_DESCRIPTION, + FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION, + FileAsset.EntityTypeContext.WORKITEM_TEMPLATE_DESCRIPTION, + FileAsset.EntityTypeContext.PAGE_TEMPLATE_DESCRIPTION, + FileAsset.EntityTypeContext.PROJECT_DESCRIPTION, + } + + # Map entity type category to allowed types and error message + if entity_type in special_entity_types: + valid_types = settings.ATTACHMENT_MIME_TYPES + error_message = "Invalid file type." + else: + valid_types = allowed_types + error_message = "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed." + + if type not in valid_types: return Response( - { - "error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.", - "status": False, - }, + {"error": error_message, "status": False}, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/packages/constants/src/feature-flag.ts b/packages/constants/src/feature-flag.ts index f6069f4e1f..115b60f9a2 100644 --- a/packages/constants/src/feature-flag.ts +++ b/packages/constants/src/feature-flag.ts @@ -3,23 +3,15 @@ import { EProductSubscriptionEnum } from "./payment"; export enum E_FEATURE_FLAGS { BULK_OPS_ONE = "BULK_OPS_ONE", BULK_OPS_PRO = "BULK_OPS_PRO", - COLLABORATION_CURSOR = "COLLABORATION_CURSOR", - EDITOR_AI_OPS = "EDITOR_AI_OPS", ESTIMATE_WITH_TIME = "ESTIMATE_WITH_TIME", ISSUE_TYPES = "ISSUE_TYPES", EPICS = "EPICS", WORK_ITEM_CONVERSION = "WORK_ITEM_CONVERSION", OIDC_SAML_AUTH = "OIDC_SAML_AUTH", - PAGE_ISSUE_EMBEDS = "PAGE_ISSUE_EMBEDS", - PAGE_PUBLISH = "PAGE_PUBLISH", - MOVE_PAGES = "MOVE_PAGES", - NESTED_PAGES = "NESTED_PAGES", - SHARED_PAGES = "SHARED_PAGES", VIEW_ACCESS_PRIVATE = "VIEW_ACCESS_PRIVATE", VIEW_LOCK = "VIEW_LOCK", VIEW_PUBLISH = "VIEW_PUBLISH", WORKSPACE_ACTIVE_CYCLES = "WORKSPACE_ACTIVE_CYCLES", - WORKSPACE_PAGES = "WORKSPACE_PAGES", ISSUE_WORKLOG = "ISSUE_WORKLOG", PROJECT_GROUPING = "PROJECT_GROUPING", CYCLE_PROGRESS_CHARTS = "CYCLE_PROGRESS_CHARTS", @@ -92,7 +84,16 @@ export enum E_FEATURE_FLAGS { DASHBOARDS_ADVANCED = "DASHBOARDS_ADVANCED", // applications APPLICATIONS = "APPLICATIONS", - + // pages and editor + COLLABORATION_CURSOR = "COLLABORATION_CURSOR", + EDITOR_AI_OPS = "EDITOR_AI_OPS", + PAGE_ISSUE_EMBEDS = "PAGE_ISSUE_EMBEDS", + PAGE_PUBLISH = "PAGE_PUBLISH", + MOVE_PAGES = "MOVE_PAGES", + NESTED_PAGES = "NESTED_PAGES", + WORKSPACE_PAGES = "WORKSPACE_PAGES", + SHARED_PAGES = "SHARED_PAGES", + EDITOR_ATTACHMENTS = "EDITOR_ATTACHMENTS", // analytics ANALYTICS_ADVANCED = "ANALYTICS_ADVANCED", } diff --git a/packages/editor/src/ce/extensions/core/extensions.ts b/packages/editor/src/ce/extensions/core/extensions.ts index cecfb38b47..a72bcc2159 100644 --- a/packages/editor/src/ce/extensions/core/extensions.ts +++ b/packages/editor/src/ce/extensions/core/extensions.ts @@ -1,13 +1,13 @@ -import { Extensions } from "@tiptap/core"; +import type { Extensions } from "@tiptap/core"; // types -import { TExtensions, TFileHandler } from "@/types"; +import type { IEditorProps } from "@/types"; -type Props = { - disabledExtensions: TExtensions[]; - fileHandler: TFileHandler; -}; +export type TCoreAdditionalExtensionsProps = Pick< + IEditorProps, + "disabledExtensions" | "flaggedExtensions" | "fileHandler" +>; -export const CoreEditorAdditionalExtensions = (props: Props): Extensions => { +export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => { const {} = props; return []; }; diff --git a/packages/editor/src/ce/extensions/core/read-only-extensions.ts b/packages/editor/src/ce/extensions/core/read-only-extensions.ts index 398848e31d..4f9306da30 100644 --- a/packages/editor/src/ce/extensions/core/read-only-extensions.ts +++ b/packages/editor/src/ce/extensions/core/read-only-extensions.ts @@ -1,12 +1,15 @@ -import { Extensions } from "@tiptap/core"; +import type { Extensions } from "@tiptap/core"; // types -import { TExtensions } from "@/types"; +import type { IReadOnlyEditorProps } from "@/types"; -type Props = { - disabledExtensions: TExtensions[]; -}; +export type TCoreReadOnlyEditorAdditionalExtensionsProps = Pick< + IReadOnlyEditorProps, + "disabledExtensions" | "flaggedExtensions" +>; -export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => { +export const CoreReadOnlyEditorAdditionalExtensions = ( + props: TCoreReadOnlyEditorAdditionalExtensionsProps +): Extensions => { const {} = props; return []; }; diff --git a/packages/editor/src/ce/extensions/document-extensions.tsx b/packages/editor/src/ce/extensions/document-extensions.tsx index d13e2d6969..55c908faae 100644 --- a/packages/editor/src/ce/extensions/document-extensions.tsx +++ b/packages/editor/src/ce/extensions/document-extensions.tsx @@ -1,36 +1,39 @@ -import { HocuspocusProvider } from "@hocuspocus/provider"; -import { AnyExtension, Extensions } from "@tiptap/core"; +import type { HocuspocusProvider } from "@hocuspocus/provider"; +import type { AnyExtension, Extensions } from "@tiptap/core"; import { SlashCommands } from "@/extensions"; // plane editor types -import { TEmbedConfig } from "@/plane-editor/types"; +import type { TEmbedConfig } from "@/plane-editor/types"; // types -import { TExtensions, TFileHandler, TUserDetails } from "@/types"; +import type { IEditorProps, TExtensions, TUserDetails } from "@/types"; -export type TDocumentEditorAdditionalExtensionsProps = { - disabledExtensions: TExtensions[]; +export type TDocumentEditorAdditionalExtensionsProps = Pick< + IEditorProps, + "disabledExtensions" | "flaggedExtensions" | "fileHandler" +> & { embedConfig: TEmbedConfig | undefined; - fileHandler: TFileHandler; + isEditable: boolean; provider?: HocuspocusProvider; userDetails: TUserDetails; }; export type TDocumentEditorAdditionalExtensionsRegistry = { - isEnabled: (disabledExtensions: TExtensions[]) => boolean; + isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean; getExtension: (props: TDocumentEditorAdditionalExtensionsProps) => AnyExtension | undefined; }; const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [ { isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"), - getExtension: ({ disabledExtensions }) => SlashCommands({ disabledExtensions }), + getExtension: ({ disabledExtensions, flaggedExtensions }) => + SlashCommands({ disabledExtensions, flaggedExtensions }), }, ]; export const DocumentEditorAdditionalExtensions = (props: TDocumentEditorAdditionalExtensionsProps) => { - const { disabledExtensions = [] } = props; + const { disabledExtensions, flaggedExtensions } = props; const documentExtensions: Extensions = extensionRegistry - .filter((config) => config.isEnabled(disabledExtensions)) + .filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions)) .map((config) => config.getExtension(props)) .filter((extension): extension is AnyExtension => extension !== undefined); diff --git a/packages/editor/src/ce/extensions/rich-text/extensions.tsx b/packages/editor/src/ce/extensions/rich-text/extensions.tsx index 0eedd1e87f..520dfa10e8 100644 --- a/packages/editor/src/ce/extensions/rich-text/extensions.tsx +++ b/packages/editor/src/ce/extensions/rich-text/extensions.tsx @@ -2,19 +2,19 @@ import { AnyExtension, Extensions } from "@tiptap/core"; // extensions import { SlashCommands } from "@/extensions/slash-commands/root"; // types -import { TExtensions, TFileHandler } from "@/types"; +import { IEditorProps, TExtensions } from "@/types"; -export type TRichTextEditorAdditionalExtensionsProps = { - disabledExtensions: TExtensions[]; - fileHandler: TFileHandler; -}; +export type TRichTextEditorAdditionalExtensionsProps = Pick< + IEditorProps, + "disabledExtensions" | "flaggedExtensions" | "fileHandler" +>; /** * Registry entry configuration for extensions */ export type TRichTextEditorAdditionalExtensionsRegistry = { /** Determines if the extension should be enabled based on disabled extensions */ - isEnabled: (disabledExtensions: TExtensions[]) => boolean; + isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean; /** Returns the extension instance(s) when enabled */ getExtension: (props: TRichTextEditorAdditionalExtensionsProps) => AnyExtension | undefined; }; @@ -22,18 +22,19 @@ export type TRichTextEditorAdditionalExtensionsRegistry = { const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [ { isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"), - getExtension: ({ disabledExtensions }) => + getExtension: ({ disabledExtensions, flaggedExtensions }) => SlashCommands({ disabledExtensions, + flaggedExtensions, }), }, ]; export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => { - const { disabledExtensions } = props; + const { disabledExtensions, flaggedExtensions } = props; const extensions: Extensions = extensionRegistry - .filter((config) => config.isEnabled(disabledExtensions)) + .filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions)) .map((config) => config.getExtension(props)) .filter((extension): extension is AnyExtension => extension !== undefined); diff --git a/packages/editor/src/ce/extensions/rich-text/read-only-extensions.tsx b/packages/editor/src/ce/extensions/rich-text/read-only-extensions.tsx index 0156301170..0b7cbc7306 100644 --- a/packages/editor/src/ce/extensions/rich-text/read-only-extensions.tsx +++ b/packages/editor/src/ce/extensions/rich-text/read-only-extensions.tsx @@ -1,11 +1,11 @@ import { AnyExtension, Extensions } from "@tiptap/core"; // types -import { TExtensions, TReadOnlyFileHandler } from "@/types"; +import { IReadOnlyEditorProps, TExtensions } from "@/types"; -export type TRichTextReadOnlyEditorAdditionalExtensionsProps = { - disabledExtensions: TExtensions[]; - fileHandler: TReadOnlyFileHandler; -}; +export type TRichTextReadOnlyEditorAdditionalExtensionsProps = Pick< + IReadOnlyEditorProps, + "disabledExtensions" | "flaggedExtensions" | "fileHandler" +>; /** * Registry entry configuration for extensions diff --git a/packages/editor/src/ce/extensions/slash-commands.tsx b/packages/editor/src/ce/extensions/slash-commands.tsx index faefa74523..d61d056c8d 100644 --- a/packages/editor/src/ce/extensions/slash-commands.tsx +++ b/packages/editor/src/ce/extensions/slash-commands.tsx @@ -1,11 +1,9 @@ // extensions -import { TSlashCommandAdditionalOption } from "@/extensions"; +import type { TSlashCommandAdditionalOption } from "@/extensions"; // types -import { TExtensions } from "@/types"; +import type { IEditorProps } from "@/types"; -type Props = { - disabledExtensions?: TExtensions[]; -}; +type Props = Pick; export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => { const {} = props; diff --git a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx index 3262634c2e..56dfe7e6cf 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx @@ -11,10 +11,11 @@ import { getEditorClassNames } from "@/helpers/common"; // hooks import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor"; // types -import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types"; +import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types"; -const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { +const CollaborativeDocumentEditor: React.FC = (props) => { const { + onChange, onTransaction, aiHandler, bubbleMenuEnabled = true, @@ -25,11 +26,11 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { editorClassName = "", embedHandler, fileHandler, + flaggedExtensions, forwardedRef, handleEditorReady, id, mentionHandler, - onChange, pageRestorationInProgress, placeholder, realtimeConfig, @@ -62,9 +63,11 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { embedHandler, extensions, fileHandler, + flaggedExtensions, forwardedRef, handleEditorReady, id, + isSmoothCursorEnabled, mentionHandler, onChange, onTransaction, @@ -73,9 +76,8 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { serverHandler, tabIndex, titleRef, - user, updatePageProperties, - isSmoothCursorEnabled, + user, }); const editorContainerClassNames = getEditorClassNames({ @@ -105,7 +107,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { ); }; -const CollaborativeDocumentEditorWithRef = React.forwardRef( +const CollaborativeDocumentEditorWithRef = React.forwardRef( (props, ref) => ( } /> ) diff --git a/packages/editor/src/core/components/editors/document/editor.tsx b/packages/editor/src/core/components/editors/document/editor.tsx index 53c9ac9f5d..29690b9d5f 100644 --- a/packages/editor/src/core/components/editors/document/editor.tsx +++ b/packages/editor/src/core/components/editors/document/editor.tsx @@ -24,8 +24,9 @@ const DocumentEditor = (props: IDocumentEditor) => { editorClassName = "", embedHandler, fileHandler, - id, + flaggedExtensions, forwardedRef, + id, handleEditorReady, initialValue, isSmoothCursorEnabled, @@ -51,6 +52,8 @@ const DocumentEditor = (props: IDocumentEditor) => { ...DocumentEditorAdditionalExtensions({ disabledExtensions, embedConfig: embedHandler, + flaggedExtensions, + isEditable: true, fileHandler, userDetails: user, }) @@ -65,8 +68,10 @@ const DocumentEditor = (props: IDocumentEditor) => { enableHistory: true, extensions, fileHandler, + flaggedExtensions, forwardedRef, handleEditorReady, + id, initialValue, isSmoothCursorEnabled, mentionHandler, diff --git a/packages/editor/src/core/components/editors/document/page-renderer.tsx b/packages/editor/src/core/components/editors/document/page-renderer.tsx index 9dfafec519..d5e3532c03 100644 --- a/packages/editor/src/core/components/editors/document/page-renderer.tsx +++ b/packages/editor/src/core/components/editors/document/page-renderer.tsx @@ -7,7 +7,7 @@ import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus" // types import { TAIHandler, TDisplayConfig } from "@/types"; -type IPageRenderer = { +type Props = { aiHandler?: TAIHandler; bubbleMenuEnabled: boolean; displayConfig: TDisplayConfig; @@ -19,7 +19,7 @@ type IPageRenderer = { tabIndex?: number; }; -export const PageRenderer = (props: IPageRenderer) => { +export const PageRenderer = (props: Props) => { const { aiHandler, bubbleMenuEnabled, diff --git a/packages/editor/src/core/components/editors/document/read-only-editor.tsx b/packages/editor/src/core/components/editors/document/read-only-editor.tsx index 79e994a772..ba04bda453 100644 --- a/packages/editor/src/core/components/editors/document/read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/document/read-only-editor.tsx @@ -1,5 +1,5 @@ import { Extensions } from "@tiptap/core"; -import { forwardRef, MutableRefObject } from "react"; +import React, { forwardRef, MutableRefObject } from "react"; // components import { PageRenderer } from "@/components/editors"; // constants @@ -12,33 +12,11 @@ import { getEditorClassNames } from "@/helpers/common"; import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; // plane editor extensions import { PageEmbedReadOnlyExtension } from "@/plane-editor/extensions"; -// plane web types -import { TReadOnlyEmbedConfig } from "@/plane-editor/types"; +import { CustomAttachmentExtension } from "@/plane-editor/extensions/attachments/extension"; // types -import { - EditorReadOnlyRefApi, - TDisplayConfig, - TExtensions, - TReadOnlyFileHandler, - TReadOnlyMentionHandler, -} from "@/types"; +import { EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps } from "@/types"; -interface IDocumentReadOnlyEditor { - disabledExtensions: TExtensions[]; - id: string; - initialValue: string; - containerClassName: string; - displayConfig?: TDisplayConfig; - editorClassName?: string; - embedHandler: TReadOnlyEmbedConfig; - fileHandler: TReadOnlyFileHandler; - tabIndex?: number; - handleEditorReady?: (value: boolean) => void; - mentionHandler: TReadOnlyMentionHandler; - forwardedRef?: React.MutableRefObject; -} - -const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { +const DocumentReadOnlyEditor: React.FC = (props) => { const { containerClassName, disabledExtensions, @@ -46,13 +24,20 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { editorClassName = "", embedHandler, fileHandler, + flaggedExtensions, id, forwardedRef, handleEditorReady, initialValue, mentionHandler, } = props; - const extensions: Extensions = []; + const extensions: Extensions = [ + CustomAttachmentExtension({ + fileHandler, + isFlagged: flaggedExtensions.includes("attachments"), + isEditable: false, + }), + ]; if (embedHandler?.issue) { extensions.push( WorkItemEmbedExtension({ @@ -74,6 +59,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { editorClassName, extensions, fileHandler, + flaggedExtensions, forwardedRef, handleEditorReady, initialValue, @@ -97,7 +83,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { ); }; -const DocumentReadOnlyEditorWithRef = forwardRef((props, ref) => ( +const DocumentReadOnlyEditorWithRef = forwardRef((props, ref) => ( } /> )); diff --git a/packages/editor/src/core/components/editors/editor-wrapper.tsx b/packages/editor/src/core/components/editors/editor-wrapper.tsx index 514866fb28..c19d202d79 100644 --- a/packages/editor/src/core/components/editors/editor-wrapper.tsx +++ b/packages/editor/src/core/components/editors/editor-wrapper.tsx @@ -27,6 +27,7 @@ export const EditorWrapper: React.FC = (props) => { id, initialValue, fileHandler, + flaggedExtensions, forwardedRef, handleEditorReady, isSmoothCursorEnabled, @@ -46,6 +47,7 @@ export const EditorWrapper: React.FC = (props) => { enableHistory: true, extensions, fileHandler, + flaggedExtensions, forwardedRef, handleEditorReady, isSmoothCursorEnabled, diff --git a/packages/editor/src/core/components/editors/lite-text/editor.tsx b/packages/editor/src/core/components/editors/lite-text/editor.tsx index 527ef5b79d..c10782a8dd 100644 --- a/packages/editor/src/core/components/editors/lite-text/editor.tsx +++ b/packages/editor/src/core/components/editors/lite-text/editor.tsx @@ -4,23 +4,25 @@ import { EditorWrapper } from "@/components/editors/editor-wrapper"; // extensions import { EnterKeyExtension } from "@/extensions"; // types -import { EditorRefApi, ILiteTextEditor } from "@/types"; +import { EditorRefApi, ILiteTextEditorProps } from "@/types"; -const LiteTextEditor = (props: ILiteTextEditor) => { +const LiteTextEditor: React.FC = (props) => { const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [], isSmoothCursorEnabled } = props; - const extensions = useMemo( - () => [ - ...externalExtensions, - ...(disabledExtensions?.includes("enter-key") ? [] : [EnterKeyExtension(onEnterKeyPress)]), - ], - [externalExtensions, disabledExtensions, onEnterKeyPress] - ); + const extensions = useMemo(() => { + const resolvedExtensions = [...externalExtensions]; + + if (!disabledExtensions?.includes("enter-key")) { + resolvedExtensions.push(EnterKeyExtension(onEnterKeyPress)); + } + + return resolvedExtensions; + }, [externalExtensions, disabledExtensions, onEnterKeyPress]); return ; }; -const LiteTextEditorWithRef = forwardRef((props, ref) => ( +const LiteTextEditorWithRef = forwardRef((props, ref) => ( } /> )); diff --git a/packages/editor/src/core/components/editors/lite-text/read-only-editor.tsx b/packages/editor/src/core/components/editors/lite-text/read-only-editor.tsx index b721c84c5d..75e02791de 100644 --- a/packages/editor/src/core/components/editors/lite-text/read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/lite-text/read-only-editor.tsx @@ -2,9 +2,9 @@ import { forwardRef } from "react"; // components import { ReadOnlyEditorWrapper } from "@/components/editors"; // types -import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor } from "@/types"; +import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps } from "@/types"; -const LiteTextReadOnlyEditorWithRef = forwardRef((props, ref) => ( +const LiteTextReadOnlyEditorWithRef = forwardRef((props, ref) => ( } /> )); diff --git a/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx b/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx index ffed8ba8bd..b6abd1a6a5 100644 --- a/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx +++ b/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx @@ -17,6 +17,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => { editorClassName = "", extensions, fileHandler, + flaggedExtensions, forwardedRef, id, initialValue, @@ -28,6 +29,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => { editorClassName, extensions, fileHandler, + flaggedExtensions, forwardedRef, initialValue, mentionHandler, diff --git a/packages/editor/src/core/components/editors/rich-text/editor.tsx b/packages/editor/src/core/components/editors/rich-text/editor.tsx index c1c216e258..f17a72df4c 100644 --- a/packages/editor/src/core/components/editors/rich-text/editor.tsx +++ b/packages/editor/src/core/components/editors/rich-text/editor.tsx @@ -7,15 +7,16 @@ import { SideMenuExtension } from "@/extensions"; // plane editor imports import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/extensions"; // types -import { EditorRefApi, IRichTextEditor } from "@/types"; +import { EditorRefApi, IRichTextEditorProps } from "@/types"; -const RichTextEditor = (props: IRichTextEditor) => { +const RichTextEditor: React.FC = (props) => { const { + bubbleMenuEnabled = true, disabledExtensions, dragDropEnabled, - fileHandler, - bubbleMenuEnabled = true, extensions: externalExtensions = [], + fileHandler, + flaggedExtensions, isSmoothCursorEnabled, } = props; @@ -29,11 +30,12 @@ const RichTextEditor = (props: IRichTextEditor) => { ...RichTextEditorAdditionalExtensions({ disabledExtensions, fileHandler, + flaggedExtensions, }), ]; return extensions; - }, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler]); + }, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler, flaggedExtensions]); return ( @@ -42,7 +44,7 @@ const RichTextEditor = (props: IRichTextEditor) => { ); }; -const RichTextEditorWithRef = forwardRef((props, ref) => ( +const RichTextEditorWithRef = forwardRef((props, ref) => ( } /> )); diff --git a/packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx b/packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx index 18d960ca53..efad3d6ac1 100644 --- a/packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx @@ -2,23 +2,22 @@ import { forwardRef, useCallback } from "react"; // plane editor extensions import { RichTextReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/read-only-extensions"; // types -import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types"; +import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps } from "@/types"; // local imports import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper"; -const RichTextReadOnlyEditorWithRef = forwardRef((props, ref) => { - const { disabledExtensions, fileHandler } = props; +const RichTextReadOnlyEditorWithRef = forwardRef((props, ref) => { + const { disabledExtensions, fileHandler, flaggedExtensions } = props; const getExtensions = useCallback(() => { - const extensions = [ - ...RichTextReadOnlyEditorAdditionalExtensions({ - disabledExtensions, - fileHandler, - }), - ]; + const extensions = RichTextReadOnlyEditorAdditionalExtensions({ + disabledExtensions, + fileHandler, + flaggedExtensions, + }); return extensions; - }, [disabledExtensions, fileHandler]); + }, [disabledExtensions, fileHandler, flaggedExtensions]); return ( & { enableHistory: boolean; isSmoothCursorEnabled: boolean; - fileHandler: TFileHandler; - mentionHandler: TMentionHandler; - placeholder?: string | ((isFocused: boolean, value: string) => string); - tabIndex?: number; editable: boolean; }; export const CoreEditorExtensions = (args: TArguments): Extensions => { - const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex, editable,isSmoothCursorEnabled } = args; + const { + disabledExtensions, + enableHistory, + fileHandler, + flaggedExtensions, + isSmoothCursorEnabled, + mentionHandler, + placeholder, + tabIndex, + editable, + } = args; const extensions = [ StarterKit.configure({ @@ -179,6 +187,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { CustomColorExtension, ...CoreEditorAdditionalExtensions({ disabledExtensions, + flaggedExtensions, fileHandler, }), ]; diff --git a/packages/editor/src/core/extensions/read-only-extensions.ts b/packages/editor/src/core/extensions/read-only-extensions.ts index b28e8a67ab..0f422c620b 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.ts +++ b/packages/editor/src/core/extensions/read-only-extensions.ts @@ -31,16 +31,12 @@ import { isValidHttpUrl } from "@/helpers/common"; // plane editor extensions import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions"; // types -import { TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types"; +import type { IReadOnlyEditorProps } from "@/types"; -type Props = { - disabledExtensions: TExtensions[]; - fileHandler: TReadOnlyFileHandler; - mentionHandler: TReadOnlyMentionHandler; -}; +type Props = Pick; export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { - const { disabledExtensions, fileHandler, mentionHandler } = props; + const { disabledExtensions, fileHandler, flaggedExtensions, mentionHandler } = props; const extensions = [ StarterKit.configure({ @@ -133,6 +129,7 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { }), ...CoreReadOnlyEditorAdditionalExtensions({ disabledExtensions, + flaggedExtensions, }), ]; diff --git a/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx index 993282c892..c65de3b0a2 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx @@ -112,7 +112,7 @@ const fuzzySearch = (query: string, text: string): { match: boolean; score: numb export const getSlashCommandFilteredSections = (args: TExtensionProps) => ({ query }: { query: string }): TSlashCommandSection[] => { - const { additionalOptions: externalAdditionalOptions, disabledExtensions } = args; + const { additionalOptions: externalAdditionalOptions, disabledExtensions, flaggedExtensions } = args; const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [ { key: "general", @@ -353,6 +353,7 @@ export const getSlashCommandFilteredSections = ...(externalAdditionalOptions ?? []), ...coreEditorAdditionalSlashCommandOptions({ disabledExtensions, + flaggedExtensions, }), ]?.forEach((item) => { const sectionToPushTo = SLASH_COMMAND_SECTIONS.find((s) => s.key === item.section) ?? SLASH_COMMAND_SECTIONS[0]; diff --git a/packages/editor/src/core/extensions/slash-commands/root.tsx b/packages/editor/src/core/extensions/slash-commands/root.tsx index d7fc6d9c07..fc444c42ca 100644 --- a/packages/editor/src/core/extensions/slash-commands/root.tsx +++ b/packages/editor/src/core/extensions/slash-commands/root.tsx @@ -1,14 +1,14 @@ +import { computePosition, flip, shift } from "@floating-ui/dom"; import { Editor, Range, Extension } from "@tiptap/core"; import { ReactRenderer, posToDOMRect } from "@tiptap/react"; import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; -import { computePosition, flip, shift } from "@floating-ui/dom"; import { FC } from "react"; // constants import { CORE_EXTENSIONS } from "@/constants/extension"; // helpers import { CommandListInstance } from "@/helpers/tippy"; // types -import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types"; +import { IEditorProps, ISlashCommandItem, TEditorCommands, TSlashCommandSectionKeys } from "@/types"; // components import { getSlashCommandFilteredSections } from "./command-items-list"; import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu"; @@ -132,9 +132,8 @@ const Command = Extension.create({ }, }); -export type TExtensionProps = { +export type TExtensionProps = Pick & { additionalOptions?: TSlashCommandAdditionalOption[]; - disabledExtensions?: TExtensions[]; }; export const SlashCommands = (props: TExtensionProps) => diff --git a/packages/editor/src/core/extensions/utility.ts b/packages/editor/src/core/extensions/utility.ts index 1354a5fc51..b4c82ee4b7 100644 --- a/packages/editor/src/core/extensions/utility.ts +++ b/packages/editor/src/core/extensions/utility.ts @@ -6,7 +6,7 @@ import { DropHandlerPlugin } from "@/plugins/drop"; import { FilePlugins } from "@/plugins/file/root"; import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard"; // types -import { TExtensions, TFileHandler, TReadOnlyFileHandler } from "@/types"; +import type { IEditorProps, TFileHandler, TReadOnlyFileHandler } from "@/types"; // prosemirror plugins import { codemark } from "./code-mark"; @@ -23,8 +23,7 @@ export interface UtilityExtensionStorage { uploadInProgress: boolean; } -type Props = { - disabledExtensions: TExtensions[]; +type Props = Pick & { fileHandler: TFileHandler | TReadOnlyFileHandler; isEditable: boolean; }; diff --git a/packages/editor/src/core/helpers/file.ts b/packages/editor/src/core/helpers/file.ts index 33d3c7d781..a7e0a071b8 100644 --- a/packages/editor/src/core/helpers/file.ts +++ b/packages/editor/src/core/helpers/file.ts @@ -2,6 +2,7 @@ export enum EFileError { INVALID_FILE_TYPE = "INVALID_FILE_TYPE", FILE_SIZE_TOO_LARGE = "FILE_SIZE_TOO_LARGE", NO_FILE_SELECTED = "NO_FILE_SELECTED", + UPLOAD_FAILED = "UPLOAD_FAILED", } type TArgs = { diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts index e7e18e2270..eb0893d92e 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -13,26 +13,23 @@ import { useRealtimeEvents } from "@/hooks/use-realtime-events"; // plane editor extensions import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions"; // types -import { TCollaborativeEditorProps } from "@/types"; +import { TCollaborativeEditorHookProps } from "@/types"; +// local imports import { useEditorNavigation } from "./use-editor-navigation"; import { useTitleEditor } from "./use-title-editor"; -/** - * Hook that creates a collaborative editor with title and main editor components - * Handles real-time collaboration, local persistence, and keyboard navigation between editors - */ -export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { +export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => { const { - onChange, onTransaction, isSmoothCursorEnabled, disabledExtensions, editable, - editorClassName, + editorClassName = "", editorProps = {}, embedHandler, - extensions, + extensions = [], fileHandler, + flaggedExtensions, forwardedRef, handleEditorReady, id, @@ -125,7 +122,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { // Initialize main document editor const editor = useEditor({ - embedConfig: embedHandler, disabledExtensions, id, editable, @@ -145,15 +141,13 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { document: provider.document, field: "default", }), - - // User-provided extensions - ...(extensions ?? []), - - // Additional document editor extensions + ...extensions, ...DocumentEditorAdditionalExtensions({ disabledExtensions, embedConfig: embedHandler, fileHandler, + flaggedExtensions, + isEditable: editable, provider, userDetails: user, }), @@ -162,11 +156,11 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { mainNavigationExtension, ], fileHandler, + flaggedExtensions, + forwardedRef, handleEditorReady, isSmoothCursorEnabled, - forwardedRef, mentionHandler, - onChange, onTransaction, placeholder, provider, diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index c7825d637c..022c9a0e26 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -1,8 +1,6 @@ -import { HocuspocusProvider } from "@hocuspocus/provider"; import { DOMSerializer } from "@tiptap/pm/model"; -import { EditorProps } from "@tiptap/pm/view"; -import { useEditor as useTiptapEditor, Extensions, findChildren } from "@tiptap/react"; -import { useImperativeHandle, MutableRefObject, useEffect } from "react"; +import { useEditor as useTiptapEditor, findChildren } from "@tiptap/react"; +import { useImperativeHandle, useEffect } from "react"; import * as Y from "yjs"; // components import { getEditorMenuItems } from "@/components/menus"; @@ -19,64 +17,31 @@ import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helper // props import { CoreEditorProps } from "@/props"; // types -import type { - TDocumentEventsServer, - EditorRefApi, - TEditorCommands, - TFileHandler, - TExtensions, - TMentionHandler, - TEmbedConfig, -} from "@/types"; +import type { TDocumentEventsServer, TEditorCommands, TEditorHookProps } from "@/types"; -export interface CustomEditorProps { - editable?: boolean; - editorClassName: string; - editorProps?: EditorProps; - embedConfig?: TEmbedConfig | undefined; - enableHistory: boolean; - disabledExtensions: TExtensions[]; - extensions?: Extensions; - fileHandler: TFileHandler; - forwardedRef?: MutableRefObject; - handleEditorReady?: (value: boolean) => void; - isSmoothCursorEnabled: boolean; - id?: string; - initialValue?: string; - mentionHandler: TMentionHandler; - onChange?: (json: object, html: string) => void; - onTransaction?: () => void; - autofocus?: boolean; - placeholder?: string | ((isFocused: boolean, value: string) => string); - provider?: HocuspocusProvider; - tabIndex?: number; - // undefined when prop is not passed, null if intentionally passed to stop - // swr syncing - value?: string | null | undefined; -} - -export const useEditor = (props: CustomEditorProps) => { +export const useEditor = (props: TEditorHookProps) => { const { + autofocus = false, disabledExtensions, editable = true, - editorClassName, + editorClassName = "", editorProps = {}, enableHistory, extensions = [], fileHandler, + flaggedExtensions, forwardedRef, handleEditorReady, id = "", initialValue, + isSmoothCursorEnabled = false, mentionHandler, onChange, onTransaction, placeholder, + provider, tabIndex, value, - provider, - autofocus = false, - isSmoothCursorEnabled = false, } = props; const editor = useTiptapEditor( @@ -99,6 +64,7 @@ export const useEditor = (props: CustomEditorProps) => { disabledExtensions, enableHistory, fileHandler, + flaggedExtensions, mentionHandler, placeholder, tabIndex, diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index 6a6e25d9fd..d259470ac9 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -1,8 +1,8 @@ -import { HocuspocusProvider } from "@hocuspocus/provider"; -import { EditorProps } from "@tiptap/pm/view"; -import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react"; -import { useImperativeHandle, MutableRefObject, useEffect } from "react"; +import { useEditor as useTiptapEditor } from "@tiptap/react"; +import { useImperativeHandle, useEffect } from "react"; import * as Y from "yjs"; +// constants +import { CORE_EDITOR_META } from "@/constants/meta"; // extensions import { CoreReadOnlyEditorExtensions } from "@/extensions"; // helpers @@ -11,32 +11,19 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; // props import { CoreReadOnlyEditorProps } from "@/props"; // types -import type { EditorReadOnlyRefApi, TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types"; -import { CORE_EDITOR_META } from "@/constants/meta"; +import type { TReadOnlyEditorHookProps } from "@/types"; -interface CustomReadOnlyEditorProps { - disabledExtensions: TExtensions[]; - editorClassName: string; - editorProps?: EditorProps; - extensions?: Extensions; - forwardedRef?: MutableRefObject; - initialValue?: string; - fileHandler: TReadOnlyFileHandler; - handleEditorReady?: (value: boolean) => void; - mentionHandler: TReadOnlyMentionHandler; - provider?: HocuspocusProvider; -} - -export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { +export const useReadOnlyEditor = (props: TReadOnlyEditorHookProps) => { const { disabledExtensions, - initialValue, - editorClassName, - forwardedRef, - extensions = [], + editorClassName = "", editorProps = {}, + extensions = [], fileHandler, + flaggedExtensions, + forwardedRef, handleEditorReady, + initialValue, mentionHandler, provider, } = props; @@ -59,8 +46,9 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { extensions: [ ...CoreReadOnlyEditorExtensions({ disabledExtensions, - mentionHandler, fileHandler, + flaggedExtensions, + mentionHandler, }), ...extensions, ], diff --git a/packages/editor/src/core/hooks/use-title-editor.ts b/packages/editor/src/core/hooks/use-title-editor.ts index f9ab760dc5..48ff2d2755 100644 --- a/packages/editor/src/core/hooks/use-title-editor.ts +++ b/packages/editor/src/core/hooks/use-title-editor.ts @@ -5,6 +5,8 @@ import { DOMSerializer } from "@tiptap/pm/model"; import { useEditor } from "@tiptap/react"; import { useImperativeHandle } from "react"; import * as Y from "yjs"; +// constants +import { CORE_EDITOR_META } from "@/constants/meta"; // extensions import { IMarking, SmoothCursorExtension } from "@/extensions"; import { TitleExtensions } from "@/extensions/title-extension"; @@ -70,7 +72,7 @@ export const useTitleEditor = (props: TitleEditorProps) => { clearEditor: (emitUpdate = false) => { editor ?.chain() - .setMeta("skipImageDeletion", true) + .setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true) .setMeta("intentionalDeletion", true) .clearContent(emitUpdate) .run(); diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 406fb023e3..35d688977e 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -21,6 +21,7 @@ const generalSelectors = [ ".image-component", ".image-upload-component", ".editor-callout-component", + ".editor-attachment-component", ".page-embed-component", ].join(", "); diff --git a/packages/editor/src/core/plugins/drop.ts b/packages/editor/src/core/plugins/drop.ts index ad8070924b..fdca8bbb6d 100644 --- a/packages/editor/src/core/plugins/drop.ts +++ b/packages/editor/src/core/plugins/drop.ts @@ -117,7 +117,12 @@ export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => { pos, event, }); - } else if (fileType === "attachment") { + } else if (fileType === "attachment" && !disabledExtensions?.includes("attachments")) { + editor.commands.insertAttachmentComponent({ + file, + pos, + event, + }); } } catch (error) { console.error(`Error while ${event}ing file:`, error); diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration.ts index 579bb383a8..bd033a0ec2 100644 --- a/packages/editor/src/core/types/collaboration.ts +++ b/packages/editor/src/core/types/collaboration.ts @@ -1,56 +1,5 @@ -import { Extensions } from "@tiptap/core"; -import { EditorProps } from "@tiptap/pm/view"; -// plane editor types -import { TEmbedConfig } from "@/plane-editor/types"; -// types -import { - EditorReadOnlyRefApi, - EditorRefApi, - EditorTitleRefApi, - TExtensions, - TFileHandler, - TMentionHandler, - TReadOnlyFileHandler, - TReadOnlyMentionHandler, - TRealtimeConfig, - TUserDetails, -} from "@/types"; - export type TServerHandler = { onConnect?: () => void; onServerError?: () => void; onServerSynced?: () => void; }; - -type TCollaborativeEditorHookProps = { - disabledExtensions: TExtensions[]; - editable: boolean; - editorClassName: string; - editorProps?: EditorProps; - extensions?: Extensions; - handleEditorReady?: (value: boolean) => void; - id: string; - realtimeConfig: TRealtimeConfig; - serverHandler?: TServerHandler; - user: TUserDetails; -}; - -export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { - isSmoothCursorEnabled: boolean; - onTransaction?: () => void; - embedHandler?: TEmbedConfig; - fileHandler: TFileHandler; - forwardedRef?: React.MutableRefObject; - mentionHandler: TMentionHandler; - onChange?: (description_json: object, description_html: string) => void; - placeholder?: string | ((isFocused: boolean, value: string) => string); - tabIndex?: number; - titleRef?: React.MutableRefObject; - updatePageProperties?: (pageId: string, messageType: string, payload?: any, performAction?: boolean) => void; -}; - -export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & { - fileHandler: TReadOnlyFileHandler; - forwardedRef?: React.MutableRefObject; - mentionHandler: TReadOnlyMentionHandler; -}; diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts index ace2220ed0..60ccfa8412 100644 --- a/packages/editor/src/core/types/config.ts +++ b/packages/editor/src/core/types/config.ts @@ -1,3 +1,6 @@ +// plane imports +import { TWebhookConnectionQueryParams } from "@plane/types"; + export type TReadOnlyFileHandler = { checkIfAssetExists: (assetId: string) => Promise; getAssetSrc: (path: string) => Promise; @@ -30,3 +33,15 @@ export type TDisplayConfig = { lineSpacing?: TEditorLineSpacing; wideLayout?: boolean; }; + +export type TUserDetails = { + color: string; + id: string; + name: string; + cookie?: string; +}; + +export type TRealtimeConfig = { + url: string; + queryParams: TWebhookConnectionQueryParams; +}; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index cc52ca9da1..763502e942 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -1,13 +1,11 @@ -import { Extensions, JSONContent } from "@tiptap/core"; -import { Selection } from "@tiptap/pm/state"; -// plane types -import { TWebhookConnectionQueryParams } from "@plane/types"; +import type { Extensions, JSONContent } from "@tiptap/core"; +import type { Selection } from "@tiptap/pm/state"; // extension types -import { TTextAlign } from "@/extensions"; +import type { TTextAlign } from "@/extensions"; // helpers -import { IMarking } from "@/helpers/scroll-to-node"; +import type { IMarking } from "@/helpers/scroll-to-node"; // types -import { +import type { EventToPayloadMap, TAIHandler, TDisplayConfig, @@ -19,7 +17,9 @@ import { TMentionHandler, TReadOnlyFileHandler, TReadOnlyMentionHandler, + TRealtimeConfig, TServerHandler, + TUserDetails, } from "@/types"; export type TEditorCommands = @@ -133,41 +133,39 @@ export interface EditorRefApi extends EditorReadOnlyRefApi { // editor props export interface IEditorProps { + autofocus?: boolean; + bubbleMenuEnabled?: boolean; containerClassName?: string; displayConfig?: TDisplayConfig; disabledExtensions: TExtensions[]; editorClassName?: string; + extensions?: Extensions; + flaggedExtensions: TExtensions[]; fileHandler: TFileHandler; forwardedRef?: React.MutableRefObject; + handleEditorReady?: (value: boolean) => void; id: string; initialValue: string; mentionHandler: TMentionHandler; onChange?: (json: object, html: string) => void; - onTransaction?: () => void; - handleEditorReady?: (value: boolean) => void; isSmoothCursorEnabled: boolean; - autofocus?: boolean; onEnterKeyPress?: (e?: any) => void; + onTransaction?: () => void; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; value?: string | null; - bubbleMenuEnabled?: boolean; } -export interface ILiteTextEditor extends IEditorProps { - extensions?: Extensions; -} -export interface IRichTextEditor extends IEditorProps { - extensions?: Extensions; + +export type ILiteTextEditorProps = IEditorProps; +export interface IRichTextEditorProps extends IEditorProps { dragDropEnabled?: boolean; } -export interface ICollaborativeDocumentEditor extends Omit { +export interface ICollaborativeDocumentEditorProps + extends Omit { aiHandler?: TAIHandler; - bubbleMenuEnabled?: boolean; editable: boolean; embedHandler: TEmbedConfig; - handleEditorReady?: (value: boolean) => void; - id: string; realtimeConfig: TRealtimeConfig; serverHandler?: TServerHandler; user: TUserDetails; @@ -183,57 +181,37 @@ export interface ICollaborativeDocumentEditor extends Omit { aiHandler?: TAIHandler; - bubbleMenuEnabled?: boolean; embedHandler: TEmbedConfig; - handleEditorReady?: (value: boolean) => void; - id: string; user: TUserDetails; } // read only editor props -export interface IReadOnlyEditorProps { - containerClassName?: string; - disabledExtensions: TExtensions[]; - displayConfig?: TDisplayConfig; - editorClassName?: string; - extensions?: Extensions; +export interface IReadOnlyEditorProps + extends Pick< + IEditorProps, + | "containerClassName" + | "disabledExtensions" + | "flaggedExtensions" + | "displayConfig" + | "editorClassName" + | "extensions" + | "handleEditorReady" + | "id" + | "initialValue" + > { fileHandler: TReadOnlyFileHandler; forwardedRef?: React.MutableRefObject; - id: string; - initialValue: string; mentionHandler: TReadOnlyMentionHandler; } -export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps; +export type ILiteTextReadOnlyEditorProps = IReadOnlyEditorProps; -export type IRichTextReadOnlyEditor = IReadOnlyEditorProps; +export type IRichTextReadOnlyEditorProps = IReadOnlyEditorProps; -export interface ICollaborativeDocumentReadOnlyEditor extends Omit { +export interface IDocumentReadOnlyEditorProps extends IReadOnlyEditorProps { embedHandler: TEmbedConfig; - handleEditorReady?: (value: boolean) => void; - id: string; - realtimeConfig: TRealtimeConfig; - serverHandler?: TServerHandler; - user: TUserDetails; } -export interface IDocumentReadOnlyEditor extends IReadOnlyEditorProps { - embedHandler: TEmbedConfig; - handleEditorReady?: (value: boolean) => void; -} - -export type TUserDetails = { - color: string; - id: string; - name: string; - cookie?: string; -}; - -export type TRealtimeConfig = { - url: string; - queryParams: TWebhookConnectionQueryParams; -}; - export interface EditorEvents { beforeCreate: never; create: never; diff --git a/packages/editor/src/core/types/extensions.ts b/packages/editor/src/core/types/extensions.ts index a2fcebc0eb..33aee7a9f7 100644 --- a/packages/editor/src/core/types/extensions.ts +++ b/packages/editor/src/core/types/extensions.ts @@ -6,4 +6,5 @@ export type TExtensions = | "slash-commands" | "enter-key" | "image" - | "nested-pages"; + | "nested-pages" + | "attachments"; diff --git a/packages/editor/src/core/types/hook.ts b/packages/editor/src/core/types/hook.ts new file mode 100644 index 0000000000..4ac9e40863 --- /dev/null +++ b/packages/editor/src/core/types/hook.ts @@ -0,0 +1,63 @@ +import type { HocuspocusProvider } from "@hocuspocus/provider"; +import type { EditorProps } from "@tiptap/pm/view"; +// local imports +import type { + EditorTitleRefApi, + ICollaborativeDocumentEditorProps, + IEditorProps, + IReadOnlyEditorProps, +} from "./editor"; + +type TCoreHookProps = Pick< + IEditorProps, + | "disabledExtensions" + | "editorClassName" + | "extensions" + | "flaggedExtensions" + | "handleEditorReady" + | "isSmoothCursorEnabled" +> & { + editorProps?: EditorProps; +}; + +export type TEditorHookProps = TCoreHookProps & + Pick< + IEditorProps, + | "autofocus" + | "fileHandler" + | "forwardedRef" + | "id" + | "mentionHandler" + | "onChange" + | "onTransaction" + | "placeholder" + | "tabIndex" + | "value" + > & { + editable: boolean; + enableHistory: boolean; + initialValue?: string; + provider?: HocuspocusProvider; + }; + +export type TCollaborativeEditorHookProps = TCoreHookProps & + Pick< + TEditorHookProps, + | "editable" + | "fileHandler" + | "forwardedRef" + | "id" + | "mentionHandler" + | "onChange" + | "onTransaction" + | "placeholder" + | "tabIndex" + > & + Pick & { + titleRef?: React.MutableRefObject; + updatePageProperties?: (pageId: string, messageType: string, payload?: any, performAction?: boolean) => void; + }; + +export type TReadOnlyEditorHookProps = Omit & + Pick & + Pick; diff --git a/packages/editor/src/core/types/index.ts b/packages/editor/src/core/types/index.ts index 66cb249425..619fa0c784 100644 --- a/packages/editor/src/core/types/index.ts +++ b/packages/editor/src/core/types/index.ts @@ -4,6 +4,7 @@ export * from "./config"; export * from "./editor"; export * from "./embed"; export * from "./extensions"; +export * from "./hook"; export * from "./mention"; export * from "./slash-commands-suggestion"; export * from "@/plane-editor/types"; diff --git a/packages/editor/src/ee/constants/extensions.ts b/packages/editor/src/ee/constants/extensions.ts index b06a641307..9ede403f2a 100644 --- a/packages/editor/src/ee/constants/extensions.ts +++ b/packages/editor/src/ee/constants/extensions.ts @@ -1,3 +1,4 @@ export enum ADDITIONAL_EXTENSIONS { - PAGE_EMBED_COMPONENT = "page-embed-component", -} \ No newline at end of file + PAGE_EMBED_COMPONENT = "pageEmbedComponent", + ATTACHMENT = "attachmentComponent", +} diff --git a/packages/editor/src/ee/constants/utility.ts b/packages/editor/src/ee/constants/utility.ts index feb62b3529..f53e2b6fbd 100644 --- a/packages/editor/src/ee/constants/utility.ts +++ b/packages/editor/src/ee/constants/utility.ts @@ -1 +1,16 @@ -export { NODE_FILE_MAP } from "../../ce/constants/utility"; +// ce imports +import { NODE_FILE_MAP as CORE_NODE_FILE_MAP } from "src/ce/constants/utility"; +// local imports +import { ExtensionFileSetStorageKey } from "../types/storage"; +import { ADDITIONAL_EXTENSIONS } from "./extensions"; + +export const NODE_FILE_MAP: { + [key: string]: { + fileSetName: ExtensionFileSetStorageKey; + }; +} = { + ...CORE_NODE_FILE_MAP, + [ADDITIONAL_EXTENSIONS.ATTACHMENT]: { + fileSetName: "deletedAttachmentSet", + }, +}; diff --git a/packages/editor/src/ee/extensions/attachments/components/block.tsx b/packages/editor/src/ee/extensions/attachments/components/block.tsx new file mode 100644 index 0000000000..75f971e35f --- /dev/null +++ b/packages/editor/src/ee/extensions/attachments/components/block.tsx @@ -0,0 +1,56 @@ +import { File } from "lucide-react"; +import { useEffect, useState } from "react"; +// plane imports +import { convertBytesToSize } from "@plane/utils"; +// local imports +import { EAttachmentBlockAttributeNames } from "../types"; +import { CustomAttachmentNodeViewProps } from "./node-view"; + +type Props = CustomAttachmentNodeViewProps & { + resolvedSource: string; +}; + +export const CustomAttachmentBlock: React.FC = (props) => { + const { extension, node, resolvedSource } = props; + // states + const [hasCheckedExistence, setHasCheckedExistence] = useState(false); + // derived values + const { src } = node.attrs; + + useEffect(() => { + if (hasCheckedExistence || !src) return; + const checkExistence = async () => { + try { + const doesAttachmentExist = await extension.options.checkIfAttachmentExists?.(src); + if (!doesAttachmentExist) { + await extension.options.restoreAttachment?.(src); + } + } catch (error) { + console.error("Error in checking attachment existence", error); + } finally { + setHasCheckedExistence(true); + } + }; + checkExistence(); + }, [extension.options, hasCheckedExistence, src]); + + return ( + +
+ +
+
+

{node.attrs[EAttachmentBlockAttributeNames.FILE_NAME]}

+

+ {convertBytesToSize(Number(node.attrs[EAttachmentBlockAttributeNames.FILE_SIZE] || 0))} +

+
+
+ ); +}; diff --git a/packages/editor/src/ee/extensions/attachments/components/flagged-state.tsx b/packages/editor/src/ee/extensions/attachments/components/flagged-state.tsx new file mode 100644 index 0000000000..67506579d2 --- /dev/null +++ b/packages/editor/src/ee/extensions/attachments/components/flagged-state.tsx @@ -0,0 +1,19 @@ +import { FileKey2 } from "lucide-react"; + +export const CustomAttachmentFlaggedState = () => ( + + + + +

+ {/* {t("attachmentComponent.upgrade.description")} */} + Upgrade your plan to view this attachment. +

+
+); diff --git a/packages/editor/src/ee/extensions/attachments/components/node-view.tsx b/packages/editor/src/ee/extensions/attachments/components/node-view.tsx new file mode 100644 index 0000000000..c511f4fd5e --- /dev/null +++ b/packages/editor/src/ee/extensions/attachments/components/node-view.tsx @@ -0,0 +1,54 @@ +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { useEffect, useRef, useState } from "react"; +// local imports +import { type AttachmentExtension, type TAttachmentBlockAttributes } from "../types"; +import { CustomAttachmentBlock } from "./block"; +import { CustomAttachmentFlaggedState } from "./flagged-state"; +import { CustomAttachmentUploader } from "./uploader"; + +export type CustomAttachmentNodeViewProps = Omit & { + extension: AttachmentExtension; + node: NodeViewProps["node"] & { + attrs: TAttachmentBlockAttributes; + }; + updateAttributes: (attrs: Partial) => void; +}; + +export const CustomAttachmentNodeView: React.FC = (props) => { + const { extension, node } = props; + // states + const [resolvedSource, setResolvedSource] = useState(null); + // refs + const attachmentComponentRef = useRef(null); + // derived values + const { src } = node.attrs; + const isAttachmentUploaded = !!src; + const isExtensionFlagged = extension.options.isFlagged; + + useEffect(() => { + if (!src || resolvedSource) return; + const getAttachmentSource = async () => { + const source = await extension.options.getAttachmentSource?.(src); + setResolvedSource(source); + }; + getAttachmentSource(); + }, [extension.options, resolvedSource, src]); + + return ( + + {isExtensionFlagged ? ( +
+ +
+ ) : ( +
+ {isAttachmentUploaded ? ( + <>{resolvedSource && } + ) : ( + + )} +
+ )} +
+ ); +}; diff --git a/packages/editor/src/ee/extensions/attachments/components/upload-status.tsx b/packages/editor/src/ee/extensions/attachments/components/upload-status.tsx new file mode 100644 index 0000000000..b8741e5350 --- /dev/null +++ b/packages/editor/src/ee/extensions/attachments/components/upload-status.tsx @@ -0,0 +1,60 @@ +import { Editor } from "@tiptap/core"; +import { useEditorState } from "@tiptap/react"; +import { useEffect, useRef, useState } from "react"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// helpers +import { getExtensionStorage } from "@/helpers/get-extension-storage"; + +type Props = { + editor: Editor; + nodeId: string; +}; + +export const CustomAttachmentUploadStatus: React.FC = (props) => { + const { editor, nodeId } = props; + // Displayed status that will animate smoothly + const [displayStatus, setDisplayStatus] = useState(0); + // Animation frame ID for cleanup + const animationFrameRef = useRef(null); + // subscribe to image upload status + const uploadStatus: number | undefined = useEditorState({ + editor, + selector: ({ editor }) => getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.assetsUploadStatus?.[nodeId], + }); + + useEffect(() => { + const animateToValue = (start: number, end: number, startTime: number) => { + const duration = 200; + + const animation = (currentTime: number) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + + // Easing function for smooth animation + const easeOutCubic = 1 - Math.pow(1 - progress, 3); + + // Calculate current display value + const currentValue = Math.floor(start + (end - start) * easeOutCubic); + setDisplayStatus(currentValue); + + // Continue animation if not complete + if (progress < 1) { + animationFrameRef.current = requestAnimationFrame((time) => animation(time)); + } + }; + animationFrameRef.current = requestAnimationFrame((time) => animation(time)); + }; + animateToValue(displayStatus, uploadStatus == undefined ? 100 : uploadStatus, performance.now()); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [displayStatus, uploadStatus]); + + if (uploadStatus === undefined) return null; + + return <>{displayStatus}%; +}; diff --git a/packages/editor/src/ee/extensions/attachments/components/uploader-details.tsx b/packages/editor/src/ee/extensions/attachments/components/uploader-details.tsx new file mode 100644 index 0000000000..2bed571b3d --- /dev/null +++ b/packages/editor/src/ee/extensions/attachments/components/uploader-details.tsx @@ -0,0 +1,100 @@ +import { Editor } from "@tiptap/core"; +import { useEditorState } from "@tiptap/react"; +import { useMemo } from "react"; +// plane imports +import { cn } from "@plane/utils"; +// helpers +import { EFileError } from "@/helpers/file"; +// local imports +import { getAttachmentExtensionErrorMap } from "../utils"; +import { CustomAttachmentUploadStatus } from "./upload-status"; + +type Props = { + blockId: string; + editor: Editor; + fileBeingUploaded: File | null; + maxFileSize: number; +}; + +export const CustomAttachmentUploaderDetails: React.FC = (props) => { + const { blockId, editor, fileBeingUploaded, maxFileSize } = props; + // subscribe to editor storage + const fileUploadErrorMap = useEditorState({ + editor, + selector: ({ editor }) => getAttachmentExtensionErrorMap(editor), + }); + const fileUploadError = fileUploadErrorMap?.get(blockId); + + const errorMessage = useMemo(() => { + let title = ""; + let description: React.ReactNode = ""; + + switch (fileUploadError?.error) { + case EFileError.FILE_SIZE_TOO_LARGE: + // title = t("attachmentComponent.errors.file_too_large.title"); + title = "File too large."; + // description = t("attachmentComponent.errors.file_too_large.description", { + // maxFileSize: maxFileSize / 1024 / 1024, + // }); + description = `Maximum size per file is ${maxFileSize / 1024 / 1024}MB`; + break; + case EFileError.INVALID_FILE_TYPE: + // title = t("attachmentComponent.errors.unsupported_file_type.title"); + title = "Unsupported file type."; + description = ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + {/* {t("attachmentComponent.errors.unsupported_file_type.description")} */} + See supported formats + + ); + break; + default: + // title = t("attachmentComponent.errors.default.title"); + title = "Upload failed."; + // description = t("attachmentComponent.errors.default.description"); + description = "Something went wrong. Please try again."; + } + return { + title, + description, + }; + }, [fileUploadError, maxFileSize]); + + return ( +
+

+ {fileBeingUploaded + ? fileBeingUploaded?.name + : fileUploadError + ? `${fileUploadError.file.name}- ${errorMessage.title}` + : // : t("attachmentComponent.uploader.drag_and_drop") + "Drop files here or click to upload"} +

+

+ {fileBeingUploaded ? ( + + ) : fileUploadError ? ( + errorMessage.description + ) : ( + // t("attachmentComponent.errors.file_too_large.description", { + // maxFileSize: maxFileSize / 1024 / 1024, + // }) + `Maximum size per file is ${maxFileSize / 1024 / 1024}MB` + )} +

+
+ ); +}; diff --git a/packages/editor/src/ee/extensions/attachments/components/uploader.tsx b/packages/editor/src/ee/extensions/attachments/components/uploader.tsx new file mode 100644 index 0000000000..f161f6e6ea --- /dev/null +++ b/packages/editor/src/ee/extensions/attachments/components/uploader.tsx @@ -0,0 +1,207 @@ +import { FileUp } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +// plane imports +import { cn } from "@plane/utils"; +// constants +import { ACCEPTED_ATTACHMENT_MIME_TYPES } from "@/constants/config"; +import { CORE_EXTENSIONS } from "@/constants/extension"; +// helpers +import { EFileError } from "@/helpers/file"; +import { getExtensionStorage } from "@/helpers/get-extension-storage"; +// hooks +import { uploadFirstFileAndInsertRemaining, useDropZone, useUploader } from "@/hooks/use-file-upload"; +// plane editor imports +import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions"; +// local imports +import { EAttachmentBlockAttributeNames } from "../types"; +import { getAttachmentExtensionErrorMap, getAttachmentExtensionFileMap } from "../utils"; +import { CustomAttachmentNodeViewProps } from "./node-view"; +import { CustomAttachmentUploaderDetails } from "./uploader-details"; + +export const CustomAttachmentUploader: React.FC = (props) => { + const { editor, extension, getPos, node, updateAttributes } = props; + // states + const [fileBeingUploaded, setFileBeingUploaded] = useState(null); + // refs + const fileInputRef = useRef(null); + const hasTriggeredFilePickerRef = useRef(false); + // derived values + const { id: attachmentBlockId } = node.attrs; + const maxFileSize = getExtensionStorage(editor, ADDITIONAL_EXTENSIONS.ATTACHMENT)?.maxFileSize; + const attachmentExtensionFileMap = useMemo(() => getAttachmentExtensionFileMap(editor), [editor]); + const attachmentExtensionErrorMap = useMemo(() => getAttachmentExtensionErrorMap(editor), [editor]); + + // upload handler + const onUpload = useCallback( + (url: string, file: File) => { + if (!url || !file || !attachmentBlockId) return; + // update the node view's attributes post upload + updateAttributes({ + [EAttachmentBlockAttributeNames.SOURCE]: url, + [EAttachmentBlockAttributeNames.FILE_NAME]: file.name, + [EAttachmentBlockAttributeNames.FILE_TYPE]: file.type, + [EAttachmentBlockAttributeNames.FILE_SIZE]: file.size, + }); + // delete from the attachment file map + attachmentExtensionFileMap?.delete(attachmentBlockId); + + const pos = getPos(); + // get current node + const getCurrentSelection = editor.state.selection; + const currentNode = editor.state.doc.nodeAt(getCurrentSelection.from); + + // only if the cursor is at the current image component, manipulate + // the cursor position + if (currentNode && currentNode.type.name === node.type.name && currentNode.attrs.src === url) { + // control cursor position after upload + const nextNode = editor.state.doc.nodeAt(pos + 1); + + if (nextNode && nextNode.type.name === CORE_EXTENSIONS.PARAGRAPH) { + // If there is a paragraph node after the image component, move the focus to the next node + editor.commands.setTextSelection(pos + 1); + } else { + // create a new paragraph after the image component post upload + editor.commands.createParagraphNear(); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [attachmentBlockId, attachmentExtensionFileMap, updateAttributes, getPos] + ); + + const uploadAttachmentEditorCommand = useCallback( + async (file: File) => { + try { + setFileBeingUploaded(file); + return await extension.options.uploadAttachment?.(attachmentBlockId ?? "", file); + } catch (error) { + attachmentExtensionErrorMap?.set(attachmentBlockId ?? "", { + error: EFileError.UPLOAD_FAILED, + file, + }); + console.error("Error in uploading attachment via uploader:", error); + } finally { + setFileBeingUploaded(null); + } + }, + [attachmentBlockId, attachmentExtensionErrorMap, extension.options] + ); + + const handleProgressStatus = useCallback( + (isUploading: boolean) => { + getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).uploadInProgress = isUploading; + }, + [editor] + ); + + const handleInvalidFile = useCallback( + (error: EFileError, file: File) => { + attachmentExtensionErrorMap?.set(attachmentBlockId ?? "", { + error, + file, + }); + setFileBeingUploaded(null); + }, + [attachmentBlockId, attachmentExtensionErrorMap] + ); + + // file upload + const { uploadFile } = useUploader({ + acceptedMimeTypes: ACCEPTED_ATTACHMENT_MIME_TYPES, + editorCommand: uploadAttachmentEditorCommand, + handleProgressStatus, + maxFileSize, + onInvalidFile: handleInvalidFile, + onUpload, + }); + // dropzone + const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ + editor, + pos: getPos(), + type: "attachment", + uploader: uploadFile, + }); + + const handleFileChange = useCallback( + async (e: React.ChangeEvent) => { + e.preventDefault(); + const filesList = e.target.files; + if (!filesList) return; + + await uploadFirstFileAndInsertRemaining({ + editor, + filesList, + pos: getPos(), + type: "attachment", + uploader: uploadFile, + }); + }, + [editor, getPos, uploadFile] + ); + + // the meta data of the image component + const meta = useMemo( + () => attachmentExtensionFileMap?.get(attachmentBlockId ?? ""), + [attachmentBlockId, attachmentExtensionFileMap] + ); + + // after the attachment component is mounted we start the upload process based on + // its upload status + useEffect(() => { + if (!meta || !attachmentBlockId) return; + + if (meta.event === "drop" && "file" in meta) { + uploadFile(meta.file); + } else if (meta.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) { + if (meta.hasOpenedFileInputOnce) return; + fileInputRef.current.click(); + hasTriggeredFilePickerRef.current = true; + attachmentExtensionFileMap?.set(attachmentBlockId, { + ...meta, + hasOpenedFileInputOnce: true, + }); + } + }, [attachmentBlockId, attachmentExtensionFileMap, meta, uploadFile]); + + return ( +
{ + if (editor.isEditable && !fileBeingUploaded) fileInputRef.current?.click(); + }} + role="button" + // aria-label={t("attachmentComponent.aria.click_to_upload")} + aria-label="Click to upload attachment" + aria-disabled={!editor.isEditable} + > +
+ +
+ + +
+ ); +}; diff --git a/packages/editor/src/ee/extensions/attachments/extension-config.ts b/packages/editor/src/ee/extensions/attachments/extension-config.ts new file mode 100644 index 0000000000..a32c82fe12 --- /dev/null +++ b/packages/editor/src/ee/extensions/attachments/extension-config.ts @@ -0,0 +1,44 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +// plane editor imports +import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions"; +// local imports +import { type AttachmentExtension, EAttachmentBlockAttributeNames, type InsertAttachmentComponentProps } from "./types"; +import { DEFAULT_ATTACHMENT_BLOCK_ATTRIBUTES } from "./utils"; + +declare module "@tiptap/core" { + interface Commands { + [ADDITIONAL_EXTENSIONS.ATTACHMENT]: { + insertAttachmentComponent: (props: InsertAttachmentComponentProps) => ReturnType; + }; + } +} + +export const CustomAttachmentExtensionConfig: AttachmentExtension = Node.create({ + name: ADDITIONAL_EXTENSIONS.ATTACHMENT, + group: "block", + atom: true, + + addAttributes() { + const attributes = { + ...Object.values(EAttachmentBlockAttributeNames).reduce((acc, value) => { + acc[value] = { + default: DEFAULT_ATTACHMENT_BLOCK_ATTRIBUTES[value], + }; + return acc; + }, {}), + }; + return attributes; + }, + + parseHTML() { + return [ + { + tag: "attachment-component", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["attachment-component", mergeAttributes(HTMLAttributes)]; + }, +}); diff --git a/packages/editor/src/ee/extensions/attachments/extension.ts b/packages/editor/src/ee/extensions/attachments/extension.ts new file mode 100644 index 0000000000..a67831afb3 --- /dev/null +++ b/packages/editor/src/ee/extensions/attachments/extension.ts @@ -0,0 +1,113 @@ +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { v4 as uuidv4 } from "uuid"; +// helpers +import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; +// types +import { TFileHandler, TReadOnlyFileHandler } from "@/types"; +// block +import { CustomAttachmentNodeView } from "./components/node-view"; +// config +import { CustomAttachmentExtensionConfig } from "./extension-config"; +// types +import { TAttachmentBlockAttributes } from "./types"; +// utils +import { DEFAULT_ATTACHMENT_BLOCK_ATTRIBUTES, getAttachmentExtensionFileMap } from "./utils"; + +type Props = { + fileHandler: TReadOnlyFileHandler | TFileHandler; + isEditable: boolean; + isFlagged: boolean; +}; + +export const CustomAttachmentExtension = (props: Props) => { + const { fileHandler, isEditable, isFlagged } = props; + // derived values + const { checkIfAssetExists, getAssetSrc, restore } = fileHandler; + + return CustomAttachmentExtensionConfig.extend({ + selectable: isEditable, + draggable: isEditable, + + addOptions() { + const upload = "upload" in fileHandler ? fileHandler.upload : undefined; + + return { + checkIfAttachmentExists: checkIfAssetExists, + getAttachmentSource: getAssetSrc, + isFlagged, + restoreAttachment: restore, + uploadAttachment: upload, + }; + }, + + addStorage() { + const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0; + + return { + fileMap: new Map(), + deletedAttachmentSet: new Map(), + errorMap: new Map(), + maxFileSize, + // escape markdown for attachments + markdown: { + serialize() {}, + }, + }; + }, + + addCommands() { + return { + insertAttachmentComponent: + (props) => + ({ commands }) => { + const { event, file, pos } = props; + // generate a unique id to keep track of dropped + // files' data and for logging transactions + const fileId = uuidv4(); + + const attachmentExtensionFileMap = getAttachmentExtensionFileMap(this.editor); + if (attachmentExtensionFileMap) { + if (event === "drop" && file) { + attachmentExtensionFileMap.set(fileId, { + file, + event, + }); + } else if (event === "insert") { + attachmentExtensionFileMap.set(fileId, { + event, + hasOpenedFileInputOnce: false, + }); + } + } + // create default attributes + const attributes: TAttachmentBlockAttributes = { + ...DEFAULT_ATTACHMENT_BLOCK_ATTRIBUTES, + id: fileId, + }; + + if (pos) { + return commands.insertContentAt(pos, { + type: this.name, + attrs: attributes, + }); + } + return commands.insertContent({ + type: this.name, + attrs: attributes, + }); + }, + }; + }, + + addKeyboardShortcuts() { + return { + ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), + ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(CustomAttachmentNodeView); + }, + }); +}; diff --git a/packages/editor/src/ee/extensions/attachments/types.ts b/packages/editor/src/ee/extensions/attachments/types.ts new file mode 100644 index 0000000000..ed44f021fa --- /dev/null +++ b/packages/editor/src/ee/extensions/attachments/types.ts @@ -0,0 +1,42 @@ +import { Node } from "@tiptap/core"; +// extensions +import { InsertImageComponentProps, UploadEntity } from "@/extensions/custom-image/custom-image"; +// helpers +import { EFileError } from "@/helpers/file"; +// types +import { TFileHandler } from "@/types"; + +export enum EAttachmentBlockAttributeNames { + ID = "id", + SOURCE = "src", + FILE_NAME = "data-name", + FILE_TYPE = "data-file-type", + FILE_SIZE = "data-file-size", +} + +export type TAttachmentBlockAttributes = { + [EAttachmentBlockAttributeNames.SOURCE]: string | null; + [EAttachmentBlockAttributeNames.ID]: string | null; + [EAttachmentBlockAttributeNames.FILE_NAME]: string | null; + [EAttachmentBlockAttributeNames.FILE_TYPE]: string | null; + [EAttachmentBlockAttributeNames.FILE_SIZE]: number | string | null; +}; + +export type InsertAttachmentComponentProps = InsertImageComponentProps; + +export type AttachmentExtensionOptions = { + checkIfAttachmentExists: TFileHandler["checkIfAssetExists"]; + getAttachmentSource: TFileHandler["getAssetSrc"]; + isFlagged: boolean; + restoreAttachment: TFileHandler["restore"]; + uploadAttachment?: TFileHandler["upload"]; +}; + +export type AttachmentExtensionStorage = { + deletedAttachmentSet: Map; + fileMap: Map; + errorMap: Map; + maxFileSize: number; +}; + +export type AttachmentExtension = Node; diff --git a/packages/editor/src/ee/extensions/attachments/utils.ts b/packages/editor/src/ee/extensions/attachments/utils.ts new file mode 100644 index 0000000000..bea1b81f2a --- /dev/null +++ b/packages/editor/src/ee/extensions/attachments/utils.ts @@ -0,0 +1,21 @@ +import { Editor } from "@tiptap/core"; +// helpers +import { getExtensionStorage } from "@/helpers/get-extension-storage"; +// plane editor imports +import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions"; +// local imports +import { EAttachmentBlockAttributeNames, TAttachmentBlockAttributes } from "./types"; + +export const DEFAULT_ATTACHMENT_BLOCK_ATTRIBUTES: TAttachmentBlockAttributes = { + [EAttachmentBlockAttributeNames.SOURCE]: null, + [EAttachmentBlockAttributeNames.ID]: null, + [EAttachmentBlockAttributeNames.FILE_NAME]: null, + [EAttachmentBlockAttributeNames.FILE_TYPE]: null, + [EAttachmentBlockAttributeNames.FILE_SIZE]: null, +}; + +export const getAttachmentExtensionFileMap = (editor: Editor) => + getExtensionStorage(editor, ADDITIONAL_EXTENSIONS.ATTACHMENT)?.fileMap; + +export const getAttachmentExtensionErrorMap = (editor: Editor) => + getExtensionStorage(editor, ADDITIONAL_EXTENSIONS.ATTACHMENT)?.errorMap; diff --git a/packages/editor/src/ee/extensions/core/extensions.ts b/packages/editor/src/ee/extensions/core/extensions.ts index cecfb38b47..eb34b8269c 100644 --- a/packages/editor/src/ee/extensions/core/extensions.ts +++ b/packages/editor/src/ee/extensions/core/extensions.ts @@ -1,13 +1,8 @@ import { Extensions } from "@tiptap/core"; -// types -import { TExtensions, TFileHandler } from "@/types"; +// ce imports +import { TCoreAdditionalExtensionsProps } from "src/ce/extensions"; -type Props = { - disabledExtensions: TExtensions[]; - fileHandler: TFileHandler; -}; - -export const CoreEditorAdditionalExtensions = (props: Props): Extensions => { +export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => { const {} = props; return []; }; diff --git a/packages/editor/src/ee/extensions/core/read-only-extensions.ts b/packages/editor/src/ee/extensions/core/read-only-extensions.ts index 398848e31d..fa699dd1be 100644 --- a/packages/editor/src/ee/extensions/core/read-only-extensions.ts +++ b/packages/editor/src/ee/extensions/core/read-only-extensions.ts @@ -1,12 +1,10 @@ import { Extensions } from "@tiptap/core"; -// types -import { TExtensions } from "@/types"; +// ce imports +import { TCoreReadOnlyEditorAdditionalExtensionsProps } from "src/ce/extensions"; -type Props = { - disabledExtensions: TExtensions[]; -}; - -export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => { +export const CoreReadOnlyEditorAdditionalExtensions = ( + props: TCoreReadOnlyEditorAdditionalExtensionsProps +): Extensions => { const {} = props; return []; }; diff --git a/packages/editor/src/ee/extensions/core/without-props.ts b/packages/editor/src/ee/extensions/core/without-props.ts index a7f8bdbff5..542c484853 100644 --- a/packages/editor/src/ee/extensions/core/without-props.ts +++ b/packages/editor/src/ee/extensions/core/without-props.ts @@ -1,6 +1,7 @@ import { Extensions } from "@tiptap/core"; +import { CustomAttachmentExtensionConfig } from "../attachments/extension-config"; import { PageEmbedExtensionConfig } from "../page-embed/extension-config"; -export const CoreEditorAdditionalExtensionsWithoutProps: Extensions = []; +export const CoreEditorAdditionalExtensionsWithoutProps: Extensions = [CustomAttachmentExtensionConfig]; export const DocumentEditorAdditionalExtensionsWithoutProps: Extensions = [PageEmbedExtensionConfig]; diff --git a/packages/editor/src/ee/extensions/document-extensions.tsx b/packages/editor/src/ee/extensions/document-extensions.tsx index 1049adf708..125fb52ade 100644 --- a/packages/editor/src/ee/extensions/document-extensions.tsx +++ b/packages/editor/src/ee/extensions/document-extensions.tsx @@ -1,5 +1,5 @@ -import { Extension } from "@tiptap/core"; -import { FileText } from "lucide-react"; +import { AnyExtension, Extensions } from "@tiptap/core"; +import { FileText, Paperclip } from "lucide-react"; // core imports import { TDocumentEditorAdditionalExtensionsProps, @@ -16,17 +16,22 @@ import { IssueEmbedSuggestions, IssueListRenderer, PageEmbedExtension } from "@/ // types import { TExtensions } from "@/types"; // local imports +import { insertAttachment } from "../helpers/editor-commands"; +import { CustomAttachmentExtension } from "./attachments/extension"; import { CustomCollaborationCursor } from "./collaboration-cursor"; /** * Registry for slash commands * Each entry defines a single slash command option with its own enabling logic */ -const slashCommandRegistry = [ +const slashCommandRegistry: { + isEnabled: (disabledExtensions: TExtensions[]) => boolean; + getOption: (props: TDocumentEditorAdditionalExtensionsProps) => TSlashCommandAdditionalOption | null; +}[] = [ { // Work item embed slash command - isEnabled: (disabledExtensions: TExtensions[]) => !disabledExtensions.includes("issue-embed"), - getOption: (): TSlashCommandAdditionalOption => ({ + isEnabled: (disabledExtensions) => !disabledExtensions.includes("issue-embed"), + getOption: () => ({ commandKey: "issue-embed", key: "issue-embed", title: "Work item", @@ -42,8 +47,8 @@ const slashCommandRegistry = [ }, { // Page embed slash command - isEnabled: (disabledExtensions: TExtensions[]) => !disabledExtensions.includes("nested-pages"), - getOption: ({ embedConfig }: TDocumentEditorAdditionalExtensionsProps): TSlashCommandAdditionalOption | null => { + isEnabled: (disabledExtensions) => !disabledExtensions.includes("nested-pages"), + getOption: ({ embedConfig }) => { // Only enable if page config with createCallback exists const pageConfig = embedConfig?.page; if (!pageConfig?.createCallback) return null; @@ -73,6 +78,21 @@ const slashCommandRegistry = [ }; }, }, + { + // Attachment slash command + isEnabled: (disabledExtensions) => !disabledExtensions.includes("attachments"), + getOption: () => ({ + commandKey: "attachment", + key: "attachment", + title: "Attachment", + description: "Insert a file", + searchTerms: ["image", "photo", "picture", "pdf", "media", "upload", "audio", "video", "file", "attachment"], + icon: , + command: ({ editor, range }) => insertAttachment({ editor, event: "insert", range }), + section: "general", + pushAfter: "image", + }), + }, ]; /** @@ -93,11 +113,12 @@ const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [ return SlashCommands({ additionalOptions: slashCommandOptions, disabledExtensions: props.disabledExtensions, + flaggedExtensions: props.flaggedExtensions, }); }, }, { - // Issue embed suggestions extension + // Work item embed suggestions extension isEnabled: (disabledExtensions) => !disabledExtensions.includes("issue-embed"), getExtension: ({ embedConfig }) => { const issueConfig = embedConfig?.issue; @@ -125,7 +146,7 @@ const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [ }, { // Page embed extension - isEnabled: () => true, + isEnabled: (disabledExtensions) => !disabledExtensions.includes("nested-pages"), getExtension: ({ embedConfig }) => { const pageConfig = embedConfig?.page; @@ -141,19 +162,29 @@ const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [ }); }, }, + { + // Attachment extension + isEnabled: (disabledExtensions) => !disabledExtensions.includes("attachments"), + getExtension: ({ flaggedExtensions, fileHandler, isEditable }) => + CustomAttachmentExtension({ + fileHandler, + isFlagged: flaggedExtensions.includes("attachments"), + isEditable, + }), + }, ]; /** * Returns all enabled extensions for the document editor */ export const DocumentEditorAdditionalExtensions = (props: TDocumentEditorAdditionalExtensionsProps) => { - const { disabledExtensions = [] } = props; + const { disabledExtensions, flaggedExtensions } = props; // Filter enabled extensions and flatten the result - const extensions: Extension[] = extensionRegistry - .filter((config) => config.isEnabled(disabledExtensions)) + const extensions: Extensions = extensionRegistry + .filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions)) .map((config) => config.getExtension(props)) - .filter((extension): extension is Extension => extension !== undefined); + .filter((extension): extension is AnyExtension => extension !== undefined); return extensions; }; diff --git a/packages/editor/src/ee/extensions/rich-text/extensions.tsx b/packages/editor/src/ee/extensions/rich-text/extensions.tsx index 54d83e7d41..3d55a593f2 100644 --- a/packages/editor/src/ee/extensions/rich-text/extensions.tsx +++ b/packages/editor/src/ee/extensions/rich-text/extensions.tsx @@ -1 +1,78 @@ -export * from "src/ce/extensions/rich-text/extensions"; +import { AnyExtension, Extensions } from "@tiptap/core"; +import { Paperclip } from "lucide-react"; +// core imports +import { + TRichTextEditorAdditionalExtensionsProps, + TRichTextEditorAdditionalExtensionsRegistry, +} from "src/ce/extensions/rich-text/extensions"; +// extensions +import { SlashCommands, TSlashCommandAdditionalOption } from "@/extensions/slash-commands/root"; +// types +import { TExtensions } from "@/types"; +// local imports +import { insertAttachment } from "../../helpers/editor-commands"; +import { CustomAttachmentExtension } from "../attachments/extension"; + +/** + * Registry for slash commands + * Each entry defines a single slash command option with its own enabling logic + */ +const slashCommandRegistry: { + isEnabled: (disabledExtensions: TExtensions[]) => boolean; + getOption: (props: TRichTextEditorAdditionalExtensionsProps) => TSlashCommandAdditionalOption | null; +}[] = [ + { + isEnabled: (disabledExtensions) => !disabledExtensions.includes("attachments"), + getOption: () => ({ + commandKey: "attachment", + key: "attachment", + title: "Attachment", + description: "Insert a file", + searchTerms: ["image", "photo", "picture", "pdf", "media", "upload", "audio", "video", "file", "attachment"], + icon: , + command: ({ editor, range }) => insertAttachment({ editor, event: "insert", range }), + section: "general", + pushAfter: "image", + }), + }, +]; + +const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [ + { + isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"), + getExtension: (props) => { + const { disabledExtensions, flaggedExtensions } = props; + // Get enabled slash command options from the registry + const slashCommandOptions = slashCommandRegistry + .filter((command) => command.isEnabled(disabledExtensions || [])) + .map((command) => command.getOption(props)) + .filter((option): option is TSlashCommandAdditionalOption => option !== null); + + return SlashCommands({ + additionalOptions: slashCommandOptions, + disabledExtensions, + flaggedExtensions, + }); + }, + }, + { + isEnabled: (disabledExtensions) => !disabledExtensions.includes("attachments"), + getExtension: ({ flaggedExtensions, fileHandler }) => + CustomAttachmentExtension({ + fileHandler, + isFlagged: flaggedExtensions.includes("attachments"), + isEditable: true, + }), + }, +]; + +export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => { + const { disabledExtensions, flaggedExtensions } = props; + + const extensions: Extensions = extensionRegistry + .filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions)) + .map((config) => config.getExtension(props)) + .filter((extension): extension is AnyExtension => extension !== undefined); + + return extensions; +}; diff --git a/packages/editor/src/ee/extensions/rich-text/read-only-extensions.tsx b/packages/editor/src/ee/extensions/rich-text/read-only-extensions.tsx index e05745154f..fb73d07e99 100644 --- a/packages/editor/src/ee/extensions/rich-text/read-only-extensions.tsx +++ b/packages/editor/src/ee/extensions/rich-text/read-only-extensions.tsx @@ -1 +1,31 @@ -export * from "src/ce/extensions/rich-text/read-only-extensions"; +import { AnyExtension, Extensions } from "@tiptap/core"; +// core imports +import { + TRichTextReadOnlyEditorAdditionalExtensionsProps, + TRichTextReadOnlyEditorAdditionalExtensionsRegistry, +} from "src/ce/extensions/rich-text/read-only-extensions"; +// local imports +import { CustomAttachmentExtension } from "../attachments/extension"; + +const extensionRegistry: TRichTextReadOnlyEditorAdditionalExtensionsRegistry[] = [ + { + isEnabled: (disabledExtensions) => !disabledExtensions.includes("attachments"), + getExtension: ({ fileHandler, flaggedExtensions }) => + CustomAttachmentExtension({ + fileHandler, + isFlagged: flaggedExtensions.includes("attachments"), + isEditable: false, + }), + }, +]; + +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; +}; diff --git a/packages/editor/src/ee/helpers/editor-commands.ts b/packages/editor/src/ee/helpers/editor-commands.ts new file mode 100644 index 0000000000..0180445719 --- /dev/null +++ b/packages/editor/src/ee/helpers/editor-commands.ts @@ -0,0 +1,24 @@ +import { Editor, Range } from "@tiptap/core"; +// plane editor extensions +import { type InsertAttachmentComponentProps } from "@/plane-editor/extensions/attachments/types"; + +export const insertAttachment = ({ + editor, + event, + pos, + file, + range, +}: { + editor: Editor; + event: "insert" | "drop"; + pos?: number | null; + file?: File; + range?: Range; +}) => { + if (range) editor.chain().focus().deleteRange(range).run(); + + const attachmentOptions: InsertAttachmentComponentProps = { event }; + if (pos) attachmentOptions.pos = pos; + if (file) attachmentOptions.file = file; + return editor?.chain().focus().insertAttachmentComponent(attachmentOptions).run(); +}; diff --git a/packages/editor/src/ee/types/storage.ts b/packages/editor/src/ee/types/storage.ts new file mode 100644 index 0000000000..c2db9f25d9 --- /dev/null +++ b/packages/editor/src/ee/types/storage.ts @@ -0,0 +1,15 @@ +import { + ExtensionStorageMap as CoreExtensionStorageMap, + ExtensionFileSetStorageKey as CoreExtensionFileSetStorageKey, +} from "src/ce/types/storage"; +// local imports +import { ADDITIONAL_EXTENSIONS } from "../constants/extensions"; +import { type AttachmentExtensionStorage } from "../extensions/attachments/types"; + +export type ExtensionStorageMap = CoreExtensionStorageMap & { + [ADDITIONAL_EXTENSIONS.ATTACHMENT]: AttachmentExtensionStorage; +}; + +export type ExtensionFileSetStorageKey = + | CoreExtensionFileSetStorageKey + | Extract; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 02e272e5e0..090e1a8872 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -40,5 +40,4 @@ export { type IMarking, useEditorMarkings } from "@/hooks/use-editor-markings"; export { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; // types -export type { CustomEditorProps } from "@/hooks/use-editor"; export * from "@/types"; diff --git a/packages/i18n/src/locales/cs/editor-extended.json b/packages/i18n/src/locales/cs/editor-extended.json index 0967ef424b..ec39e323f9 100644 --- a/packages/i18n/src/locales/cs/editor-extended.json +++ b/packages/i18n/src/locales/cs/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Přetáhněte soubory pro nahrání externích souborů" + }, + "errors": { + "file_too_large": { + "title": "Soubor je příliš velký.", + "description": "Maximální velikost na soubor je {maxFileSize} MB" + }, + "unsupported_file_type": { + "title": "Nepodporovaný typ souboru.", + "description": "Zobrazit podporované formáty" + }, + "default": { + "title": "Nahrávání selhalo.", + "description": "Něco se pokazilo. Zkuste to znovu." + } + }, + "upgrade": { + "description": "Upgradujte svůj plán pro zobrazení této přílohy." + }, + "aria": { + "click_to_upload": "Klikněte pro nahrání přílohy" + } + } +} diff --git a/packages/i18n/src/locales/de/editor-extended.json b/packages/i18n/src/locales/de/editor-extended.json index 0967ef424b..c0ecc74d31 100644 --- a/packages/i18n/src/locales/de/editor-extended.json +++ b/packages/i18n/src/locales/de/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Zum Hochladen externe Dateien ziehen und ablegen" + }, + "errors": { + "file_too_large": { + "title": "Datei zu groß.", + "description": "Maximale Größe pro Datei ist {maxFileSize} MB" + }, + "unsupported_file_type": { + "title": "Nicht unterstützter Dateityp.", + "description": "Unterstützte Formate anzeigen" + }, + "default": { + "title": "Upload fehlgeschlagen.", + "description": "Etwas ist schief gelaufen. Bitte versuchen Sie es erneut." + } + }, + "upgrade": { + "description": "Aktualisieren Sie Ihren Plan, um diesen Anhang anzuzeigen." + }, + "aria": { + "click_to_upload": "Klicken Sie, um Anhang hochzuladen" + } + } +} diff --git a/packages/i18n/src/locales/en/editor-extended.json b/packages/i18n/src/locales/en/editor-extended.json index 0967ef424b..88aa7778c4 100644 --- a/packages/i18n/src/locales/en/editor-extended.json +++ b/packages/i18n/src/locales/en/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Drop files here or click to upload" + }, + "errors": { + "file_too_large": { + "title": "File too large.", + "description": "Maximum size per file is {maxFileSize}MB" + }, + "unsupported_file_type": { + "title": "Unsupported file type.", + "description": "See supported formats" + }, + "default": { + "title": "Upload failed.", + "description": "Something went wrong. Please try again." + } + }, + "upgrade": { + "description": "Upgrade your plan to view this attachment." + }, + "aria": { + "click_to_upload": "Click to upload attachment" + } + } +} diff --git a/packages/i18n/src/locales/es/editor-extended.json b/packages/i18n/src/locales/es/editor-extended.json index 0967ef424b..672b4fc212 100644 --- a/packages/i18n/src/locales/es/editor-extended.json +++ b/packages/i18n/src/locales/es/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Arrastra y suelta para subir archivos externos" + }, + "errors": { + "file_too_large": { + "title": "Archivo demasiado grande.", + "description": "El tamaño máximo por archivo es {maxFileSize} MB" + }, + "unsupported_file_type": { + "title": "Tipo de archivo no compatible.", + "description": "Ver formatos compatibles" + }, + "default": { + "title": "Error al subir.", + "description": "Algo salió mal. Por favor, inténtelo de nuevo." + } + }, + "upgrade": { + "description": "Actualice su plan para ver este archivo adjunto." + }, + "aria": { + "click_to_upload": "Haga clic para subir archivo adjunto" + } + } +} diff --git a/packages/i18n/src/locales/fr/editor-extended.json b/packages/i18n/src/locales/fr/editor-extended.json index 0967ef424b..4a278f0a57 100644 --- a/packages/i18n/src/locales/fr/editor-extended.json +++ b/packages/i18n/src/locales/fr/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Glissez-déposez pour télécharger des fichiers externes" + }, + "errors": { + "file_too_large": { + "title": "Fichier trop volumineux.", + "description": "La taille maximale par fichier est de {maxFileSize} MB" + }, + "unsupported_file_type": { + "title": "Type de fichier non pris en charge.", + "description": "Voir les formats pris en charge" + }, + "default": { + "title": "Échec du téléchargement.", + "description": "Quelque chose s'est mal passé. Veuillez réessayer." + } + }, + "upgrade": { + "description": "Mettez à niveau votre plan pour voir cette pièce jointe." + }, + "aria": { + "click_to_upload": "Cliquez pour télécharger la pièce jointe" + } + } +} diff --git a/packages/i18n/src/locales/id/editor-extended.json b/packages/i18n/src/locales/id/editor-extended.json index 0967ef424b..ad0c526dfd 100644 --- a/packages/i18n/src/locales/id/editor-extended.json +++ b/packages/i18n/src/locales/id/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Seret dan lepas untuk mengunggah file eksternal" + }, + "errors": { + "file_too_large": { + "title": "File terlalu besar.", + "description": "Ukuran maksimum per file adalah {maxFileSize} MB" + }, + "unsupported_file_type": { + "title": "Jenis file tidak didukung.", + "description": "Lihat format yang didukung" + }, + "default": { + "title": "Upload gagal.", + "description": "Terjadi kesalahan. Silakan coba lagi." + } + }, + "upgrade": { + "description": "Tingkatkan paket Anda untuk melihat lampiran ini." + }, + "aria": { + "click_to_upload": "Klik untuk mengunggah lampiran" + } + } +} diff --git a/packages/i18n/src/locales/it/editor-extended.json b/packages/i18n/src/locales/it/editor-extended.json index 0967ef424b..c71d52c4e0 100644 --- a/packages/i18n/src/locales/it/editor-extended.json +++ b/packages/i18n/src/locales/it/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Trascina e rilascia per caricare file esterni" + }, + "errors": { + "file_too_large": { + "title": "File troppo grande.", + "description": "La dimensione massima per file è {maxFileSize} MB" + }, + "unsupported_file_type": { + "title": "Tipo di file non supportato.", + "description": "Vedi formati supportati" + }, + "default": { + "title": "Caricamento fallito.", + "description": "Qualcosa è andato storto. Riprova." + } + }, + "upgrade": { + "description": "Aggiorna il tuo piano per visualizzare questo allegato." + }, + "aria": { + "click_to_upload": "Clicca per caricare allegato" + } + } +} diff --git a/packages/i18n/src/locales/ja/editor-extended.json b/packages/i18n/src/locales/ja/editor-extended.json index 0967ef424b..a8d6d2c480 100644 --- a/packages/i18n/src/locales/ja/editor-extended.json +++ b/packages/i18n/src/locales/ja/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "外部ファイルをアップロードするにはドラッグ&ドロップしてください" + }, + "errors": { + "file_too_large": { + "title": "ファイルが大きすぎます。", + "description": "ファイルあたりの最大サイズは {maxFileSize} MB です" + }, + "unsupported_file_type": { + "title": "サポートされていないファイル形式です。", + "description": "サポートされている形式を確認" + }, + "default": { + "title": "アップロードに失敗しました。", + "description": "問題が発生しました。もう一度お試しください。" + } + }, + "upgrade": { + "description": "この添付ファイルを表示するにはプランをアップグレードしてください。" + }, + "aria": { + "click_to_upload": "クリックして添付ファイルをアップロード" + } + } +} diff --git a/packages/i18n/src/locales/ko/editor-extended.json b/packages/i18n/src/locales/ko/editor-extended.json index 0967ef424b..147a7101a3 100644 --- a/packages/i18n/src/locales/ko/editor-extended.json +++ b/packages/i18n/src/locales/ko/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "외부 파일을 업로드하려면 드래그 앤 드롭하세요" + }, + "errors": { + "file_too_large": { + "title": "파일이 너무 큽니다.", + "description": "파일당 최대 크기는 {maxFileSize} MB입니다" + }, + "unsupported_file_type": { + "title": "지원되지 않는 파일 형식입니다.", + "description": "지원되는 형식 보기" + }, + "default": { + "title": "업로드 실패.", + "description": "문제가 발생했습니다. 다시 시도해 주세요." + } + }, + "upgrade": { + "description": "이 첨부 파일을 보려면 플랜을 업그레이드하세요." + }, + "aria": { + "click_to_upload": "첨부 파일을 업로드하려면 클릭하세요" + } + } +} diff --git a/packages/i18n/src/locales/pl/editor-extended.json b/packages/i18n/src/locales/pl/editor-extended.json index 0967ef424b..484bb5da04 100644 --- a/packages/i18n/src/locales/pl/editor-extended.json +++ b/packages/i18n/src/locales/pl/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Przeciągnij i upuść, aby przesłać pliki zewnętrzne" + }, + "errors": { + "file_too_large": { + "title": "Plik za duży.", + "description": "Maksymalny rozmiar na plik to {maxFileSize} MB" + }, + "unsupported_file_type": { + "title": "Nieobsługiwany typ pliku.", + "description": "Zobacz obsługiwane formaty" + }, + "default": { + "title": "Przesyłanie nie powiodło się.", + "description": "Coś poszło nie tak. Spróbuj ponownie." + } + }, + "upgrade": { + "description": "Uaktualnij swój plan, aby wyświetlić ten załącznik." + }, + "aria": { + "click_to_upload": "Kliknij, aby przesłać załącznik" + } + } +} diff --git a/packages/i18n/src/locales/pt-BR/editor-extended.json b/packages/i18n/src/locales/pt-BR/editor-extended.json index 0967ef424b..0d8121184b 100644 --- a/packages/i18n/src/locales/pt-BR/editor-extended.json +++ b/packages/i18n/src/locales/pt-BR/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Arraste e solte para fazer upload de arquivos externos" + }, + "errors": { + "file_too_large": { + "title": "Arquivo muito grande.", + "description": "O tamanho máximo por arquivo é {maxFileSize} MB" + }, + "unsupported_file_type": { + "title": "Tipo de arquivo não suportado.", + "description": "Ver formatos suportados" + }, + "default": { + "title": "Falha no upload.", + "description": "Algo deu errado. Tente novamente." + } + }, + "upgrade": { + "description": "Atualize seu plano para visualizar este anexo." + }, + "aria": { + "click_to_upload": "Clique para fazer upload do anexo" + } + } +} diff --git a/packages/i18n/src/locales/ro/editor-extended.json b/packages/i18n/src/locales/ro/editor-extended.json index 0967ef424b..6f97833600 100644 --- a/packages/i18n/src/locales/ro/editor-extended.json +++ b/packages/i18n/src/locales/ro/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Trageți și plasați pentru a încărca fișiere externe" + }, + "errors": { + "file_too_large": { + "title": "Fișier prea mare.", + "description": "Dimensiunea maximă per fișier este {maxFileSize} MB" + }, + "unsupported_file_type": { + "title": "Tip de fișier neacceptat.", + "description": "Vezi formatele acceptate" + }, + "default": { + "title": "Încărcarea a eșuat.", + "description": "Ceva a mers prost. Vă rugăm să încercați din nou." + } + }, + "upgrade": { + "description": "Actualizați planul pentru a vizualiza această atașare." + }, + "aria": { + "click_to_upload": "Faceți clic pentru a încărca atașamentul" + } + } +} diff --git a/packages/i18n/src/locales/ru/editor-extended.json b/packages/i18n/src/locales/ru/editor-extended.json index 0967ef424b..bc2b1fbd38 100644 --- a/packages/i18n/src/locales/ru/editor-extended.json +++ b/packages/i18n/src/locales/ru/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Перетащите для загрузки внешних файлов" + }, + "errors": { + "file_too_large": { + "title": "Файл слишком большой.", + "description": "Максимальный размер файла {maxFileSize} МБ" + }, + "unsupported_file_type": { + "title": "Неподдерживаемый тип файла.", + "description": "Посмотреть поддерживаемые форматы" + }, + "default": { + "title": "Загрузка не удалась.", + "description": "Что-то пошло не так. Попробуйте еще раз." + } + }, + "upgrade": { + "description": "Обновите план для просмотра этого вложения." + }, + "aria": { + "click_to_upload": "Нажмите для загрузки вложения" + } + } +} diff --git a/packages/i18n/src/locales/sk/editor-extended.json b/packages/i18n/src/locales/sk/editor-extended.json index 0967ef424b..34e8f9f949 100644 --- a/packages/i18n/src/locales/sk/editor-extended.json +++ b/packages/i18n/src/locales/sk/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Pretiahni a pusť pre nahranie externých súborov" + }, + "errors": { + "file_too_large": { + "title": "Súbor je príliš veľký.", + "description": "Maximálna veľkosť na súbor je {maxFileSize} MB" + }, + "unsupported_file_type": { + "title": "Nepodporovaný typ súboru.", + "description": "Zobraziť podporované formáty" + }, + "default": { + "title": "Nahrávanie zlyhalo.", + "description": "Niečo sa pokazilo. Skúste to znovu." + } + }, + "upgrade": { + "description": "Upgradujte svoj plán pre zobrazenie tejto prílohy." + }, + "aria": { + "click_to_upload": "Kliknite pre nahranie prílohy" + } + } +} diff --git a/packages/i18n/src/locales/tr-TR/editor-extended.json b/packages/i18n/src/locales/tr-TR/editor-extended.json index 0967ef424b..fe0dbc7b47 100644 --- a/packages/i18n/src/locales/tr-TR/editor-extended.json +++ b/packages/i18n/src/locales/tr-TR/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Harici dosyaları yüklemek için sürükle ve bırak" + }, + "errors": { + "file_too_large": { + "title": "Dosya çok büyük.", + "description": "Dosya başına maksimum boyut {maxFileSize} MB" + }, + "unsupported_file_type": { + "title": "Desteklenmeyen dosya türü.", + "description": "Desteklenen formatları gör" + }, + "default": { + "title": "Yükleme başarısız.", + "description": "Bir şeyler ters gitti. Lütfen tekrar deneyin." + } + }, + "upgrade": { + "description": "Bu eki görüntülemek için planınızı yükseltin." + }, + "aria": { + "click_to_upload": "Ek yüklemek için tıklayın" + } + } +} diff --git a/packages/i18n/src/locales/ua/editor-extended.json b/packages/i18n/src/locales/ua/editor-extended.json index 0967ef424b..869c6250ef 100644 --- a/packages/i18n/src/locales/ua/editor-extended.json +++ b/packages/i18n/src/locales/ua/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Перетягніть для завантаження зовнішніх файлів" + }, + "errors": { + "file_too_large": { + "title": "Файл занадто великий.", + "description": "Максимальний розмір файлу {maxFileSize} МБ" + }, + "unsupported_file_type": { + "title": "Непідтримуваний тип файлу.", + "description": "Переглянути підтримувані формати" + }, + "default": { + "title": "Завантаження не вдалося.", + "description": "Щось пішло не так. Спробуйте ще раз." + } + }, + "upgrade": { + "description": "Оновіть план для перегляду цього вкладення." + }, + "aria": { + "click_to_upload": "Натисніть для завантаження вкладення" + } + } +} diff --git a/packages/i18n/src/locales/vi-VN/editor-extended.json b/packages/i18n/src/locales/vi-VN/editor-extended.json index 0967ef424b..3ffd1170ea 100644 --- a/packages/i18n/src/locales/vi-VN/editor-extended.json +++ b/packages/i18n/src/locales/vi-VN/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "Kéo và thả để tải lên tệp bên ngoài" + }, + "errors": { + "file_too_large": { + "title": "Tệp quá lớn.", + "description": "Kích thước tối đa mỗi tệp là {maxFileSize} MB" + }, + "unsupported_file_type": { + "title": "Loại tệp không được hỗ trợ.", + "description": "Xem các định dạng được hỗ trợ" + }, + "default": { + "title": "Tải lên thất bại.", + "description": "Đã xảy ra lỗi. Vui lòng thử lại." + } + }, + "upgrade": { + "description": "Nâng cấp gói của bạn để xem tệp đính kèm này." + }, + "aria": { + "click_to_upload": "Nhấp để tải lên tệp đính kèm" + } + } +} diff --git a/packages/i18n/src/locales/zh-CN/editor-extended.json b/packages/i18n/src/locales/zh-CN/editor-extended.json index 0967ef424b..ae92dd95cd 100644 --- a/packages/i18n/src/locales/zh-CN/editor-extended.json +++ b/packages/i18n/src/locales/zh-CN/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "拖拽上传外部文件" + }, + "errors": { + "file_too_large": { + "title": "文件过大。", + "description": "每个文件的最大大小为 {maxFileSize} MB" + }, + "unsupported_file_type": { + "title": "不支持的文件类型。", + "description": "查看支持的格式" + }, + "default": { + "title": "上传失败。", + "description": "出现错误。请重试。" + } + }, + "upgrade": { + "description": "升级您的计划以查看此附件。" + }, + "aria": { + "click_to_upload": "点击上传附件" + } + } +} diff --git a/packages/i18n/src/locales/zh-TW/editor-extended.json b/packages/i18n/src/locales/zh-TW/editor-extended.json index 0967ef424b..ff944dd120 100644 --- a/packages/i18n/src/locales/zh-TW/editor-extended.json +++ b/packages/i18n/src/locales/zh-TW/editor-extended.json @@ -1 +1,27 @@ -{} +{ + "attachmentComponent": { + "uploader": { + "drag_and_drop": "拖拽上傳外部檔案" + }, + "errors": { + "file_too_large": { + "title": "檔案過大。", + "description": "每個檔案的最大大小為 {maxFileSize} MB" + }, + "unsupported_file_type": { + "title": "不支援的檔案類型。", + "description": "查看支援的格式" + }, + "default": { + "title": "上傳失敗。", + "description": "出現錯誤。請重試。" + } + }, + "upgrade": { + "description": "升級您的計劃以查看此附件。" + }, + "aria": { + "click_to_upload": "點擊上傳附件" + } + } +} diff --git a/space/core/components/editor/lite-text-editor.tsx b/space/core/components/editor/lite-text-editor.tsx index 2663e0ca20..82da909b3e 100644 --- a/space/core/components/editor/lite-text-editor.tsx +++ b/space/core/components/editor/lite-text-editor.tsx @@ -1,6 +1,6 @@ import React from "react"; // plane imports -import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TFileHandler } from "@plane/editor"; +import { EditorRefApi, ILiteTextEditorProps, LiteTextEditorWithRef, TFileHandler } from "@plane/editor"; import { MakeOptional } from "@plane/types"; import { cn } from "@plane/utils"; // components @@ -11,8 +11,8 @@ import { isCommentEmpty } from "@/helpers/string.helper"; interface LiteTextEditorWrapperProps extends MakeOptional< - Omit, - "disabledExtensions" + Omit, + "disabledExtensions" | "flaggedExtensions" > { anchor: string; workspaceId: string; @@ -30,6 +30,7 @@ export const LiteTextEditor = React.forwardRef(ref: React.ForwardedRef): ref is React.MutableRefObject { @@ -44,6 +45,7 @@ export const LiteTextEditor = React.forwardRef, - "disabledExtensions" + Omit, + "disabledExtensions" | "flaggedExtensions" > & { anchor: string; workspaceId: string; }; export const LiteTextReadOnlyEditor = React.forwardRef( - ({ anchor, workspaceId, disabledExtensions, ...props }, ref) => { + ({ anchor, workspaceId, disabledExtensions, flaggedExtensions, ...props }, ref) => { const { getMemberById } = useMember(); return ( , - "disabledExtensions" + Omit, + "disabledExtensions" | "flaggedExtensions" > { anchor: string; uploadFile: TFileHandler["upload"]; @@ -20,7 +20,7 @@ interface RichTextEditorWrapperProps } export const RichTextEditor = forwardRef((props, ref) => { - const { anchor, containerClassName, uploadFile, workspaceId, disabledExtensions, ...rest } = props; + const { anchor, containerClassName, uploadFile, workspaceId, disabledExtensions, flaggedExtensions, ...rest } = props; const { getMemberById } = useMember(); return ( , - "disabledExtensions" + Omit, + "disabledExtensions" | "flaggedExtensions" > & { anchor: string; workspaceId: string; }; export const RichTextReadOnlyEditor = React.forwardRef( - ({ anchor, workspaceId, disabledExtensions, ...props }, ref) => { + ({ anchor, workspaceId, disabledExtensions, flaggedExtensions, ...props }, ref) => { const { getMemberById } = useMember(); return ( = observer((props) => { ref={editorRef} id={pageDetails.id} disabledExtensions={[]} + flaggedExtensions={[]} initialValue={pageDetails.description_html ?? "

"} containerClassName="p-0 pb-64 border-none" fileHandler={getReadOnlyEditorFileHandlers({ diff --git a/web/ce/hooks/use-editor-flagging.ts b/web/ce/hooks/use-editor-flagging.ts index 05fdff91fa..7e04919ce6 100644 --- a/web/ce/hooks/use-editor-flagging.ts +++ b/web/ce/hooks/use-editor-flagging.ts @@ -1,17 +1,35 @@ // editor import { TExtensions } from "@plane/editor"; +export type TEditorFlaggingHookReturnType = { + document: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; + liteText: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; + richText: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; +}; + /** * @description extensions disabled in various editors */ -export const useEditorFlagging = ( - workspaceSlug: string -): { - documentEditor: TExtensions[]; - liteTextEditor: TExtensions[]; - richTextEditor: TExtensions[]; -} => ({ - documentEditor: ["ai", "collaboration-cursor"], - liteTextEditor: ["ai", "collaboration-cursor"], - richTextEditor: ["ai", "collaboration-cursor"], +export const useEditorFlagging = (workspaceSlug: string): TEditorFlaggingHookReturnType => ({ + document: { + disabled: ["ai", "collaboration-cursor"], + flagged: [], + }, + liteText: { + disabled: ["ai", "collaboration-cursor"], + flagged: [], + }, + richText: { + disabled: ["ai", "collaboration-cursor"], + flagged: [], + }, }); diff --git a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx index 03e55956b4..ef7ec1defb 100644 --- a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx +++ b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; // plane constants import { EIssueCommentAccessSpecifier } from "@plane/constants"; // plane editor -import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TFileHandler } from "@plane/editor"; +import { EditorRefApi, ILiteTextEditorProps, LiteTextEditorWithRef, TFileHandler } from "@plane/editor"; // i18n import { useTranslation } from "@plane/i18n"; // components @@ -22,8 +22,8 @@ const workspaceService = new WorkspaceService(); interface LiteTextEditorWrapperProps extends MakeOptional< - Omit, - "disabledExtensions" + Omit, + "disabledExtensions" | "flaggedExtensions" | "isSmoothCursorEnabled" > { workspaceSlug: string; workspaceId: string; @@ -58,13 +58,13 @@ export const LiteTextEditor = React.forwardRef , - "disabledExtensions" + Omit, + "disabledExtensions" | "flaggedExtensions" > & { workspaceId: string; workspaceSlug: string; @@ -28,14 +28,15 @@ export const LiteTextReadOnlyEditor = React.forwardRef, - "disabledExtensions" + Omit, + "disabledExtensions" | "flaggedExtensions" | "isSmoothCursorEnabled" > { searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise; workspaceSlug: string; @@ -42,7 +42,7 @@ export const RichTextEditor = forwardRef await searchMentionCallback(payload), @@ -53,7 +53,8 @@ export const RichTextEditor = forwardRef, - "disabledExtensions" + Omit, + "disabledExtensions" | "flaggedExtensions" > & { workspaceId: string; workspaceSlug: string; @@ -30,14 +30,15 @@ export const RichTextReadOnlyEditor = React.forwardRef { + extends Omit< + ILiteTextEditorProps, + "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "isSmoothCursorEnabled" | "mentionHandler" + > { workspaceSlug: string; workspaceId: string; projectId?: string; @@ -49,7 +52,7 @@ export const StickyEditor = React.forwardRef = observer((props) => { searchEntity: handlers.fetchEntity, }); // editor flaggings - const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString(), storeType); + const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug?.toString(), storeType); // page filters const { fontSize, fontStyle, isFullWidth } = usePageFilters(); // derived values @@ -289,7 +289,8 @@ export const PageEditorBody: React.FC = observer((props) => { realtimeConfig={realtimeConfig} serverHandler={serverHandler} user={userConfig} - disabledExtensions={disabledExtensions} + disabledExtensions={documentEditorExtensions.disabled} + flaggedExtensions={documentEditorExtensions.flagged} aiHandler={{ menu: getAIMenu, }} diff --git a/web/core/components/pages/version/editor.tsx b/web/core/components/pages/version/editor.tsx index 347ea4c577..ac72fb5afa 100644 --- a/web/core/components/pages/version/editor.tsx +++ b/web/core/components/pages/version/editor.tsx @@ -38,7 +38,7 @@ export const PagesVersionEditor: React.FC = observer((props // derived values const workspaceDetails = getWorkspaceBySlug(workspaceSlug?.toString() ?? ""); // editor flaggings - const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? ""); + const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? ""); // editor config const { getReadOnlyEditorFileHandlers } = useEditorConfig(); // issue-embed @@ -111,7 +111,8 @@ export const PagesVersionEditor: React.FC = observer((props id={activeVersion ?? ""} initialValue={description ?? "

"} containerClassName="p-0 pb-64 border-none" - disabledExtensions={disabledExtensions} + disabledExtensions={documentEditorExtensions.disabled} + flaggedExtensions={documentEditorExtensions.flagged} displayConfig={displayConfig} editorClassName="pl-10" fileHandler={getReadOnlyEditorFileHandlers({ diff --git a/web/ee/components/common/input/description-input.tsx b/web/ee/components/common/input/description-input.tsx index 4ec4cb9e33..d71dcf75ec 100644 --- a/web/ee/components/common/input/description-input.tsx +++ b/web/ee/components/common/input/description-input.tsx @@ -5,15 +5,15 @@ import debounce from "lodash/debounce"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; // plane imports -import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; +import type { EditorReadOnlyRefApi, EditorRefApi, TExtensions } from "@plane/editor"; import { useTranslation } from "@plane/i18n"; import { TNameDescriptionLoader } from "@plane/types"; import { EFileAssetType } from "@plane/types/src/enums"; import { Loader } from "@plane/ui"; +import { getDescriptionPlaceholderI18n } from "@plane/utils"; // components import { RichTextEditor, RichTextReadOnlyEditor } from "@/components/editor"; // helpers -import { getDescriptionPlaceholderI18n } from "@plane/utils"; // hooks import { useEditorAsset, useWorkspace } from "@/hooks/store"; // plane web services @@ -26,6 +26,7 @@ interface IFormData { } export type DescriptionInputProps = { + disabledExtensions?: TExtensions[]; editorReadOnlyRef?: React.RefObject; editorRef?: React.RefObject; workspaceSlug: string; @@ -56,6 +57,7 @@ export const DescriptionInput: FC = observer((props) => { swrDescription, containerClassName, disabled, + disabledExtensions, } = props; // states const [localDescription, setLocalDescription] = useState({ @@ -117,6 +119,7 @@ export const DescriptionInput: FC = observer((props) => {

"} value={swrDescription ?? null} workspaceSlug={workspaceSlug} @@ -161,6 +164,7 @@ export const DescriptionInput: FC = observer((props) => { = (props) => { }) } containerClassName="pt-3 min-h-[120px] border-[0.5px] border-custom-border-200 rounded-lg relative focus:ring-1 focus:ring-custom-primary" + disabledExtensions={["attachments"]} uploadFile={async (blockId, file) => { try { const { asset_id } = await uploadEditorAsset({ diff --git a/web/ee/components/customers/detail/main.tsx b/web/ee/components/customers/detail/main.tsx index d73ecc874f..759bae4076 100644 --- a/web/ee/components/customers/detail/main.tsx +++ b/web/ee/components/customers/detail/main.tsx @@ -168,6 +168,7 @@ export const CustomerMainRoot: FC = observer((props) => { setIsSubmitting={setIsSubmitting} containerClassName="border-none min-h-[88px]" disabled={!isEditable} + disabledExtensions={["attachments"]} /> = observer((props) => { onChange={(_description: object, description_html: string) => { onChange(description_html); }} + disabledExtensions={["attachments"]} displayConfig={{ fontSize: "small-font" }} placeholder={(isFocused, description) => t(getDescriptionPlaceholderI18n(isFocused, description))} searchMentionCallback={async (payload) => diff --git a/web/ee/components/pages/embed/preview.tsx b/web/ee/components/pages/embed/preview.tsx index 89c8f7a3f8..c0d87dc9a8 100644 --- a/web/ee/components/pages/embed/preview.tsx +++ b/web/ee/components/pages/embed/preview.tsx @@ -33,7 +33,7 @@ export const PageEmbedPreview: React.FC = observer((props) => { const { workspaceSlug, projectId } = useParams(); const { fetchPageDetails } = usePageStore(storeType); // editor flaggings - const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? ""); + const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? ""); // issue-embed const { issueEmbedProps } = useIssueEmbed({ workspaceSlug: workspaceSlug?.toString() ?? "", @@ -168,7 +168,8 @@ export const PageEmbedPreview: React.FC = observer((props) => { initialValue={description_html ?? "

"} containerClassName="p-0 pl-3 border-none" editorClassName="p-2.5 text-xs" - disabledExtensions={disabledExtensions} + disabledExtensions={documentEditorExtensions.disabled} + flaggedExtensions={documentEditorExtensions.flagged} displayConfig={{ fontSize: "small-font", }} diff --git a/web/ee/components/pages/version/editor.tsx b/web/ee/components/pages/version/editor.tsx index 523571abde..57063ea8e5 100644 --- a/web/ee/components/pages/version/editor.tsx +++ b/web/ee/components/pages/version/editor.tsx @@ -27,7 +27,7 @@ export const WorkspacePagesVersionEditor: React.FC = observ // editor config const { getReadOnlyEditorFileHandlers } = useEditorConfig(); // editor flagging - const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? ""); + const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? ""); // page filters const { fontSize, fontStyle } = usePageFilters(); @@ -86,7 +86,8 @@ export const WorkspacePagesVersionEditor: React.FC = observ id={activeVersion ?? ""} initialValue={description ?? "

"} containerClassName="p-0 pb-64 border-none" - disabledExtensions={disabledExtensions} + disabledExtensions={documentEditorExtensions.disabled} + flaggedExtensions={documentEditorExtensions.flagged} displayConfig={displayConfig} editorClassName="pl-10" fileHandler={getReadOnlyEditorFileHandlers({ diff --git a/web/ee/components/templates/settings/page/form/editor.tsx b/web/ee/components/templates/settings/page/form/editor.tsx index 416b420986..1c613f2384 100644 --- a/web/ee/components/templates/settings/page/form/editor.tsx +++ b/web/ee/components/templates/settings/page/form/editor.tsx @@ -36,10 +36,10 @@ export const PageTemplateEditor = observer((props: Props) => { const { getWorkspaceBySlug } = useWorkspace(); const { uploadEditorAsset } = useEditorAsset(); const { getEditorFileHandlers } = useEditorConfig(); - const { documentEditor: documentEditorDisabledExtensions } = useEditorFlagging(workspaceSlug); + const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug); // derived values const additionalDisabledExtensions: TExtensions[] = ["issue-embed"]; - const disabledExtensions = [...documentEditorDisabledExtensions, ...additionalDisabledExtensions]; + const disabledExtensions = [...documentEditorExtensions.disabled, ...additionalDisabledExtensions]; const workspaceId = useMemo( () => (workspaceSlug ? (getWorkspaceBySlug(workspaceSlug)?.id ?? "") : ""), [getWorkspaceBySlug, workspaceSlug] @@ -97,6 +97,7 @@ export const PageTemplateEditor = observer((props: Props) => { return ( { - const isIssueEmbedEnabled = useFlag(workspaceSlug, "PAGE_ISSUE_EMBEDS"); +export const useEditorFlagging = (workspaceSlug: string, storeType?: EPageStoreType): TEditorFlaggingHookReturnType => { + const isWorkItemEmbedEnabled = useFlag(workspaceSlug, "PAGE_ISSUE_EMBEDS"); const isEditorAIOpsEnabled = useFlag(workspaceSlug, "EDITOR_AI_OPS"); const isCollaborationCursorEnabled = useFlag(workspaceSlug, "COLLABORATION_CURSOR"); - // extensions disabled in the document editor - const documentEditor: TExtensions[] = []; - if (!isIssueEmbedEnabled) documentEditor.push("issue-embed"); - if (!isEditorAIOpsEnabled) documentEditor.push("ai"); - if (!isCollaborationCursorEnabled) documentEditor.push("collaboration-cursor"); - const { isNestedPagesEnabled } = usePageStore(storeType || EPageStoreType.WORKSPACE); + const isEditorAttachmentsEnabled = useFlag(workspaceSlug, "EDITOR_ATTACHMENTS"); + // disabled and flagged in the document editor + const documentDisabled: TExtensions[] = []; + const documentFlagged: TExtensions[] = []; + // disabled and flagged in the rich text editor + const richTextDisabled: TExtensions[] = []; + const richTextFlagged: TExtensions[] = []; + if (!isWorkItemEmbedEnabled) { + documentFlagged.push("issue-embed"); + } + if (!isEditorAIOpsEnabled) { + documentDisabled.push("ai"); + } + if (!isCollaborationCursorEnabled) { + documentDisabled.push("collaboration-cursor"); + } if (storeType && !isNestedPagesEnabled(workspaceSlug)) { - documentEditor.push("nested-pages"); + documentFlagged.push("nested-pages"); + } + if (!isEditorAttachmentsEnabled) { + documentFlagged.push("attachments"); + richTextFlagged.push("attachments"); } return { - documentEditor, - liteTextEditor: [], - richTextEditor: [], + document: { + disabled: documentDisabled, + flagged: documentFlagged, + }, + liteText: { + disabled: [], + flagged: [], + }, + richText: { + disabled: richTextDisabled, + flagged: richTextFlagged, + }, }; };