feat: handle image duplication for project pages

This commit is contained in:
VipinDevelops
2025-10-28 18:58:40 +05:30
parent 56701442c2
commit 38bdcab1c3
11 changed files with 248 additions and 28 deletions

View File

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

View File

@@ -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 ?? (async () => ""),
validation: { validation: {
maxFileSize, maxFileSize,
}, },

View File

@@ -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: string;
project_id?: string;
}
): Promise<{ asset_id: string }> {
return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/duplicate-assets/${assetId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
} }

View File

@@ -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: string;
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?.toString() ?? "", assetId, {
entity_id: entityId,
entity_type: entityType,
project_id: projectId,
});
return { asset_id };
};
} }

View File

@@ -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 { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types"; import { 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,20 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
[editor, getPos, isTouchDevice] [editor, getPos, isTouchDevice]
); );
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or) // Check if image is duplicating
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete const isDuplicating = isImageDuplicating(status);
const showImageLoader = !(resolvedImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad;
// show the image upload status only when the resolvedImageSrc is not ready const showImageLoader =
const showUploadStatus = !resolvedImageSrc; (!resolvedImageSrc && !isDuplicating) || !initialResizeComplete || hasErroredOnFirstLoad || 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; // show the image upload status only when not duplicating and no resolved source
// 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 showUploadStatus = !resolvedImageSrc && !isDuplicating;
const showImageResizer = editor.isEditable && resolvedImageSrc && initialResizeComplete;
// show the image utils only if image is loaded and not duplicating
const showImageToolbar = resolvedImageSrc && resolvedDownloadSrc && initialResizeComplete && !isDuplicating;
// show the image resizer only if image is loaded and not duplicating
const showImageResizer = editor.isEditable && resolvedImageSrc && initialResizeComplete && !isDuplicating;
// show the preview image from the file system if the remote image's src is not set // 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;

View File

@@ -1,7 +1,8 @@
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
// local imports // local imports
import type { CustomImageExtensionType, TCustomImageAttributes } from "../types"; import type { CustomImageExtensionType, TCustomImageAttributes } from "../types";
import { isImageDuplicationFailed } from "../utils";
import { CustomImageBlock } from "./block"; import { CustomImageBlock } from "./block";
import { CustomImageUploader } from "./uploader"; import { CustomImageUploader } from "./uploader";
@@ -14,8 +15,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);
@@ -25,6 +26,7 @@ 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);
useEffect(() => { useEffect(() => {
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container"); const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
@@ -60,10 +62,56 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
getImageSource(); getImageSource();
}, [imgNodeSrc, extension.options]); }, [imgNodeSrc, extension.options]);
// Memoize the duplication function to prevent unnecessary re-runs
const handleDuplication = useCallback(async () => {
if (status !== "duplicating" || !extension.options.duplicateImage || !imgNodeSrc) {
return;
}
try {
hasRetriedOnMount.current = true;
const newAssetId = await extension.options.duplicateImage!(imgNodeSrc);
// Update node with new source and success status
updateAttributes({
src: newAssetId,
status: "duplicated",
});
} catch (error) {
console.error("Failed to duplicate image:", error);
// Update status to failed
updateAttributes({ status: "duplication-failed" });
}
}, [status, imgNodeSrc, extension.options.duplicateImage, updateAttributes]);
// Handle image duplication when status is duplicating
useEffect(() => {
if (status === "duplicating") {
handleDuplication();
}
}, [status, handleDuplication]);
useEffect(() => {
if (isImageDuplicationFailed(status) && !hasRetriedOnMount.current && imgNodeSrc) {
hasRetriedOnMount.current = true;
// Add a small delay before retrying to avoid immediate retries
updateAttributes({ status: "duplicating" });
}
}, [status, imgNodeSrc, updateAttributes]);
useEffect(() => {
if (status === "duplicated") {
hasRetriedOnMount.current = false;
}
}, [status]);
const isDuplicationFailed = isImageDuplicationFailed(status);
const shouldShowBlock = (isUploaded || imageFromFileSystem) && !failedToLoadImage;
return ( 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 && !isDuplicationFailed ? (
<CustomImageBlock <CustomImageBlock
editorContainer={editorContainer} editorContainer={editorContainer}
imageFromFileSystem={imageFromFileSystem} imageFromFileSystem={imageFromFileSystem}
@@ -76,6 +124,7 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
) : ( ) : (
<CustomImageUploader <CustomImageUploader
failedToLoadImage={failedToLoadImage} failedToLoadImage={failedToLoadImage}
isDuplicationFailed={isDuplicationFailed}
loadImageFromFileSystem={setImageFromFileSystem} loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={editor.storage.imageComponent?.maxFileSize} maxFileSize={editor.storage.imageComponent?.maxFileSize}
setIsUploaded={setIsUploaded} setIsUploaded={setIsUploaded}

View File

@@ -1,4 +1,4 @@
import { ImageIcon } from "lucide-react"; import { ImageIcon, RotateCcw } from "lucide-react";
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react"; import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
// plane imports // plane imports
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
@@ -15,6 +15,7 @@ import type { CustomImageNodeViewProps } from "./node-view";
type CustomImageUploaderProps = CustomImageNodeViewProps & { type CustomImageUploaderProps = CustomImageNodeViewProps & {
failedToLoadImage: boolean; failedToLoadImage: boolean;
isDuplicationFailed: boolean;
loadImageFromFileSystem: (file: string) => void; loadImageFromFileSystem: (file: string) => void;
maxFileSize: number; maxFileSize: number;
setIsUploaded: (isUploaded: boolean) => void; setIsUploaded: (isUploaded: boolean) => void;
@@ -32,6 +33,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
selected, selected,
setIsUploaded, setIsUploaded,
updateAttributes, updateAttributes,
isDuplicationFailed,
} = props; } = props;
// refs // refs
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -49,6 +51,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: "uploaded",
}); });
imageComponentImageFileMap?.delete(imageEntityId); imageComponentImageFileMap?.delete(imageEntityId);
@@ -108,6 +111,11 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
onInvalidFile: handleInvalidFile, onInvalidFile: handleInvalidFile,
onUpload, onUpload,
}); });
useEffect(() => {
if (isImageBeingUploaded) {
updateAttributes({ status: "uploading" });
}
}, [isImageBeingUploaded, updateAttributes]);
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
editor, editor,
@@ -160,7 +168,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const getDisplayMessage = useCallback(() => { const getDisplayMessage = useCallback(() => {
const isUploading = isImageBeingUploaded; const isUploading = isImageBeingUploaded;
if (failedToLoadImage) { if (failedToLoadImage || isDuplicationFailed) {
return "Error loading image"; return "Error loading image";
} }
@@ -173,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, isDuplicationFailed]);
const handleRetryClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (isDuplicationFailed && editor.isEditable) {
updateAttributes({ status: "duplicating" });
}
},
[isDuplicationFailed, editor.isEditable, updateAttributes]
);
return ( return (
<div <div
@@ -184,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 || isDuplicationFailed,
"hover:text-red-500": failedToLoadImage && editor.isEditable, "hover:text-red-500": (failedToLoadImage || isDuplicationFailed) && editor.isEditable,
"bg-red-500/10": failedToLoadImage && selected, "bg-red-500/10": (failedToLoadImage || isDuplicationFailed) && selected,
"hover:bg-red-500/10": failedToLoadImage && selected && editor.isEditable, "hover:bg-red-500/10": (failedToLoadImage || isDuplicationFailed) && selected && editor.isEditable,
} }
)} )}
onDrop={onDrop} onDrop={onDrop}
@@ -195,13 +213,23 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
contentEditable={false} contentEditable={false}
onClick={() => { onClick={() => {
if (!failedToLoadImage && editor.isEditable) { if (!failedToLoadImage && editor.isEditable && !isDuplicationFailed) {
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>
{isDuplicationFailed && editor.isEditable && (
<button
onClick={handleRetryClick}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-custom-text-200 bg-custom-background-80 hover:bg-custom-background-70 border border-custom-border-300 hover:border-custom-border-200 rounded-md transition-all duration-200 ease-in-out"
title="Retry duplication"
>
<RotateCcw className="size-3" />
Retry
</button>
)}
<input <input
className="size-0 overflow-hidden" className="size-0 overflow-hidden"
ref={fileInputRef} ref={fileInputRef}

View File

@@ -1,3 +1,4 @@
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
// constants // constants
@@ -29,13 +30,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,6 +95,7 @@ export const CustomImageExtension = (props: Props) => {
const attributes = { const attributes = {
id: fileId, id: fileId,
status: "pending",
}; };
if (props.pos) { if (props.pos) {
@@ -115,6 +118,64 @@ export const CustomImageExtension = (props: Props) => {
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
}; };
}, },
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("paste-image-duplication"),
props: {
handlePaste: (view, event, _slice) => {
if (!event.clipboardData) return false;
const htmlContent = event.clipboardData.getData("text/html");
if (!htmlContent || htmlContent.includes('data-duplicated="true"')) return false;
const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlContent;
const imageComponents = tempDiv.querySelectorAll("image-component");
if (imageComponents.length === 0) return false;
event.preventDefault();
event.stopPropagation();
let updatedHtml = htmlContent;
imageComponents.forEach((component) => {
const src = component.getAttribute("src");
if (src) {
const newId = uuidv4();
const originalTag = component.outerHTML;
const modifiedTag = originalTag
.replace(`<image-component`, `<image-component status="duplicating"`)
.replace(/id="[^"]*"/, `id="${newId}"`);
updatedHtml = updatedHtml.replace(originalTag, modifiedTag);
}
});
updatedHtml = updatedHtml.replace(
"<meta charset='utf-8'>",
"<meta charset='utf-8' data-duplicated=\"true\">"
);
const newDataTransfer = new DataTransfer();
newDataTransfer.setData("text/html", updatedHtml);
if (event.clipboardData) {
newDataTransfer.setData("text/plain", event.clipboardData.getData("text/plain"));
}
const pasteEvent = new ClipboardEvent("paste", {
clipboardData: newDataTransfer,
bubbles: true,
cancelable: true,
});
view.dom.dispatchEvent(pasteEvent);
return true;
},
},
}),
];
},
addNodeView() { addNodeView() {
return ReactNodeViewRenderer((props) => ( return ReactNodeViewRenderer((props) => (

View File

@@ -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 type TCustomImageStatus =
| "pending"
| "uploading"
| "uploaded"
| "duplicating"
| "duplicated"
| "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]: TCustomImageStatus;
}; };
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 = {

View File

@@ -1,7 +1,13 @@
import type { Editor } from "@tiptap/core"; import type { Editor } from "@tiptap/core";
import { AlignCenter, AlignLeft, AlignRight, type LucideIcon } from "lucide-react"; import { AlignCenter, AlignLeft, AlignRight, type LucideIcon } from "lucide-react";
// local imports // local imports
import { ECustomImageAttributeNames, TCustomImageAlignment, type Pixel, type TCustomImageAttributes } from "./types"; import {
ECustomImageAttributeNames,
TCustomImageAlignment,
TCustomImageStatus,
type Pixel,
type TCustomImageAttributes,
} from "./types";
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = { export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
[ECustomImageAttributeNames.SOURCE]: null, [ECustomImageAttributeNames.SOURCE]: null,
@@ -10,6 +16,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]: "pending",
}; };
export const getImageComponentImageFileMap = (editor: Editor) => editor.storage.imageComponent?.fileMap; export const getImageComponentImageFileMap = (editor: Editor) => editor.storage.imageComponent?.fileMap;
@@ -51,3 +58,10 @@ export const IMAGE_ALIGNMENT_OPTIONS: {
}, },
]; ];
export const getImageBlockId = (id: string) => `editor-image-block-${id}`; export const getImageBlockId = (id: string) => `editor-image-block-${id}`;
export const isImageDuplicating = (status: TCustomImageStatus) => status === "duplicating";
export const isImageDuplicationComplete = (status: TCustomImageStatus) =>
status === "duplicated" || status === "duplication-failed";
export const isImageDuplicationFailed = (status: TCustomImageStatus) => status === "duplication-failed";

View File

@@ -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