From f94f345bd6d71c459c43ea9f6e4c9eb3e4bb9be5 Mon Sep 17 00:00:00 2001 From: shiva raj badu Date: Wed, 27 Aug 2025 17:41:57 +0545 Subject: [PATCH 1/2] feat(Nextjs): Undo Redo Feature on presentation added --- Dockerfile.dev | 2 +- .../components/TiptapTextReplacer.tsx | 58 +++++++- .../hooks/use-keyboard-shortcut.ts | 33 +++++ .../components/PresentationPage.tsx | 3 + .../presentation/components/SlideContent.tsx | 7 + .../hooks/PresentationUndoRedo.ts | 84 +++++++++++ .../presentation/hooks/useAutoSave.tsx | 13 +- .../presentation/hooks/usePresentationData.ts | 8 +- .../components/ConfigurationSelects.tsx | 4 +- servers/nextjs/store/slices/undoRedoSlice.ts | 136 ++++++++++++++++++ servers/nextjs/store/store.ts | 2 + 11 files changed, 342 insertions(+), 8 deletions(-) create mode 100644 servers/nextjs/app/(presentation-generator)/hooks/use-keyboard-shortcut.ts create mode 100644 servers/nextjs/app/(presentation-generator)/presentation/hooks/PresentationUndoRedo.ts create mode 100644 servers/nextjs/store/slices/undoRedoSlice.ts diff --git a/Dockerfile.dev b/Dockerfile.dev index 6de18da2..1dabde3e 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -27,7 +27,7 @@ ENV TEMP_DIRECTORY=/tmp/presenton # ENV PYTHONPATH="${PYTHONPATH}:/app/servers/fastapi" # Install ollama -RUN curl -fsSL http://ollama.com/install.sh | sh +# RUN curl -fsSL http://ollama.com/install.sh | sh # Install dependencies for FastAPI RUN pip install aiohttp aiomysql aiosqlite asyncpg fastapi[standard] \ diff --git a/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx b/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx index 5c488601..3037d490 100644 --- a/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx @@ -80,6 +80,12 @@ const TiptapTextReplacer: React.FC = ({ setProcessedElements((prev) => new Set(prev).add(htmlElement)); // Render TiptapText const root = ReactDOM.createRoot(tiptapContainer); + // Tag the container so we can update just this node on slideData changes + if (dataPath?.path) { + tiptapContainer.setAttribute("data-tiptap-path", dataPath.path); + } + tiptapContainer.setAttribute("data-tiptap-tag", htmlElement.tagName); + tiptapContainer.setAttribute("data-tiptap-value", trimmedText); root.render( = ({ if (text.length < 2) return true; // Skip elements that look like numbers or single characters (might be icons/UI) - if (/^[0-9]+$/.test(text) || text.length === 1) return true; + // if (/^[0-9]+$/.test(text) || text.length === 1) return true; + if (text.length <3) return true; return false; }; @@ -290,13 +297,60 @@ const TiptapTextReplacer: React.FC = ({ }; // Replace text elements after a short delay to ensure DOM is ready - const timer = setTimeout(replaceTextElements, 500); + const timer = setTimeout(replaceTextElements, 1000); return () => { clearTimeout(timer); }; }, [slideData, slideIndex]); + // Update only the changed editors when slideData changes + useEffect(() => { + if (!containerRef.current) return; + + const getNestedValue = (data: any, path: string): string => { + if (!data) return ""; + const keys = path.split(/[.\[\]]+/).filter(Boolean); + let current: any = data; + for (const key of keys) { + if (current == null) return ""; + if (isNaN(Number(key))) current = current[key]; + else current = current[Number(key)]; + } + return typeof current === "string" ? current : ""; + }; + + const nodes = containerRef.current.querySelectorAll( + '[data-tiptap-path]' + ); + nodes.forEach((node) => { + const path = node.getAttribute('data-tiptap-path'); + if (!path) return; + const nextValue = getNestedValue(slideData, path); + const prevValue = node.getAttribute('data-tiptap-value') || ''; + if (nextValue === prevValue) return; + + const root = (node as any).__tiptapRoot as ReactDOM.Root | undefined; + const originalEl = (node as any).__tiptapElement as HTMLElement | undefined; + const tag = node.getAttribute('data-tiptap-tag') || 'P'; + if (!root || !originalEl) return; + + node.setAttribute('data-tiptap-value', nextValue); + root.render( + { + if (path && onContentChange) onContentChange(content, path, slideIndex); + node.setAttribute('data-tiptap-value', content); + }} + placeholder="Enter text..." + /> + ); + }); + }, [slideData, slideIndex, onContentChange]); return (
{children} diff --git a/servers/nextjs/app/(presentation-generator)/hooks/use-keyboard-shortcut.ts b/servers/nextjs/app/(presentation-generator)/hooks/use-keyboard-shortcut.ts new file mode 100644 index 00000000..f5d4b875 --- /dev/null +++ b/servers/nextjs/app/(presentation-generator)/hooks/use-keyboard-shortcut.ts @@ -0,0 +1,33 @@ +import { useEffect, useCallback } from 'react'; + +type KeyboardEvent = { + key: string; + ctrlKey: boolean; + shiftKey: boolean; + preventDefault: () => void; +}; + +export const useKeyboardShortcut = ( + keys: string[], + callback: (e: KeyboardEvent) => void, + deps: any[] = [] +) => { + const handleKeyPress = useCallback( + (event: KeyboardEvent) => { + const isCtrlPressed = event.ctrlKey; + + if (keys.includes(event.key.toLowerCase()) && isCtrlPressed) { + event.preventDefault(); + callback(event); + } + }, + [callback, ...deps] + ); + + useEffect(() => { + document.addEventListener('keydown', handleKeyPress as any); + return () => { + document.removeEventListener('keydown', handleKeyPress as any); + }; + }, [handleKeyPress]); +}; \ No newline at end of file diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx index 4df3792b..0a65d37c 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx @@ -22,6 +22,7 @@ import { PresentationPageProps } from "../types"; import LoadingState from "./LoadingState"; import { useLayout } from "../../context/LayoutContext"; import { useFontLoader } from "../../hooks/useFontLoader"; +import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo"; const PresentationPage: React.FC = ({ presentation_id, }) => { @@ -74,6 +75,8 @@ const PresentationPage: React.FC = ({ fetchUserSlides ); + usePresentationUndoRedo(); + const onSlideChange = (newSlide: number) => { handleSlideChange(newSlide, presentationData); }; diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx index 689840f3..ef5cbf1d 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx @@ -20,6 +20,7 @@ import { useGroupLayouts } from "../../hooks/useGroupLayouts"; import { usePathname } from "next/navigation"; import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import NewSlide from "../../components/NewSlide"; +import { addToHistory } from "@/store/slices/undoRedoSlice"; interface SlideContentProps { slide: any; @@ -73,7 +74,13 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { const onDeleteSlide = async () => { try { trackEvent(MixpanelEvent.Slide_Delete_API_Call); + // Add current state to past + dispatch(addToHistory({ + slides: presentationData?.slides, + actionType: "DELETE_SLIDE" + })); dispatch(deletePresentationSlide(slide.index)); + } catch (error: any) { console.error("Error deleting slide:", error); toast.error("Error deleting slide.", { diff --git a/servers/nextjs/app/(presentation-generator)/presentation/hooks/PresentationUndoRedo.ts b/servers/nextjs/app/(presentation-generator)/presentation/hooks/PresentationUndoRedo.ts new file mode 100644 index 00000000..1652f001 --- /dev/null +++ b/servers/nextjs/app/(presentation-generator)/presentation/hooks/PresentationUndoRedo.ts @@ -0,0 +1,84 @@ +import { useDispatch, useSelector } from "react-redux"; +import { RootState } from "@/store/store"; +import { finishUndoRedo, redo, undo } from "@/store/slices/undoRedoSlice"; +import { useKeyboardShortcut } from "../../hooks/use-keyboard-shortcut"; +import { setPresentationData } from "@/store/slices/presentationGeneration"; + + + + + +export const usePresentationUndoRedo = () => { + const dispatch = useDispatch(); + const undoRedoState = useSelector((state: RootState) => state.undoRedo); + const { presentationData } = useSelector((state: RootState) => state.presentationGeneration); + + // Handle undo + useKeyboardShortcut( + ["z"], + (e) => { + if (e.ctrlKey && !e.shiftKey && undoRedoState.past.length > 0) { + e.preventDefault(); + + // Get the previous state before dispatching undo + const previousState = undoRedoState.past[undoRedoState.past.length - 1]; + + // Perform undo + dispatch(undo()); + + // Use the previousState directly instead of relying on the updated undoRedoState + if (previousState) { + // Create a deep copy to ensure no reference issues + const newSlides = JSON.parse(JSON.stringify(previousState.slides)); + + // Update the presentation data with the properly structured slides + dispatch( + setPresentationData({ + ...presentationData!, + slides: newSlides, + }) + ); + } + // Reset the undo/redo flag + setTimeout(() => { + dispatch(finishUndoRedo()); + }, 100); + } + }, + [undoRedoState.past, presentationData] + ); + // Handle redo + useKeyboardShortcut( + ["z"], + (e) => { + if (e.ctrlKey && e.shiftKey && undoRedoState.future.length > 0) { + e.preventDefault(); + + // Get the next state before dispatching redo + const nextState = undoRedoState.future[0]; + + // Perform redo + dispatch(redo()); + + // Use the nextState directly instead of relying on the updated undoRedoState + if (nextState) { + // Create a deep copy to ensure no reference issues + const newSlides = JSON.parse(JSON.stringify(nextState.slides)); + + // Update the presentation data with the properly structured slides + dispatch( + setPresentationData({ + ...presentationData!, + slides: newSlides, + }) + ); + } + // Reset the undo/redo flag + setTimeout(() => { + dispatch(finishUndoRedo()); + }, 100); + } + }, + [undoRedoState.future, presentationData] + ); +} \ No newline at end of file diff --git a/servers/nextjs/app/(presentation-generator)/presentation/hooks/useAutoSave.tsx b/servers/nextjs/app/(presentation-generator)/presentation/hooks/useAutoSave.tsx index cb4a5107..cee3058d 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/hooks/useAutoSave.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/hooks/useAutoSave.tsx @@ -1,8 +1,10 @@ 'use client' import { useEffect, useRef, useCallback, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { RootState } from '@/store/store'; import { PresentationGenerationApi } from '../../services/api/presentation-generation'; +import { addToHistory } from '@/store/slices/undoRedoSlice'; +import { Slide } from '../../types/slide'; interface UseAutoSaveOptions { debounceMs?: number; @@ -13,6 +15,7 @@ export const useAutoSave = ({ debounceMs = 2000, enabled = true, }: UseAutoSaveOptions = {}) => { + const dispatch = useDispatch(); const { presentationData, isStreaming, isLoading, isLayoutLoading } = useSelector( (state: RootState) => state.presentationGeneration ); @@ -66,6 +69,10 @@ export const useAutoSave = ({ // Trigger debounced save debouncedSave(presentationData); + dispatch(addToHistory({ + slides: presentationData.slides, + actionType: "AUTO_SAVE" + })); // Cleanup timeout on unmount return () => { @@ -78,4 +85,6 @@ export const useAutoSave = ({ return { isSaving, }; -}; \ No newline at end of file +}; + + diff --git a/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts b/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts index fef7505a..07587878 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts +++ b/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts @@ -1,8 +1,10 @@ -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { useDispatch } from "react-redux"; import { toast } from "sonner"; import { setPresentationData } from "@/store/slices/presentationGeneration"; import { DashboardApi } from '../../services/api/dashboard'; +import { addToHistory } from "@/store/slices/undoRedoSlice"; + export const usePresentationData = ( presentationId: string, @@ -16,6 +18,10 @@ export const usePresentationData = ( const data = await DashboardApi.getPresentation(presentationId); if (data) { dispatch(setPresentationData(data)); + dispatch(addToHistory({ + slides: data.slides, + actionType: "initial_load" + })); setLoading(false); } } catch (error) { diff --git a/servers/nextjs/app/(presentation-generator)/upload/components/ConfigurationSelects.tsx b/servers/nextjs/app/(presentation-generator)/upload/components/ConfigurationSelects.tsx index e2be22c2..563f092e 100644 --- a/servers/nextjs/app/(presentation-generator)/upload/components/ConfigurationSelects.tsx +++ b/servers/nextjs/app/(presentation-generator)/upload/components/ConfigurationSelects.tsx @@ -73,7 +73,7 @@ const SlideCountSelect: React.FC<{ {/* Sticky custom input at the top */}
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} @@ -97,7 +97,7 @@ const SlideCountSelect: React.FC<{ } }} onBlur={applyCustomValue} - placeholder="X" + placeholder="--" className="h-8 w-16 px-2 text-sm" /> slides diff --git a/servers/nextjs/store/slices/undoRedoSlice.ts b/servers/nextjs/store/slices/undoRedoSlice.ts new file mode 100644 index 00000000..8d1d65a5 --- /dev/null +++ b/servers/nextjs/store/slices/undoRedoSlice.ts @@ -0,0 +1,136 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { Slide } from '@/app/(presentation-generator)/types/slide'; + +interface HistoryState { + slides: Slide[]; + timestamp: number; + actionType: string; +} + +interface UndoRedoState { + past: HistoryState[]; + present: HistoryState | null; + future: HistoryState[]; + maxHistorySize: number; + isUndoRedoInProgress: boolean; +} + +// Helper function for deep copy +const deepCopy = (obj: T): T => { + return JSON.parse(JSON.stringify(obj)); +}; + +const initialState: UndoRedoState = { + past: [], + present: null, + future: [], + maxHistorySize: 30, + isUndoRedoInProgress: false +}; + +const undoRedoSlice = createSlice({ + name: 'undoRedo', + initialState, + reducers: { + addToHistory: (state, action: PayloadAction<{slides: Slide[], actionType: string}>) => { + // Skip if undo/redo is in progress + if (state.isUndoRedoInProgress) { + return; + } + + // Deep copy the slides to avoid reference issues + const newSlides = deepCopy(action.payload.slides); + + // Only add to history if the slides have actually changed + if (!state.present) { + state.present = { + slides: newSlides, + timestamp: Date.now(), + actionType: action.payload.actionType + }; + + return; + } + + // Skip if slides are identical + if (JSON.stringify(state.present.slides) === JSON.stringify(newSlides)) { + return; + } + + // Add current state to past + state.past.push(state.present); + + // Limit history size + if (state.past.length > state.maxHistorySize) { + state.past.shift(); + } + + // Clear future on new change + state.future = []; + + // Set new present + state.present = { + slides: newSlides, + timestamp: Date.now(), + actionType: action.payload.actionType + }; + + + }, + + undo: (state) => { + if (state.past.length === 0) { + + return; + } + + state.isUndoRedoInProgress = true; + + // Move present to future + if (state.present) { + state.future.unshift(deepCopy(state.present)); + } + + // Get last past state + const previous = state.past[state.past.length - 1]; + state.past = state.past.slice(0, -1); + state.present = deepCopy(previous); + + + }, + + redo: (state) => { + if (state.future.length === 0) { + + return; + } + + state.isUndoRedoInProgress = true; + + // Move present to past + if (state.present) { + state.past.push(deepCopy(state.present)); + } + + // Get first future state + const next = state.future[0]; + state.future = state.future.slice(1); + state.present = deepCopy(next); + + + }, + + finishUndoRedo: (state) => { + state.isUndoRedoInProgress = false; + }, + + clearHistory: (state) => { + state.past = []; + state.future = []; + // Keep present + } + } +}); + +export const { addToHistory, undo, redo, finishUndoRedo, clearHistory } = undoRedoSlice.actions; +export default undoRedoSlice.reducer; \ No newline at end of file diff --git a/servers/nextjs/store/store.ts b/servers/nextjs/store/store.ts index 680ed2b7..110c6ca4 100644 --- a/servers/nextjs/store/store.ts +++ b/servers/nextjs/store/store.ts @@ -3,11 +3,13 @@ import { configureStore } from "@reduxjs/toolkit"; import presentationGenerationReducer from "./slices/presentationGeneration"; import pptGenUploadReducer from "./slices/presentationGenUpload"; import userConfigReducer from "./slices/userConfig"; +import undoRedoReducer from "./slices/undoRedoSlice"; export const store = configureStore({ reducer: { presentationGeneration: presentationGenerationReducer, pptGenUpload: pptGenUploadReducer, userConfig: userConfigReducer, + undoRedo: undoRedoReducer, }, }); From 894a5e9858610ea6d3c9e61c9241adf8946aaa80 Mon Sep 17 00:00:00 2001 From: shiva raj badu Date: Fri, 29 Aug 2025 14:17:21 +0545 Subject: [PATCH 2/2] feat: Text editing level undo redo implemented --- .../components/TiptapText.tsx | 20 ++- .../components/TiptapTextReplacer.tsx | 138 +++++++++--------- .../hooks/useGroupLayouts.tsx | 1 + .../components/PresentationPage.tsx | 2 +- .../presentation/hooks/useAutoSave.tsx | 6 +- .../presentation/hooks/usePresentationData.ts | 3 +- .../store/slices/presentationGeneration.ts | 2 - 7 files changed, 92 insertions(+), 80 deletions(-) diff --git a/servers/nextjs/app/(presentation-generator)/components/TiptapText.tsx b/servers/nextjs/app/(presentation-generator)/components/TiptapText.tsx index beedd2ec..6a39ecc4 100644 --- a/servers/nextjs/app/(presentation-generator)/components/TiptapText.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/TiptapText.tsx @@ -13,22 +13,21 @@ import { Code, } from "lucide-react"; + interface TiptapTextProps { content: string; + onContentChange?: (content: string) => void; className?: string; placeholder?: string; - element?: HTMLElement; - tag?: "H1" | "H2" | "H3" | "H4" | "H5" | "H6" | "P" | "SPAN" | "DIV" | any; + } const TiptapText: React.FC = ({ content, - element, onContentChange, className = "", placeholder = "Enter text...", - tag = "p", }) => { const editor = useEditor({ extensions: [StarterKit, Markdown, Underline], @@ -41,6 +40,8 @@ const TiptapText: React.FC = ({ }, }, onBlur: ({ editor }) => { + // const element = editor?.options.element; + // element?.classList.add("tiptap-text-edited"); const markdown = editor?.storage.markdown.getMarkdown(); if (onContentChange) { onContentChange(markdown); @@ -52,10 +53,15 @@ const TiptapText: React.FC = ({ // Update editor content when content prop changes useEffect(() => { - if (editor && content !== editor.getText()) { - editor.commands.setContent(content || placeholder); + if (!editor) return; + // Compare against current plain text to avoid unnecessary updates + const currentText = editor?.storage.markdown.getMarkdown(); + if ((content || "") !== currentText) { + editor.commands.setContent(content || ""); } - }, [content, editor, placeholder]); + }, [content, editor]); + + if (!editor) { return
{content || placeholder}
; diff --git a/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx b/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx index 3037d490..5cf980d2 100644 --- a/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/TiptapTextReplacer.tsx @@ -3,6 +3,12 @@ import React, { useRef, useEffect, useState, ReactNode } from "react"; import ReactDOM from "react-dom/client"; import TiptapText from "./TiptapText"; +import { useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { Markdown } from "tiptap-markdown"; +import Underline from "@tiptap/extension-underline"; + +const extensions = [StarterKit, Markdown, Underline]; interface TiptapTextReplacerProps { children: ReactNode; @@ -21,10 +27,17 @@ const TiptapTextReplacer: React.FC = ({ slideIndex, onContentChange = () => {}, }) => { + + + const containerRef = useRef(null); const [processedElements, setProcessedElements] = useState( new Set() ); + // Track created React roots to update content when slideData changes + const rootsRef = useRef< + Map + >(new Map()); useEffect(() => { if (!containerRef.current) return; @@ -38,6 +51,7 @@ const TiptapTextReplacer: React.FC = ({ const htmlElement = element as HTMLElement; // Skip if already processed + if ( processedElements.has(htmlElement) || htmlElement.classList.contains("tiptap-text-editor") || @@ -46,6 +60,7 @@ const TiptapTextReplacer: React.FC = ({ return; } + // console.log("htmlElement", htmlElement); // Skip if element is inside an ignored element tree if (isInIgnoredElementTree(htmlElement)) return; @@ -55,10 +70,10 @@ const TiptapTextReplacer: React.FC = ({ // Check if element has meaningful text content if (!trimmedText || trimmedText.length <= 2) return; - + // Skip elements that contain other elements with text (to avoid double processing) if (hasTextChildren(htmlElement)) return; - + // Skip certain element types that shouldn't be editable if (shouldSkipElement(htmlElement)) return; @@ -72,7 +87,7 @@ const TiptapTextReplacer: React.FC = ({ const tiptapContainer = document.createElement("div"); tiptapContainer.style.cssText = allStyles || ""; tiptapContainer.className = Array.from(allClasses).join(" "); - + // Replace the element htmlElement.parentNode?.replaceChild(tiptapContainer, htmlElement); // Mark as processed @@ -80,17 +95,19 @@ const TiptapTextReplacer: React.FC = ({ setProcessedElements((prev) => new Set(prev).add(htmlElement)); // Render TiptapText const root = ReactDOM.createRoot(tiptapContainer); - // Tag the container so we can update just this node on slideData changes - if (dataPath?.path) { - tiptapContainer.setAttribute("data-tiptap-path", dataPath.path); - } - tiptapContainer.setAttribute("data-tiptap-tag", htmlElement.tagName); - tiptapContainer.setAttribute("data-tiptap-value", trimmedText); + const initialContent = dataPath.path + ? getValueByPath(slideData, dataPath.path) ?? trimmedText + : trimmedText; + rootsRef.current.set(tiptapContainer, { + root, + dataPath: dataPath.path, + + fallbackText: trimmedText, + }); root.render( { if (dataPath && onContentChange) { onContentChange(content, dataPath.path, slideIndex); @@ -102,6 +119,34 @@ const TiptapTextReplacer: React.FC = ({ }); }; + + // Replace text elements after a short delay to ensure DOM is ready + const timer = setTimeout(replaceTextElements, 1000); + + return () => { + clearTimeout(timer); + }; + }, [slideData, slideIndex]); + + // When slideData changes, update existing editors' content using the stored dataPath + useEffect(() => { + if (!rootsRef.current || rootsRef.current.size === 0) return; + rootsRef.current.forEach(({ root, dataPath, fallbackText }) => { + const newContent = dataPath ? getValueByPath(slideData, dataPath) ?? fallbackText : fallbackText; + root.render( + { + if (dataPath && onContentChange) { + onContentChange(content, dataPath, slideIndex); + } + }} + placeholder="Enter text..." + /> + ); + }); + }, [slideData, slideIndex]); + // helper functions // Function to check if element is inside an ignored element tree const isInIgnoredElementTree = (element: HTMLElement): boolean => { // List of element types that should be ignored entirely with all their children @@ -189,6 +234,21 @@ const TiptapTextReplacer: React.FC = ({ return false; }; + // Resolve nested values by path like "a.b[0].c" + const getValueByPath = (obj: any, path: string): any => { + if (!obj || !path) return undefined; + const tokens = path + .replace(/\[(\d+)\]/g, ".$1") + .split(".") + .filter(Boolean); + let current: any = obj; + for (const token of tokens) { + if (current == null) return undefined; + current = current[token as keyof typeof current]; + } + return current; + }; + // Helper function to get only direct text content (not from children) const getDirectTextContent = (element: HTMLElement): string => { let text = ""; @@ -296,61 +356,7 @@ const TiptapTextReplacer: React.FC = ({ return { path: "", originalText: "" }; }; - // Replace text elements after a short delay to ensure DOM is ready - const timer = setTimeout(replaceTextElements, 1000); - return () => { - clearTimeout(timer); - }; - }, [slideData, slideIndex]); - - // Update only the changed editors when slideData changes - useEffect(() => { - if (!containerRef.current) return; - - const getNestedValue = (data: any, path: string): string => { - if (!data) return ""; - const keys = path.split(/[.\[\]]+/).filter(Boolean); - let current: any = data; - for (const key of keys) { - if (current == null) return ""; - if (isNaN(Number(key))) current = current[key]; - else current = current[Number(key)]; - } - return typeof current === "string" ? current : ""; - }; - - const nodes = containerRef.current.querySelectorAll( - '[data-tiptap-path]' - ); - nodes.forEach((node) => { - const path = node.getAttribute('data-tiptap-path'); - if (!path) return; - const nextValue = getNestedValue(slideData, path); - const prevValue = node.getAttribute('data-tiptap-value') || ''; - if (nextValue === prevValue) return; - - const root = (node as any).__tiptapRoot as ReactDOM.Root | undefined; - const originalEl = (node as any).__tiptapElement as HTMLElement | undefined; - const tag = node.getAttribute('data-tiptap-tag') || 'P'; - if (!root || !originalEl) return; - - node.setAttribute('data-tiptap-value', nextValue); - root.render( - { - if (path && onContentChange) onContentChange(content, path, slideIndex); - node.setAttribute('data-tiptap-value', content); - }} - placeholder="Enter text..." - /> - ); - }); - }, [slideData, slideIndex, onContentChange]); return (
{children} diff --git a/servers/nextjs/app/(presentation-generator)/hooks/useGroupLayouts.tsx b/servers/nextjs/app/(presentation-generator)/hooks/useGroupLayouts.tsx index f6652e85..bc848f1b 100644 --- a/servers/nextjs/app/(presentation-generator)/hooks/useGroupLayouts.tsx +++ b/servers/nextjs/app/(presentation-generator)/hooks/useGroupLayouts.tsx @@ -32,6 +32,7 @@ export const useGroupLayouts = () => { // Render slide content with group validation, automatic Tiptap text editing, and editable images/icons const renderSlideContent = useMemo(() => { return (slide: any, isEditMode: boolean) => { + const Layout = getGroupLayout(slide.layout, slide.layout_group); if (loading) { return ( diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx index 0a65d37c..0bbba51a 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx @@ -144,7 +144,7 @@ const PresentationPage: React.FC = ({ isMobilePanelOpen={isMobilePanelOpen} setIsMobilePanelOpen={setIsMobilePanelOpen} /> - +
{ const dispatch = useDispatch(); @@ -69,6 +68,7 @@ export const useAutoSave = ({ // Trigger debounced save debouncedSave(presentationData); + dispatch(addToHistory({ slides: presentationData.slides, actionType: "AUTO_SAVE" @@ -81,7 +81,7 @@ export const useAutoSave = ({ } }; }, [presentationData, enabled, debouncedSave]); - + return { isSaving, }; diff --git a/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts b/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts index 07587878..cb2a6e15 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts +++ b/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts @@ -3,7 +3,7 @@ import { useDispatch } from "react-redux"; import { toast } from "sonner"; import { setPresentationData } from "@/store/slices/presentationGeneration"; import { DashboardApi } from '../../services/api/dashboard'; -import { addToHistory } from "@/store/slices/undoRedoSlice"; +import { addToHistory, clearHistory } from "@/store/slices/undoRedoSlice"; export const usePresentationData = ( @@ -18,6 +18,7 @@ export const usePresentationData = ( const data = await DashboardApi.getPresentation(presentationId); if (data) { dispatch(setPresentationData(data)); + dispatch(clearHistory()); dispatch(addToHistory({ slides: data.slides, actionType: "initial_load" diff --git a/servers/nextjs/store/slices/presentationGeneration.ts b/servers/nextjs/store/slices/presentationGeneration.ts index 67c03cbc..9c10e3dd 100644 --- a/servers/nextjs/store/slices/presentationGeneration.ts +++ b/servers/nextjs/store/slices/presentationGeneration.ts @@ -262,8 +262,6 @@ const presentationGenerationSlice = createSlice({ current[Number(finalKey)] = updatedValue; } - // Add debugging - console.log('Redux: Updated slide image at path:', path, 'with URL:', url); }; // Update the slide image