From 83679806fdca211d04c1e2270e40b0da22051e59 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:05:01 +0530 Subject: [PATCH] [WIKI-419] chore: new asset duplicate endpoint added (#7172) * chore: new asset duplicate endpoint added * chore: change the type in url * chore: added rate limiting for image duplication endpoint * chore: added rate limiting per asset id * chore: added throttle class * chore: added validations for entity * chore: added extra validations * chore: removed the comment * chore: reverted the frontend code * chore: added the response key * feat: handle image duplication for web * feat: custom image duplication update * fix: remove paste logic for image * fix : remove entity validation * refactor: remove entity id for duplication * feat: handle duplication in utils * feat: add asset duplication registry * chore: update the set attribute method * fix: add ref for api check * chore :remove logs * chore : add entity types types * refactor: rename duplication success status value * chore: update attribute to enums * chore: update variable name * chore: set uploading state * chore : update enum name * chore : update replace command * chore: fix retry UI * chore: remove default logic * refactor: optimize imports in custom image extension files and improve error handling in image duplication * fix:type error * Update packages/editor/src/core/extensions/custom-image/components/node-view.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: enhance asset duplication handler to ignore HTTP sources --------- Co-authored-by: NarayanBavisetti Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Co-authored-by: VipinDevelops Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- apps/api/plane/app/urls/asset.py | 6 + apps/api/plane/app/views/__init__.py | 1 + apps/api/plane/app/views/asset/v2.py | 171 ++++++++++++++++-- apps/api/plane/settings/common.py | 9 +- apps/api/plane/throttles/asset.py | 11 ++ apps/space/helpers/editor.helper.ts | 4 + .../pages/(detail)/[pageId]/page.tsx | 14 +- .../components/comments/card/edit-form.tsx | 4 + .../components/comments/comment-create.tsx | 5 + .../components/editor/document/editor.tsx | 2 + .../components/editor/lite-text/editor.tsx | 2 + .../rich-text/description-input/root.tsx | 15 +- .../components/editor/rich-text/editor.tsx | 2 + .../editor/sticky-editor/editor.tsx | 3 + .../modals/create-modal/issue-description.tsx | 16 +- .../issue-detail/issue-activity/helper.tsx | 17 +- .../components/description-editor.tsx | 17 +- .../components/stickies/sticky/inputs.tsx | 1 + .../core/hooks/editor/use-editor-config.ts | 4 +- apps/web/core/services/file.service.ts | 18 +- apps/web/core/store/editor/asset.store.ts | 24 ++- .../src/ce/helpers/asset-duplication.ts | 40 ++++ .../custom-image/components/block.tsx | 14 +- .../custom-image/components/node-view.tsx | 67 ++++++- .../custom-image/components/uploader.tsx | 55 ++++-- .../extensions/custom-image/extension.tsx | 8 +- .../src/core/extensions/custom-image/types.ts | 11 ++ .../src/core/extensions/custom-image/utils.ts | 11 +- .../editor/src/core/extensions/utility.ts | 2 + .../editor/src/core/plugins/paste-asset.ts | 77 ++++++++ packages/editor/src/core/props.ts | 3 - packages/editor/src/core/types/config.ts | 1 + .../src/issues/activity/issue_comment.ts | 1 + 33 files changed, 581 insertions(+), 55 deletions(-) create mode 100644 apps/api/plane/throttles/asset.py create mode 100644 packages/editor/src/ce/helpers/asset-duplication.ts create mode 100644 packages/editor/src/core/plugins/paste-asset.ts diff --git a/apps/api/plane/app/urls/asset.py b/apps/api/plane/app/urls/asset.py index 93356b04cb..4b7e2b220c 100644 --- a/apps/api/plane/app/urls/asset.py +++ b/apps/api/plane/app/urls/asset.py @@ -13,6 +13,7 @@ from plane.app.views import ( ProjectAssetEndpoint, ProjectBulkAssetEndpoint, AssetCheckEndpoint, + DuplicateAssetEndpoint, WorkspaceAssetDownloadEndpoint, ProjectAssetDownloadEndpoint, ) @@ -91,6 +92,11 @@ urlpatterns = [ AssetCheckEndpoint.as_view(), name="asset-check", ), + path( + "assets/v2/workspaces//duplicate-assets//", + DuplicateAssetEndpoint.as_view(), + name="duplicate-assets", + ), path( "assets/v2/workspaces//download//", WorkspaceAssetDownloadEndpoint.as_view(), diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 9d81754e29..87ad0e8cc1 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -107,6 +107,7 @@ from .asset.v2 import ( ProjectAssetEndpoint, ProjectBulkAssetEndpoint, AssetCheckEndpoint, + DuplicateAssetEndpoint, WorkspaceAssetDownloadEndpoint, ProjectAssetDownloadEndpoint, ) diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py index 610c5335f9..c0580c1149 100644 --- a/apps/api/plane/app/views/asset/v2.py +++ b/apps/api/plane/app/views/asset/v2.py @@ -19,6 +19,7 @@ from plane.settings.storage import S3Storage from plane.app.permissions import allow_permission, ROLE from plane.utils.cache import invalidate_cache_directly from plane.bgtasks.storage_metadata_task import get_asset_object_metadata +from plane.throttles.asset import AssetRateThrottle class UserAssetsV2Endpoint(BaseAPIView): @@ -44,7 +45,9 @@ class UserAssetsV2Endpoint(BaseAPIView): # Save the new avatar user.avatar_asset_id = asset_id user.save() - invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request) + invalidate_cache_directly( + path="/api/users/me/", url_params=False, user=True, request=request + ) invalidate_cache_directly( path="/api/users/me/settings/", url_params=False, @@ -62,7 +65,9 @@ class UserAssetsV2Endpoint(BaseAPIView): # Save the new cover image user.cover_image_asset_id = asset_id user.save() - invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request) + invalidate_cache_directly( + path="/api/users/me/", url_params=False, user=True, request=request + ) invalidate_cache_directly( path="/api/users/me/settings/", url_params=False, @@ -78,7 +83,9 @@ class UserAssetsV2Endpoint(BaseAPIView): user = User.objects.get(id=asset.user_id) user.avatar_asset_id = None user.save() - invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request) + invalidate_cache_directly( + path="/api/users/me/", url_params=False, user=True, request=request + ) invalidate_cache_directly( path="/api/users/me/settings/", url_params=False, @@ -91,7 +98,9 @@ class UserAssetsV2Endpoint(BaseAPIView): user = User.objects.get(id=asset.user_id) user.cover_image_asset_id = None user.save() - invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request) + invalidate_cache_directly( + path="/api/users/me/", url_params=False, user=True, request=request + ) invalidate_cache_directly( path="/api/users/me/settings/", url_params=False, @@ -151,7 +160,9 @@ class UserAssetsV2Endpoint(BaseAPIView): # Get the presigned URL storage = S3Storage(request=request) # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) + presigned_url = storage.generate_presigned_post( + object_name=asset_key, file_type=type, file_size=size_limit + ) # Return the presigned URL return Response( { @@ -188,7 +199,9 @@ class UserAssetsV2Endpoint(BaseAPIView): asset.is_deleted = True asset.deleted_at = timezone.now() # get the entity and save the asset id for the request field - self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request) + self.entity_asset_delete( + entity_type=asset.entity_type, asset=asset, request=request + ) asset.save(update_fields=["is_deleted", "deleted_at"]) return Response(status=status.HTTP_204_NO_CONTENT) @@ -252,14 +265,18 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): workspace.logo = "" workspace.logo_asset_id = asset_id workspace.save() - invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request) + invalidate_cache_directly( + path="/api/workspaces/", url_params=False, user=False, request=request + ) invalidate_cache_directly( path="/api/users/me/workspaces/", url_params=False, user=True, request=request, ) - invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request) + invalidate_cache_directly( + path="/api/instances/", url_params=False, user=False, request=request + ) return # Project Cover @@ -286,14 +303,18 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): return workspace.logo_asset_id = None workspace.save() - invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request) + invalidate_cache_directly( + path="/api/workspaces/", url_params=False, user=False, request=request + ) invalidate_cache_directly( path="/api/users/me/workspaces/", url_params=False, user=True, request=request, ) - invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request) + invalidate_cache_directly( + path="/api/instances/", url_params=False, user=False, request=request + ) return # Project Cover elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: @@ -354,13 +375,17 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): workspace=workspace, created_by=request.user, entity_type=entity_type, - **self.get_entity_id_field(entity_type=entity_type, entity_id=entity_identifier), + **self.get_entity_id_field( + entity_type=entity_type, entity_id=entity_identifier + ), ) # Get the presigned URL storage = S3Storage(request=request) # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) + presigned_url = storage.generate_presigned_post( + object_name=asset_key, file_type=type, file_size=size_limit + ) # Return the presigned URL return Response( { @@ -397,7 +422,9 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): asset.is_deleted = True asset.deleted_at = timezone.now() # get the entity and save the asset id for the request field - self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request) + self.entity_asset_delete( + entity_type=asset.entity_type, asset=asset, request=request + ) asset.save(update_fields=["is_deleted", "deleted_at"]) return Response(status=status.HTTP_204_NO_CONTENT) @@ -560,7 +587,9 @@ class ProjectAssetEndpoint(BaseAPIView): # Get the presigned URL storage = S3Storage(request=request) # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) + presigned_url = storage.generate_presigned_post( + object_name=asset_key, file_type=type, file_size=size_limit + ) # Return the presigned URL return Response( { @@ -590,7 +619,9 @@ class ProjectAssetEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def delete(self, request, slug, project_id, pk): # Get the asset - asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id) + asset = FileAsset.objects.get( + id=pk, workspace__slug=slug, project_id=project_id + ) # Check deleted assets asset.is_deleted = True asset.deleted_at = timezone.now() @@ -601,7 +632,9 @@ class ProjectAssetEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, pk): # get the asset id - asset = FileAsset.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + asset = FileAsset.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) # Check if the asset is uploaded if not asset.is_uploaded: @@ -634,7 +667,9 @@ class ProjectBulkAssetEndpoint(BaseAPIView): # Check if the asset ids are provided if not asset_ids: - return Response({"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST + ) # get the asset id assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug) @@ -688,10 +723,110 @@ class AssetCheckEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def get(self, request, slug, asset_id): - asset = FileAsset.all_objects.filter(id=asset_id, workspace__slug=slug, deleted_at__isnull=True).exists() + asset = FileAsset.all_objects.filter( + id=asset_id, workspace__slug=slug, deleted_at__isnull=True + ).exists() return Response({"exists": asset}, status=status.HTTP_200_OK) +class DuplicateAssetEndpoint(BaseAPIView): + + throttle_classes = [AssetRateThrottle] + + def get_entity_id_field(self, entity_type, entity_id): + # Workspace Logo + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: + return {"workspace_id": entity_id} + + # Project Cover + if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + return {"project_id": entity_id} + + # User Avatar and Cover + if entity_type in [ + FileAsset.EntityTypeContext.USER_AVATAR, + FileAsset.EntityTypeContext.USER_COVER, + ]: + return {"user_id": entity_id} + + # Issue Attachment and Description + if entity_type in [ + FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, + ]: + return {"issue_id": entity_id} + + # Page Description + if entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION: + return {"page_id": entity_id} + + # Comment Description + if entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: + return {"comment_id": entity_id} + + return {} + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def post(self, request, slug, asset_id): + project_id = request.data.get("project_id", None) + entity_id = request.data.get("entity_id", None) + entity_type = request.data.get("entity_type", None) + + + if ( + not entity_type + or entity_type not in FileAsset.EntityTypeContext.values + ): + return Response( + {"error": "Invalid entity type or entity id"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = Workspace.objects.get(slug=slug) + if project_id: + # check if project exists in the workspace + if not Project.objects.filter(id=project_id, workspace=workspace).exists(): + return Response( + {"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND + ) + + storage = S3Storage(request=request) + original_asset = FileAsset.objects.filter( + workspace=workspace, id=asset_id, is_uploaded=True + ).first() + + if not original_asset: + return Response( + {"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND + ) + + destination_key = ( + f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}" + ) + duplicated_asset = FileAsset.objects.create( + attributes={ + "name": original_asset.attributes.get("name"), + "type": original_asset.attributes.get("type"), + "size": original_asset.attributes.get("size"), + }, + asset=destination_key, + size=original_asset.size, + workspace=workspace, + created_by_id=request.user.id, + entity_type=entity_type, + project_id=project_id if project_id else None, + storage_metadata=original_asset.storage_metadata, + **self.get_entity_id_field(entity_type=entity_type, entity_id=entity_id), + ) + storage.copy_object(original_asset.asset, destination_key) + # Update the is_uploaded field for all newly created assets + FileAsset.objects.filter(id=duplicated_asset.id).update(is_uploaded=True) + + return Response( + {"asset_id": str(duplicated_asset.id)}, status=status.HTTP_200_OK + ) + + class WorkspaceAssetDownloadEndpoint(BaseAPIView): """Endpoint to generate a download link for an asset with content-disposition=attachment.""" diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index d47bf6293f..4178052166 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -69,7 +69,14 @@ MIDDLEWARE = [ # Rest Framework settings REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",), + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework.authentication.SessionAuthentication", + ), + "DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.AnonRateThrottle",), + "DEFAULT_THROTTLE_RATES": { + "anon": "30/minute", + "asset_id": "5/minute", + }, "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), diff --git a/apps/api/plane/throttles/asset.py b/apps/api/plane/throttles/asset.py new file mode 100644 index 0000000000..4846500493 --- /dev/null +++ b/apps/api/plane/throttles/asset.py @@ -0,0 +1,11 @@ +from rest_framework.throttling import SimpleRateThrottle + + +class AssetRateThrottle(SimpleRateThrottle): + scope = "asset_id" + + def get_cache_key(self, request, view): + asset_id = view.kwargs.get("asset_id") + if not asset_id: + return None + return f"throttle_asset_{asset_id}" diff --git a/apps/space/helpers/editor.helper.ts b/apps/space/helpers/editor.helper.ts index 43b265af55..f8fbd1158f 100644 --- a/apps/space/helpers/editor.helper.ts +++ b/apps/space/helpers/editor.helper.ts @@ -58,6 +58,10 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => { await sitesFileService.restoreNewAsset(anchor, src); } }, + duplicate: async (assetId: string) => + // Duplication is not supported for sites/space app + // Return the same assetId as a fallback + assetId, validation: { maxFileSize: MAX_FILE_SIZE, }, diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index 6fdd3a1c4e..bf0b1dcc3d 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -46,7 +46,7 @@ function PageDetailsPage({ params }: Route.ComponentProps) { storeType, }); const { getWorkspaceBySlug } = useWorkspace(); - const { uploadEditorAsset } = useEditorAsset(); + const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset(); // derived values const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug)?.id ?? "") : ""; const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {}; @@ -115,11 +115,21 @@ function PageDetailsPage({ params }: Route.ComponentProps) { }); return asset_id; }, + duplicateFile: async (assetId: string) => { + const { asset_id } = await duplicateEditorAsset({ + assetId, + entityId: id, + entityType: EFileAssetType.PAGE_DESCRIPTION, + projectId, + workspaceSlug, + }); + return asset_id; + }, workspaceId, workspaceSlug, }), }), - [getEditorFileHandlers, id, uploadEditorAsset, projectId, workspaceId, workspaceSlug] + [getEditorFileHandlers, projectId, workspaceId, workspaceSlug, uploadEditorAsset, id, duplicateEditorAsset] ); const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo( diff --git a/apps/web/core/components/comments/card/edit-form.tsx b/apps/web/core/components/comments/card/edit-form.tsx index fcaa6b3525..9927f54a71 100644 --- a/apps/web/core/components/comments/card/edit-form.tsx +++ b/apps/web/core/components/comments/card/edit-form.tsx @@ -94,6 +94,10 @@ export const CommentCardEditForm: React.FC = observer((props) => { const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id); return asset_id; }} + duplicateFile={async (assetId: string) => { + const { asset_id } = await activityOperations.duplicateCommentAsset(assetId, comment.id); + return asset_id; + }} projectId={projectId} parentClassName="p-2" displayConfig={{ diff --git a/apps/web/core/components/comments/comment-create.tsx b/apps/web/core/components/comments/comment-create.tsx index a9b2423f90..584867c427 100644 --- a/apps/web/core/components/comments/comment-create.tsx +++ b/apps/web/core/components/comments/comment-create.tsx @@ -133,6 +133,11 @@ export const CommentCreate: FC = observer((props) => { setUploadedAssetIds((prev) => [...prev, asset_id]); return asset_id; }} + duplicateFile={async (assetId: string) => { + const { asset_id } = await activityOperations.duplicateCommentAsset(assetId); + setUploadedAssetIds((prev) => [...prev, asset_id]); + return asset_id; + }} showToolbarInitially={showToolbarInitially} parentClassName="p-2" displayConfig={{ diff --git a/apps/web/core/components/editor/document/editor.tsx b/apps/web/core/components/editor/document/editor.tsx index 199e93179a..bbb7a54e02 100644 --- a/apps/web/core/components/editor/document/editor.tsx +++ b/apps/web/core/components/editor/document/editor.tsx @@ -29,6 +29,7 @@ type DocumentEditorWrapperProps = MakeOptional< editable: true; searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise; uploadFile: TFileHandler["upload"]; + duplicateFile: TFileHandler["duplicate"]; } ); @@ -71,6 +72,7 @@ export const DocumentEditor = forwardRef "", + duplicateFile: editable ? props.duplicateFile : async () => "", workspaceId, workspaceSlug, })} diff --git a/apps/web/core/components/editor/lite-text/editor.tsx b/apps/web/core/components/editor/lite-text/editor.tsx index 2fb6764406..e352849bae 100644 --- a/apps/web/core/components/editor/lite-text/editor.tsx +++ b/apps/web/core/components/editor/lite-text/editor.tsx @@ -45,6 +45,7 @@ type LiteTextEditorWrapperProps = MakeOptional< | { editable: true; uploadFile: TFileHandler["upload"]; + duplicateFile: TFileHandler["duplicate"]; } ); @@ -127,6 +128,7 @@ export const LiteTextEditor = React.forwardRef "", + duplicateFile: editable ? props.duplicateFile : async () => "", workspaceId, workspaceSlug, })} diff --git a/apps/web/core/components/editor/rich-text/description-input/root.tsx b/apps/web/core/components/editor/rich-text/description-input/root.tsx index 13e05dc711..f098b9616c 100644 --- a/apps/web/core/components/editor/rich-text/description-input/root.tsx +++ b/apps/web/core/components/editor/rich-text/description-input/root.tsx @@ -115,7 +115,7 @@ export const DescriptionInput: React.FC = observer((props) => { const hasUnsavedChanges = useRef(false); // store hooks const { getWorkspaceBySlug } = useWorkspace(); - const { uploadEditorAsset } = useEditorAsset(); + const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset(); // derived values const workspaceDetails = getWorkspaceBySlug(workspaceSlug); // translation @@ -240,6 +240,19 @@ export const DescriptionInput: React.FC = observer((props) => { throw new Error("Asset upload failed. Please try again later."); } }} + duplicateFile={async (assetId: string) => { + try { + const { asset_id } = await duplicateEditorAsset({ + assetId, + entityType: fileAssetType, + projectId, + workspaceSlug, + }); + return asset_id; + } catch { + throw new Error("Asset duplication failed. Please try again later."); + } + }} /> )} /> diff --git a/apps/web/core/components/editor/rich-text/editor.tsx b/apps/web/core/components/editor/rich-text/editor.tsx index a78e4266d4..271c46d8c8 100644 --- a/apps/web/core/components/editor/rich-text/editor.tsx +++ b/apps/web/core/components/editor/rich-text/editor.tsx @@ -29,6 +29,7 @@ type RichTextEditorWrapperProps = MakeOptional< editable: true; searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise; uploadFile: TFileHandler["upload"]; + duplicateFile: TFileHandler["duplicate"]; } ); @@ -69,6 +70,7 @@ export const RichTextEditor = forwardRef "", + duplicateFile: editable ? props.duplicateFile : async () => "", workspaceId, workspaceSlug, })} diff --git a/apps/web/core/components/editor/sticky-editor/editor.tsx b/apps/web/core/components/editor/sticky-editor/editor.tsx index 49ca1ed3d1..502cad20ee 100644 --- a/apps/web/core/components/editor/sticky-editor/editor.tsx +++ b/apps/web/core/components/editor/sticky-editor/editor.tsx @@ -31,6 +31,7 @@ interface StickyEditorWrapperProps showToolbarInitially?: boolean; showToolbar?: boolean; uploadFile: TFileHandler["upload"]; + duplicateFile: TFileHandler["duplicate"]; parentClassName?: string; handleColorChange: (data: Partial) => Promise; handleDelete: () => void; @@ -48,6 +49,7 @@ export const StickyEditor = React.forwardRef = observer((props // i18n const { t } = useTranslation(); // store hooks - const { uploadEditorAsset } = useEditorAsset(); + const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset(); const { loader } = useProjectInbox(); const { isMobile } = usePlatformOS(); @@ -102,6 +102,20 @@ export const InboxIssueDescription: FC = observer((props throw new Error("Asset upload failed. Please try again later."); } }} + duplicateFile={async (assetId: string) => { + try { + const { asset_id } = await duplicateEditorAsset({ + assetId, + entityType: EFileAssetType.ISSUE_DESCRIPTION, + projectId, + workspaceSlug, + }); + onAssetUpload?.(asset_id); + return asset_id; + } catch { + throw new Error("Asset duplication failed. Please try again later."); + } + }} /> ); }); diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/helper.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/helper.tsx index 54ef0f717c..fc90e858e1 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/helper.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/helper.tsx @@ -27,7 +27,7 @@ export const useCommentOperations = ( } = useIssueDetail(); const { getProjectById } = useProject(); const { getUserDetails } = useMember(); - const { uploadEditorAsset } = useEditorAsset(); + const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset(); const { data: currentUser } = useUser(); // derived values const issueDetails = issueId ? getIssueById(issueId) : undefined; @@ -136,6 +136,21 @@ export const useCommentOperations = ( throw new Error(t("issue.comments.upload.error")); } }, + duplicateCommentAsset: async (assetId, commentId) => { + try { + if (!workspaceSlug || !projectId) throw new Error("Missing fields"); + const res = await duplicateEditorAsset({ + assetId, + entityId: commentId || undefined, + entityType: EFileAssetType.COMMENT_DESCRIPTION, + projectId, + workspaceSlug, + }); + return res; + } catch { + throw new Error("Asset duplication failed. Please try again later."); + } + }, addCommentReaction: async (commentId, reaction) => { try { if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields"); diff --git a/apps/web/core/components/issues/issue-modal/components/description-editor.tsx b/apps/web/core/components/issues/issue-modal/components/description-editor.tsx index 40b6fac4d1..0cd3161262 100644 --- a/apps/web/core/components/issues/issue-modal/components/description-editor.tsx +++ b/apps/web/core/components/issues/issue-modal/components/description-editor.tsx @@ -77,7 +77,7 @@ export const IssueDescriptionEditor: React.FC = ob const { getWorkspaceBySlug } = useWorkspace(); const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id ?? ""; const { config } = useInstance(); - const { uploadEditorAsset } = useEditorAsset(); + const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset(); // platform const { isMobile } = usePlatformOS(); @@ -221,6 +221,21 @@ export const IssueDescriptionEditor: React.FC = ob throw new Error("Asset upload failed. Please try again later."); } }} + duplicateFile={async (assetId: string) => { + try { + const { asset_id } = await duplicateEditorAsset({ + assetId, + entityId: issueId, + entityType: isDraft ? EFileAssetType.DRAFT_ISSUE_DESCRIPTION : EFileAssetType.ISSUE_DESCRIPTION, + projectId, + workspaceSlug, + }); + onAssetUpload(asset_id); + return asset_id; + } catch { + throw new Error("Asset duplication failed. Please try again later."); + } + }} /> )} /> diff --git a/apps/web/core/components/stickies/sticky/inputs.tsx b/apps/web/core/components/stickies/sticky/inputs.tsx index 068a9aa4f0..b7ba84f1c5 100644 --- a/apps/web/core/components/stickies/sticky/inputs.tsx +++ b/apps/web/core/components/stickies/sticky/inputs.tsx @@ -86,6 +86,7 @@ export const StickyInput = (props: TProps) => { } )} uploadFile={async () => ""} + duplicateFile={async () => ""} showToolbar={showToolbar} parentClassName="border-none p-0" handleDelete={handleDelete} diff --git a/apps/web/core/hooks/editor/use-editor-config.ts b/apps/web/core/hooks/editor/use-editor-config.ts index 6b1e6c59a8..cee66cca43 100644 --- a/apps/web/core/hooks/editor/use-editor-config.ts +++ b/apps/web/core/hooks/editor/use-editor-config.ts @@ -14,6 +14,7 @@ const fileService = new FileService(); type TArgs = { projectId?: string; uploadFile: TFileHandler["upload"]; + duplicateFile: TFileHandler["duplicate"]; workspaceId: string; workspaceSlug: string; }; @@ -27,7 +28,7 @@ export const useEditorConfig = () => { const getEditorFileHandlers = useCallback( (args: TArgs): TFileHandler => { - const { projectId, uploadFile, workspaceId, workspaceSlug } = args; + const { projectId, uploadFile, duplicateFile, workspaceId, workspaceSlug } = args; return { assetsUploadStatus: assetsUploadPercentage, @@ -85,6 +86,7 @@ export const useEditorConfig = () => { } }, upload: uploadFile, + duplicate: duplicateFile, validation: { maxFileSize, }, diff --git a/apps/web/core/services/file.service.ts b/apps/web/core/services/file.service.ts index 680772c638..0d2551fb9c 100644 --- a/apps/web/core/services/file.service.ts +++ b/apps/web/core/services/file.service.ts @@ -2,7 +2,7 @@ import type { AxiosRequestConfig } from "axios"; // plane types import { API_BASE_URL } from "@plane/constants"; import { getFileMetaDataForUpload, generateFileUploadPayload } from "@plane/services"; -import type { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; +import type { EFileAssetType, TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; import { getAssetIdFromUrl } from "@plane/utils"; // helpers // services @@ -281,4 +281,20 @@ export class FileService extends APIService { throw err?.response?.data; }); } + + async duplicateAsset( + workspaceSlug: string, + assetId: string, + data: { + entity_id?: string; + entity_type: EFileAssetType; + project_id?: string; + } + ): Promise<{ asset_id: string }> { + return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/duplicate-assets/${assetId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } diff --git a/apps/web/core/store/editor/asset.store.ts b/apps/web/core/store/editor/asset.store.ts index 7dd6f1c44b..7974f02b96 100644 --- a/apps/web/core/store/editor/asset.store.ts +++ b/apps/web/core/store/editor/asset.store.ts @@ -3,7 +3,7 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx" import { computedFn } from "mobx-utils"; import { v4 as uuidv4 } from "uuid"; // plane types -import type { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; +import type { EFileAssetType, TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; // services import { FileService } from "@/services/file.service"; import type { TAttachmentUploadStatus } from "../issue/issue-details/attachment.store"; @@ -27,6 +27,19 @@ export interface IEditorAssetStore { projectId?: string; workspaceSlug: string; }) => Promise; + duplicateEditorAsset: ({ + assetId, + entityId, + entityType, + projectId, + workspaceSlug, + }: { + assetId: string; + entityId?: string; + entityType: EFileAssetType; + projectId?: string; + workspaceSlug: string; + }) => Promise<{ asset_id: string }>; } export class EditorAssetStore implements IEditorAssetStore { @@ -117,4 +130,13 @@ export class EditorAssetStore implements IEditorAssetStore { }); } }; + duplicateEditorAsset: IEditorAssetStore["duplicateEditorAsset"] = async (args) => { + const { assetId, entityId, entityType, projectId, workspaceSlug } = args; + const { asset_id } = await this.fileService.duplicateAsset(workspaceSlug, assetId, { + entity_id: entityId, + entity_type: entityType, + project_id: projectId, + }); + return { asset_id }; + }; } diff --git a/packages/editor/src/ce/helpers/asset-duplication.ts b/packages/editor/src/ce/helpers/asset-duplication.ts new file mode 100644 index 0000000000..2a2a6e4079 --- /dev/null +++ b/packages/editor/src/ce/helpers/asset-duplication.ts @@ -0,0 +1,40 @@ +import { v4 as uuidv4 } from "uuid"; +import { ECustomImageAttributeNames, ECustomImageStatus } from "@/extensions/custom-image/types"; + +export type AssetDuplicationContext = { + element: Element; + originalHtml: string; +}; + +export type AssetDuplicationResult = { + modifiedHtml: string; + shouldProcess: boolean; +}; + +export type AssetDuplicationHandler = (context: AssetDuplicationContext) => AssetDuplicationResult; + +const imageComponentHandler: AssetDuplicationHandler = ({ element, originalHtml }) => { + const src = element.getAttribute("src"); + + if (!src || src.startsWith("http")) { + return { modifiedHtml: originalHtml, shouldProcess: false }; + } + + // Capture the original HTML BEFORE making any modifications + const originalTag = element.outerHTML; + + // Use setAttribute to update attributes + const newId = uuidv4(); + element.setAttribute(ECustomImageAttributeNames.STATUS, ECustomImageStatus.DUPLICATING); + element.setAttribute(ECustomImageAttributeNames.ID, newId); + + // Get the modified HTML AFTER the changes + const modifiedTag = element.outerHTML; + const modifiedHtml = originalHtml.replaceAll(originalTag, modifiedTag); + + return { modifiedHtml, shouldProcess: true }; +}; + +export const assetDuplicationHandlers: Record = { + "image-component": imageComponentHandler, +}; diff --git a/packages/editor/src/core/extensions/custom-image/components/block.tsx b/packages/editor/src/core/extensions/custom-image/components/block.tsx index a7c68552a8..c2d2c8ac58 100644 --- a/packages/editor/src/core/extensions/custom-image/components/block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/block.tsx @@ -4,7 +4,7 @@ import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from import { cn } from "@plane/utils"; // local imports import type { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types"; -import { ensurePixelString, getImageBlockId } from "../utils"; +import { ensurePixelString, getImageBlockId, isImageDuplicating } from "../utils"; import type { CustomImageNodeViewProps } from "./node-view"; import { ImageToolbarRoot } from "./toolbar"; import { ImageUploadStatus } from "./upload-status"; @@ -42,6 +42,7 @@ export const CustomImageBlock: React.FC = (props) => { aspectRatio: nodeAspectRatio, src: imgNodeSrc, alignment: nodeAlignment, + status, } = node.attrs; // states const [size, setSize] = useState({ @@ -202,15 +203,16 @@ export const CustomImageBlock: React.FC = (props) => { [editor, getPos, isTouchDevice] ); + const isDuplicating = isImageDuplicating(status); // show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or) // if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete - const showImageLoader = !(resolvedImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad; - // show the image upload status only when the resolvedImageSrc is not ready - const showUploadStatus = !resolvedImageSrc; + const showImageLoader = + (!resolvedImageSrc && !isDuplicating) || !initialResizeComplete || hasErroredOnFirstLoad || isDuplicating; // show the image upload status only when the resolvedImageSrc is not ready + const showUploadStatus = !resolvedImageSrc && !isDuplicating; // show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) - const showImageToolbar = resolvedImageSrc && resolvedDownloadSrc && initialResizeComplete; + const showImageToolbar = resolvedImageSrc && resolvedDownloadSrc && initialResizeComplete && !isDuplicating; // show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) - const showImageResizer = editor.isEditable && resolvedImageSrc && initialResizeComplete; + const showImageResizer = editor.isEditable && resolvedImageSrc && initialResizeComplete && !isDuplicating; // show the preview image from the file system if the remote image's src is not set const displayedImageSrc = resolvedImageSrc || imageFromFileSystem; diff --git a/packages/editor/src/core/extensions/custom-image/components/node-view.tsx b/packages/editor/src/core/extensions/custom-image/components/node-view.tsx index 7ccfb9a288..059e81ec9a 100644 --- a/packages/editor/src/core/extensions/custom-image/components/node-view.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/node-view.tsx @@ -3,6 +3,8 @@ import type { NodeViewProps } from "@tiptap/react"; import { useEffect, useRef, useState } from "react"; // local imports import type { CustomImageExtensionType, TCustomImageAttributes } from "../types"; +import { ECustomImageStatus } from "../types"; +import { hasImageDuplicationFailed } from "../utils"; import { CustomImageBlock } from "./block"; import { CustomImageUploader } from "./uploader"; @@ -15,8 +17,8 @@ export type CustomImageNodeViewProps = Omit = (props) => { - const { editor, extension, node } = props; - const { src: imgNodeSrc } = node.attrs; + const { editor, extension, node, updateAttributes } = props; + const { src: imgNodeSrc, status } = node.attrs; const [isUploaded, setIsUploaded] = useState(!!imgNodeSrc); const [resolvedSrc, setResolvedSrc] = useState(undefined); @@ -26,6 +28,8 @@ export const CustomImageNodeView: React.FC = (props) = const [editorContainer, setEditorContainer] = useState(null); const imageComponentRef = useRef(null); + const hasRetriedOnMount = useRef(false); + const isDuplicatingRef = useRef(false); useEffect(() => { const closestEditorContainer = imageComponentRef.current?.closest(".editor-container"); @@ -61,10 +65,66 @@ export const CustomImageNodeView: React.FC = (props) = getImageSource(); }, [imgNodeSrc, extension.options]); + // Handle image duplication when status is duplicating + useEffect(() => { + const handleDuplication = async () => { + if (status !== ECustomImageStatus.DUPLICATING || !extension.options.duplicateImage || !imgNodeSrc) { + return; + } + + // Prevent duplicate calls - check if already duplicating this asset + if (isDuplicatingRef.current) { + return; + } + + isDuplicatingRef.current = true; + try { + hasRetriedOnMount.current = true; + + const newAssetId = await extension.options.duplicateImage!(imgNodeSrc); + + if (!newAssetId) { + throw new Error("Duplication returned invalid asset ID"); + } + + // Update node with new source and success status + updateAttributes({ + src: newAssetId, + status: ECustomImageStatus.UPLOADED, + }); + } catch (error: unknown) { + console.error("Failed to duplicate image:", error); + // Update status to failed + updateAttributes({ status: ECustomImageStatus.DUPLICATION_FAILED }); + } finally { + isDuplicatingRef.current = false; + } + }; + + handleDuplication(); + }, [status, imgNodeSrc, extension.options.duplicateImage, updateAttributes]); + + useEffect(() => { + if (hasImageDuplicationFailed(status) && !hasRetriedOnMount.current && imgNodeSrc) { + hasRetriedOnMount.current = true; + // Add a small delay before retrying to avoid immediate retries + updateAttributes({ status: ECustomImageStatus.DUPLICATING }); + } + }, [status, imgNodeSrc, updateAttributes]); + + useEffect(() => { + if (status === ECustomImageStatus.UPLOADED) { + hasRetriedOnMount.current = false; + } + }, [status]); + + const hasDuplicationFailed = hasImageDuplicationFailed(status); + const shouldShowBlock = (isUploaded || imageFromFileSystem) && !failedToLoadImage; + return (
- {(isUploaded || imageFromFileSystem) && !failedToLoadImage ? ( + {shouldShowBlock && !hasDuplicationFailed ? ( = (props) = ) : ( void; maxFileSize: number; setIsUploaded: (isUploaded: boolean) => void; @@ -33,6 +35,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { selected, setIsUploaded, updateAttributes, + hasDuplicationFailed, } = props; // refs const fileInputRef = useRef(null); @@ -50,6 +53,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { // Update the node view's src attribute post upload updateAttributes({ src: url, + status: ECustomImageStatus.UPLOADED, }); imageComponentImageFileMap?.delete(imageEntityId); @@ -84,8 +88,11 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { ); const uploadImageEditorCommand = useCallback( - async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file), - [extension.options, imageEntityId] + async (file: File) => { + updateAttributes({ status: ECustomImageStatus.UPLOADING }); + return await extension.options.uploadImage?.(imageEntityId ?? "", file); + }, + [extension.options, imageEntityId, updateAttributes] ); const handleProgressStatus = useCallback( @@ -161,7 +168,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { const getDisplayMessage = useCallback(() => { const isUploading = isImageBeingUploaded; - if (failedToLoadImage) { + if (failedToLoadImage || hasDuplicationFailed) { return "Error loading image"; } @@ -174,7 +181,17 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { } return "Add an image"; - }, [draggedInside, editor.isEditable, failedToLoadImage, isImageBeingUploaded]); + }, [draggedInside, editor.isEditable, failedToLoadImage, isImageBeingUploaded, hasDuplicationFailed]); + + const handleRetryClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (hasDuplicationFailed && editor.isEditable) { + updateAttributes({ status: ECustomImageStatus.DUPLICATING }); + } + }, + [hasDuplicationFailed, editor.isEditable, updateAttributes] + ); return (
{ "bg-custom-background-80 text-custom-text-200": draggedInside && editor.isEditable, "text-custom-primary-200 bg-custom-primary-100/10 border-custom-primary-200/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200": selected && editor.isEditable, - "text-red-500 cursor-default": failedToLoadImage, - "hover:text-red-500": failedToLoadImage && editor.isEditable, - "bg-red-500/10": failedToLoadImage && selected, - "hover:bg-red-500/10": failedToLoadImage && selected && editor.isEditable, + "text-red-500 cursor-default": failedToLoadImage || hasDuplicationFailed, + "hover:text-red-500": (failedToLoadImage || hasDuplicationFailed) && editor.isEditable, + "bg-red-500/10": (failedToLoadImage || hasDuplicationFailed) && selected, + "hover:bg-red-500/10": (failedToLoadImage || hasDuplicationFailed) && selected && editor.isEditable, } )} onDrop={onDrop} @@ -196,13 +213,29 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { onDragLeave={onDragLeave} contentEditable={false} onClick={() => { - if (!failedToLoadImage && editor.isEditable) { + if (!failedToLoadImage && editor.isEditable && !hasDuplicationFailed) { fileInputRef.current?.click(); } }} > -
{getDisplayMessage()}
+
{getDisplayMessage()}
+ {hasDuplicationFailed && editor.isEditable && ( + + )} { addOptions() { const upload = "upload" in fileHandler ? fileHandler.upload : undefined; - + const duplicate = "duplicate" in fileHandler ? fileHandler.duplicate : undefined; return { ...this.parent?.(), getImageDownloadSource: getAssetDownloadSrc, getImageSource: getAssetSrc, restoreImage: restoreImageFn, uploadImage: upload, + duplicateImage: duplicate, }; }, @@ -93,7 +95,8 @@ export const CustomImageExtension = (props: Props) => { } const attributes = { - id: fileId, + [ECustomImageAttributeNames.ID]: fileId, + [ECustomImageAttributeNames.STATUS]: ECustomImageStatus.PENDING, }; if (props.pos) { @@ -116,7 +119,6 @@ export const CustomImageExtension = (props: Props) => { ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), }; }, - addNodeView() { return ReactNodeViewRenderer((props) => ( diff --git a/packages/editor/src/core/extensions/custom-image/types.ts b/packages/editor/src/core/extensions/custom-image/types.ts index 9d6f84d264..eed345ff78 100644 --- a/packages/editor/src/core/extensions/custom-image/types.ts +++ b/packages/editor/src/core/extensions/custom-image/types.ts @@ -9,6 +9,7 @@ export enum ECustomImageAttributeNames { ASPECT_RATIO = "aspectRatio", SOURCE = "src", ALIGNMENT = "alignment", + STATUS = "status", } export type Pixel = `${number}px`; @@ -23,6 +24,14 @@ export type TCustomImageSize = { export type TCustomImageAlignment = "left" | "center" | "right"; +export enum ECustomImageStatus { + PENDING = "pending", + UPLOADING = "uploading", + UPLOADED = "uploaded", + DUPLICATING = "duplicating", + DUPLICATION_FAILED = "duplication-failed", +} + export type TCustomImageAttributes = { [ECustomImageAttributeNames.ID]: string | null; [ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null; @@ -30,6 +39,7 @@ export type TCustomImageAttributes = { [ECustomImageAttributeNames.ASPECT_RATIO]: number | null; [ECustomImageAttributeNames.SOURCE]: string | null; [ECustomImageAttributeNames.ALIGNMENT]: TCustomImageAlignment; + [ECustomImageAttributeNames.STATUS]: ECustomImageStatus; }; export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; @@ -45,6 +55,7 @@ export type CustomImageExtensionOptions = { getImageSource: TFileHandler["getAssetSrc"]; restoreImage: TFileHandler["restore"]; uploadImage?: TFileHandler["upload"]; + duplicateImage?: TFileHandler["duplicate"]; }; export type CustomImageExtensionStorage = { diff --git a/packages/editor/src/core/extensions/custom-image/utils.ts b/packages/editor/src/core/extensions/custom-image/utils.ts index 0b0d17ffd0..86faf55ec6 100644 --- a/packages/editor/src/core/extensions/custom-image/utils.ts +++ b/packages/editor/src/core/extensions/custom-image/utils.ts @@ -2,7 +2,7 @@ import type { Editor } from "@tiptap/core"; import { AlignCenter, AlignLeft, AlignRight } from "lucide-react"; import type { LucideIcon } from "lucide-react"; // local imports -import { ECustomImageAttributeNames } from "./types"; +import { ECustomImageAttributeNames, ECustomImageStatus } from "./types"; import type { TCustomImageAlignment, Pixel, TCustomImageAttributes } from "./types"; export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = { @@ -12,6 +12,7 @@ export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = { [ECustomImageAttributeNames.HEIGHT]: "auto", [ECustomImageAttributeNames.ASPECT_RATIO]: null, [ECustomImageAttributeNames.ALIGNMENT]: "left", + [ECustomImageAttributeNames.STATUS]: ECustomImageStatus.PENDING, }; export const getImageComponentImageFileMap = (editor: Editor) => editor.storage.imageComponent?.fileMap; @@ -53,3 +54,11 @@ export const IMAGE_ALIGNMENT_OPTIONS: { }, ]; export const getImageBlockId = (id: string) => `editor-image-block-${id}`; + +export const isImageDuplicating = (status: ECustomImageStatus) => status === ECustomImageStatus.DUPLICATING; + +export const isImageDuplicationComplete = (status: ECustomImageStatus) => + status === ECustomImageStatus.UPLOADED || status === ECustomImageStatus.DUPLICATION_FAILED; + +export const hasImageDuplicationFailed = (status: ECustomImageStatus) => + status === ECustomImageStatus.DUPLICATION_FAILED; diff --git a/packages/editor/src/core/extensions/utility.ts b/packages/editor/src/core/extensions/utility.ts index ef833e4e78..1bff7589b9 100644 --- a/packages/editor/src/core/extensions/utility.ts +++ b/packages/editor/src/core/extensions/utility.ts @@ -9,6 +9,7 @@ import { DropHandlerPlugin } from "@/plugins/drop"; import { FilePlugins } from "@/plugins/file/root"; import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard"; // types +import { PasteAssetPlugin } from "@/plugins/paste-asset"; import type { IEditorProps, TEditorAsset, TFileHandler } from "@/types"; type TActiveDropbarExtensions = @@ -80,6 +81,7 @@ export const UtilityExtension = (props: Props) => { disabledExtensions, editor: this.editor, }), + PasteAssetPlugin(), ]; }, diff --git a/packages/editor/src/core/plugins/paste-asset.ts b/packages/editor/src/core/plugins/paste-asset.ts new file mode 100644 index 0000000000..67ab9056d3 --- /dev/null +++ b/packages/editor/src/core/plugins/paste-asset.ts @@ -0,0 +1,77 @@ +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { assetDuplicationHandlers } from "@/plane-editor/helpers/asset-duplication"; + +export const PasteAssetPlugin = (): Plugin => + new Plugin({ + key: new PluginKey("paste-asset-duplication"), + props: { + handlePaste: (view, event) => { + if (!event.clipboardData) return false; + + const htmlContent = event.clipboardData.getData("text/html"); + if (!htmlContent || htmlContent.includes('data-uploaded="true"')) return false; + + // Process the HTML content using the registry + const { processedHtml, hasChanges } = processAssetDuplication(htmlContent); + + if (!hasChanges) return false; + + event.preventDefault(); + event.stopPropagation(); + + // Mark the content as already processed to avoid infinite loops + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = processedHtml; + const metaTag = tempDiv.querySelector("meta[charset='utf-8']"); + if (metaTag) { + metaTag.setAttribute("data-uploaded", "true"); + } + const finalHtml = tempDiv.innerHTML; + + const newDataTransfer = new DataTransfer(); + newDataTransfer.setData("text/html", finalHtml); + if (event.clipboardData) { + newDataTransfer.setData("text/plain", event.clipboardData.getData("text/plain")); + } + + const pasteEvent = new ClipboardEvent("paste", { + clipboardData: newDataTransfer, + bubbles: true, + cancelable: true, + }); + + view.dom.dispatchEvent(pasteEvent); + + return true; + }, + }, + }); + +// Utility function to process HTML content with all registered handlers +const processAssetDuplication = (htmlContent: string): { processedHtml: string; hasChanges: boolean } => { + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = htmlContent; + + let processedHtml = htmlContent; + let hasChanges = false; + + // Process each registered component type + for (const [componentName, handler] of Object.entries(assetDuplicationHandlers)) { + const elements = tempDiv.querySelectorAll(componentName); + + if (elements.length > 0) { + elements.forEach((element) => { + const result = handler({ element, originalHtml: processedHtml }); + if (result.shouldProcess) { + processedHtml = result.modifiedHtml; + hasChanges = true; + } + }); + + // Update tempDiv with processed HTML for next iteration + tempDiv.innerHTML = processedHtml; + } + } + + return { processedHtml, hasChanges }; +}; diff --git a/packages/editor/src/core/props.ts b/packages/editor/src/core/props.ts index 30e9a436de..98821d67d2 100644 --- a/packages/editor/src/core/props.ts +++ b/packages/editor/src/core/props.ts @@ -27,8 +27,5 @@ export const CoreEditorProps = (props: TArgs): EditorProps => { } }, }, - transformPastedHTML(html) { - return html.replace(//g, ""); - }, }; }; diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts index 8d6dfc20d2..0419ea5528 100644 --- a/packages/editor/src/core/types/config.ts +++ b/packages/editor/src/core/types/config.ts @@ -11,6 +11,7 @@ export type TFileHandler = { getAssetSrc: (path: string) => Promise; restore: (assetSrc: string) => Promise; upload: (blockId: string, file: File) => Promise; + duplicate: (assetId: string) => Promise; validation: { /** * @description max file size in bytes diff --git a/packages/types/src/issues/activity/issue_comment.ts b/packages/types/src/issues/activity/issue_comment.ts index a4d887be54..f5a411e53d 100644 --- a/packages/types/src/issues/activity/issue_comment.ts +++ b/packages/types/src/issues/activity/issue_comment.ts @@ -47,6 +47,7 @@ export type TCommentsOperations = { updateComment: (commentId: string, data: Partial) => Promise; removeComment: (commentId: string) => Promise; uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise; + duplicateCommentAsset: (assetId: string, commentId?: string) => Promise<{ asset_id: string }>; addCommentReaction: (commentId: string, reactionEmoji: string) => Promise; deleteCommentReaction: (commentId: string, reactionEmoji: string) => Promise; react: (commentId: string, reactionEmoji: string, userReactions: string[]) => Promise;