feat(Nextjs): Basic image & icon editor
This commit is contained in:
parent
a40984d4da
commit
54ef377226
7 changed files with 605 additions and 494 deletions
|
|
@ -28,6 +28,7 @@ interface IconsEditorProps {
|
|||
isWhite?: boolean;
|
||||
className?: string;
|
||||
icon_prompt?: string[] | null;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const IconsEditor = ({
|
||||
|
|
@ -39,6 +40,7 @@ const IconsEditor = ({
|
|||
slideIndex,
|
||||
elementId,
|
||||
icon_prompt,
|
||||
onClose,
|
||||
}: IconsEditorProps) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
|
|
@ -97,124 +99,76 @@ const IconsEditor = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{ background: hasBg ? backgroundColor : "transparent" }}
|
||||
onClick={handleIconClick}
|
||||
className={cn(
|
||||
"relative overflow-hidden w-[34px] h-[34px] md:w-[64px] max-md:pointer-events-none md:h-[64px] flex items-center justify-center cursor-pointer group",
|
||||
hasBg && ` rounded-[50%]`,
|
||||
className
|
||||
)}
|
||||
data-slide-element
|
||||
data-slide-index={slideIndex}
|
||||
data-element-type={hasBg ? "filledbox" : "emptybox"}
|
||||
data-element-id={`${elementId}-container`}
|
||||
<Sheet open={true} onOpenChange={() => onClose?.()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[400px]"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{icon ? (
|
||||
<img
|
||||
src={getStaticFileUrl(icon)}
|
||||
alt="slide icon"
|
||||
className={`object-contain w-[16px] h-[16px] md:w-[32px] md:h-[32px] ${hasBg ? "brightness-0 invert" : ""
|
||||
}`}
|
||||
data-slide-element
|
||||
style={{
|
||||
filter: hasBg
|
||||
? "brightness(0) invert"
|
||||
: "sepia(100%) hue-rotate(190deg) saturate(500%)",
|
||||
<SheetHeader>
|
||||
<SheetTitle>Choose Icon</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6 space-y-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleIconSearch();
|
||||
}}
|
||||
data-slide-index={slideIndex}
|
||||
data-element-type="picture"
|
||||
data-is-icon
|
||||
data-element-id={`${elementId}-image`}
|
||||
data-is-network={false}
|
||||
data-image-path={icon}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[32px] h-[32px] relative">
|
||||
<Skeleton className="w-[32px] h-[32px] bg-gray-100 " />
|
||||
{initialIcon !== undefined && (
|
||||
<p className="absolute top-1/2 left-1/2 -translate-x-[30%] -translate-y-1/2 w-full text-center text-sm text-[#51459e]">
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
</p>
|
||||
>
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 w-4 h-4" />
|
||||
|
||||
<Input
|
||||
placeholder="Search icons..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full text-semibold text-[#51459e]"
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Icons grid */}
|
||||
<div className="max-h-[80vh] hide-scrollbar overflow-y-auto p-1">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: 40 }).map((_, index) => (
|
||||
<Skeleton key={index} className="w-16 h-16 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : icons.length > 0 ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{icons.map((iconSrc, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => handleIconChange(iconSrc)}
|
||||
className="w-12 h-12 cursor-pointer group relative rounded-lg overflow-hidden hover:bg-gray-100 p-2"
|
||||
>
|
||||
<img
|
||||
src={getStaticFileUrl(iconSrc)}
|
||||
alt={`Icon ${idx + 1}`}
|
||||
className="w-full h-full object-contain "
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full h-[60vh] text-center text-gray-500 space-y-4">
|
||||
<Search className="w-12 h-12 text-gray-400" />
|
||||
<p className="text-sm">No icons found for your search.</p>
|
||||
<p className="text-xs">Try refining your search query.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-all duration-200" />
|
||||
</div>
|
||||
|
||||
<Sheet open={isEditorOpen} onOpenChange={setIsEditorOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[400px]"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Choose Icon</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6 space-y-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleIconSearch();
|
||||
}}
|
||||
>
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 w-4 h-4" />
|
||||
|
||||
<Input
|
||||
placeholder="Search icons..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full text-semibold text-[#51459e]"
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Icons grid */}
|
||||
<div className="max-h-[80vh] hide-scrollbar overflow-y-auto p-1">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: 40 }).map((_, index) => (
|
||||
<Skeleton key={index} className="w-16 h-16 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : icons.length > 0 ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{icons.map((iconSrc, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => handleIconChange(iconSrc)}
|
||||
className="w-12 h-12 cursor-pointer group relative rounded-lg overflow-hidden hover:bg-gray-100 p-2"
|
||||
>
|
||||
<img
|
||||
src={getStaticFileUrl(iconSrc)}
|
||||
alt={`Icon ${idx + 1}`}
|
||||
className="w-full h-full object-contain "
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full h-[60vh] text-center text-gray-500 space-y-4">
|
||||
<Search className="w-12 h-12 text-gray-400" />
|
||||
<p className="text-sm">No icons found for your search.</p>
|
||||
<p className="text-xs">Try refining your search query.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ interface ImageEditorProps {
|
|||
className?: string;
|
||||
promptContent?: string;
|
||||
properties?: null | any;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const ImageEditor = ({
|
||||
|
|
@ -56,6 +57,7 @@ const ImageEditor = ({
|
|||
elementId,
|
||||
promptContent,
|
||||
properties,
|
||||
onClose,
|
||||
}: ImageEditorProps) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
|
|
@ -289,376 +291,178 @@ const ImageEditor = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={imageContainerRef}
|
||||
className={cn(
|
||||
"relative group max-md:h-[200px] max-lg:h-[300px] max-md:pointer-events-none lg:aspect-[4/4] w-full cursor-pointer rounded-lg overflow-hidden",
|
||||
isFocusPointMode ? "cursor-crosshair" : "",
|
||||
className
|
||||
)}
|
||||
data-slide-element
|
||||
data-slide-index={slideIndex}
|
||||
data-element-type="picture"
|
||||
data-element-id={elementId}
|
||||
onClick={(e) => {
|
||||
if (initialImage !== undefined) {
|
||||
if (isFocusPointMode) {
|
||||
handleFocusPointClick(e);
|
||||
} else {
|
||||
handleImageClick();
|
||||
}
|
||||
}
|
||||
}}
|
||||
<Sheet open={true} onOpenChange={() => onClose?.()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[600px]"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{image ? (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={getImageUrl(image)}
|
||||
alt={title}
|
||||
className="w-full h-full transition-all duration-200 "
|
||||
style={{
|
||||
objectFit: objectFit,
|
||||
objectPosition: `${focusPoint.x}% ${focusPoint.y}%`,
|
||||
}}
|
||||
data-slide-index={slideIndex}
|
||||
data-element-type="picture"
|
||||
data-is-image
|
||||
data-object-fit={objectFit}
|
||||
data-focial-point-x={focusPoint.x}
|
||||
data-focial-point-y={focusPoint.y}
|
||||
data-element-id={`${elementId}-image`}
|
||||
data-is-network={image && image.startsWith("http")}
|
||||
data-image-path={image}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full relative">
|
||||
<Skeleton className="w-full h-full bg-gray-300 animate-pulse" />
|
||||
{
|
||||
<p className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full text-center text-sm text-gray-500">
|
||||
{initialImage !== undefined
|
||||
? "Click to add image"
|
||||
: "Loading..."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<SheetHeader>
|
||||
<SheetTitle>Update Image</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-all duration-200 rounded-lg" />
|
||||
<div className="mt-6">
|
||||
<Tabs defaultValue="generate" className="w-full">
|
||||
<TabsList className="grid bg-blue-100 border border-blue-300 w-full grid-cols-2 mx-auto ">
|
||||
<TabsTrigger className="font-medium" value="generate">
|
||||
AI Generate
|
||||
</TabsTrigger>
|
||||
|
||||
{isFocusPointMode && (
|
||||
<div className="absolute inset-0 bg-black/20 flex items-center justify-center">
|
||||
<div className="text-white text-center p-2 bg-black/50 rounded">
|
||||
<p className="text-sm font-medium">
|
||||
Click anywhere to set focus point
|
||||
</p>
|
||||
<button
|
||||
className="mt-2 px-3 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFocusPointMode();
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
<TabsTrigger className="font-medium" value="upload">
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Focus point marker */}
|
||||
<div
|
||||
className="absolute w-8 h-8 border-2 border-white rounded-full transform -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{
|
||||
left: `${focusPoint.x}%`,
|
||||
top: `${focusPoint.y}%`,
|
||||
boxShadow: "0 0 0 2px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
</div>
|
||||
<div className="absolute w-16 h-0.5 bg-white/70 left-1/2 -translate-x-1/2"></div>
|
||||
<div className="absolute w-0.5 h-16 bg-white/70 top-1/2 -translate-y-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<TabsContent value="generate" className="mt-4 space-y-4">
|
||||
<div></div>
|
||||
<div className="space-y-4">
|
||||
<div className="">
|
||||
<h3 className="text-sm font-medium mb-1">Current Prompt</h3>
|
||||
|
||||
{/* Image Toolbar */}
|
||||
{isToolbarOpen && !isFocusPointMode && (
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className="absolute bottom-2 left-1/2 transform -translate-x-1/2 bg-white rounded-full shadow-lg z-10 toolbar-popover"
|
||||
>
|
||||
<div className="flex items-center p-1 space-x-1">
|
||||
<ToolTip content="Edit">
|
||||
<button
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors "
|
||||
onClick={handleOpenEditor}
|
||||
title="Edit Image"
|
||||
>
|
||||
<Edit className="w-4 h-4 text-gray-700" />
|
||||
</button>
|
||||
</ToolTip>
|
||||
<ToolTip content="Focus Point">
|
||||
<button
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors "
|
||||
onClick={toggleFocusPointMode}
|
||||
title="Set Focus Point"
|
||||
>
|
||||
<Move className="w-4 h-4 text-gray-700" />
|
||||
</button>
|
||||
</ToolTip>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors "
|
||||
title="Fit Options"
|
||||
>
|
||||
<Maximize className="w-4 h-4 text-gray-700" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-2" ref={popoverContentRef}>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<button
|
||||
className={cn(
|
||||
"text-left px-2 py-1 text-sm rounded flex items-center",
|
||||
objectFit === "cover"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "hover:bg-gray-100"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleFitChange("cover");
|
||||
}}
|
||||
>
|
||||
<div className="w-4 h-4 mr-2 border border-current rounded overflow-hidden relative">
|
||||
<div className="absolute inset-0 bg-current opacity-20"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-3 bg-current rounded-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
Cover
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"text-left px-2 py-1 text-sm rounded flex items-center",
|
||||
objectFit === "contain"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "hover:bg-gray-100"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleFitChange("contain");
|
||||
}}
|
||||
>
|
||||
<div className="w-4 h-4 mr-2 border border-current rounded overflow-hidden relative">
|
||||
<div className="absolute inset-0 bg-current opacity-20"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-3 h-2 bg-current rounded-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
Contain
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"text-left px-2 py-1 text-sm rounded flex items-center",
|
||||
objectFit === "fill"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "hover:bg-gray-100"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleFitChange("fill");
|
||||
}}
|
||||
>
|
||||
<div className="w-4 h-4 mr-2 border border-current rounded overflow-hidden relative">
|
||||
<div className="absolute inset-0 bg-current opacity-20"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-3 h-3 bg-current rounded-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
Fill
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Sheet open={isEditorOpen} onOpenChange={setIsEditorOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[600px]"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Update Image</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6">
|
||||
<Tabs defaultValue="generate" className="w-full">
|
||||
<TabsList className="grid bg-blue-100 border border-blue-300 w-full grid-cols-2 mx-auto ">
|
||||
<TabsTrigger className="font-medium" value="generate">
|
||||
AI Generate
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger className="font-medium" value="upload">
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="generate" className="mt-4 space-y-4">
|
||||
<div></div>
|
||||
<div className="space-y-4">
|
||||
<div className="">
|
||||
<h3 className="text-sm font-medium mb-1">Current Prompt</h3>
|
||||
|
||||
<p className="text-sm text-gray-500">{promptContent}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-medium mb-2">
|
||||
Image Description
|
||||
</h3>
|
||||
<Textarea
|
||||
placeholder="Describe the image you want to generate..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleGenerateImage}
|
||||
className="w-full"
|
||||
disabled={!prompt || isGenerating}
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
{isGenerating ? "Generating..." : "Generate Image"}
|
||||
</Button>
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{isGenerating || previewImages.length === 0
|
||||
? Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="aspect-[4/3] w-full rounded-lg"
|
||||
/>
|
||||
))
|
||||
: previewImages.map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleImageChange(image as string)}
|
||||
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={
|
||||
image
|
||||
? getStaticFileUrl(image)
|
||||
: ""
|
||||
}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{promptContent}</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="upload" className="mt-4 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
<div>
|
||||
<h3 className="text-base font-medium mb-2">
|
||||
Image Description
|
||||
</h3>
|
||||
<Textarea
|
||||
placeholder="Describe the image you want to generate..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleGenerateImage}
|
||||
className="w-full"
|
||||
disabled={!prompt || isGenerating}
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
{isGenerating ? "Generating..." : "Generate Image"}
|
||||
</Button>
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{isGenerating || previewImages.length === 0
|
||||
? Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="aspect-[4/3] w-full rounded-lg"
|
||||
/>
|
||||
))
|
||||
: previewImages.map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleImageChange(image as string)}
|
||||
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={
|
||||
image
|
||||
? getStaticFileUrl(image)
|
||||
: ""
|
||||
}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="upload" className="mt-4 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
|
||||
isUploading
|
||||
? "border-gray-400 bg-gray-50"
|
||||
: "border-gray-300"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
|
||||
isUploading
|
||||
? "border-gray-400 bg-gray-50"
|
||||
: "border-gray-300"
|
||||
"flex flex-col items-center",
|
||||
isUploading ? "cursor-wait" : "cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className={cn(
|
||||
"flex flex-col items-center",
|
||||
isUploading ? "cursor-wait" : "cursor-pointer"
|
||||
)}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
|
||||
) : (
|
||||
<Upload className="w-8 h-8 text-gray-500 mb-2" />
|
||||
)}
|
||||
<span className="text-sm text-gray-600">
|
||||
{isUploading
|
||||
? "Uploading your image..."
|
||||
: "Click to upload an image"}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 mt-1">
|
||||
Maximum file size: 5MB
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{uploadError && (
|
||||
<p className="text-red-500 text-sm text-center">
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
{isUploading ? (
|
||||
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
|
||||
) : (
|
||||
<Upload className="w-8 h-8 text-gray-500 mb-2" />
|
||||
)}
|
||||
<span className="text-sm text-gray-600">
|
||||
{isUploading
|
||||
? "Uploading your image..."
|
||||
: "Click to upload an image"}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 mt-1">
|
||||
Maximum file size: 5MB
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{uploadError && (
|
||||
<p className="text-red-500 text-sm text-center">
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(uploadedImageUrl || isUploading) && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Uploaded Image Preview
|
||||
</h3>
|
||||
<div className="aspect-[4/3] relative rounded-lg overflow-hidden border border-gray-200">
|
||||
{isUploading ? (
|
||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
|
||||
<span className="text-sm text-gray-500">
|
||||
Processing...
|
||||
{(uploadedImageUrl || isUploading) && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Uploaded Image Preview
|
||||
</h3>
|
||||
<div className="aspect-[4/3] relative rounded-lg overflow-hidden border border-gray-200">
|
||||
{isUploading ? (
|
||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mb-2" />
|
||||
<span className="text-sm text-gray-500">
|
||||
Processing...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
uploadedImageUrl && (
|
||||
<div
|
||||
onClick={() =>
|
||||
handleImageChange(uploadedImageUrl)
|
||||
}
|
||||
className="cursor-pointer group w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={getStaticFileUrl(uploadedImageUrl)}
|
||||
alt="Uploaded preview"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200" />
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
|
||||
Click to use this image
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
uploadedImageUrl && (
|
||||
<div
|
||||
onClick={() =>
|
||||
handleImageChange(uploadedImageUrl)
|
||||
}
|
||||
className="cursor-pointer group w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={getStaticFileUrl(uploadedImageUrl)}
|
||||
alt="Uploaded preview"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200" />
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="bg-white/90 px-3 py-1 rounded-full text-sm font-medium">
|
||||
Click to use this image
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,354 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useRef, useEffect, ReactNode, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import ImageEditor from './ImageEditor';
|
||||
import IconsEditor from './IconsEditor';
|
||||
|
||||
interface SmartEditableContextType {
|
||||
slideIndex: number;
|
||||
slideId: string;
|
||||
isEditMode: boolean;
|
||||
slideData: any;
|
||||
}
|
||||
|
||||
const SmartEditableContext = createContext<SmartEditableContextType | null>(null);
|
||||
|
||||
interface SmartEditableProviderProps {
|
||||
children: ReactNode;
|
||||
slideIndex: number;
|
||||
slideId: string;
|
||||
slideData: any;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
interface EditableElement {
|
||||
type: 'image' | 'icon';
|
||||
element: HTMLImageElement;
|
||||
dataPath: string;
|
||||
props: any;
|
||||
}
|
||||
|
||||
export const SmartEditableProvider: React.FC<SmartEditableProviderProps> = ({
|
||||
children,
|
||||
slideIndex,
|
||||
slideId,
|
||||
slideData,
|
||||
isEditMode = true,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [editableElements, setEditableElements] = useState<EditableElement[]>([]);
|
||||
const [activeEditor, setActiveEditor] = useState<{
|
||||
type: 'image' | 'icon';
|
||||
element: HTMLImageElement;
|
||||
props: any;
|
||||
rect: DOMRect;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode || !containerRef.current || !slideData) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
|
||||
const findEditableElements = () => {
|
||||
const elements: EditableElement[] = [];
|
||||
|
||||
console.log('🔍 Starting smart detection with slideData:', slideData);
|
||||
|
||||
// Scan data structure for __image_url__ and __icon_url__ patterns
|
||||
const detectEditableElementsFromData = (data: any, path: string = '') => {
|
||||
if (!data || typeof data !== 'object') return;
|
||||
|
||||
// Check for __image_url__ pattern
|
||||
if (data.__image_url__) {
|
||||
console.log(`📸 Found __image_url__ at ${path}:`, data.__image_url__);
|
||||
const imgElement = findDOMElementByImageUrl(container, data.__image_url__);
|
||||
if (imgElement) {
|
||||
elements.push({
|
||||
type: 'image',
|
||||
element: imgElement,
|
||||
dataPath: path,
|
||||
props: {
|
||||
slideIndex,
|
||||
elementId: `image-${path.replace(/[^\w]/g, '-')}`,
|
||||
initialImage: data.__image_url__,
|
||||
title: imgElement.alt || 'Image',
|
||||
promptContent: data.__image_prompt__ || '',
|
||||
imageIdx: elements.filter(e => e.type === 'image').length
|
||||
}
|
||||
});
|
||||
console.log(`✅ Matched image to DOM element:`, imgElement);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for __icon_url__ pattern
|
||||
if (data.__icon_url__) {
|
||||
console.log(`🎯 Found __icon_url__ at ${path}:`, data.__icon_url__);
|
||||
const imgElement = findDOMElementByImageUrl(container, data.__icon_url__);
|
||||
if (imgElement) {
|
||||
elements.push({
|
||||
type: 'icon',
|
||||
element: imgElement,
|
||||
dataPath: path,
|
||||
props: {
|
||||
slideIndex,
|
||||
elementId: `icon-${path.replace(/[^\w]/g, '-')}`,
|
||||
icon: data.__icon_url__,
|
||||
index: elements.filter(e => e.type === 'icon').length,
|
||||
backgroundColor: '#3B82F6',
|
||||
hasBg: false,
|
||||
icon_prompt: data.__icon_query__ ? [data.__icon_query__] : []
|
||||
}
|
||||
});
|
||||
console.log(`✅ Matched icon to DOM element:`, imgElement);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively scan nested objects and arrays
|
||||
Object.keys(data).forEach(key => {
|
||||
const value = data[key];
|
||||
const newPath = path ? `${path}.${key}` : key;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
detectEditableElementsFromData(item, `${newPath}[${index}]`);
|
||||
});
|
||||
} else if (value && typeof value === 'object') {
|
||||
detectEditableElementsFromData(value, newPath);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
detectEditableElementsFromData(slideData);
|
||||
console.log('🎉 Final detected elements:', elements);
|
||||
setEditableElements(elements);
|
||||
};
|
||||
|
||||
const findDOMElementByImageUrl = (container: HTMLElement, targetUrl: string): HTMLImageElement | null => {
|
||||
const allImages = Array.from(container.getElementsByTagName('img'));
|
||||
|
||||
for (const img of allImages) {
|
||||
if (isMatchingImageUrl(img.src, targetUrl)) {
|
||||
return img;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isMatchingImageUrl = (domSrc: string, dataSrc: string): boolean => {
|
||||
// Direct match
|
||||
if (domSrc === dataSrc) return true;
|
||||
|
||||
// Handle app_data paths
|
||||
if (dataSrc.includes('/app_data/images/') || domSrc.includes('/app_data/images/')) {
|
||||
const getFilename = (path: string) => path.split('/').pop() || '';
|
||||
return getFilename(domSrc) === getFilename(dataSrc);
|
||||
}
|
||||
|
||||
// Handle placeholder URLs
|
||||
if (dataSrc.includes('placeholder') || domSrc.includes('placeholder')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract and compare filenames
|
||||
const getFilename = (path: string) => path.split('/').pop() || '';
|
||||
return getFilename(domSrc) === getFilename(dataSrc) && getFilename(domSrc) !== '';
|
||||
};
|
||||
|
||||
// Add event delegation for clicks
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'IMG') {
|
||||
const imgElement = target as HTMLImageElement;
|
||||
const editableElement = editableElements.find(el => el.element === imgElement);
|
||||
|
||||
if (editableElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const rect = imgElement.getBoundingClientRect();
|
||||
setActiveEditor({
|
||||
type: editableElement.type,
|
||||
element: imgElement,
|
||||
props: editableElement.props,
|
||||
rect
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add hover effects
|
||||
const handleMouseEnter = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'IMG') {
|
||||
const imgElement = target as HTMLImageElement;
|
||||
const isEditable = editableElements.some(el => el.element === imgElement);
|
||||
|
||||
if (isEditable) {
|
||||
imgElement.style.cursor = 'pointer';
|
||||
imgElement.style.filter = 'brightness(0.9)';
|
||||
imgElement.style.transition = 'filter 0.2s ease';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'IMG') {
|
||||
const imgElement = target as HTMLImageElement;
|
||||
const isEditable = editableElements.some(el => el.element === imgElement);
|
||||
|
||||
if (isEditable) {
|
||||
imgElement.style.filter = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set up event listeners after elements are found
|
||||
const timer = setTimeout(() => {
|
||||
findEditableElements();
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
container.removeEventListener('click', handleClick);
|
||||
container.removeEventListener('mouseenter', handleMouseEnter, true);
|
||||
container.removeEventListener('mouseleave', handleMouseLeave, true);
|
||||
};
|
||||
}, [slideIndex, slideId, slideData, isEditMode, editableElements]);
|
||||
|
||||
// Set up event listeners when editableElements change
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || editableElements.length === 0) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'IMG') {
|
||||
const imgElement = target as HTMLImageElement;
|
||||
const editableElement = editableElements.find(el => el.element === imgElement);
|
||||
|
||||
if (editableElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const rect = imgElement.getBoundingClientRect();
|
||||
setActiveEditor({
|
||||
type: editableElement.type,
|
||||
element: imgElement,
|
||||
props: editableElement.props,
|
||||
rect
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'IMG') {
|
||||
const imgElement = target as HTMLImageElement;
|
||||
const isEditable = editableElements.some(el => el.element === imgElement);
|
||||
|
||||
if (isEditable) {
|
||||
imgElement.style.cursor = 'pointer';
|
||||
imgElement.style.filter = 'brightness(0.9)';
|
||||
imgElement.style.transition = 'filter 0.2s ease';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'IMG') {
|
||||
const imgElement = target as HTMLImageElement;
|
||||
const isEditable = editableElements.some(el => el.element === imgElement);
|
||||
|
||||
if (isEditable) {
|
||||
imgElement.style.filter = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
container.addEventListener('mouseenter', handleMouseEnter, true);
|
||||
container.addEventListener('mouseleave', handleMouseLeave, true);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('click', handleClick);
|
||||
container.removeEventListener('mouseenter', handleMouseEnter, true);
|
||||
container.removeEventListener('mouseleave', handleMouseLeave, true);
|
||||
};
|
||||
}, [editableElements]);
|
||||
|
||||
return (
|
||||
<SmartEditableContext.Provider value={{ slideIndex, slideId, isEditMode, slideData }}>
|
||||
<div ref={containerRef} className="smart-editable-container">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Render active editor as a modal/overlay */}
|
||||
{activeEditor && (
|
||||
<EditorOverlay
|
||||
activeEditor={activeEditor}
|
||||
onClose={() => setActiveEditor(null)}
|
||||
/>
|
||||
)}
|
||||
</SmartEditableContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Simple overlay component for editors
|
||||
const EditorOverlay: React.FC<{
|
||||
activeEditor: {
|
||||
type: 'image' | 'icon';
|
||||
element: HTMLImageElement;
|
||||
props: any;
|
||||
rect: DOMRect;
|
||||
};
|
||||
onClose: () => void;
|
||||
}> = ({ activeEditor, onClose }) => {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
// Close if clicked outside the editor
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.editor-modal')) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const EditorComponent = activeEditor.type === 'image' ? ImageEditor : IconsEditor;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
||||
<div className="editor-modal">
|
||||
<EditorComponent
|
||||
{...activeEditor.props}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export const useSmartEditable = () => {
|
||||
const context = useContext(SmartEditableContext);
|
||||
if (!context) {
|
||||
throw new Error('useSmartEditable must be used within SmartEditableProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
'use client'
|
||||
import React, { useMemo } from 'react';
|
||||
import { useLayout } from '../context/LayoutContext';
|
||||
import { SmartEditableProvider } from '../components/SmartEditableWrapper';
|
||||
|
||||
export const useGroupLayouts = () => {
|
||||
const {
|
||||
|
|
@ -28,9 +29,9 @@ export const useGroupLayouts = () => {
|
|||
};
|
||||
}, [getLayoutsByGroup]);
|
||||
|
||||
// Render slide content with group validation
|
||||
// Render slide content with group validation and smart editing capabilities
|
||||
const renderSlideContent = useMemo(() => {
|
||||
return (slide: any) => {
|
||||
return (slide: any, isEditMode: boolean = true) => {
|
||||
const Layout = getGroupLayout(slide.layout, slide.layout_group);
|
||||
if (!Layout) {
|
||||
return (
|
||||
|
|
@ -41,6 +42,19 @@ export const useGroupLayouts = () => {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<SmartEditableProvider
|
||||
slideIndex={slide.index}
|
||||
slideId={slide.id || `slide-${slide.index}`}
|
||||
slideData={slide.content}
|
||||
isEditMode={isEditMode}
|
||||
>
|
||||
<Layout data={slide.content} />
|
||||
</SmartEditableProvider>
|
||||
);
|
||||
}
|
||||
return <Layout data={slide.content} />;
|
||||
};
|
||||
}, [getGroupLayout]);
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ const SidePanel = ({
|
|||
<div className=" bg-white relative overflow-hidden aspect-video">
|
||||
<div className="absolute bg-gray-100/5 z-40 top-0 left-0 w-full h-full" />
|
||||
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
|
||||
{renderSlideContent(slide)}
|
||||
{renderSlideContent(slide, false)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -294,7 +294,7 @@ const SidePanel = ({
|
|||
index={index}
|
||||
selectedSlide={selectedSlide}
|
||||
onSlideClick={onSlideClick}
|
||||
renderSlideContent={renderSlideContent}
|
||||
renderSlideContent={(slide) => renderSlideContent(slide, false)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
|
|
|||
|
|
@ -39,22 +39,7 @@ const SlideContent = ({
|
|||
);
|
||||
|
||||
// Use the centralized group layouts hook
|
||||
const { getGroupLayout, loading } = useGroupLayouts();
|
||||
|
||||
// Memoized layout component to prevent re-renders
|
||||
const LayoutComponent = useMemo(() => {
|
||||
const Layout = getGroupLayout(slide.layout, slide.layout_group);
|
||||
if (!Layout) {
|
||||
return () => (
|
||||
<div className="flex flex-col items-center justify-center h-full bg-gray-100 rounded-lg">
|
||||
<p className="text-gray-600 text-center">
|
||||
Layout "{slide.layout}" not found in current group
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return Layout;
|
||||
}, [slide.layout, getGroupLayout]);
|
||||
const { renderSlideContent, loading } = useGroupLayouts();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const element = document.getElementById(
|
||||
|
|
@ -142,8 +127,8 @@ const SlideContent = ({
|
|||
|
||||
// Memoized slide content rendering to prevent unnecessary re-renders
|
||||
const slideContent = useMemo(() => {
|
||||
return <LayoutComponent data={slide.content} />;
|
||||
}, [LayoutComponent, slide.content]);
|
||||
return renderSlideContent(slide, true); // Enable edit mode for main content
|
||||
}, [renderSlideContent, slide]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ interface SortableSlideProps {
|
|||
index: number;
|
||||
selectedSlide: number;
|
||||
onSlideClick: (index: any) => void;
|
||||
renderSlideContent: (slide: any) => React.ReactElement;
|
||||
renderSlideContent: (slide: any, isEditMode?: boolean) => React.ReactElement;
|
||||
}
|
||||
|
||||
export function SortableSlide({ slide, index, selectedSlide, onSlideClick, renderSlideContent }: SortableSlideProps) {
|
||||
|
|
@ -57,7 +57,7 @@ export function SortableSlide({ slide, index, selectedSlide, onSlideClick, rende
|
|||
<div className=" slide-box relative overflow-hidden aspect-video">
|
||||
<div className="absolute bg-transparent z-40 top-0 left-0 w-full h-full" />
|
||||
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
|
||||
{renderSlideContent(slide)}
|
||||
{renderSlideContent(slide, false)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue