diff --git a/apps/api/plane/app/urls/asset.py b/apps/api/plane/app/urls/asset.py index 93356b04c..4b7e2b220 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 9d81754e2..87ad0e8cc 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 610c5335f..c0580c114 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 d47bf6293..417805216 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 000000000..484650049 --- /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 43b265af5..f8fbd1158 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 6fdd3a1c4..bf0b1dcc3 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 fcaa6b352..9927f54a7 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 a9b2423f9..584867c42 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 199e93179..bbb7a54e0 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 2fb676440..e352849ba 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 13e05dc71..f098b9616 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 a78e4266d..271c46d8c 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 49ca1ed3d..502cad20e 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 54ef0f717..fc90e858e 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 40b6fac4d..0cd316126 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 068a9aa4f..b7ba84f1c 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 6b1e6c59a..cee66cca4 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 680772c63..0d2551fb9 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 7dd6f1c44..7974f02b9 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 000000000..2a2a6e407 --- /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 a7c68552a..c2d2c8ac5 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 7ccfb9a28..059e81ec9 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 9d6f84d26..eed345ff7 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 0b0d17ffd..86faf55ec 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 ef833e4e7..1bff7589b 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 000000000..67ab9056d --- /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 30e9a436d..98821d67d 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 8d6dfc20d..0419ea552 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 a4d887be5..f5a411e53 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;