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