Adding Undo/Redo Support to Pascal Editor Operations with Zundo: Complete Implementation Guide

Pascal Editor implements robust undo/redo functionality by wrapping its Zustand store with the Zundo library's temporal() enhancer, which automatically records state snapshots and exposes undo() and redo() actions across keyboard shortcuts, command palette entries, and tool-specific workflows.

Pascal Editor manages a complex, immutable scene graph through a centralized Zustand store, where every node operation must be reversible. By integrating Zundo's temporal state management, the editor records granular history snapshots without boilerplate, enabling developers to add undo/redo support to Pascal Editor operations with Zundo seamlessly across all editor interactions.

Core Architecture: The Temporal Store Wrapper

The foundation of Pascal Editor's history system lies in the store configuration at packages/core/src/store/use-scene.ts. Here, the Zustand store is enhanced with Zundo's temporal() wrapper to create a time-travel capable state container.

Configuring the Temporal State Interface

The TypeScript definition extends the standard Zustand store with a temporal sub-store that manages history stacks for specific state slices:

import type { TemporalState } from 'zundo'
import { temporal } from 'zundo'

type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
  temporal: StoreApi<
    TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections'>>
  >
}

const useScene: UseSceneStore = create<SceneState>()(
  temporal(
    (set, get) => ({
      /* …initial state & actions… */
    })
  )
)

This configuration selectively tracks only mutable scene data—nodes, rootNodeIds, and collections—while ignoring transient UI state like selection highlights or viewport position.

Automatic Snapshotting on State Mutations

Zundo automatically pushes snapshots onto the history stack whenever tracked slices change. When store actions like createNode, updateNode, or deleteNode execute, Zundo captures the previous state before applying mutations. Calling undo() restores the prior snapshot and moves the current state onto a redo stack, enabling full history traversal without manual state cloning or deep-copy logic.

Integration Points for Undo/Redo Support

Pascal Editor exposes the temporal API through three primary integration points, ensuring consistent history navigation across interaction patterns.

Keyboard Shortcuts in use-keyboard.ts

Global keyboard handlers in packages/editor/src/hooks/use-keyboard.ts bind standard history commands to Zundo's temporal methods. The implementation detects platform-specific modifiers and invokes the appropriate temporal action:

// In packages/editor/src/hooks/use-keyboard.ts (lines 56-62)
useScene.temporal.getState().undo()  // ⌘/Ctrl + Z
useScene.temporal.getState().redo()  // ⇧⌘/Ctrl + Shift + Z

This provides instantaneous history navigation during active editing sessions.

Command Palette Commands

The command palette in packages/editor/src/components/ui/command-palette/editor-commands.tsx registers "Undo" and "Redo" as searchable commands. Each entry's execute callback accesses useScene.temporal.getState() directly, ensuring that palette-driven history actions remain synchronized with keyboard shortcuts. This pattern appears at lines 97-104 of the source file, where commands invoke undo() or redo() without additional abstraction layers.

Tool-Level Pause and Resume for Draft Operations

Complex tools require temporal isolation during multi-step operations. When creating draft nodes or performing drag gestures, tools call pause() to suspend history recording, preventing intermediate states from polluting the undo stack.

In packages/editor/src/components/tools/item/use-draft-node.ts (lines 43-56), the implementation demonstrates this pattern:

useScene.temporal.getState().pause()
// …mutate draft node position…
useScene.temporal.getState().resume()

Only the final committed state appears in history, while drag increments remain invisible. The move-window-tool.tsx file implements similar logic for window repositioning operations.

Implementation Code Examples

Basic Undo/Redo Buttons

Connect components directly to the temporal API for custom history controls:

import { useScene } from '@pascal-app/core'

export const UndoRedoButtons = () => {
  const { undo, redo } = useScene.temporal.getState()

  return (
    <div className="flex gap-2">
      <button onClick={undo}>Undo ⌘Z</button>
      <button onClick={redo}>Redo ⇧⌘Z</button>
    </div>
  )
}

Custom Editor Command Registration

Add history commands to the palette by implementing the Command interface:

import { useScene } from '@pascal-app/core'
import { Command } from './command-types'

export const myUndoCommand: Command = {
  id: 'my.history.undo',
  label: 'Undo',
  group: 'History',
  icon: <Undo2 />,
  execute: () => useScene.temporal.getState().undo(),
}

Complex Tool with Temporal Control

Isolate draft states from history during multi-step operations:

import { useScene } from '@pascal-app/core'

export const moveNode = (id: string, newPos: [number, number, number]) => {
  const { pause, resume, getState } = useScene.temporal.getState()

  pause()                     // Stop history recording
  getState().updateNode(id, { position: newPos })
  resume()                    // Record final state as single entry
}

Summary

  • Temporal wrapper: Zundo's temporal() enhancer in use-scene.ts automatically tracks nodes, rootNodeIds, and collections without manual snapshot management.
  • Multiple access points: History navigation is available via keyboard shortcuts (use-keyboard.ts), command palette entries (editor-commands.tsx), and direct API calls.
  • Draft operation support: The pause() and resume() methods enable tools to suppress intermediate states during complex interactions like drag-and-drop.
  • Immutable history: Each undo operation restores previous immutable snapshots, while redo maintains a forward stack for bidirectional navigation.

Frequently Asked Questions

How does Zundo track state changes in Pascal Editor?

Zundo automatically intercepts state mutations performed through the Zustand store's setter functions. When actions like createNode or updateNode modify the tracked slices (nodes, rootNodeIds, collections), Zundo pushes the pre-mutation state onto an internal history array before applying changes. This mechanism requires no additional instrumentation in the action definitions themselves.

Can I exclude specific state properties from undo history?

Yes. The temporal() configuration in packages/core/src/store/use-scene.ts uses TypeScript's Pick utility to explicitly whitelist only nodes, rootNodeIds, and collections. Properties omitted from this selection—such as UI preferences, viewport camera positions, or selection highlights—mutate without generating history entries, keeping the undo stack focused on scene content changes.

How do I prevent draft operations from creating multiple history entries?

Use the pause() and resume() methods available on useScene.temporal.getState(). Call pause() before initiating draft mutations (such as dragging a window or resizing a node), perform all intermediate updates, then call resume() before the final commit. This pattern, demonstrated in use-draft-node.ts and move-window-tool.tsx, ensures only the final committed state appears as a single undoable entry.

Where are the keyboard shortcuts for undo/redo implemented?

Global keyboard shortcuts reside in packages/editor/src/hooks/use-keyboard.ts (lines 56-62), where the handler detects ⌘/Ctrl + Z for undo and ⇧⌘/Ctrl + Shift + Z for redo. These shortcuts directly invoke useScene.temporal.getState().undo() and redo(), bypassing any intermediate dispatch layers for immediate response.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →