presenton/servers/nextjs/app/(presentation-generator)/components/EditableLayoutWrapper.tsx

362 lines
No EOL
13 KiB
TypeScript

"use client";
import React, { ReactNode, useRef, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { updateSlideImage, updateSlideIcon } from '@/store/slices/presentationGeneration';
import ImageEditor from './ImageEditor';
import IconsEditor from './IconsEditor';
interface EditableLayoutWrapperProps {
children: ReactNode;
slideIndex: number;
slideData: any;
isEditMode?: boolean;
}
interface EditableElement {
id: string;
type: 'image' | 'icon';
src: string;
dataPath: string;
data: any;
element: HTMLImageElement;
}
const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
children,
slideIndex,
slideData,
}) => {
const dispatch = useDispatch();
const containerRef = useRef<HTMLDivElement>(null);
const [editableElements, setEditableElements] = useState<EditableElement[]>([]);
const [activeEditor, setActiveEditor] = useState<EditableElement | null>(null);
/**
* Recursively searches for ALL image/icon data paths in the slide data structure
*/
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__ && targetUrl.includes(data.__image_url__)) {
matches.push({ path, type: 'image', data });
}
if (data.__icon_url__ && targetUrl.includes(data.__icon_url__)) {
matches.push({ path, type: 'icon', data });
}
// Recursively check nested objects and arrays
for (const [key, value] of Object.entries(data)) {
const newPath = path ? `${path}.${key}` : key;
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const results = findAllDataPaths(targetUrl, value[i], `${newPath}[${i}]`);
matches.push(...results);
}
} else if (value && typeof value === 'object') {
const results = findAllDataPaths(targetUrl, value, newPath);
matches.push(...results);
}
}
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];
};
/**
* Checks if two URLs match using various comparison strategies
*/
const isMatchingUrl = (url1: string, url2: string): boolean => {
if (!url1 || !url2) return false;
// Direct match
if (url1 === url2) return true;
// Remove protocol and domain differences
const cleanUrl1 = url1 && url1.replace(/^https?:\/\/[^\/]+/, '').replace(/^\/+/, '');
const cleanUrl2 = url2 && url2.replace(/^https?:\/\/[^\/]+/, '').replace(/^\/+/, '');
if (cleanUrl1 === cleanUrl2) return true;
// 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 !== '' && filename1.length > 10) { // Ensure significant filename
return true;
}
}
// 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 !== '' && filename1.length > 10) { // Ensure significant filename
return true;
}
return false; // Remove the overly permissive substring matching
};
/**
* Finds and processes images in the DOM, making them editable
*/
const findAndProcessImages = () => {
if (!containerRef.current) return;
const imgElements = containerRef.current.querySelectorAll('img:not([data-editable-processed])');
const newEditableElements: EditableElement[] = [];
imgElements.forEach((img, index) => {
const htmlImg = img as HTMLImageElement;
const src = htmlImg.src;
if (src) {
const result = findBestDataPath(src, htmlImg, slideData);
if (result) {
const { path: dataPath, type, data } = result;
// 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,
src,
dataPath,
data,
element: htmlImg
};
newEditableElements.push(editableElement);
// Add click handler directly to the image
const clickHandler = (e: Event) => {
e.preventDefault();
e.stopPropagation();
setActiveEditor(editableElement);
};
htmlImg.addEventListener('click', clickHandler);
// Add hover effects without changing layout
htmlImg.style.cursor = 'pointer';
htmlImg.style.transition = 'filter 0.2s, transform 0.2s';
const mouseEnterHandler = () => {
htmlImg.style.filter = 'brightness(0.9)';
};
const mouseLeaveHandler = () => {
htmlImg.style.filter = 'brightness(1)';
};
htmlImg.addEventListener('mouseenter', mouseEnterHandler);
htmlImg.addEventListener('mouseleave', mouseLeaveHandler);
// Store cleanup functions
(htmlImg as any)._editableCleanup = () => {
htmlImg.removeEventListener('click', clickHandler);
htmlImg.removeEventListener('mouseenter', mouseEnterHandler);
htmlImg.removeEventListener('mouseleave', mouseLeaveHandler);
htmlImg.style.cursor = '';
htmlImg.style.transition = '';
htmlImg.style.filter = '';
htmlImg.style.transform = '';
htmlImg.removeAttribute('data-editable-processed');
};
}
}
});
setEditableElements(prev => [...prev, ...newEditableElements]);
};
/**
* Cleanup function to remove event listeners and reset styles
*/
const cleanupElements = () => {
editableElements.forEach(({ element }) => {
if ((element as any)._editableCleanup) {
(element as any)._editableCleanup();
}
});
setEditableElements([]);
};
// Wait for LoadableComponent to render and then process images
useEffect(() => {
const timer = setTimeout(() => {
findAndProcessImages();
}, 300);
return () => {
clearTimeout(timer);
cleanupElements();
};
}, [slideData, children]);
// Re-run when container content changes
useEffect(() => {
if (!containerRef.current) return;
const observer = new MutationObserver((mutations) => {
const hasNewImages = mutations.some(mutation =>
Array.from(mutation.addedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(
(node as Element).tagName === 'IMG' ||
(node as Element).querySelector('img:not([data-editable-processed])')
)
)
);
if (hasNewImages) {
setTimeout(findAndProcessImages, 100);
}
});
observer.observe(containerRef.current, {
childList: true,
subtree: true
});
return () => observer.disconnect();
}, [slideData]);
/**
* Handles closing the active editor
*/
const handleEditorClose = () => {
setActiveEditor(null);
};
/**
* Handles image change from ImageEditor
*/
const handleImageChange = (newImageUrl: string, prompt?: string) => {
if (activeEditor && activeEditor.element) {
// Update the DOM element immediately for visual feedback
activeEditor.element.src = newImageUrl;
// Update Redux store
dispatch(updateSlideImage({
slideIndex,
dataPath: activeEditor.dataPath,
imageUrl: newImageUrl,
prompt: prompt || activeEditor.data?.__image_prompt__ || ''
}));
setActiveEditor(null);
}
};
/**
* Handles icon change from IconsEditor
*/
const handleIconChange = (newIconUrl: string, query?: string) => {
if (activeEditor && activeEditor.element) {
// Update the DOM element immediately for visual feedback
activeEditor.element.src = newIconUrl;
// Update Redux store
dispatch(updateSlideIcon({
slideIndex,
dataPath: activeEditor.dataPath,
iconUrl: newIconUrl,
query: query || activeEditor.data?.__icon_query__ || ''
}));
}
};
return (
<div ref={containerRef} className="editable-layout-wrapper">
{children}
{/* Render ImageEditor when an image is being edited */}
{activeEditor && activeEditor.type === 'image' && (
<ImageEditor
initialImage={activeEditor.src}
slideIndex={slideIndex}
promptContent={activeEditor.data?.__image_prompt__ || ''}
imageIdx={0}
properties={null}
onClose={handleEditorClose}
onImageChange={handleImageChange}
>
</ImageEditor>
)}
{/* Render IconsEditor when an icon is being edited */}
{activeEditor && activeEditor.type === 'icon' && (
<IconsEditor
icon_prompt={activeEditor.data?.__icon_query__ ? [activeEditor.data.__icon_query__] : []}
onClose={handleEditorClose}
onIconChange={handleIconChange}
>
</IconsEditor>
)}
</div>
);
};
export default EditableLayoutWrapper;