Compare commits

...

19 Commits

Author SHA1 Message Date
VipinDevelops 38bdcab1c3 feat: handle image duplication for project pages 2025-10-28 18:58:40 +05:30
NarayanBavisetti 56701442c2 chore: added the response key 2025-10-14 17:26:44 +05:30
NarayanBavisetti 4a061332c4 chore: reverted the frontend code 2025-10-14 17:23:59 +05:30
NarayanBavisetti 7b116b7af0 chore: removed the comment 2025-10-14 17:20:14 +05:30
NarayanBavisetti 155857c370 chore: added extra validations 2025-10-14 17:18:36 +05:30
NarayanBavisetti fe68d0f25f Merge branch 'preview' of github.com:makeplane/plane into chore/asset-duplicate-endpoint 2025-10-14 16:18:50 +05:30
NarayanBavisetti 10bca485cf Merge branch 'chore/asset-duplicate-endpoint' of github.com:makeplane/plane into chore/asset-duplicate-endpoint 2025-10-14 15:11:13 +05:30
NarayanBavisetti 9fc0fa79a1 chore: added validations for entity 2025-10-14 15:08:58 +05:30
NarayanBavisetti 992dac3447 Merge branch 'preview' of github.com:makeplane/plane into chore/asset-duplicate-endpoint 2025-10-14 14:55:11 +05:30
VipinDevelops 8ad152940a Merge branch 'preview' into chore/asset-duplicate-endpoint 2025-10-13 18:52:48 +05:30
Bavisetti Narayan 96f95ac1c4 Merge branch 'preview' into chore/asset-duplicate-endpoint 2025-10-06 21:00:04 +05:30
NarayanBavisetti c54aa7ad3d chore: added throttle class 2025-07-16 15:52:33 +05:30
NarayanBavisetti f8df5c0f2e Merge branch 'preview' of github.com:makeplane/plane into chore/asset-duplicate-endpoint 2025-07-16 15:32:49 +05:30
Bavisetti Narayan 8605789ca6 Merge branch 'preview' into chore/asset-duplicate-endpoint 2025-06-23 21:02:29 +05:30
Bavisetti Narayan 3cb427f839 Merge branch 'preview' into chore/asset-duplicate-endpoint 2025-06-13 17:42:10 +05:30
NarayanBavisetti cdd6a7be10 chore: added rate limiting per asset id 2025-06-09 17:29:28 +05:30
NarayanBavisetti 9704f21c6e chore: added rate limiting for image duplication endpoint 2025-06-06 15:44:58 +05:30
NarayanBavisetti 0e369f87ac chore: change the type in url 2025-06-05 18:23:17 +05:30
Aaryan Khandelwal d52cce150f chore: new asset duplicate endpoint added 2025-06-05 17:16:28 +05:30
16 changed files with 427 additions and 47 deletions
+6
View File
@@ -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/<str:slug>/duplicate-assets/<uuid:asset_id>/",
DuplicateAssetEndpoint.as_view(),
name="duplicate-assets",
),
path(
"assets/v2/workspaces/<str:slug>/download/<uuid:asset_id>/",
WorkspaceAssetDownloadEndpoint.as_view(),
+1
View File
@@ -107,6 +107,7 @@ from .asset.v2 import (
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
DuplicateAssetEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
+153 -18
View File
@@ -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_id
or 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."""
+8 -1
View File
@@ -68,7 +68,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",),
+11
View File
@@ -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}"
@@ -44,7 +44,7 @@ const PageDetailsPage = observer(() => {
storeType,
});
const { getWorkspaceBySlug } = useWorkspace();
const { uploadEditorAsset } = useEditorAsset();
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
// derived values
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {};
@@ -131,11 +131,21 @@ const PageDetailsPage = observer(() => {
});
return asset_id;
},
duplicateFile: async (assetId: string) => {
const { asset_id } = await duplicateEditorAsset({
assetId,
entityId: id ?? "",
entityType: EFileAssetType.PAGE_DESCRIPTION,
projectId: projectId?.toString() ?? "",
workspaceSlug: workspaceSlug?.toString() ?? "",
});
return asset_id;
},
workspaceId,
workspaceSlug: workspaceSlug?.toString() ?? "",
}),
}),
[getEditorFileHandlers, id, uploadEditorAsset, projectId, workspaceId, workspaceSlug]
[getEditorFileHandlers, projectId, workspaceId, workspaceSlug, uploadEditorAsset, id, duplicateEditorAsset]
);
const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo(
@@ -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 ?? (async () => ""),
validation: {
maxFileSize,
},
+16
View File
@@ -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: string;
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;
});
}
}
+22
View File
@@ -27,6 +27,19 @@ export interface IEditorAssetStore {
projectId?: string;
workspaceSlug: string;
}) => Promise<TFileSignedURLResponse>;
duplicateEditorAsset: ({
assetId,
entityId,
entityType,
projectId,
workspaceSlug,
}: {
assetId: string;
entityId: string;
entityType: string;
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?.toString() ?? "", assetId, {
entity_id: entityId,
entity_type: entityType,
project_id: projectId,
});
return { asset_id };
};
}
@@ -4,7 +4,7 @@ import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from
import { cn } from "@plane/utils";
// local imports
import { 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<CustomImageBlockProps> = (props) => {
aspectRatio: nodeAspectRatio,
src: imgNodeSrc,
alignment: nodeAlignment,
status,
} = node.attrs;
// states
const [size, setSize] = useState<TCustomImageSize>({
@@ -202,15 +203,20 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
[editor, getPos, isTouchDevice]
);
// 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;
// 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;
// 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;
// Check if image is duplicating
const isDuplicating = isImageDuplicating(status);
const showImageLoader =
(!resolvedImageSrc && !isDuplicating) || !initialResizeComplete || hasErroredOnFirstLoad || isDuplicating;
// show the image upload status only when not duplicating and no resolved source
const showUploadStatus = !resolvedImageSrc && !isDuplicating;
// show the image utils only if image is loaded and not duplicating
const showImageToolbar = resolvedImageSrc && resolvedDownloadSrc && initialResizeComplete && !isDuplicating;
// show the image resizer only if image is loaded and not duplicating
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;
@@ -1,7 +1,8 @@
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
// local imports
import type { CustomImageExtensionType, TCustomImageAttributes } from "../types";
import { isImageDuplicationFailed } from "../utils";
import { CustomImageBlock } from "./block";
import { CustomImageUploader } from "./uploader";
@@ -14,8 +15,8 @@ export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "update
};
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (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<string | undefined>(undefined);
@@ -25,6 +26,7 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
const [editorContainer, setEditorContainer] = useState<HTMLDivElement | null>(null);
const imageComponentRef = useRef<HTMLDivElement>(null);
const hasRetriedOnMount = useRef(false);
useEffect(() => {
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
@@ -60,10 +62,56 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
getImageSource();
}, [imgNodeSrc, extension.options]);
// Memoize the duplication function to prevent unnecessary re-runs
const handleDuplication = useCallback(async () => {
if (status !== "duplicating" || !extension.options.duplicateImage || !imgNodeSrc) {
return;
}
try {
hasRetriedOnMount.current = true;
const newAssetId = await extension.options.duplicateImage!(imgNodeSrc);
// Update node with new source and success status
updateAttributes({
src: newAssetId,
status: "duplicated",
});
} catch (error) {
console.error("Failed to duplicate image:", error);
// Update status to failed
updateAttributes({ status: "duplication-failed" });
}
}, [status, imgNodeSrc, extension.options.duplicateImage, updateAttributes]);
// Handle image duplication when status is duplicating
useEffect(() => {
if (status === "duplicating") {
handleDuplication();
}
}, [status, handleDuplication]);
useEffect(() => {
if (isImageDuplicationFailed(status) && !hasRetriedOnMount.current && imgNodeSrc) {
hasRetriedOnMount.current = true;
// Add a small delay before retrying to avoid immediate retries
updateAttributes({ status: "duplicating" });
}
}, [status, imgNodeSrc, updateAttributes]);
useEffect(() => {
if (status === "duplicated") {
hasRetriedOnMount.current = false;
}
}, [status]);
const isDuplicationFailed = isImageDuplicationFailed(status);
const shouldShowBlock = (isUploaded || imageFromFileSystem) && !failedToLoadImage;
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
{shouldShowBlock && !isDuplicationFailed ? (
<CustomImageBlock
editorContainer={editorContainer}
imageFromFileSystem={imageFromFileSystem}
@@ -76,6 +124,7 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
) : (
<CustomImageUploader
failedToLoadImage={failedToLoadImage}
isDuplicationFailed={isDuplicationFailed}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={editor.storage.imageComponent?.maxFileSize}
setIsUploaded={setIsUploaded}
@@ -1,4 +1,4 @@
import { ImageIcon } from "lucide-react";
import { ImageIcon, RotateCcw } from "lucide-react";
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
// plane imports
import { cn } from "@plane/utils";
@@ -15,6 +15,7 @@ import type { CustomImageNodeViewProps } from "./node-view";
type CustomImageUploaderProps = CustomImageNodeViewProps & {
failedToLoadImage: boolean;
isDuplicationFailed: boolean;
loadImageFromFileSystem: (file: string) => void;
maxFileSize: number;
setIsUploaded: (isUploaded: boolean) => void;
@@ -32,6 +33,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
selected,
setIsUploaded,
updateAttributes,
isDuplicationFailed,
} = props;
// refs
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -49,6 +51,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
// Update the node view's src attribute post upload
updateAttributes({
src: url,
status: "uploaded",
});
imageComponentImageFileMap?.delete(imageEntityId);
@@ -108,6 +111,11 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
onInvalidFile: handleInvalidFile,
onUpload,
});
useEffect(() => {
if (isImageBeingUploaded) {
updateAttributes({ status: "uploading" });
}
}, [isImageBeingUploaded, updateAttributes]);
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
editor,
@@ -160,7 +168,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const getDisplayMessage = useCallback(() => {
const isUploading = isImageBeingUploaded;
if (failedToLoadImage) {
if (failedToLoadImage || isDuplicationFailed) {
return "Error loading image";
}
@@ -173,7 +181,17 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
}
return "Add an image";
}, [draggedInside, editor.isEditable, failedToLoadImage, isImageBeingUploaded]);
}, [draggedInside, editor.isEditable, failedToLoadImage, isImageBeingUploaded, isDuplicationFailed]);
const handleRetryClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (isDuplicationFailed && editor.isEditable) {
updateAttributes({ status: "duplicating" });
}
},
[isDuplicationFailed, editor.isEditable, updateAttributes]
);
return (
<div
@@ -184,10 +202,10 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
"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 || isDuplicationFailed,
"hover:text-red-500": (failedToLoadImage || isDuplicationFailed) && editor.isEditable,
"bg-red-500/10": (failedToLoadImage || isDuplicationFailed) && selected,
"hover:bg-red-500/10": (failedToLoadImage || isDuplicationFailed) && selected && editor.isEditable,
}
)}
onDrop={onDrop}
@@ -195,13 +213,23 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
onDragLeave={onDragLeave}
contentEditable={false}
onClick={() => {
if (!failedToLoadImage && editor.isEditable) {
if (!failedToLoadImage && editor.isEditable && !isDuplicationFailed) {
fileInputRef.current?.click();
}
}}
>
<ImageIcon className="size-4" />
<div className="text-base font-medium">{getDisplayMessage()}</div>
<div className="text-base font-medium flex-1">{getDisplayMessage()}</div>
{isDuplicationFailed && editor.isEditable && (
<button
onClick={handleRetryClick}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-custom-text-200 bg-custom-background-80 hover:bg-custom-background-70 border border-custom-border-300 hover:border-custom-border-200 rounded-md transition-all duration-200 ease-in-out"
title="Retry duplication"
>
<RotateCcw className="size-3" />
Retry
</button>
)}
<input
className="size-0 overflow-hidden"
ref={fileInputRef}
@@ -1,3 +1,4 @@
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// constants
@@ -29,13 +30,14 @@ export const CustomImageExtension = (props: Props) => {
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,6 +95,7 @@ export const CustomImageExtension = (props: Props) => {
const attributes = {
id: fileId,
status: "pending",
};
if (props.pos) {
@@ -115,6 +118,64 @@ export const CustomImageExtension = (props: Props) => {
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("paste-image-duplication"),
props: {
handlePaste: (view, event, _slice) => {
if (!event.clipboardData) return false;
const htmlContent = event.clipboardData.getData("text/html");
if (!htmlContent || htmlContent.includes('data-duplicated="true"')) return false;
const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlContent;
const imageComponents = tempDiv.querySelectorAll("image-component");
if (imageComponents.length === 0) return false;
event.preventDefault();
event.stopPropagation();
let updatedHtml = htmlContent;
imageComponents.forEach((component) => {
const src = component.getAttribute("src");
if (src) {
const newId = uuidv4();
const originalTag = component.outerHTML;
const modifiedTag = originalTag
.replace(`<image-component`, `<image-component status="duplicating"`)
.replace(/id="[^"]*"/, `id="${newId}"`);
updatedHtml = updatedHtml.replace(originalTag, modifiedTag);
}
});
updatedHtml = updatedHtml.replace(
"<meta charset='utf-8'>",
"<meta charset='utf-8' data-duplicated=\"true\">"
);
const newDataTransfer = new DataTransfer();
newDataTransfer.setData("text/html", updatedHtml);
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;
},
},
}),
];
},
addNodeView() {
return ReactNodeViewRenderer((props) => (
@@ -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 type TCustomImageStatus =
| "pending"
| "uploading"
| "uploaded"
| "duplicating"
| "duplicated"
| "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]: TCustomImageStatus;
};
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 = {
@@ -1,7 +1,13 @@
import type { Editor } from "@tiptap/core";
import { AlignCenter, AlignLeft, AlignRight, type LucideIcon } from "lucide-react";
// local imports
import { ECustomImageAttributeNames, TCustomImageAlignment, type Pixel, type TCustomImageAttributes } from "./types";
import {
ECustomImageAttributeNames,
TCustomImageAlignment,
TCustomImageStatus,
type Pixel,
type TCustomImageAttributes,
} from "./types";
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
[ECustomImageAttributeNames.SOURCE]: null,
@@ -10,6 +16,7 @@ export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
[ECustomImageAttributeNames.HEIGHT]: "auto",
[ECustomImageAttributeNames.ASPECT_RATIO]: null,
[ECustomImageAttributeNames.ALIGNMENT]: "left",
[ECustomImageAttributeNames.STATUS]: "pending",
};
export const getImageComponentImageFileMap = (editor: Editor) => editor.storage.imageComponent?.fileMap;
@@ -51,3 +58,10 @@ export const IMAGE_ALIGNMENT_OPTIONS: {
},
];
export const getImageBlockId = (id: string) => `editor-image-block-${id}`;
export const isImageDuplicating = (status: TCustomImageStatus) => status === "duplicating";
export const isImageDuplicationComplete = (status: TCustomImageStatus) =>
status === "duplicated" || status === "duplication-failed";
export const isImageDuplicationFailed = (status: TCustomImageStatus) => status === "duplication-failed";
+1
View File
@@ -11,6 +11,7 @@ export type TFileHandler = {
getAssetSrc: (path: string) => Promise<string>;
restore: (assetSrc: string) => Promise<void>;
upload: (blockId: string, file: File) => Promise<string>;
duplicate?: (assetId: string) => Promise<string>;
validation: {
/**
* @description max file size in bytes