feat(Nextjs): Basic image & icon editor

This commit is contained in:
shiva raj badu 2025-07-18 03:50:09 +05:45
parent a40984d4da
commit 54ef377226
7 changed files with 605 additions and 494 deletions

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

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

View file

@ -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]);

View file

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

View file

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

View file

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