Skip to content

Conversation

@majed-amin
Copy link
Contributor

@majed-amin majed-amin commented Jan 22, 2026

Feature: Undo/Redo Functionality

Implemented the ability to undo and redo changes to gradient configurations with keyboard shortcuts.

What's New

  • Undo/Redo Actions: Revert and restore changes to gradients with full history tracking
  • Keyboard Shortcuts: Ctrl+Z (undo), Ctrl+Shift+Z and Ctrl+Y (redo)
  • Smart History Recording: Debounced recording for slider changes, immediate for discrete actions
  • Toast Notifications: Visual feedback for undo/redo operations
  • Memory Optimized: 30-entry history limit for efficient memory usage

Technical Changes

New Files:

  • app/composables/useHistory.ts - Generic history composable with past/present/future stacks
  • test/unit/useHistory.test.ts - 17 unit tests (all passing)

Modified Files:

  • app/composables/useMeshGradient.ts - Integrated history tracking with watchPausable
  • app/composables/useAppKeyboardShortcuts.ts - Added undo/redo keyboard shortcuts
  • app/components/mesh/partials/KeyboardShortcutsDialog.vue - Updated shortcuts documentation
  • README.md - Marked feature as complete

Performance Optimizations

  • Debounced history recording (300ms) for continuous slider/color changes
  • Immediate recording for discrete actions (add/remove/duplicate layer, themes, randomize, reset)
  • watchPausable from VueUse to prevent double recording during undo/redo
  • structuredClone() for fast deep cloning
  • 30-entry history limit to prevent memory bloat
  • Tested with 8 layers - smooth performance, ~200MB baseline memory

Testing

  • 50/50 tests passing (17 new history tests + existing tests)
  • Linting clean
  • Build successful
  • Tested in dev and production modes
  • Manual testing: rapid undo/redo, mixed operations, theme switching, max layers

Summary by CodeRabbit

  • New Features

    • Full undo/redo history with keyboard shortcuts (standard undo/redo keys), typing guards to avoid triggering while typing, and toast notifications.
  • Documentation

    • README updated to mark Undo/Redo as completed.
  • Tests

    • Added unit tests covering history behavior (push/undo/redo/clear/reset and cloning semantics).

✏️ Tip: You can customize this high-level summary in your review settings.

@netlify
Copy link

netlify bot commented Jan 22, 2026

Deploy Preview for mesh-magic ready!

Name Link
🔨 Latest commit 776142e
🔍 Latest deploy log https://app.netlify.com/projects/mesh-magic/deploys/697297aa129efa0008b99380
😎 Deploy Preview https://deploy-preview-4--mesh-magic.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link

coderabbitai bot commented Jan 22, 2026

📝 Walkthrough

Walkthrough

Adds undo/redo history: new generic history composable, deep-clone utility, integration into mesh state with debounced/pausable recording, keyboard shortcut bindings and UI entries for undo/redo, README update, and unit tests for the history composable.

Changes

Cohort / File(s) Summary
Documentation
README.md
Marks Undo/Redo feature completed in the checklist.
Keyboard Shortcuts UI
app/components/mesh/partials/KeyboardShortcutsDialog.vue
Adds entries: Undo (Ctrl/Cmd+Z), Redo (Ctrl/Cmd+Shift+Z), Redo alt (Ctrl/Cmd+Y).
Keyboard Shortcut Handlers
app/composables/useAppKeyboardShortcuts.ts
Adds typing guard helper and binds undo/redo shortcuts; standardizes typing checks for other shortcuts.
Generic History Composable
app/composables/useHistory.ts
New composable providing past/present/future stacks, push/undo/redo/clear/reset, canUndo/canRedo, optional deep clone and limit.
Mesh Gradient Integration
app/composables/useMeshGradient.ts
Integrates useHistory with immediate/debounced recording, watchPausable batching, hooks history pushes into mutations, implements undo/redo flows and exposes canUndo/canRedo.
Deep-clone Utility
app/utils/clone.ts
New exported deepClone<T>(value: T): T using structuredClone with JSON fallback.
Saved Gradients
app/composables/useSavedGradients.ts
Replaces local clone implementation with imported deepClone.
Tests
test/unit/useHistory.test.ts
New unit tests covering initialization, push/undo/redo, limits, clear/reset, and deep vs shallow behavior.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant KB as Keyboard Shortcuts
    participant Mesh as useMeshGradient
    participant History as useHistory
    participant UI as Toast/UI

    User->>KB: Press Ctrl+Z (Undo)
    KB->>KB: check isTypingInInput
    KB->>Mesh: call undo()
    Mesh->>Mesh: cancel debounced record & pause watcher
    Mesh->>History: undo()
    History->>History: move present -> future, restore previous
    History-->>Mesh: provide restored config
    Mesh->>Mesh: apply config and resume watcher
    Mesh->>UI: show "Undid change" toast

    User->>KB: Press Ctrl+Y (Redo)
    KB->>KB: check isTypingInInput
    KB->>Mesh: call redo()
    Mesh->>Mesh: cancel debounced record & pause watcher
    Mesh->>History: redo()
    History->>History: move present -> past, restore next
    History-->>Mesh: provide restored config
    Mesh->>Mesh: apply config and resume watcher
    Mesh->>UI: show "Redid change" toast
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • Hetari

"🐰 I hopped and nudged the timeline true,
Ctrl+Z folds back what I undo,
Ctrl+Y hops forward, bright and merry,
Layers settle soft, the canvas airy,
A carrot-toast to history's queue!"

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately describes the main change: implementing undo/redo functionality with keyboard shortcuts, which is the central focus of all modifications across the codebase.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@app/composables/useMeshGradient.ts`:
- Around line 160-171: The debounced history update can still fire after
pauseHistory() is called, causing duplicates; modify debouncedRecordHistory (or
replace useDebounceFn with a small internal debounce) so pending timers are
cancelable and/or no-op when history is paused: either keep the debounce timer
id (e.g., timerId private to the composable) and call clearTimeout(timerId)
inside recordHistory() immediately after pauseHistory(), or add a shared flag
(e.g., isHistoryPaused) that debouncedRecordHistory checks at invocation and
returns early if paused; update references in recordHistory, pauseHistory,
resumeHistory and any mutators like addLayer so they use the cancel/guard to
prevent the scheduled call from pushing a duplicate state.
🧹 Nitpick comments (3)
app/composables/useAppKeyboardShortcuts.ts (2)

52-70: Potentially redundant handler for uppercase 'Z'.

The handler for uppercase "Z" (lines 52-70) may be redundant. The lowercase "z" handler (lines 26-50) already checks e.shiftKey and handles Ctrl+Shift+Z. Browser key event behavior for onKeyStroke("Z") can be inconsistent—some browsers report lowercase z even with Shift held.

Consider removing this handler or verify via testing that it's necessary for your target browsers.


26-90: Consider extracting repeated focus-check logic.

The input/textarea focus check is duplicated across all four keyboard handlers. A small helper function would reduce repetition.

♻️ Optional refactor to reduce duplication
// Add at the top of setupShortcuts or as a module-level function
const isTypingInInput = (): boolean => {
  const activeElement = document.activeElement;
  return !!activeElement && (
    activeElement.tagName.toLowerCase() === "input" ||
    activeElement.tagName.toLowerCase() === "textarea"
  );
};

// Then use in handlers:
onKeyStroke("z", (e: KeyboardEvent) => {
  if (e.ctrlKey || e.metaKey) {
    e.preventDefault();
    if (isTypingInInput()) return;
    
    if (e.shiftKey) {
      if (canRedo.value) redo();
    } else {
      if (canUndo.value) undo();
    }
  }
});
app/composables/useHistory.ts (1)

33-42: Duplicate deepClone implementation.

This deepClone function is identical to the one in app/composables/useSavedGradients.ts (lines 50-59). Consider extracting it to a shared utility (e.g., ~/utils/clone.ts) to avoid duplication.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/composables/useMeshGradient.ts (1)

226-234: Shallow copy in duplicateLayer causes shared references for nested arrays.

The spread operator creates a shallow copy, so x, y, blur, and opacity arrays are shared between the original and duplicated layer. Modifying one will affect the other.

🐛 Proposed fix: deep clone the layer
+import { deepClone } from "~/utils/clone";

 const duplicateLayer = (index: number) => {
   if (config.value.layers.length === maxLayerCount.value) return;

   const source = config.value.layers[index];
   if (!source) return;
-  const dup = { ...source, id: generateLayerId() };
+  const dup = { ...deepClone(source), id: generateLayerId() };
   config.value.layers.splice(index + 1, 0, dup);
   recordHistory();
 };
app/composables/useAppKeyboardShortcuts.ts (1)

99-140: Missing isTypingInInput guard on A, Backspace, and Delete shortcuts.

These shortcuts could trigger while typing in an input field, causing unintended layer additions or deletions.

🐛 Proposed fix: add typing guard
 // Add new layer (A)
 registerKeyStroke("a", () => {
+  if (isTypingInInput()) return;
+  
   if (config.value.layers.length >= 8) {
     // ...
   }
   // ...
 });

 // Delete last layer (Backspace / Delete)
 registerKeyStroke("Backspace", () => {
+  if (isTypingInInput()) return;
+  
   if (config.value.layers.length <= 1) {
     // ...
   }
   // ...
 });

 registerKeyStroke("Delete", () => {
+  if (isTypingInInput()) return;
+  
   if (config.value.layers.length <= 1) {
     // ...
   }
   // ...
 });
🤖 Fix all issues with AI agents
In `@app/composables/useHistory.ts`:
- Around line 113-119: The reset() function currently clones the reactive
initialValue (which may have mutated) so reset restores the current state
instead of the original; modify useHistory to capture and store a non-reactive
snapshot (e.g., clonedInitial or storedInitial) at creation time (right after
receiving initialValue in useHistory), and change reset() to set present.value =
clone(storedInitial) and then call clear(); update references to initialValue in
reset to use the stored snapshot so reset restores the true original state.
🧹 Nitpick comments (3)
app/composables/useHistory.ts (1)

51-67: Consider using shallowRef for history stacks.

The past and future arrays don't need deep reactivity since their contents are already clones. Using shallowRef would reduce reactivity overhead for large history stacks.

♻️ Optional optimization
-import { ref, computed } from "vue";
+import { ref, shallowRef, computed } from "vue";

 // History stacks
-const past = ref<T[]>([]);
+const past = shallowRef<T[]>([]);
 const present = ref<T>(deep ? deepClone(initialValue) : initialValue);
-const future = ref<T[]>([]);
+const future = shallowRef<T[]>([]);

Note: With shallowRef, you'd need to reassign the array (e.g., past.value = [...past.value, item]) instead of using .push() to trigger reactivity, or manually call triggerRef().

app/composables/useMeshGradient.ts (1)

193-201: Watch target syntax is unusual but functional.

The arrow function () => config.value creates a computed getter that returns the config object. This works but could be simplified to just config (the state ref) since watchPausable handles refs directly.

♻️ Optional simplification
 const { pause: pauseHistory, resume: resumeHistory } = watchPausable(
-  () => config.value,
+  config,
   () => {
     debouncedRecordHistory();
   },
   { deep: true, flush: 'sync' }
 );
app/composables/useAppKeyboardShortcuts.ts (1)

19-29: Consider adding contenteditable check to isTypingInInput.

The current check misses contenteditable elements, which are also text input contexts.

♻️ Suggested enhancement
 const isTypingInInput = (): boolean => {
   const activeElement = document.activeElement;
-  return !!activeElement && (
-    activeElement.tagName.toLowerCase() === "input" ||
-    activeElement.tagName.toLowerCase() === "textarea"
-  );
+  if (!activeElement) return false;
+  const tagName = activeElement.tagName.toLowerCase();
+  return (
+    tagName === "input" ||
+    tagName === "textarea" ||
+    activeElement.getAttribute("contenteditable") === "true"
+  );
 };

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/composables/useMeshGradient.ts (1)

255-320: Cancel pending debounced history before discrete actions.

randomize, applyTheme, and reset pause the watcher but don’t cancel a pending debounce. If a debounce was queued (e.g., from a slider) it can still fire and create a duplicate history entry after these actions.

🔧 Suggested fix
const randomize = (
  minLayers = 1,
  maxLayers = maxLayerCount.value,
  newBaseColor?: string,
) => {
+  cancelDebouncedHistory();
  // Pause watcher to avoid double recording
  pauseHistory();
  ...
};

const applyTheme = (name: keyof typeof themes) => {
  const t = themes[name];
  if (!t) return;

+  cancelDebouncedHistory();
  // Pause watcher to avoid double recording
  pauseHistory();
  ...
};

const reset = () => {
+  cancelDebouncedHistory();
  // Pause watcher to avoid double recording
  pauseHistory();
  ...
};
🤖 Fix all issues with AI agents
In `@app/composables/useAppKeyboardShortcuts.ts`:
- Around line 36-67: The key handlers for "z" and "y" call e.preventDefault()
before checking isTypingInInput(), which blocks native undo/redo in inputs;
update both onKeyStroke("z", ...) and onKeyStroke("y", ...) to first return
early if isTypingInInput() is true, and only call e.preventDefault() when
handling an app-level undo/redo (i.e., when canUndo.value/canRedo.value leads to
calling undo()/redo()); keep the existing checks for ctrlKey/metaKey and
shiftKey for redo logic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant