diff --git a/components/editor/action-bar/components/action-bar-buttons.tsx b/components/editor/action-bar/components/action-bar-buttons.tsx index 4b75c123..7d7db87a 100644 --- a/components/editor/action-bar/components/action-bar-buttons.tsx +++ b/components/editor/action-bar/components/action-bar-buttons.tsx @@ -10,6 +10,7 @@ import { EditButton } from "./edit-button"; import { MoreOptions } from "./more-options"; import { AIGenerateButton } from "./ai-generate-button"; import { ShareButton } from "./share-button"; +import { UndoRedoButtons } from "./undo-redo-buttons"; interface ActionBarButtonsProps { onImportClick: () => void; @@ -28,7 +29,7 @@ export function ActionBarButtons({ onShareClick, isSaving, }: ActionBarButtonsProps) { - const { themeState, resetToCurrentPreset, hasThemeChangedFromCheckpoint } = useEditorStore(); + const { themeState, resetToCurrentPreset, hasUnsavedChanges } = useEditorStore(); const { getPreset } = useThemePresetStore(); const currentPreset = themeState?.preset ? getPreset(themeState?.preset) : undefined; @@ -40,7 +41,9 @@ export function ActionBarButtons({ - + + + diff --git a/components/editor/action-bar/components/undo-redo-buttons.tsx b/components/editor/action-bar/components/undo-redo-buttons.tsx new file mode 100644 index 00000000..89e0fa81 --- /dev/null +++ b/components/editor/action-bar/components/undo-redo-buttons.tsx @@ -0,0 +1,35 @@ +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useDialogActions } from "@/hooks/use-dialog-actions"; +import { Redo, Undo } from "lucide-react"; + +export function UndoRedoButtons() { + const { handleUndo, handleRedo, canUndo, canRedo } = useDialogActions(); + + return ( + +
+ + + + + +

Undo

+
+
+ + + + + +

Redo

+
+
+
+
+ ); +} diff --git a/hooks/use-dialog-actions.tsx b/hooks/use-dialog-actions.tsx index 5a40a0e8..3f491de7 100644 --- a/hooks/use-dialog-actions.tsx +++ b/hooks/use-dialog-actions.tsx @@ -42,6 +42,10 @@ interface DialogActionsContextType { handleGenerateTheme: (promptText: string, jsonPromptText: string) => Promise; handleShareClick: (id?: string) => Promise; saveTheme: (themeName: string) => Promise; + handleUndo: () => void; + handleRedo: () => void; + canUndo: boolean; + canRedo: boolean; } function useDialogActionsStore(): DialogActionsContextType { @@ -54,8 +58,16 @@ function useDialogActionsStore(): DialogActionsContextType { const [shareUrl, setShareUrl] = useState(""); const [dialogKey, setDialogKey] = useState(0); - const { themeState, setThemeState, applyThemePreset, hasThemeChangedFromCheckpoint } = - useEditorStore(); + const { + themeState, + setThemeState, + applyThemePreset, + hasThemeChangedFromCheckpoint, + undo, + redo, + canUndo: editorCanUndo, + canRedo: editorCanRedo, + } = useEditorStore(); const { getPreset } = useThemePresetStore(); const { data: session } = authClient.useSession(); const { openAuthDialog } = useAuthStore(); @@ -76,6 +88,14 @@ function useDialogActionsStore(): DialogActionsContextType { setShareAfterSave(true); }); + const handleUndo = () => { + undo(); + }; + + const handleRedo = () => { + redo(); + }; + const handleGenerateTheme = async (promptText: string, jsonPromptText: string) => { if (!promptText.trim()) return; await generateTheme({ @@ -215,6 +235,10 @@ function useDialogActionsStore(): DialogActionsContextType { handleGenerateTheme, handleShareClick, saveTheme, + handleUndo, + handleRedo, + canUndo: editorCanUndo(), + canRedo: editorCanRedo(), }; return value; diff --git a/store/editor-store.ts b/store/editor-store.ts index d4c251f8..afc4541e 100644 --- a/store/editor-store.ts +++ b/store/editor-store.ts @@ -5,9 +5,19 @@ import { defaultThemeState } from "@/config/theme"; import { getPresetThemeStyles } from "@/utils/theme-preset-helper"; import { isDeepEqual } from "@/lib/utils"; +const MAX_HISTORY_COUNT = 30; +const HISTORY_OVERRIDE_THRESHOLD_MS = 500; // 0.5 seconds + +interface ThemeHistoryEntry { + state: ThemeEditorState; + timestamp: number; +} + interface EditorStore { themeState: ThemeEditorState; themeCheckpoint: ThemeEditorState | null; + history: ThemeHistoryEntry[]; + future: ThemeHistoryEntry[]; setThemeState: (state: ThemeEditorState) => void; applyThemePreset: (preset: string) => void; saveThemeCheckpoint: () => void; @@ -15,6 +25,10 @@ interface EditorStore { resetToCurrentPreset: () => void; hasThemeChangedFromCheckpoint: () => boolean; hasUnsavedChanges: () => boolean; + undo: () => void; + redo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; } export const useEditorStore = create()( @@ -22,23 +36,86 @@ export const useEditorStore = create()( (set, get) => ({ themeState: defaultThemeState, themeCheckpoint: null, - setThemeState: (state: ThemeEditorState) => { - set({ themeState: state }); + history: [], + future: [], + setThemeState: (newState: ThemeEditorState) => { + const oldThemeState = get().themeState; + let currentHistory = get().history; + + // Check if only currentMode changed + const oldStateWithoutMode = { ...oldThemeState, currentMode: undefined }; + const newStateWithoutMode = { ...newState, currentMode: undefined }; + + if ( + isDeepEqual(oldStateWithoutMode, newStateWithoutMode) && + oldThemeState.currentMode !== newState.currentMode + ) { + // Only currentMode changed, or nothing changed but currentMode (which is fine) + // Just update themeState without affecting history or future + set({ themeState: newState }); + return; + } + + const currentTime = Date.now(); + + // If other things changed, or if it's an actual identical state set (though less likely here) + // Proceed with history logic + const lastHistoryEntry = + currentHistory.length > 0 ? currentHistory[currentHistory.length - 1] : null; + + if ( + lastHistoryEntry && + currentTime - lastHistoryEntry.timestamp < HISTORY_OVERRIDE_THRESHOLD_MS + ) { + // Override the last history entry + currentHistory = [ + ...currentHistory.slice(0, -1), + { state: newState, timestamp: currentTime }, + ]; + } else { + // Add a new history entry + currentHistory = [...currentHistory, { state: newState, timestamp: currentTime }]; + } + + if (currentHistory.length > MAX_HISTORY_COUNT) { + currentHistory.shift(); // Remove the oldest entry + } + + console.log( + "currentHistory", + currentHistory?.map((h) => h.timestamp) + ); + set({ + themeState: newState, + history: currentHistory, + future: [], // Clear future on new state change + }); }, applyThemePreset: (preset: string) => { - const themeState = get().themeState; + const currentThemeState = get().themeState; + const oldHistory = get().history; + const currentTime = Date.now(); + const newStyles = getPresetThemeStyles(preset); const newThemeState: ThemeEditorState = { - ...themeState, + ...currentThemeState, preset, styles: newStyles, hslAdjustments: defaultThemeState.hslAdjustments, }; - const updates: Partial & { themeState: ThemeEditorState } = { + + const newHistoryEntry = { state: currentThemeState, timestamp: currentTime }; + let updatedHistory = [...oldHistory, newHistoryEntry]; + if (updatedHistory.length > MAX_HISTORY_COUNT) { + updatedHistory.shift(); + } + + set({ themeState: newThemeState, - themeCheckpoint: newThemeState, - }; - set(updates); + themeCheckpoint: newThemeState, // Applying a preset also updates the checkpoint + history: updatedHistory, + future: [], + }); }, saveThemeCheckpoint: () => { set({ themeCheckpoint: get().themeState }); @@ -46,11 +123,23 @@ export const useEditorStore = create()( restoreThemeCheckpoint: () => { const checkpoint = get().themeCheckpoint; if (checkpoint) { + const oldThemeState = get().themeState; + const oldHistory = get().history; + const currentTime = Date.now(); + + const newHistoryEntry = { state: oldThemeState, timestamp: currentTime }; + let updatedHistory = [...oldHistory, newHistoryEntry]; + if (updatedHistory.length > MAX_HISTORY_COUNT) { + updatedHistory.shift(); + } + set({ themeState: { ...checkpoint, currentMode: get().themeState.currentMode, }, + history: updatedHistory, + future: [], }); } else { console.warn("No theme checkpoint available to restore to."); @@ -71,19 +160,77 @@ export const useEditorStore = create()( return stylesChanged || hslChanged; }, resetToCurrentPreset: () => { - const themeState = get().themeState; - const presetThemeStyles = getPresetThemeStyles(themeState.preset ?? "default"); + const currentThemeState = get().themeState; + + const presetThemeStyles = getPresetThemeStyles(currentThemeState.preset ?? "default"); const newThemeState: ThemeEditorState = { - ...themeState, + ...currentThemeState, styles: presetThemeStyles, - preset: themeState.preset, hslAdjustments: defaultThemeState.hslAdjustments, }; + set({ themeState: newThemeState, themeCheckpoint: newThemeState, + history: [], + future: [], }); }, + undo: () => { + const history = get().history; + if (history.length === 0) { + return; + } + + const currentThemeState = get().themeState; + const future = get().future; + + const newFutureEntry = { state: currentThemeState, timestamp: Date.now() }; + const newFuture = [newFutureEntry, ...future]; + + if (history.length === 1) { + get().resetToCurrentPreset(); + set({ + future: newFuture, + }); + return; + } + + const lastHistoryEntry = history[history.length - 2]; + const newHistory = history.slice(0, -1); + + set({ + themeState: lastHistoryEntry.state, + themeCheckpoint: lastHistoryEntry.state, + history: newHistory, + future: newFuture, + }); + }, + redo: () => { + const future = get().future; + if (future.length === 0) { + return; + } + const history = get().history; + + const firstFutureEntry = future[0]; + const newFuture = future.slice(1); + + const newHistoryEntry = { state: firstFutureEntry.state, timestamp: Date.now() }; + let updatedHistory = [...history, newHistoryEntry]; + if (updatedHistory.length > MAX_HISTORY_COUNT) { + updatedHistory.shift(); + } + + set({ + themeState: firstFutureEntry.state, + themeCheckpoint: firstFutureEntry.state, + history: updatedHistory, + future: newFuture, + }); + }, + canUndo: () => get().history.length > 0, + canRedo: () => get().future.length > 0, }), { name: "editor-storage",