mirror of
https://github.com/makeplane/plane.git
synced 2025-12-16 20:07:56 +01:00
[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 <narayan3119@gmail.com> Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Co-authored-by: VipinDevelops <vipinchaudhary1809@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
d462546055
commit
83679806fd
@@ -13,6 +13,7 @@ from plane.app.views import (
|
|||||||
ProjectAssetEndpoint,
|
ProjectAssetEndpoint,
|
||||||
ProjectBulkAssetEndpoint,
|
ProjectBulkAssetEndpoint,
|
||||||
AssetCheckEndpoint,
|
AssetCheckEndpoint,
|
||||||
|
DuplicateAssetEndpoint,
|
||||||
WorkspaceAssetDownloadEndpoint,
|
WorkspaceAssetDownloadEndpoint,
|
||||||
ProjectAssetDownloadEndpoint,
|
ProjectAssetDownloadEndpoint,
|
||||||
)
|
)
|
||||||
@@ -91,6 +92,11 @@ urlpatterns = [
|
|||||||
AssetCheckEndpoint.as_view(),
|
AssetCheckEndpoint.as_view(),
|
||||||
name="asset-check",
|
name="asset-check",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"assets/v2/workspaces/<str:slug>/duplicate-assets/<uuid:asset_id>/",
|
||||||
|
DuplicateAssetEndpoint.as_view(),
|
||||||
|
name="duplicate-assets",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"assets/v2/workspaces/<str:slug>/download/<uuid:asset_id>/",
|
"assets/v2/workspaces/<str:slug>/download/<uuid:asset_id>/",
|
||||||
WorkspaceAssetDownloadEndpoint.as_view(),
|
WorkspaceAssetDownloadEndpoint.as_view(),
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ from .asset.v2 import (
|
|||||||
ProjectAssetEndpoint,
|
ProjectAssetEndpoint,
|
||||||
ProjectBulkAssetEndpoint,
|
ProjectBulkAssetEndpoint,
|
||||||
AssetCheckEndpoint,
|
AssetCheckEndpoint,
|
||||||
|
DuplicateAssetEndpoint,
|
||||||
WorkspaceAssetDownloadEndpoint,
|
WorkspaceAssetDownloadEndpoint,
|
||||||
ProjectAssetDownloadEndpoint,
|
ProjectAssetDownloadEndpoint,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from plane.settings.storage import S3Storage
|
|||||||
from plane.app.permissions import allow_permission, ROLE
|
from plane.app.permissions import allow_permission, ROLE
|
||||||
from plane.utils.cache import invalidate_cache_directly
|
from plane.utils.cache import invalidate_cache_directly
|
||||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||||
|
from plane.throttles.asset import AssetRateThrottle
|
||||||
|
|
||||||
|
|
||||||
class UserAssetsV2Endpoint(BaseAPIView):
|
class UserAssetsV2Endpoint(BaseAPIView):
|
||||||
@@ -44,7 +45,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
|||||||
# Save the new avatar
|
# Save the new avatar
|
||||||
user.avatar_asset_id = asset_id
|
user.avatar_asset_id = asset_id
|
||||||
user.save()
|
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(
|
invalidate_cache_directly(
|
||||||
path="/api/users/me/settings/",
|
path="/api/users/me/settings/",
|
||||||
url_params=False,
|
url_params=False,
|
||||||
@@ -62,7 +65,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
|||||||
# Save the new cover image
|
# Save the new cover image
|
||||||
user.cover_image_asset_id = asset_id
|
user.cover_image_asset_id = asset_id
|
||||||
user.save()
|
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(
|
invalidate_cache_directly(
|
||||||
path="/api/users/me/settings/",
|
path="/api/users/me/settings/",
|
||||||
url_params=False,
|
url_params=False,
|
||||||
@@ -78,7 +83,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
|||||||
user = User.objects.get(id=asset.user_id)
|
user = User.objects.get(id=asset.user_id)
|
||||||
user.avatar_asset_id = None
|
user.avatar_asset_id = None
|
||||||
user.save()
|
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(
|
invalidate_cache_directly(
|
||||||
path="/api/users/me/settings/",
|
path="/api/users/me/settings/",
|
||||||
url_params=False,
|
url_params=False,
|
||||||
@@ -91,7 +98,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
|||||||
user = User.objects.get(id=asset.user_id)
|
user = User.objects.get(id=asset.user_id)
|
||||||
user.cover_image_asset_id = None
|
user.cover_image_asset_id = None
|
||||||
user.save()
|
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(
|
invalidate_cache_directly(
|
||||||
path="/api/users/me/settings/",
|
path="/api/users/me/settings/",
|
||||||
url_params=False,
|
url_params=False,
|
||||||
@@ -151,7 +160,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
|||||||
# Get the presigned URL
|
# Get the presigned URL
|
||||||
storage = S3Storage(request=request)
|
storage = S3Storage(request=request)
|
||||||
# Generate a presigned URL to share an S3 object
|
# 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 the presigned URL
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@@ -188,7 +199,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
|||||||
asset.is_deleted = True
|
asset.is_deleted = True
|
||||||
asset.deleted_at = timezone.now()
|
asset.deleted_at = timezone.now()
|
||||||
# get the entity and save the asset id for the request field
|
# 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"])
|
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@@ -252,14 +265,18 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
|||||||
workspace.logo = ""
|
workspace.logo = ""
|
||||||
workspace.logo_asset_id = asset_id
|
workspace.logo_asset_id = asset_id
|
||||||
workspace.save()
|
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(
|
invalidate_cache_directly(
|
||||||
path="/api/users/me/workspaces/",
|
path="/api/users/me/workspaces/",
|
||||||
url_params=False,
|
url_params=False,
|
||||||
user=True,
|
user=True,
|
||||||
request=request,
|
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
|
return
|
||||||
|
|
||||||
# Project Cover
|
# Project Cover
|
||||||
@@ -286,14 +303,18 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
|||||||
return
|
return
|
||||||
workspace.logo_asset_id = None
|
workspace.logo_asset_id = None
|
||||||
workspace.save()
|
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(
|
invalidate_cache_directly(
|
||||||
path="/api/users/me/workspaces/",
|
path="/api/users/me/workspaces/",
|
||||||
url_params=False,
|
url_params=False,
|
||||||
user=True,
|
user=True,
|
||||||
request=request,
|
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
|
return
|
||||||
# Project Cover
|
# Project Cover
|
||||||
elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
|
elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
|
||||||
@@ -354,13 +375,17 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
|||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
entity_type=entity_type,
|
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
|
# Get the presigned URL
|
||||||
storage = S3Storage(request=request)
|
storage = S3Storage(request=request)
|
||||||
# Generate a presigned URL to share an S3 object
|
# 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 the presigned URL
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@@ -397,7 +422,9 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
|||||||
asset.is_deleted = True
|
asset.is_deleted = True
|
||||||
asset.deleted_at = timezone.now()
|
asset.deleted_at = timezone.now()
|
||||||
# get the entity and save the asset id for the request field
|
# 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"])
|
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@@ -560,7 +587,9 @@ class ProjectAssetEndpoint(BaseAPIView):
|
|||||||
# Get the presigned URL
|
# Get the presigned URL
|
||||||
storage = S3Storage(request=request)
|
storage = S3Storage(request=request)
|
||||||
# Generate a presigned URL to share an S3 object
|
# 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 the presigned URL
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@@ -590,7 +619,9 @@ class ProjectAssetEndpoint(BaseAPIView):
|
|||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||||
def delete(self, request, slug, project_id, pk):
|
def delete(self, request, slug, project_id, pk):
|
||||||
# Get the asset
|
# 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
|
# Check deleted assets
|
||||||
asset.is_deleted = True
|
asset.is_deleted = True
|
||||||
asset.deleted_at = timezone.now()
|
asset.deleted_at = timezone.now()
|
||||||
@@ -601,7 +632,9 @@ class ProjectAssetEndpoint(BaseAPIView):
|
|||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||||
def get(self, request, slug, project_id, pk):
|
def get(self, request, slug, project_id, pk):
|
||||||
# get the asset id
|
# 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
|
# Check if the asset is uploaded
|
||||||
if not asset.is_uploaded:
|
if not asset.is_uploaded:
|
||||||
@@ -634,7 +667,9 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
# Check if the asset ids are provided
|
# Check if the asset ids are provided
|
||||||
if not asset_ids:
|
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
|
# get the asset id
|
||||||
assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug)
|
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")
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||||
def get(self, request, slug, asset_id):
|
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)
|
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):
|
class WorkspaceAssetDownloadEndpoint(BaseAPIView):
|
||||||
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
|
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,14 @@ MIDDLEWARE = [
|
|||||||
|
|
||||||
# Rest Framework settings
|
# Rest Framework settings
|
||||||
REST_FRAMEWORK = {
|
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_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||||
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
||||||
"DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
|
"DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
|
||||||
|
|||||||
11
apps/api/plane/throttles/asset.py
Normal file
11
apps/api/plane/throttles/asset.py
Normal 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}"
|
||||||
@@ -58,6 +58,10 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
|
|||||||
await sitesFileService.restoreNewAsset(anchor, src);
|
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: {
|
validation: {
|
||||||
maxFileSize: MAX_FILE_SIZE,
|
maxFileSize: MAX_FILE_SIZE,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function PageDetailsPage({ params }: Route.ComponentProps) {
|
|||||||
storeType,
|
storeType,
|
||||||
});
|
});
|
||||||
const { getWorkspaceBySlug } = useWorkspace();
|
const { getWorkspaceBySlug } = useWorkspace();
|
||||||
const { uploadEditorAsset } = useEditorAsset();
|
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
|
||||||
// derived values
|
// derived values
|
||||||
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug)?.id ?? "") : "";
|
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug)?.id ?? "") : "";
|
||||||
const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {};
|
const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {};
|
||||||
@@ -115,11 +115,21 @@ function PageDetailsPage({ params }: Route.ComponentProps) {
|
|||||||
});
|
});
|
||||||
return asset_id;
|
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,
|
workspaceId,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
[getEditorFileHandlers, id, uploadEditorAsset, projectId, workspaceId, workspaceSlug]
|
[getEditorFileHandlers, projectId, workspaceId, workspaceSlug, uploadEditorAsset, id, duplicateEditorAsset]
|
||||||
);
|
);
|
||||||
|
|
||||||
const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo(
|
const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo(
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export const CommentCardEditForm: React.FC<Props> = observer((props) => {
|
|||||||
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id);
|
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id);
|
||||||
return asset_id;
|
return asset_id;
|
||||||
}}
|
}}
|
||||||
|
duplicateFile={async (assetId: string) => {
|
||||||
|
const { asset_id } = await activityOperations.duplicateCommentAsset(assetId, comment.id);
|
||||||
|
return asset_id;
|
||||||
|
}}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
parentClassName="p-2"
|
parentClassName="p-2"
|
||||||
displayConfig={{
|
displayConfig={{
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ export const CommentCreate: FC<TCommentCreate> = observer((props) => {
|
|||||||
setUploadedAssetIds((prev) => [...prev, asset_id]);
|
setUploadedAssetIds((prev) => [...prev, asset_id]);
|
||||||
return 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}
|
showToolbarInitially={showToolbarInitially}
|
||||||
parentClassName="p-2"
|
parentClassName="p-2"
|
||||||
displayConfig={{
|
displayConfig={{
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type DocumentEditorWrapperProps = MakeOptional<
|
|||||||
editable: true;
|
editable: true;
|
||||||
searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
|
searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
|
||||||
uploadFile: TFileHandler["upload"];
|
uploadFile: TFileHandler["upload"];
|
||||||
|
duplicateFile: TFileHandler["duplicate"];
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ export const DocumentEditor = forwardRef<EditorRefApi, DocumentEditorWrapperProp
|
|||||||
fileHandler={getEditorFileHandlers({
|
fileHandler={getEditorFileHandlers({
|
||||||
projectId,
|
projectId,
|
||||||
uploadFile: editable ? props.uploadFile : async () => "",
|
uploadFile: editable ? props.uploadFile : async () => "",
|
||||||
|
duplicateFile: editable ? props.duplicateFile : async () => "",
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type LiteTextEditorWrapperProps = MakeOptional<
|
|||||||
| {
|
| {
|
||||||
editable: true;
|
editable: true;
|
||||||
uploadFile: TFileHandler["upload"];
|
uploadFile: TFileHandler["upload"];
|
||||||
|
duplicateFile: TFileHandler["duplicate"];
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -127,6 +128,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
|||||||
fileHandler={getEditorFileHandlers({
|
fileHandler={getEditorFileHandlers({
|
||||||
projectId,
|
projectId,
|
||||||
uploadFile: editable ? props.uploadFile : async () => "",
|
uploadFile: editable ? props.uploadFile : async () => "",
|
||||||
|
duplicateFile: editable ? props.duplicateFile : async () => "",
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export const DescriptionInput: React.FC<Props> = observer((props) => {
|
|||||||
const hasUnsavedChanges = useRef(false);
|
const hasUnsavedChanges = useRef(false);
|
||||||
// store hooks
|
// store hooks
|
||||||
const { getWorkspaceBySlug } = useWorkspace();
|
const { getWorkspaceBySlug } = useWorkspace();
|
||||||
const { uploadEditorAsset } = useEditorAsset();
|
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
|
||||||
// derived values
|
// derived values
|
||||||
const workspaceDetails = getWorkspaceBySlug(workspaceSlug);
|
const workspaceDetails = getWorkspaceBySlug(workspaceSlug);
|
||||||
// translation
|
// translation
|
||||||
@@ -240,6 +240,19 @@ export const DescriptionInput: React.FC<Props> = observer((props) => {
|
|||||||
throw new Error("Asset upload failed. Please try again later.");
|
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.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type RichTextEditorWrapperProps = MakeOptional<
|
|||||||
editable: true;
|
editable: true;
|
||||||
searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
|
searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
|
||||||
uploadFile: TFileHandler["upload"];
|
uploadFile: TFileHandler["upload"];
|
||||||
|
duplicateFile: TFileHandler["duplicate"];
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
|||||||
fileHandler={getEditorFileHandlers({
|
fileHandler={getEditorFileHandlers({
|
||||||
projectId,
|
projectId,
|
||||||
uploadFile: editable ? props.uploadFile : async () => "",
|
uploadFile: editable ? props.uploadFile : async () => "",
|
||||||
|
duplicateFile: editable ? props.duplicateFile : async () => "",
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface StickyEditorWrapperProps
|
|||||||
showToolbarInitially?: boolean;
|
showToolbarInitially?: boolean;
|
||||||
showToolbar?: boolean;
|
showToolbar?: boolean;
|
||||||
uploadFile: TFileHandler["upload"];
|
uploadFile: TFileHandler["upload"];
|
||||||
|
duplicateFile: TFileHandler["duplicate"];
|
||||||
parentClassName?: string;
|
parentClassName?: string;
|
||||||
handleColorChange: (data: Partial<TSticky>) => Promise<void>;
|
handleColorChange: (data: Partial<TSticky>) => Promise<void>;
|
||||||
handleDelete: () => void;
|
handleDelete: () => void;
|
||||||
@@ -48,6 +49,7 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
|
|||||||
showToolbar = true,
|
showToolbar = true,
|
||||||
parentClassName = "",
|
parentClassName = "",
|
||||||
uploadFile,
|
uploadFile,
|
||||||
|
duplicateFile,
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
@@ -83,6 +85,7 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
|
|||||||
fileHandler={getEditorFileHandlers({
|
fileHandler={getEditorFileHandlers({
|
||||||
projectId,
|
projectId,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
|
duplicateFile,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
|
|||||||
// i18n
|
// i18n
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// store hooks
|
// store hooks
|
||||||
const { uploadEditorAsset } = useEditorAsset();
|
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
|
||||||
const { loader } = useProjectInbox();
|
const { loader } = useProjectInbox();
|
||||||
const { isMobile } = usePlatformOS();
|
const { isMobile } = usePlatformOS();
|
||||||
|
|
||||||
@@ -102,6 +102,20 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
|
|||||||
throw new Error("Asset upload failed. Please try again later.");
|
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.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const useCommentOperations = (
|
|||||||
} = useIssueDetail();
|
} = useIssueDetail();
|
||||||
const { getProjectById } = useProject();
|
const { getProjectById } = useProject();
|
||||||
const { getUserDetails } = useMember();
|
const { getUserDetails } = useMember();
|
||||||
const { uploadEditorAsset } = useEditorAsset();
|
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
|
||||||
const { data: currentUser } = useUser();
|
const { data: currentUser } = useUser();
|
||||||
// derived values
|
// derived values
|
||||||
const issueDetails = issueId ? getIssueById(issueId) : undefined;
|
const issueDetails = issueId ? getIssueById(issueId) : undefined;
|
||||||
@@ -136,6 +136,21 @@ export const useCommentOperations = (
|
|||||||
throw new Error(t("issue.comments.upload.error"));
|
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) => {
|
addCommentReaction: async (commentId, reaction) => {
|
||||||
try {
|
try {
|
||||||
if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields");
|
if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields");
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob
|
|||||||
const { getWorkspaceBySlug } = useWorkspace();
|
const { getWorkspaceBySlug } = useWorkspace();
|
||||||
const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id ?? "";
|
const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id ?? "";
|
||||||
const { config } = useInstance();
|
const { config } = useInstance();
|
||||||
const { uploadEditorAsset } = useEditorAsset();
|
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
|
||||||
// platform
|
// platform
|
||||||
const { isMobile } = usePlatformOS();
|
const { isMobile } = usePlatformOS();
|
||||||
|
|
||||||
@@ -221,6 +221,21 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob
|
|||||||
throw new Error("Asset upload failed. Please try again later.");
|
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.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const StickyInput = (props: TProps) => {
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
uploadFile={async () => ""}
|
uploadFile={async () => ""}
|
||||||
|
duplicateFile={async () => ""}
|
||||||
showToolbar={showToolbar}
|
showToolbar={showToolbar}
|
||||||
parentClassName="border-none p-0"
|
parentClassName="border-none p-0"
|
||||||
handleDelete={handleDelete}
|
handleDelete={handleDelete}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const fileService = new FileService();
|
|||||||
type TArgs = {
|
type TArgs = {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
uploadFile: TFileHandler["upload"];
|
uploadFile: TFileHandler["upload"];
|
||||||
|
duplicateFile: TFileHandler["duplicate"];
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
};
|
};
|
||||||
@@ -27,7 +28,7 @@ export const useEditorConfig = () => {
|
|||||||
|
|
||||||
const getEditorFileHandlers = useCallback(
|
const getEditorFileHandlers = useCallback(
|
||||||
(args: TArgs): TFileHandler => {
|
(args: TArgs): TFileHandler => {
|
||||||
const { projectId, uploadFile, workspaceId, workspaceSlug } = args;
|
const { projectId, uploadFile, duplicateFile, workspaceId, workspaceSlug } = args;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
assetsUploadStatus: assetsUploadPercentage,
|
assetsUploadStatus: assetsUploadPercentage,
|
||||||
@@ -85,6 +86,7 @@ export const useEditorConfig = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
upload: uploadFile,
|
upload: uploadFile,
|
||||||
|
duplicate: duplicateFile,
|
||||||
validation: {
|
validation: {
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { AxiosRequestConfig } from "axios";
|
|||||||
// plane types
|
// plane types
|
||||||
import { API_BASE_URL } from "@plane/constants";
|
import { API_BASE_URL } from "@plane/constants";
|
||||||
import { getFileMetaDataForUpload, generateFileUploadPayload } from "@plane/services";
|
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";
|
import { getAssetIdFromUrl } from "@plane/utils";
|
||||||
// helpers
|
// helpers
|
||||||
// services
|
// services
|
||||||
@@ -281,4 +281,20 @@ export class FileService extends APIService {
|
|||||||
throw err?.response?.data;
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"
|
|||||||
import { computedFn } from "mobx-utils";
|
import { computedFn } from "mobx-utils";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
// plane types
|
// plane types
|
||||||
import type { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
|
import type { EFileAssetType, TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
|
||||||
// services
|
// services
|
||||||
import { FileService } from "@/services/file.service";
|
import { FileService } from "@/services/file.service";
|
||||||
import type { TAttachmentUploadStatus } from "../issue/issue-details/attachment.store";
|
import type { TAttachmentUploadStatus } from "../issue/issue-details/attachment.store";
|
||||||
@@ -27,6 +27,19 @@ export interface IEditorAssetStore {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
}) => Promise<TFileSignedURLResponse>;
|
}) => Promise<TFileSignedURLResponse>;
|
||||||
|
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 {
|
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 };
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
40
packages/editor/src/ce/helpers/asset-duplication.ts
Normal file
40
packages/editor/src/ce/helpers/asset-duplication.ts
Normal file
@@ -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<string, AssetDuplicationHandler> = {
|
||||||
|
"image-component": imageComponentHandler,
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@ import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from
|
|||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// local imports
|
// local imports
|
||||||
import type { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
|
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 type { CustomImageNodeViewProps } from "./node-view";
|
||||||
import { ImageToolbarRoot } from "./toolbar";
|
import { ImageToolbarRoot } from "./toolbar";
|
||||||
import { ImageUploadStatus } from "./upload-status";
|
import { ImageUploadStatus } from "./upload-status";
|
||||||
@@ -42,6 +42,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
|||||||
aspectRatio: nodeAspectRatio,
|
aspectRatio: nodeAspectRatio,
|
||||||
src: imgNodeSrc,
|
src: imgNodeSrc,
|
||||||
alignment: nodeAlignment,
|
alignment: nodeAlignment,
|
||||||
|
status,
|
||||||
} = node.attrs;
|
} = node.attrs;
|
||||||
// states
|
// states
|
||||||
const [size, setSize] = useState<TCustomImageSize>({
|
const [size, setSize] = useState<TCustomImageSize>({
|
||||||
@@ -202,15 +203,16 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
|||||||
[editor, getPos, isTouchDevice]
|
[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)
|
// 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
|
// 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;
|
const showImageLoader =
|
||||||
// show the image upload status only when the resolvedImageSrc is not ready
|
(!resolvedImageSrc && !isDuplicating) || !initialResizeComplete || hasErroredOnFirstLoad || isDuplicating; // show the image upload status only when the resolvedImageSrc is not ready
|
||||||
const showUploadStatus = !resolvedImageSrc;
|
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)
|
// 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)
|
// 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
|
// show the preview image from the file system if the remote image's src is not set
|
||||||
const displayedImageSrc = resolvedImageSrc || imageFromFileSystem;
|
const displayedImageSrc = resolvedImageSrc || imageFromFileSystem;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { NodeViewProps } from "@tiptap/react";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
// local imports
|
// local imports
|
||||||
import type { CustomImageExtensionType, TCustomImageAttributes } from "../types";
|
import type { CustomImageExtensionType, TCustomImageAttributes } from "../types";
|
||||||
|
import { ECustomImageStatus } from "../types";
|
||||||
|
import { hasImageDuplicationFailed } from "../utils";
|
||||||
import { CustomImageBlock } from "./block";
|
import { CustomImageBlock } from "./block";
|
||||||
import { CustomImageUploader } from "./uploader";
|
import { CustomImageUploader } from "./uploader";
|
||||||
|
|
||||||
@@ -15,8 +17,8 @@ export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "update
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
|
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
|
||||||
const { editor, extension, node } = props;
|
const { editor, extension, node, updateAttributes } = props;
|
||||||
const { src: imgNodeSrc } = node.attrs;
|
const { src: imgNodeSrc, status } = node.attrs;
|
||||||
|
|
||||||
const [isUploaded, setIsUploaded] = useState(!!imgNodeSrc);
|
const [isUploaded, setIsUploaded] = useState(!!imgNodeSrc);
|
||||||
const [resolvedSrc, setResolvedSrc] = useState<string | undefined>(undefined);
|
const [resolvedSrc, setResolvedSrc] = useState<string | undefined>(undefined);
|
||||||
@@ -26,6 +28,8 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
|
|||||||
|
|
||||||
const [editorContainer, setEditorContainer] = useState<HTMLDivElement | null>(null);
|
const [editorContainer, setEditorContainer] = useState<HTMLDivElement | null>(null);
|
||||||
const imageComponentRef = useRef<HTMLDivElement>(null);
|
const imageComponentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hasRetriedOnMount = useRef(false);
|
||||||
|
const isDuplicatingRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
|
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
|
||||||
@@ -61,10 +65,66 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
|
|||||||
getImageSource();
|
getImageSource();
|
||||||
}, [imgNodeSrc, extension.options]);
|
}, [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 (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
|
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
|
||||||
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
|
{shouldShowBlock && !hasDuplicationFailed ? (
|
||||||
<CustomImageBlock
|
<CustomImageBlock
|
||||||
editorContainer={editorContainer}
|
editorContainer={editorContainer}
|
||||||
imageFromFileSystem={imageFromFileSystem}
|
imageFromFileSystem={imageFromFileSystem}
|
||||||
@@ -77,6 +137,7 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
|
|||||||
) : (
|
) : (
|
||||||
<CustomImageUploader
|
<CustomImageUploader
|
||||||
failedToLoadImage={failedToLoadImage}
|
failedToLoadImage={failedToLoadImage}
|
||||||
|
hasDuplicationFailed={hasDuplicationFailed}
|
||||||
loadImageFromFileSystem={setImageFromFileSystem}
|
loadImageFromFileSystem={setImageFromFileSystem}
|
||||||
maxFileSize={editor.storage.imageComponent?.maxFileSize}
|
maxFileSize={editor.storage.imageComponent?.maxFileSize}
|
||||||
setIsUploaded={setIsUploaded}
|
setIsUploaded={setIsUploaded}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ImageIcon } from "lucide-react";
|
import { ImageIcon, RotateCcw } from "lucide-react";
|
||||||
import type { ChangeEvent } from "react";
|
import type { ChangeEvent } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
// plane imports
|
// plane imports
|
||||||
@@ -11,11 +11,13 @@ import type { EFileError } from "@/helpers/file";
|
|||||||
// hooks
|
// hooks
|
||||||
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
|
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||||
// local imports
|
// local imports
|
||||||
|
import { ECustomImageStatus } from "../types";
|
||||||
import { getImageComponentImageFileMap } from "../utils";
|
import { getImageComponentImageFileMap } from "../utils";
|
||||||
import type { CustomImageNodeViewProps } from "./node-view";
|
import type { CustomImageNodeViewProps } from "./node-view";
|
||||||
|
|
||||||
type CustomImageUploaderProps = CustomImageNodeViewProps & {
|
type CustomImageUploaderProps = CustomImageNodeViewProps & {
|
||||||
failedToLoadImage: boolean;
|
failedToLoadImage: boolean;
|
||||||
|
hasDuplicationFailed: boolean;
|
||||||
loadImageFromFileSystem: (file: string) => void;
|
loadImageFromFileSystem: (file: string) => void;
|
||||||
maxFileSize: number;
|
maxFileSize: number;
|
||||||
setIsUploaded: (isUploaded: boolean) => void;
|
setIsUploaded: (isUploaded: boolean) => void;
|
||||||
@@ -33,6 +35,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
|||||||
selected,
|
selected,
|
||||||
setIsUploaded,
|
setIsUploaded,
|
||||||
updateAttributes,
|
updateAttributes,
|
||||||
|
hasDuplicationFailed,
|
||||||
} = props;
|
} = props;
|
||||||
// refs
|
// refs
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -50,6 +53,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
|||||||
// Update the node view's src attribute post upload
|
// Update the node view's src attribute post upload
|
||||||
updateAttributes({
|
updateAttributes({
|
||||||
src: url,
|
src: url,
|
||||||
|
status: ECustomImageStatus.UPLOADED,
|
||||||
});
|
});
|
||||||
imageComponentImageFileMap?.delete(imageEntityId);
|
imageComponentImageFileMap?.delete(imageEntityId);
|
||||||
|
|
||||||
@@ -84,8 +88,11 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const uploadImageEditorCommand = useCallback(
|
const uploadImageEditorCommand = useCallback(
|
||||||
async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file),
|
async (file: File) => {
|
||||||
[extension.options, imageEntityId]
|
updateAttributes({ status: ECustomImageStatus.UPLOADING });
|
||||||
|
return await extension.options.uploadImage?.(imageEntityId ?? "", file);
|
||||||
|
},
|
||||||
|
[extension.options, imageEntityId, updateAttributes]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleProgressStatus = useCallback(
|
const handleProgressStatus = useCallback(
|
||||||
@@ -161,7 +168,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
|||||||
|
|
||||||
const getDisplayMessage = useCallback(() => {
|
const getDisplayMessage = useCallback(() => {
|
||||||
const isUploading = isImageBeingUploaded;
|
const isUploading = isImageBeingUploaded;
|
||||||
if (failedToLoadImage) {
|
if (failedToLoadImage || hasDuplicationFailed) {
|
||||||
return "Error loading image";
|
return "Error loading image";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +181,17 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return "Add an image";
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -185,10 +202,10 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
|||||||
"bg-custom-background-80 text-custom-text-200": draggedInside && editor.isEditable,
|
"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":
|
"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,
|
selected && editor.isEditable,
|
||||||
"text-red-500 cursor-default": failedToLoadImage,
|
"text-red-500 cursor-default": failedToLoadImage || hasDuplicationFailed,
|
||||||
"hover:text-red-500": failedToLoadImage && editor.isEditable,
|
"hover:text-red-500": (failedToLoadImage || hasDuplicationFailed) && editor.isEditable,
|
||||||
"bg-red-500/10": failedToLoadImage && selected,
|
"bg-red-500/10": (failedToLoadImage || hasDuplicationFailed) && selected,
|
||||||
"hover:bg-red-500/10": failedToLoadImage && selected && editor.isEditable,
|
"hover:bg-red-500/10": (failedToLoadImage || hasDuplicationFailed) && selected && editor.isEditable,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
@@ -196,13 +213,29 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
|||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!failedToLoadImage && editor.isEditable) {
|
if (!failedToLoadImage && editor.isEditable && !hasDuplicationFailed) {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ImageIcon className="size-4" />
|
<ImageIcon className="size-4" />
|
||||||
<div className="text-base font-medium">{getDisplayMessage()}</div>
|
<div className="text-base font-medium flex-1">{getDisplayMessage()}</div>
|
||||||
|
{hasDuplicationFailed && editor.isEditable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRetryClick}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 px-2 py-1 text-xs font-medium text-custom-text-300 hover:bg-custom-background-90 hover:text-custom-text-200 rounded-md transition-all duration-200 ease-in-out",
|
||||||
|
{
|
||||||
|
"hover:bg-red-500/20": selected,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
title="Retry duplication"
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
className="size-0 overflow-hidden"
|
className="size-0 overflow-hidden"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { CustomImageNodeViewProps } from "./components/node-view";
|
|||||||
import { CustomImageNodeView } from "./components/node-view";
|
import { CustomImageNodeView } from "./components/node-view";
|
||||||
import { CustomImageExtensionConfig } from "./extension-config";
|
import { CustomImageExtensionConfig } from "./extension-config";
|
||||||
import type { CustomImageExtensionOptions, CustomImageExtensionStorage } from "./types";
|
import type { CustomImageExtensionOptions, CustomImageExtensionStorage } from "./types";
|
||||||
|
import { ECustomImageAttributeNames, ECustomImageStatus } from "./types";
|
||||||
import { getImageComponentImageFileMap } from "./utils";
|
import { getImageComponentImageFileMap } from "./utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -30,13 +31,14 @@ export const CustomImageExtension = (props: Props) => {
|
|||||||
|
|
||||||
addOptions() {
|
addOptions() {
|
||||||
const upload = "upload" in fileHandler ? fileHandler.upload : undefined;
|
const upload = "upload" in fileHandler ? fileHandler.upload : undefined;
|
||||||
|
const duplicate = "duplicate" in fileHandler ? fileHandler.duplicate : undefined;
|
||||||
return {
|
return {
|
||||||
...this.parent?.(),
|
...this.parent?.(),
|
||||||
getImageDownloadSource: getAssetDownloadSrc,
|
getImageDownloadSource: getAssetDownloadSrc,
|
||||||
getImageSource: getAssetSrc,
|
getImageSource: getAssetSrc,
|
||||||
restoreImage: restoreImageFn,
|
restoreImage: restoreImageFn,
|
||||||
uploadImage: upload,
|
uploadImage: upload,
|
||||||
|
duplicateImage: duplicate,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -93,7 +95,8 @@ export const CustomImageExtension = (props: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const attributes = {
|
const attributes = {
|
||||||
id: fileId,
|
[ECustomImageAttributeNames.ID]: fileId,
|
||||||
|
[ECustomImageAttributeNames.STATUS]: ECustomImageStatus.PENDING,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (props.pos) {
|
if (props.pos) {
|
||||||
@@ -116,7 +119,6 @@ export const CustomImageExtension = (props: Props) => {
|
|||||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return ReactNodeViewRenderer((props) => (
|
return ReactNodeViewRenderer((props) => (
|
||||||
<CustomImageNodeView {...props} node={props.node as CustomImageNodeViewProps["node"]} />
|
<CustomImageNodeView {...props} node={props.node as CustomImageNodeViewProps["node"]} />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export enum ECustomImageAttributeNames {
|
|||||||
ASPECT_RATIO = "aspectRatio",
|
ASPECT_RATIO = "aspectRatio",
|
||||||
SOURCE = "src",
|
SOURCE = "src",
|
||||||
ALIGNMENT = "alignment",
|
ALIGNMENT = "alignment",
|
||||||
|
STATUS = "status",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Pixel = `${number}px`;
|
export type Pixel = `${number}px`;
|
||||||
@@ -23,6 +24,14 @@ export type TCustomImageSize = {
|
|||||||
|
|
||||||
export type TCustomImageAlignment = "left" | "center" | "right";
|
export type TCustomImageAlignment = "left" | "center" | "right";
|
||||||
|
|
||||||
|
export enum ECustomImageStatus {
|
||||||
|
PENDING = "pending",
|
||||||
|
UPLOADING = "uploading",
|
||||||
|
UPLOADED = "uploaded",
|
||||||
|
DUPLICATING = "duplicating",
|
||||||
|
DUPLICATION_FAILED = "duplication-failed",
|
||||||
|
}
|
||||||
|
|
||||||
export type TCustomImageAttributes = {
|
export type TCustomImageAttributes = {
|
||||||
[ECustomImageAttributeNames.ID]: string | null;
|
[ECustomImageAttributeNames.ID]: string | null;
|
||||||
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
|
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
|
||||||
@@ -30,6 +39,7 @@ export type TCustomImageAttributes = {
|
|||||||
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
|
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
|
||||||
[ECustomImageAttributeNames.SOURCE]: string | null;
|
[ECustomImageAttributeNames.SOURCE]: string | null;
|
||||||
[ECustomImageAttributeNames.ALIGNMENT]: TCustomImageAlignment;
|
[ECustomImageAttributeNames.ALIGNMENT]: TCustomImageAlignment;
|
||||||
|
[ECustomImageAttributeNames.STATUS]: ECustomImageStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
||||||
@@ -45,6 +55,7 @@ export type CustomImageExtensionOptions = {
|
|||||||
getImageSource: TFileHandler["getAssetSrc"];
|
getImageSource: TFileHandler["getAssetSrc"];
|
||||||
restoreImage: TFileHandler["restore"];
|
restoreImage: TFileHandler["restore"];
|
||||||
uploadImage?: TFileHandler["upload"];
|
uploadImage?: TFileHandler["upload"];
|
||||||
|
duplicateImage?: TFileHandler["duplicate"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CustomImageExtensionStorage = {
|
export type CustomImageExtensionStorage = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Editor } from "@tiptap/core";
|
|||||||
import { AlignCenter, AlignLeft, AlignRight } from "lucide-react";
|
import { AlignCenter, AlignLeft, AlignRight } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
// local imports
|
// local imports
|
||||||
import { ECustomImageAttributeNames } from "./types";
|
import { ECustomImageAttributeNames, ECustomImageStatus } from "./types";
|
||||||
import type { TCustomImageAlignment, Pixel, TCustomImageAttributes } from "./types";
|
import type { TCustomImageAlignment, Pixel, TCustomImageAttributes } from "./types";
|
||||||
|
|
||||||
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
|
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
|
||||||
@@ -12,6 +12,7 @@ export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
|
|||||||
[ECustomImageAttributeNames.HEIGHT]: "auto",
|
[ECustomImageAttributeNames.HEIGHT]: "auto",
|
||||||
[ECustomImageAttributeNames.ASPECT_RATIO]: null,
|
[ECustomImageAttributeNames.ASPECT_RATIO]: null,
|
||||||
[ECustomImageAttributeNames.ALIGNMENT]: "left",
|
[ECustomImageAttributeNames.ALIGNMENT]: "left",
|
||||||
|
[ECustomImageAttributeNames.STATUS]: ECustomImageStatus.PENDING,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getImageComponentImageFileMap = (editor: Editor) => editor.storage.imageComponent?.fileMap;
|
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 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;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { DropHandlerPlugin } from "@/plugins/drop";
|
|||||||
import { FilePlugins } from "@/plugins/file/root";
|
import { FilePlugins } from "@/plugins/file/root";
|
||||||
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
|
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
|
||||||
// types
|
// types
|
||||||
|
import { PasteAssetPlugin } from "@/plugins/paste-asset";
|
||||||
import type { IEditorProps, TEditorAsset, TFileHandler } from "@/types";
|
import type { IEditorProps, TEditorAsset, TFileHandler } from "@/types";
|
||||||
|
|
||||||
type TActiveDropbarExtensions =
|
type TActiveDropbarExtensions =
|
||||||
@@ -80,6 +81,7 @@ export const UtilityExtension = (props: Props) => {
|
|||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
editor: this.editor,
|
editor: this.editor,
|
||||||
}),
|
}),
|
||||||
|
PasteAssetPlugin(),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
77
packages/editor/src/core/plugins/paste-asset.ts
Normal file
77
packages/editor/src/core/plugins/paste-asset.ts
Normal file
@@ -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 };
|
||||||
|
};
|
||||||
@@ -27,8 +27,5 @@ export const CoreEditorProps = (props: TArgs): EditorProps => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
transformPastedHTML(html) {
|
|
||||||
return html.replace(/<img.*?>/g, "");
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type TFileHandler = {
|
|||||||
getAssetSrc: (path: string) => Promise<string>;
|
getAssetSrc: (path: string) => Promise<string>;
|
||||||
restore: (assetSrc: string) => Promise<void>;
|
restore: (assetSrc: string) => Promise<void>;
|
||||||
upload: (blockId: string, file: File) => Promise<string>;
|
upload: (blockId: string, file: File) => Promise<string>;
|
||||||
|
duplicate: (assetId: string) => Promise<string>;
|
||||||
validation: {
|
validation: {
|
||||||
/**
|
/**
|
||||||
* @description max file size in bytes
|
* @description max file size in bytes
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export type TCommentsOperations = {
|
|||||||
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
|
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
|
||||||
removeComment: (commentId: string) => Promise<void>;
|
removeComment: (commentId: string) => Promise<void>;
|
||||||
uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
|
uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
|
||||||
|
duplicateCommentAsset: (assetId: string, commentId?: string) => Promise<{ asset_id: string }>;
|
||||||
addCommentReaction: (commentId: string, reactionEmoji: string) => Promise<void>;
|
addCommentReaction: (commentId: string, reactionEmoji: string) => Promise<void>;
|
||||||
deleteCommentReaction: (commentId: string, reactionEmoji: string) => Promise<void>;
|
deleteCommentReaction: (commentId: string, reactionEmoji: string) => Promise<void>;
|
||||||
react: (commentId: string, reactionEmoji: string, userReactions: string[]) => Promise<void>;
|
react: (commentId: string, reactionEmoji: string, userReactions: string[]) => Promise<void>;
|
||||||
|
|||||||
Reference in New Issue
Block a user