[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:
Aaryan Khandelwal
2025-11-20 15:05:01 +05:30
committed by GitHub
parent d462546055
commit 83679806fd
33 changed files with 581 additions and 55 deletions

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(),

View File

@@ -107,6 +107,7 @@ from .asset.v2 import (
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
DuplicateAssetEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)

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_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."""

View File

@@ -69,7 +69,14 @@ MIDDLEWARE = [
# Rest Framework settings
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",),
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.AnonRateThrottle",),
"DEFAULT_THROTTLE_RATES": {
"anon": "30/minute",
"asset_id": "5/minute",
},
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
"DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),

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}"

View File

@@ -58,6 +58,10 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
await sitesFileService.restoreNewAsset(anchor, src);
}
},
duplicate: async (assetId: string) =>
// Duplication is not supported for sites/space app
// Return the same assetId as a fallback
assetId,
validation: {
maxFileSize: MAX_FILE_SIZE,
},

View File

@@ -46,7 +46,7 @@ function PageDetailsPage({ params }: Route.ComponentProps) {
storeType,
});
const { getWorkspaceBySlug } = useWorkspace();
const { uploadEditorAsset } = useEditorAsset();
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
// derived values
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug)?.id ?? "") : "";
const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {};
@@ -115,11 +115,21 @@ function PageDetailsPage({ params }: Route.ComponentProps) {
});
return asset_id;
},
duplicateFile: async (assetId: string) => {
const { asset_id } = await duplicateEditorAsset({
assetId,
entityId: id,
entityType: EFileAssetType.PAGE_DESCRIPTION,
projectId,
workspaceSlug,
});
return asset_id;
},
workspaceId,
workspaceSlug,
}),
}),
[getEditorFileHandlers, id, uploadEditorAsset, projectId, workspaceId, workspaceSlug]
[getEditorFileHandlers, projectId, workspaceId, workspaceSlug, uploadEditorAsset, id, duplicateEditorAsset]
);
const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo(

View File

@@ -94,6 +94,10 @@ export const CommentCardEditForm: React.FC<Props> = observer((props) => {
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id);
return asset_id;
}}
duplicateFile={async (assetId: string) => {
const { asset_id } = await activityOperations.duplicateCommentAsset(assetId, comment.id);
return asset_id;
}}
projectId={projectId}
parentClassName="p-2"
displayConfig={{

View File

@@ -133,6 +133,11 @@ export const CommentCreate: FC<TCommentCreate> = observer((props) => {
setUploadedAssetIds((prev) => [...prev, asset_id]);
return asset_id;
}}
duplicateFile={async (assetId: string) => {
const { asset_id } = await activityOperations.duplicateCommentAsset(assetId);
setUploadedAssetIds((prev) => [...prev, asset_id]);
return asset_id;
}}
showToolbarInitially={showToolbarInitially}
parentClassName="p-2"
displayConfig={{

View File

@@ -29,6 +29,7 @@ type DocumentEditorWrapperProps = MakeOptional<
editable: true;
searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
uploadFile: TFileHandler["upload"];
duplicateFile: TFileHandler["duplicate"];
}
);
@@ -71,6 +72,7 @@ export const DocumentEditor = forwardRef<EditorRefApi, DocumentEditorWrapperProp
fileHandler={getEditorFileHandlers({
projectId,
uploadFile: editable ? props.uploadFile : async () => "",
duplicateFile: editable ? props.duplicateFile : async () => "",
workspaceId,
workspaceSlug,
})}

View File

@@ -45,6 +45,7 @@ type LiteTextEditorWrapperProps = MakeOptional<
| {
editable: true;
uploadFile: TFileHandler["upload"];
duplicateFile: TFileHandler["duplicate"];
}
);
@@ -127,6 +128,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
fileHandler={getEditorFileHandlers({
projectId,
uploadFile: editable ? props.uploadFile : async () => "",
duplicateFile: editable ? props.duplicateFile : async () => "",
workspaceId,
workspaceSlug,
})}

View File

@@ -115,7 +115,7 @@ export const DescriptionInput: React.FC<Props> = observer((props) => {
const hasUnsavedChanges = useRef(false);
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
const { uploadEditorAsset } = useEditorAsset();
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
// derived values
const workspaceDetails = getWorkspaceBySlug(workspaceSlug);
// translation
@@ -240,6 +240,19 @@ export const DescriptionInput: React.FC<Props> = observer((props) => {
throw new Error("Asset upload failed. Please try again later.");
}
}}
duplicateFile={async (assetId: string) => {
try {
const { asset_id } = await duplicateEditorAsset({
assetId,
entityType: fileAssetType,
projectId,
workspaceSlug,
});
return asset_id;
} catch {
throw new Error("Asset duplication failed. Please try again later.");
}
}}
/>
)}
/>

View File

@@ -29,6 +29,7 @@ type RichTextEditorWrapperProps = MakeOptional<
editable: true;
searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
uploadFile: TFileHandler["upload"];
duplicateFile: TFileHandler["duplicate"];
}
);
@@ -69,6 +70,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
fileHandler={getEditorFileHandlers({
projectId,
uploadFile: editable ? props.uploadFile : async () => "",
duplicateFile: editable ? props.duplicateFile : async () => "",
workspaceId,
workspaceSlug,
})}

View File

@@ -31,6 +31,7 @@ interface StickyEditorWrapperProps
showToolbarInitially?: boolean;
showToolbar?: boolean;
uploadFile: TFileHandler["upload"];
duplicateFile: TFileHandler["duplicate"];
parentClassName?: string;
handleColorChange: (data: Partial<TSticky>) => Promise<void>;
handleDelete: () => void;
@@ -48,6 +49,7 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
showToolbar = true,
parentClassName = "",
uploadFile,
duplicateFile,
...rest
} = props;
// states
@@ -83,6 +85,7 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
fileHandler={getEditorFileHandlers({
projectId,
uploadFile,
duplicateFile,
workspaceId,
workspaceSlug,
})}

View File

@@ -49,7 +49,7 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
// i18n
const { t } = useTranslation();
// store hooks
const { uploadEditorAsset } = useEditorAsset();
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
const { loader } = useProjectInbox();
const { isMobile } = usePlatformOS();
@@ -102,6 +102,20 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
throw new Error("Asset upload failed. Please try again later.");
}
}}
duplicateFile={async (assetId: string) => {
try {
const { asset_id } = await duplicateEditorAsset({
assetId,
entityType: EFileAssetType.ISSUE_DESCRIPTION,
projectId,
workspaceSlug,
});
onAssetUpload?.(asset_id);
return asset_id;
} catch {
throw new Error("Asset duplication failed. Please try again later.");
}
}}
/>
);
});

View File

@@ -27,7 +27,7 @@ export const useCommentOperations = (
} = useIssueDetail();
const { getProjectById } = useProject();
const { getUserDetails } = useMember();
const { uploadEditorAsset } = useEditorAsset();
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
const { data: currentUser } = useUser();
// derived values
const issueDetails = issueId ? getIssueById(issueId) : undefined;
@@ -136,6 +136,21 @@ export const useCommentOperations = (
throw new Error(t("issue.comments.upload.error"));
}
},
duplicateCommentAsset: async (assetId, commentId) => {
try {
if (!workspaceSlug || !projectId) throw new Error("Missing fields");
const res = await duplicateEditorAsset({
assetId,
entityId: commentId || undefined,
entityType: EFileAssetType.COMMENT_DESCRIPTION,
projectId,
workspaceSlug,
});
return res;
} catch {
throw new Error("Asset duplication failed. Please try again later.");
}
},
addCommentReaction: async (commentId, reaction) => {
try {
if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields");

View File

@@ -77,7 +77,7 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob
const { getWorkspaceBySlug } = useWorkspace();
const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id ?? "";
const { config } = useInstance();
const { uploadEditorAsset } = useEditorAsset();
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
// platform
const { isMobile } = usePlatformOS();
@@ -221,6 +221,21 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob
throw new Error("Asset upload failed. Please try again later.");
}
}}
duplicateFile={async (assetId: string) => {
try {
const { asset_id } = await duplicateEditorAsset({
assetId,
entityId: issueId,
entityType: isDraft ? EFileAssetType.DRAFT_ISSUE_DESCRIPTION : EFileAssetType.ISSUE_DESCRIPTION,
projectId,
workspaceSlug,
});
onAssetUpload(asset_id);
return asset_id;
} catch {
throw new Error("Asset duplication failed. Please try again later.");
}
}}
/>
)}
/>

View File

@@ -86,6 +86,7 @@ export const StickyInput = (props: TProps) => {
}
)}
uploadFile={async () => ""}
duplicateFile={async () => ""}
showToolbar={showToolbar}
parentClassName="border-none p-0"
handleDelete={handleDelete}

View File

@@ -14,6 +14,7 @@ const fileService = new FileService();
type TArgs = {
projectId?: string;
uploadFile: TFileHandler["upload"];
duplicateFile: TFileHandler["duplicate"];
workspaceId: string;
workspaceSlug: string;
};
@@ -27,7 +28,7 @@ export const useEditorConfig = () => {
const getEditorFileHandlers = useCallback(
(args: TArgs): TFileHandler => {
const { projectId, uploadFile, workspaceId, workspaceSlug } = args;
const { projectId, uploadFile, duplicateFile, workspaceId, workspaceSlug } = args;
return {
assetsUploadStatus: assetsUploadPercentage,
@@ -85,6 +86,7 @@ export const useEditorConfig = () => {
}
},
upload: uploadFile,
duplicate: duplicateFile,
validation: {
maxFileSize,
},

View File

@@ -2,7 +2,7 @@ import type { AxiosRequestConfig } from "axios";
// plane types
import { API_BASE_URL } from "@plane/constants";
import { getFileMetaDataForUpload, generateFileUploadPayload } from "@plane/services";
import type { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
import type { EFileAssetType, TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
import { getAssetIdFromUrl } from "@plane/utils";
// helpers
// services
@@ -281,4 +281,20 @@ export class FileService extends APIService {
throw err?.response?.data;
});
}
async duplicateAsset(
workspaceSlug: string,
assetId: string,
data: {
entity_id?: string;
entity_type: EFileAssetType;
project_id?: string;
}
): Promise<{ asset_id: string }> {
return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/duplicate-assets/${assetId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -3,7 +3,7 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"
import { computedFn } from "mobx-utils";
import { v4 as uuidv4 } from "uuid";
// plane types
import type { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
import type { EFileAssetType, TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
// services
import { FileService } from "@/services/file.service";
import type { TAttachmentUploadStatus } from "../issue/issue-details/attachment.store";
@@ -27,6 +27,19 @@ export interface IEditorAssetStore {
projectId?: string;
workspaceSlug: string;
}) => Promise<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 {
@@ -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 };
};
}

View 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,
};

View File

@@ -4,7 +4,7 @@ import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from
import { cn } from "@plane/utils";
// local imports
import type { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
import { ensurePixelString, getImageBlockId } from "../utils";
import { ensurePixelString, getImageBlockId, isImageDuplicating } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
import { ImageToolbarRoot } from "./toolbar";
import { ImageUploadStatus } from "./upload-status";
@@ -42,6 +42,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
aspectRatio: nodeAspectRatio,
src: imgNodeSrc,
alignment: nodeAlignment,
status,
} = node.attrs;
// states
const [size, setSize] = useState<TCustomImageSize>({
@@ -202,15 +203,16 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
[editor, getPos, isTouchDevice]
);
const isDuplicating = isImageDuplicating(status);
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
const showImageLoader = !(resolvedImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad;
// show the image upload status only when the resolvedImageSrc is not ready
const showUploadStatus = !resolvedImageSrc;
const showImageLoader =
(!resolvedImageSrc && !isDuplicating) || !initialResizeComplete || hasErroredOnFirstLoad || isDuplicating; // show the image upload status only when the resolvedImageSrc is not ready
const showUploadStatus = !resolvedImageSrc && !isDuplicating;
// show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageToolbar = resolvedImageSrc && resolvedDownloadSrc && initialResizeComplete;
const showImageToolbar = resolvedImageSrc && resolvedDownloadSrc && initialResizeComplete && !isDuplicating;
// show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageResizer = editor.isEditable && resolvedImageSrc && initialResizeComplete;
const showImageResizer = editor.isEditable && resolvedImageSrc && initialResizeComplete && !isDuplicating;
// show the preview image from the file system if the remote image's src is not set
const displayedImageSrc = resolvedImageSrc || imageFromFileSystem;

View File

@@ -3,6 +3,8 @@ import type { NodeViewProps } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
// local imports
import type { CustomImageExtensionType, TCustomImageAttributes } from "../types";
import { ECustomImageStatus } from "../types";
import { hasImageDuplicationFailed } from "../utils";
import { CustomImageBlock } from "./block";
import { CustomImageUploader } from "./uploader";
@@ -15,8 +17,8 @@ export type CustomImageNodeViewProps = Omit<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);
@@ -26,6 +28,8 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
const [editorContainer, setEditorContainer] = useState<HTMLDivElement | null>(null);
const imageComponentRef = useRef<HTMLDivElement>(null);
const hasRetriedOnMount = useRef(false);
const isDuplicatingRef = useRef(false);
useEffect(() => {
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
@@ -61,10 +65,66 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
getImageSource();
}, [imgNodeSrc, extension.options]);
// Handle image duplication when status is duplicating
useEffect(() => {
const handleDuplication = async () => {
if (status !== ECustomImageStatus.DUPLICATING || !extension.options.duplicateImage || !imgNodeSrc) {
return;
}
// Prevent duplicate calls - check if already duplicating this asset
if (isDuplicatingRef.current) {
return;
}
isDuplicatingRef.current = true;
try {
hasRetriedOnMount.current = true;
const newAssetId = await extension.options.duplicateImage!(imgNodeSrc);
if (!newAssetId) {
throw new Error("Duplication returned invalid asset ID");
}
// Update node with new source and success status
updateAttributes({
src: newAssetId,
status: ECustomImageStatus.UPLOADED,
});
} catch (error: unknown) {
console.error("Failed to duplicate image:", error);
// Update status to failed
updateAttributes({ status: ECustomImageStatus.DUPLICATION_FAILED });
} finally {
isDuplicatingRef.current = false;
}
};
handleDuplication();
}, [status, imgNodeSrc, extension.options.duplicateImage, updateAttributes]);
useEffect(() => {
if (hasImageDuplicationFailed(status) && !hasRetriedOnMount.current && imgNodeSrc) {
hasRetriedOnMount.current = true;
// Add a small delay before retrying to avoid immediate retries
updateAttributes({ status: ECustomImageStatus.DUPLICATING });
}
}, [status, imgNodeSrc, updateAttributes]);
useEffect(() => {
if (status === ECustomImageStatus.UPLOADED) {
hasRetriedOnMount.current = false;
}
}, [status]);
const hasDuplicationFailed = hasImageDuplicationFailed(status);
const shouldShowBlock = (isUploaded || imageFromFileSystem) && !failedToLoadImage;
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
{shouldShowBlock && !hasDuplicationFailed ? (
<CustomImageBlock
editorContainer={editorContainer}
imageFromFileSystem={imageFromFileSystem}
@@ -77,6 +137,7 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
) : (
<CustomImageUploader
failedToLoadImage={failedToLoadImage}
hasDuplicationFailed={hasDuplicationFailed}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={editor.storage.imageComponent?.maxFileSize}
setIsUploaded={setIsUploaded}

View File

@@ -1,4 +1,4 @@
import { ImageIcon } from "lucide-react";
import { ImageIcon, RotateCcw } from "lucide-react";
import type { ChangeEvent } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
// plane imports
@@ -11,11 +11,13 @@ import type { EFileError } from "@/helpers/file";
// hooks
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
// local imports
import { ECustomImageStatus } from "../types";
import { getImageComponentImageFileMap } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
type CustomImageUploaderProps = CustomImageNodeViewProps & {
failedToLoadImage: boolean;
hasDuplicationFailed: boolean;
loadImageFromFileSystem: (file: string) => void;
maxFileSize: number;
setIsUploaded: (isUploaded: boolean) => void;
@@ -33,6 +35,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
selected,
setIsUploaded,
updateAttributes,
hasDuplicationFailed,
} = props;
// refs
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -50,6 +53,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
// Update the node view's src attribute post upload
updateAttributes({
src: url,
status: ECustomImageStatus.UPLOADED,
});
imageComponentImageFileMap?.delete(imageEntityId);
@@ -84,8 +88,11 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
);
const uploadImageEditorCommand = useCallback(
async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file),
[extension.options, imageEntityId]
async (file: File) => {
updateAttributes({ status: ECustomImageStatus.UPLOADING });
return await extension.options.uploadImage?.(imageEntityId ?? "", file);
},
[extension.options, imageEntityId, updateAttributes]
);
const handleProgressStatus = useCallback(
@@ -161,7 +168,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const getDisplayMessage = useCallback(() => {
const isUploading = isImageBeingUploaded;
if (failedToLoadImage) {
if (failedToLoadImage || hasDuplicationFailed) {
return "Error loading image";
}
@@ -174,7 +181,17 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
}
return "Add an image";
}, [draggedInside, editor.isEditable, failedToLoadImage, isImageBeingUploaded]);
}, [draggedInside, editor.isEditable, failedToLoadImage, isImageBeingUploaded, hasDuplicationFailed]);
const handleRetryClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (hasDuplicationFailed && editor.isEditable) {
updateAttributes({ status: ECustomImageStatus.DUPLICATING });
}
},
[hasDuplicationFailed, editor.isEditable, updateAttributes]
);
return (
<div
@@ -185,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 || hasDuplicationFailed,
"hover:text-red-500": (failedToLoadImage || hasDuplicationFailed) && editor.isEditable,
"bg-red-500/10": (failedToLoadImage || hasDuplicationFailed) && selected,
"hover:bg-red-500/10": (failedToLoadImage || hasDuplicationFailed) && selected && editor.isEditable,
}
)}
onDrop={onDrop}
@@ -196,13 +213,29 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
onDragLeave={onDragLeave}
contentEditable={false}
onClick={() => {
if (!failedToLoadImage && editor.isEditable) {
if (!failedToLoadImage && editor.isEditable && !hasDuplicationFailed) {
fileInputRef.current?.click();
}
}}
>
<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
className="size-0 overflow-hidden"
ref={fileInputRef}

View File

@@ -12,6 +12,7 @@ import type { CustomImageNodeViewProps } from "./components/node-view";
import { CustomImageNodeView } from "./components/node-view";
import { CustomImageExtensionConfig } from "./extension-config";
import type { CustomImageExtensionOptions, CustomImageExtensionStorage } from "./types";
import { ECustomImageAttributeNames, ECustomImageStatus } from "./types";
import { getImageComponentImageFileMap } from "./utils";
type Props = {
@@ -30,13 +31,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,7 +95,8 @@ export const CustomImageExtension = (props: Props) => {
}
const attributes = {
id: fileId,
[ECustomImageAttributeNames.ID]: fileId,
[ECustomImageAttributeNames.STATUS]: ECustomImageStatus.PENDING,
};
if (props.pos) {
@@ -116,7 +119,6 @@ export const CustomImageExtension = (props: Props) => {
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addNodeView() {
return ReactNodeViewRenderer((props) => (
<CustomImageNodeView {...props} node={props.node as CustomImageNodeViewProps["node"]} />

View File

@@ -9,6 +9,7 @@ export enum ECustomImageAttributeNames {
ASPECT_RATIO = "aspectRatio",
SOURCE = "src",
ALIGNMENT = "alignment",
STATUS = "status",
}
export type Pixel = `${number}px`;
@@ -23,6 +24,14 @@ export type TCustomImageSize = {
export type TCustomImageAlignment = "left" | "center" | "right";
export enum ECustomImageStatus {
PENDING = "pending",
UPLOADING = "uploading",
UPLOADED = "uploaded",
DUPLICATING = "duplicating",
DUPLICATION_FAILED = "duplication-failed",
}
export type TCustomImageAttributes = {
[ECustomImageAttributeNames.ID]: string | null;
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
@@ -30,6 +39,7 @@ export type TCustomImageAttributes = {
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
[ECustomImageAttributeNames.SOURCE]: string | null;
[ECustomImageAttributeNames.ALIGNMENT]: TCustomImageAlignment;
[ECustomImageAttributeNames.STATUS]: ECustomImageStatus;
};
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
@@ -45,6 +55,7 @@ export type CustomImageExtensionOptions = {
getImageSource: TFileHandler["getAssetSrc"];
restoreImage: TFileHandler["restore"];
uploadImage?: TFileHandler["upload"];
duplicateImage?: TFileHandler["duplicate"];
};
export type CustomImageExtensionStorage = {

View File

@@ -2,7 +2,7 @@ import type { Editor } from "@tiptap/core";
import { AlignCenter, AlignLeft, AlignRight } from "lucide-react";
import type { LucideIcon } from "lucide-react";
// local imports
import { ECustomImageAttributeNames } from "./types";
import { ECustomImageAttributeNames, ECustomImageStatus } from "./types";
import type { TCustomImageAlignment, Pixel, TCustomImageAttributes } from "./types";
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
@@ -12,6 +12,7 @@ export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
[ECustomImageAttributeNames.HEIGHT]: "auto",
[ECustomImageAttributeNames.ASPECT_RATIO]: null,
[ECustomImageAttributeNames.ALIGNMENT]: "left",
[ECustomImageAttributeNames.STATUS]: ECustomImageStatus.PENDING,
};
export const getImageComponentImageFileMap = (editor: Editor) => editor.storage.imageComponent?.fileMap;
@@ -53,3 +54,11 @@ export const IMAGE_ALIGNMENT_OPTIONS: {
},
];
export const getImageBlockId = (id: string) => `editor-image-block-${id}`;
export const isImageDuplicating = (status: ECustomImageStatus) => status === ECustomImageStatus.DUPLICATING;
export const isImageDuplicationComplete = (status: ECustomImageStatus) =>
status === ECustomImageStatus.UPLOADED || status === ECustomImageStatus.DUPLICATION_FAILED;
export const hasImageDuplicationFailed = (status: ECustomImageStatus) =>
status === ECustomImageStatus.DUPLICATION_FAILED;

View File

@@ -9,6 +9,7 @@ import { DropHandlerPlugin } from "@/plugins/drop";
import { FilePlugins } from "@/plugins/file/root";
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
// types
import { PasteAssetPlugin } from "@/plugins/paste-asset";
import type { IEditorProps, TEditorAsset, TFileHandler } from "@/types";
type TActiveDropbarExtensions =
@@ -80,6 +81,7 @@ export const UtilityExtension = (props: Props) => {
disabledExtensions,
editor: this.editor,
}),
PasteAssetPlugin(),
];
},

View 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 };
};

View File

@@ -27,8 +27,5 @@ export const CoreEditorProps = (props: TArgs): EditorProps => {
}
},
},
transformPastedHTML(html) {
return html.replace(/<img.*?>/g, "");
},
};
};

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

View File

@@ -47,6 +47,7 @@ export type TCommentsOperations = {
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
removeComment: (commentId: string) => Promise<void>;
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>;
deleteCommentReaction: (commentId: string, reactionEmoji: string) => Promise<void>;
react: (commentId: string, reactionEmoji: string, userReactions: string[]) => Promise<void>;