refactor(nextjs): enhance image and icon handling in EditableLayoutWrapper
This commit is contained in:
parent
9cf5ba3906
commit
2d49e536ca
12 changed files with 164 additions and 476 deletions
|
|
@ -34,18 +34,20 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
|
|||
const [activeEditor, setActiveEditor] = useState<EditableElement | null>(null);
|
||||
|
||||
/**
|
||||
* Recursively searches for image/icon data in the slide data structure
|
||||
* Recursively searches for ALL image/icon data paths in the slide data structure
|
||||
*/
|
||||
const findDataPath = (targetUrl: string, data: any, path: string = ''): { path: string; type: 'image' | 'icon'; data: any } | null => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const findAllDataPaths = (targetUrl: string, data: any, path: string = ''): { path: string; type: 'image' | 'icon'; data: any }[] => {
|
||||
if (!data || typeof data !== 'object') return [];
|
||||
|
||||
const matches: { path: string; type: 'image' | 'icon'; data: any }[] = [];
|
||||
|
||||
// Check current level for __image_url__ or __icon_url__
|
||||
if (data.__image_url__ && isMatchingUrl(data.__image_url__, targetUrl)) {
|
||||
return { path, type: 'image', data };
|
||||
matches.push({ path, type: 'image', data });
|
||||
}
|
||||
|
||||
if (data.__icon_url__ && isMatchingUrl(data.__icon_url__, targetUrl)) {
|
||||
return { path, type: 'icon', data };
|
||||
matches.push({ path, type: 'icon', data });
|
||||
}
|
||||
|
||||
// Recursively check nested objects and arrays
|
||||
|
|
@ -54,16 +56,53 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
|
|||
|
||||
if (Array.isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const result = findDataPath(targetUrl, value[i], `${newPath}[${i}]`);
|
||||
if (result) return result;
|
||||
const results = findAllDataPaths(targetUrl, value[i], `${newPath}[${i}]`);
|
||||
matches.push(...results);
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
const result = findDataPath(targetUrl, value, newPath);
|
||||
if (result) return result;
|
||||
const results = findAllDataPaths(targetUrl, value, newPath);
|
||||
matches.push(...results);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return matches;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds the best matching data path for a specific DOM element
|
||||
*/
|
||||
const findBestDataPath = (targetUrl: string, imgElement: HTMLImageElement, data: any): { path: string; type: 'image' | 'icon'; data: any } | null => {
|
||||
const allMatches = findAllDataPaths(targetUrl, data);
|
||||
|
||||
if (allMatches.length === 0) return null;
|
||||
if (allMatches.length === 1) return allMatches[0];
|
||||
|
||||
// If multiple matches, use DOM position to find the correct one
|
||||
const allImagesInContainer = containerRef.current?.querySelectorAll('img') || [];
|
||||
const imgIndex = Array.from(allImagesInContainer).indexOf(imgElement);
|
||||
|
||||
// Find images with the same URL pattern
|
||||
const sameUrlImages: HTMLImageElement[] = [];
|
||||
allImagesInContainer.forEach((img) => {
|
||||
if (isMatchingUrl((img as HTMLImageElement).src, targetUrl)) {
|
||||
sameUrlImages.push(img as HTMLImageElement);
|
||||
}
|
||||
});
|
||||
|
||||
const sameUrlIndex = sameUrlImages.indexOf(imgElement);
|
||||
|
||||
// Try to match based on position in the same URL group
|
||||
if (sameUrlIndex >= 0 && sameUrlIndex < allMatches.length) {
|
||||
return allMatches[sameUrlIndex];
|
||||
}
|
||||
|
||||
// Fallback: try to match based on overall DOM position
|
||||
if (imgIndex >= 0 && imgIndex < allMatches.length) {
|
||||
return allMatches[imgIndex];
|
||||
}
|
||||
|
||||
// Last resort: return the first match
|
||||
return allMatches[0];
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -81,30 +120,32 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
|
|||
|
||||
if (cleanUrl1 === cleanUrl2) return true;
|
||||
|
||||
// Handle app_data paths and placeholder URLs
|
||||
if (url1.includes('/app_data/') || url2.includes('/app_data/') ||
|
||||
url1.includes('placeholder') || url2.includes('placeholder')) {
|
||||
// Handle placeholder URLs - be more specific
|
||||
if ((url1.includes('placeholder') && url2.includes('placeholder')) ||
|
||||
(url1.includes('/static/images/') && url2.includes('/static/images/'))) {
|
||||
return url1 === url2; // Require exact match for placeholders
|
||||
}
|
||||
|
||||
// Handle app_data paths - be more specific about filename matching
|
||||
if (url1.includes('/app_data/') || url2.includes('/app_data/')) {
|
||||
const getFilename = (path: string) => path.split('/').pop() || '';
|
||||
const filename1 = getFilename(url1);
|
||||
const filename2 = getFilename(url2);
|
||||
if (filename1 === filename2 && filename1 !== '') return true;
|
||||
if (filename1 === filename2 && filename1 !== '' && filename1.length > 10) { // Ensure significant filename
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and compare filenames for other URLs
|
||||
// Extract and compare filenames for other URLs - be more restrictive
|
||||
const getFilename = (path: string) => path.split('/').pop() || '';
|
||||
const filename1 = getFilename(url1);
|
||||
const filename2 = getFilename(url2);
|
||||
|
||||
if (filename1 === filename2 && filename1 !== '') {
|
||||
if (filename1 === filename2 && filename1 !== '' && filename1.length > 10) { // Ensure significant filename
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if one URL is contained in another (for partial matches)
|
||||
if (url1.includes(url2) || url2.includes(url1)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return false; // Remove the overly permissive substring matching
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -121,7 +162,7 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
|
|||
const src = htmlImg.src;
|
||||
|
||||
if (src) {
|
||||
const result = findDataPath(src, slideData);
|
||||
const result = findBestDataPath(src, htmlImg, slideData);
|
||||
|
||||
if (result) {
|
||||
const { path: dataPath, type, data } = result;
|
||||
|
|
@ -129,6 +170,9 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
|
|||
// Mark as processed to prevent re-processing
|
||||
htmlImg.setAttribute('data-editable-processed', 'true');
|
||||
|
||||
// Add a unique identifier to help with debugging
|
||||
htmlImg.setAttribute('data-editable-id', `${type}-${dataPath}-${index}`);
|
||||
|
||||
const editableElement: EditableElement = {
|
||||
id: `${type}-${dataPath}-${index}`,
|
||||
type,
|
||||
|
|
@ -248,6 +292,8 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
|
|||
*/
|
||||
const handleImageChange = (newImageUrl: string, prompt?: string) => {
|
||||
if (activeEditor && activeEditor.element) {
|
||||
|
||||
|
||||
// Update the DOM element immediately for visual feedback
|
||||
activeEditor.element.src = newImageUrl;
|
||||
|
||||
|
|
@ -259,10 +305,8 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
|
|||
prompt: prompt || activeEditor.data?.__image_prompt__ || ''
|
||||
}));
|
||||
|
||||
setActiveEditor(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles icon change from IconsEditor
|
||||
*/
|
||||
|
|
@ -279,7 +323,6 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
|
|||
query: query || activeEditor.data?.__icon_query__ || ''
|
||||
}));
|
||||
|
||||
setActiveEditor(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -305,8 +348,6 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
|
|||
{/* Render IconsEditor when an icon is being edited */}
|
||||
{activeEditor && activeEditor.type === 'icon' && (
|
||||
<IconsEditor
|
||||
icon={activeEditor.src}
|
||||
index={0}
|
||||
icon_prompt={activeEditor.data?.__icon_query__ ? [activeEditor.data.__icon_query__] : []}
|
||||
onClose={handleEditorClose}
|
||||
onIconChange={handleIconChange}
|
||||
|
|
|
|||
|
|
@ -8,23 +8,18 @@ import {
|
|||
} from "@/components/ui/sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PresentationGenerationApi } from "../services/api/presentation-generation";
|
||||
import { getStaticFileUrl } from "../utils/others";
|
||||
|
||||
interface IconsEditorProps {
|
||||
icon: string;
|
||||
index: number;
|
||||
className?: string;
|
||||
icon_prompt?: string[] | null;
|
||||
onClose?: () => void;
|
||||
onIconChange?: (newIconUrl: string, query?: string) => void;
|
||||
}
|
||||
|
||||
const IconsEditor = ({
|
||||
icon: initialIcon,
|
||||
icon_prompt,
|
||||
onClose,
|
||||
onIconChange,
|
||||
|
|
@ -36,9 +31,7 @@ const IconsEditor = ({
|
|||
icon_prompt?.[0] || ""
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
||||
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
// Search for icons when component opens
|
||||
useEffect(() => {
|
||||
|
|
@ -59,7 +52,6 @@ const IconsEditor = ({
|
|||
query,
|
||||
limit: 40,
|
||||
});
|
||||
console.log("icons search data", data);
|
||||
setIcons(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching icons:", error);
|
||||
|
|
@ -79,11 +71,20 @@ const IconsEditor = ({
|
|||
}
|
||||
};
|
||||
|
||||
// Handle close with animation
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
// Delay the actual close to allow animation to complete
|
||||
setTimeout(() => {
|
||||
onClose?.();
|
||||
}, 300); // Match the Sheet animation duration
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="icons-editor-container">
|
||||
|
||||
|
||||
<Sheet open={true} onOpenChange={() => onClose?.()}>
|
||||
<Sheet open={isOpen} onOpenChange={() => handleClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[400px]"
|
||||
|
|
|
|||
|
|
@ -12,15 +12,10 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import {
|
||||
Wand2,
|
||||
Upload,
|
||||
Move,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSelector } from "react-redux";
|
||||
import { PresentationGenerationApi } from "../services/api/presentation-generation";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ThemeImagePrompt } from "../utils/others";
|
||||
|
||||
interface ImageEditorProps {
|
||||
initialImage: string | null;
|
||||
|
|
@ -44,16 +39,15 @@ const ImageEditor = ({
|
|||
onImageChange,
|
||||
|
||||
}: ImageEditorProps) => {
|
||||
|
||||
// State management
|
||||
const [image, setImage] = useState(initialImage);
|
||||
const [previewImages, setPreviewImages] = useState([initialImage]);
|
||||
const [previewImages, setPreviewImages] = useState(initialImage);
|
||||
const [prompt, setPrompt] = useState<string>("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
// Focus point and object fit for image editing
|
||||
const [isFocusPointMode, setIsFocusPointMode] = useState(false);
|
||||
|
|
@ -71,6 +65,7 @@ const ImageEditor = ({
|
|||
properties[imageIdx].initialObjectFit) ||
|
||||
"cover"
|
||||
);
|
||||
console.log("previewImages", previewImages);
|
||||
|
||||
// Refs
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
|
|
@ -78,12 +73,19 @@ const ImageEditor = ({
|
|||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const popoverContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Update local state when initial image changes
|
||||
useEffect(() => {
|
||||
setImage(initialImage);
|
||||
setPreviewImages([initialImage]);
|
||||
setPreviewImages(initialImage);
|
||||
}, [initialImage]);
|
||||
|
||||
// Handle close with animation
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
// Delay the actual close to allow animation to complete
|
||||
setTimeout(() => {
|
||||
onClose?.();
|
||||
}, 300); // Match the Sheet animation duration
|
||||
};
|
||||
|
||||
// Close toolbar when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
|
|
@ -111,10 +113,11 @@ const ImageEditor = ({
|
|||
* Handles image selection and calls the parent callback
|
||||
*/
|
||||
const handleImageChange = (newImage: string) => {
|
||||
setImage(newImage);
|
||||
|
||||
|
||||
if (onImageChange) {
|
||||
onImageChange(newImage, promptContent);
|
||||
setPreviewImages(newImage);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -188,7 +191,6 @@ const ImageEditor = ({
|
|||
setError("Please enter a prompt");
|
||||
return;
|
||||
}
|
||||
console.log("prompt", prompt);
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
|
@ -196,7 +198,7 @@ const ImageEditor = ({
|
|||
prompt: prompt,
|
||||
});
|
||||
|
||||
setPreviewImages(response.paths);
|
||||
setPreviewImages(response);
|
||||
} catch (err) {
|
||||
console.error("Error in image generation", err);
|
||||
setError("Failed to generate image. Please try again.");
|
||||
|
|
@ -257,7 +259,7 @@ const ImageEditor = ({
|
|||
<div className="image-editor-container">
|
||||
|
||||
|
||||
<Sheet open={true} onOpenChange={() => onClose?.()}>
|
||||
<Sheet open={isOpen} onOpenChange={() => handleClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[600px]"
|
||||
|
|
@ -308,28 +310,26 @@ const ImageEditor = ({
|
|||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{isGenerating || previewImages.length === 0
|
||||
{isGenerating || !previewImages
|
||||
? 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 hover:border-blue-500 transition-colors"
|
||||
>
|
||||
{image && (
|
||||
<img
|
||||
src={image}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
: <div
|
||||
onClick={() => handleImageChange(previewImages)}
|
||||
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors"
|
||||
>
|
||||
{previewImages && (
|
||||
<img
|
||||
src={previewImages}
|
||||
alt={`Preview`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -1,329 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useRef, useEffect, ReactNode, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateSlideImage, updateSlideIcon } from '../../../store/slices/presentationGeneration';
|
||||
import ImageEditor from './ImageEditor';
|
||||
import IconsEditor from './IconsEditor';
|
||||
|
||||
interface EditableElement {
|
||||
type: 'image' | 'icon';
|
||||
element: HTMLElement;
|
||||
dataPath?: string;
|
||||
props: any;
|
||||
}
|
||||
|
||||
interface SmartEditableContextType {
|
||||
slideIndex: number;
|
||||
slideId: string;
|
||||
isEditMode: boolean;
|
||||
slideData: any;
|
||||
}
|
||||
|
||||
interface SmartEditableProviderProps {
|
||||
children: ReactNode;
|
||||
slideIndex: number;
|
||||
slideId: string;
|
||||
slideData: any;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
const SmartEditableContext = createContext<SmartEditableContextType | null>(null);
|
||||
|
||||
export const useSmartEditable = () => {
|
||||
const context = useContext(SmartEditableContext);
|
||||
if (!context) {
|
||||
throw new Error('useSmartEditable must be used within SmartEditableProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
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: HTMLElement;
|
||||
props: any;
|
||||
rect: DOMRect;
|
||||
} | null>(null);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode || !containerRef.current || !slideData) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
|
||||
const findEditableElements = () => {
|
||||
const elements: EditableElement[] = [];
|
||||
|
||||
console.log('🔍 Starting smart detection with slideData:', slideData);
|
||||
|
||||
// Detect Images and Icons only (text is now handled by TiptapTextReplacer)
|
||||
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,
|
||||
initialImage: data.__image_url__,
|
||||
promptContent: data.__image_prompt__ || '',
|
||||
imageIdx: elements.filter(e => e.type === 'image').length,
|
||||
onImageChange: (newImageUrl: string, prompt?: string) => {
|
||||
console.log(`🖼️ Image changed at ${path}:`, newImageUrl);
|
||||
dispatch(updateSlideImage({
|
||||
slideIndex,
|
||||
dataPath: path,
|
||||
imageUrl: newImageUrl,
|
||||
prompt: prompt
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
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__] : [],
|
||||
onIconChange: (newIconUrl: string, query?: string) => {
|
||||
console.log(`🎯 Icon changed at ${path}:`, newIconUrl);
|
||||
dispatch(updateSlideIcon({
|
||||
slideIndex,
|
||||
dataPath: path,
|
||||
iconUrl: newIconUrl,
|
||||
query: 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) !== '';
|
||||
};
|
||||
|
||||
// Set up event listeners after elements are found
|
||||
const timer = setTimeout(() => {
|
||||
findEditableElements();
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [slideIndex, slideId, slideData, isEditMode, dispatch]);
|
||||
|
||||
// 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;
|
||||
|
||||
// Handle image/icon clicks only
|
||||
if (target.tagName === 'IMG') {
|
||||
const imgElement = target as HTMLImageElement;
|
||||
const editableElement = editableElements.find(el => el.element === imgElement);
|
||||
|
||||
if (editableElement && (editableElement.type === 'image' || editableElement.type === 'icon')) {
|
||||
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;
|
||||
|
||||
// Handle image/icon hover only
|
||||
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;
|
||||
|
||||
// Handle image/icon hover only
|
||||
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: HTMLElement;
|
||||
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]);
|
||||
|
||||
// Handle image/icon editing in modal
|
||||
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
|
||||
);
|
||||
};
|
||||
|
|
@ -166,7 +166,6 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
|
|||
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ export const useGroupLayouts = () => {
|
|||
isEditMode={isEditMode}
|
||||
layout={Layout}
|
||||
onContentChange={(content: string, dataPath: string, slideIndex?: number) => {
|
||||
console.log(`Text content changed at slide ${slideIndex}, path ${dataPath}:`, content);
|
||||
|
||||
// Dispatch Redux action to update slide content
|
||||
if (dataPath && slideIndex !== undefined) {
|
||||
|
|
|
|||
|
|
@ -21,10 +21,7 @@ const GenerateButton: React.FC<GenerateButtonProps> = ({
|
|||
const isDisabled =
|
||||
loadingState.isLoading ||
|
||||
streamState.isLoading ||
|
||||
streamState.isStreaming ||
|
||||
!outlines ||
|
||||
outlines.length === 0 ||
|
||||
!selectedLayoutGroup;
|
||||
streamState.isStreaming
|
||||
|
||||
const getButtonText = () => {
|
||||
if (loadingState.isLoading) return loadingState.message;
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ const OutlinePage: React.FC = () => {
|
|||
const { loadingState, handleSubmit } = usePresentationGeneration(
|
||||
presentation_id,
|
||||
outlines,
|
||||
selectedLayoutGroup
|
||||
selectedLayoutGroup,
|
||||
setActiveTab
|
||||
);
|
||||
|
||||
if (!presentation_id) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { toast } from "@/hooks/use-toast";
|
|||
import { clearPresentationData, setPresentationData, SlideOutline } from "@/store/slices/presentationGeneration";
|
||||
import { PresentationGenerationApi } from "../../services/api/presentation-generation";
|
||||
import { useLayout } from "../../context/LayoutContext";
|
||||
import { LayoutGroup, LoadingState } from "../types/index";
|
||||
import { LayoutGroup, LoadingState, TABS } from "../types/index";
|
||||
|
||||
const DEFAULT_LOADING_STATE: LoadingState = {
|
||||
message: "",
|
||||
|
|
@ -17,7 +17,8 @@ const DEFAULT_LOADING_STATE: LoadingState = {
|
|||
export const usePresentationGeneration = (
|
||||
presentationId: string | null,
|
||||
outlines: SlideOutline[] | null,
|
||||
selectedLayoutGroup: LayoutGroup | null
|
||||
selectedLayoutGroup: LayoutGroup | null,
|
||||
setActiveTab: (tab: string) => void
|
||||
) => {
|
||||
const dispatch = useDispatch();
|
||||
const router = useRouter();
|
||||
|
|
@ -69,7 +70,10 @@ export const usePresentationGeneration = (
|
|||
}, [selectedLayoutGroup, getLayoutById]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
|
||||
if (!selectedLayoutGroup) {
|
||||
setActiveTab(TABS.LAYOUTS);
|
||||
return;
|
||||
}
|
||||
if (!validateInputs()) return;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
|
@ -8,30 +8,21 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { DashboardApi } from "@/app/dashboard/api/dashboard";
|
||||
|
||||
|
||||
import {
|
||||
setPresentationData,
|
||||
} from "@/store/slices/presentationGeneration";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { setThemeColors, ThemeColors } from "../store/themeSlice";
|
||||
import { ThemeType } from "../upload/type";
|
||||
import { renderSlideContent } from "../components/slide_config";
|
||||
|
||||
import { useGroupLayouts } from "../hooks/useGroupLayouts";
|
||||
import { setPresentationData } from "@/store/slices/presentationGeneration";
|
||||
|
||||
|
||||
|
||||
const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
|
||||
|
||||
const { renderSlideContent, loading } = useGroupLayouts();
|
||||
const [contentLoading, setContentLoading] = useState(true);
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { currentTheme, currentColors } = useSelector(
|
||||
(state: RootState) => state.theme
|
||||
);
|
||||
const { presentationData } = useSelector(
|
||||
(state: RootState) => state.presentationGeneration
|
||||
);
|
||||
|
|
@ -45,22 +36,8 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
|
|||
const fetchUserSlides = async () => {
|
||||
try {
|
||||
const data = await DashboardApi.getPresentation(presentation_id);
|
||||
if (data) {
|
||||
if (data.presentation.theme) {
|
||||
dispatch(
|
||||
setThemeColors({
|
||||
...data.presentation.theme.colors,
|
||||
theme: data.presentation.theme.name as ThemeType,
|
||||
})
|
||||
);
|
||||
setColorsVariables(
|
||||
data.presentation.theme.colors,
|
||||
data.presentation.theme.name as ThemeType
|
||||
);
|
||||
}
|
||||
dispatch(setPresentationData(data));
|
||||
setLoading(false);
|
||||
}
|
||||
dispatch(setPresentationData(data));
|
||||
setContentLoading(false);
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
toast({
|
||||
|
|
@ -68,19 +45,11 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
|
|||
description: "Failed to load presentation",
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
console.error("Error fetching user slides:", error);
|
||||
setLoading(false);
|
||||
setContentLoading(false);
|
||||
}
|
||||
};
|
||||
const setColorsVariables = (colors: ThemeColors, theme: ThemeType) => {
|
||||
const root = document.documentElement;
|
||||
Object.entries(colors).forEach(([key, value]) => {
|
||||
const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||
root.style.setProperty(`--${theme}-${cssKey}`, value);
|
||||
});
|
||||
};
|
||||
const language = presentationData?.presentation?.language || "English";
|
||||
console.log("presentationData", presentationData);
|
||||
// Regular view
|
||||
return (
|
||||
<div className="flex overflow-hidden flex-col">
|
||||
|
|
@ -109,15 +78,14 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
|
|||
) : (
|
||||
|
||||
|
||||
<div style={{
|
||||
background: currentColors.background,
|
||||
}} className="">
|
||||
<div className="">
|
||||
<div
|
||||
className="mx-auto flex flex-col items-center overflow-hidden justify-center slide-theme"
|
||||
data-theme={currentTheme}
|
||||
className="mx-auto flex flex-col items-center overflow-hidden justify-center "
|
||||
|
||||
>
|
||||
{!presentationData ||
|
||||
loading ||
|
||||
contentLoading ||
|
||||
!presentationData?.slides ||
|
||||
presentationData?.slides.length === 0 ? (
|
||||
<div className="relative w-full h-[calc(100vh-120px)] mx-auto ">
|
||||
|
|
@ -136,10 +104,10 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
|
|||
{presentationData &&
|
||||
presentationData.slides &&
|
||||
presentationData.slides.length > 0 &&
|
||||
presentationData.slides.map((slide, index) => (
|
||||
presentationData.slides.map((slide: any, index: number) => (
|
||||
<div key={index} className="w-full">
|
||||
|
||||
{renderSlideContent(slide, language)}
|
||||
{renderSlideContent(slide, false)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ export const PresentationCard = ({
|
|||
description: "The presentation has been deleted successfully",
|
||||
variant: "default",
|
||||
});
|
||||
// Call the onDeleted callback to update the parent state
|
||||
if (onDeleted) {
|
||||
onDeleted(id);
|
||||
}
|
||||
|
|
@ -63,7 +62,6 @@ export const PresentationCard = ({
|
|||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
// Removed window.location.reload() - no longer needed
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -69,9 +69,6 @@ const presentationGenerationSlice = createSlice({
|
|||
},
|
||||
// Clear presentation data
|
||||
clearPresentationData: (state) => {
|
||||
state.presentation_id = null;
|
||||
state.error = null;
|
||||
state.isLoading = false;
|
||||
state.presentationData = null;
|
||||
},
|
||||
clearOutlines: (state) => {
|
||||
|
|
@ -238,17 +235,23 @@ const presentationGenerationSlice = createSlice({
|
|||
|
||||
// Set the image properties
|
||||
const finalKey = keys[keys.length - 1];
|
||||
const target = isNaN(Number(finalKey)) ? current[finalKey] : current[Number(finalKey)];
|
||||
|
||||
// Preserve existing properties if the target already exists
|
||||
const updatedValue = {
|
||||
...(target && typeof target === 'object' ? target : {}),
|
||||
__image_url__: url,
|
||||
__image_prompt__: promptText || (target?.__image_prompt__) || ''
|
||||
};
|
||||
|
||||
if (isNaN(Number(finalKey))) {
|
||||
current[finalKey] = {
|
||||
__image_url__: url,
|
||||
__image_prompt__: promptText || ''
|
||||
};
|
||||
current[finalKey] = updatedValue;
|
||||
} else {
|
||||
current[Number(finalKey)] = {
|
||||
__image_url__: url,
|
||||
__image_prompt__: promptText || ''
|
||||
};
|
||||
current[Number(finalKey)] = updatedValue;
|
||||
}
|
||||
|
||||
// Add debugging
|
||||
console.log('Redux: Updated slide image at path:', path, 'with URL:', url);
|
||||
};
|
||||
|
||||
// Update the slide image
|
||||
|
|
@ -308,17 +311,23 @@ const presentationGenerationSlice = createSlice({
|
|||
|
||||
// Set the icon properties
|
||||
const finalKey = keys[keys.length - 1];
|
||||
const target = isNaN(Number(finalKey)) ? current[finalKey] : current[Number(finalKey)];
|
||||
|
||||
// Preserve existing properties if the target already exists
|
||||
const updatedValue = {
|
||||
...(target && typeof target === 'object' ? target : {}),
|
||||
__icon_url__: url,
|
||||
__icon_query__: queryText || (target?.__icon_query__) || ''
|
||||
};
|
||||
|
||||
if (isNaN(Number(finalKey))) {
|
||||
current[finalKey] = {
|
||||
__icon_url__: url,
|
||||
__icon_query__: queryText || ''
|
||||
};
|
||||
current[finalKey] = updatedValue;
|
||||
} else {
|
||||
current[Number(finalKey)] = {
|
||||
__icon_url__: url,
|
||||
__icon_query__: queryText || ''
|
||||
};
|
||||
current[Number(finalKey)] = updatedValue;
|
||||
}
|
||||
|
||||
// Add debugging
|
||||
console.log('Redux: Updated slide icon at path:', path, 'with URL:', url);
|
||||
};
|
||||
|
||||
// Update the slide icon
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue