feat(Nextjs): Undo Redo Feature on presentation added

This commit is contained in:
shiva raj badu 2025-08-27 17:41:57 +05:45
parent b0aae415a3
commit f94f345bd6
No known key found for this signature in database
11 changed files with 342 additions and 8 deletions

View file

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

View file

@ -80,6 +80,12 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
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(
<TiptapText
content={trimmedText}
@ -248,7 +254,8 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
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<TiptapTextReplacerProps> = ({
};
// 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<HTMLElement>(
'[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(
<TiptapText
key={`${path}:${nextValue}`}
content={nextValue}
element={originalEl}
tag={tag}
onContentChange={(content: string) => {
if (path && onContentChange) onContentChange(content, path, slideIndex);
node.setAttribute('data-tiptap-value', content);
}}
placeholder="Enter text..."
/>
);
});
}, [slideData, slideIndex, onContentChange]);
return (
<div ref={containerRef} className="tiptap-text-replacer">
{children}

View file

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

View file

@ -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<PresentationPageProps> = ({
presentation_id,
}) => {
@ -74,6 +75,8 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
fetchUserSlides
);
usePresentationUndoRedo();
const onSlideChange = (newSlide: number) => {
handleSlideChange(newSlide, presentationData);
};

View file

@ -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.", {

View file

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

View file

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

View file

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

View file

@ -73,7 +73,7 @@ const SlideCountSelect: React.FC<{
<SelectContent className="font-instrument_sans">
{/* Sticky custom input at the top */}
<div
className="sticky top-0 z-10 bg-white dark:bg-slate-900 p-2 border-b"
className="sticky top-0 z-10 bg-white p-2 border-b"
onMouseDown={(e) => 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"
/>
<span className="text-sm font-medium">slides</span>

View file

@ -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 = <T>(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;

View file

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