Skip to main content

Examples

Time Machine

Drawing canvas with undo/redo, export/import, replay, and changesets.

Try it

Loading example…

Draw on the canvas, then use Undo/Redo to navigate history. Export to save state as JSON, or use Changesets to group multiple strokes into a single undo step.

How it works

Each brush stroke is a fact mutation captured as a time-travel snapshot. Directive’s built-in TimeTravelManager provides the full history API.

  1. Undo / Redo – Navigate through snapshot history with goBack() and goForward()
  2. Export / Import – Serialize all snapshots to JSON and restore them later
  3. Replay – Animate through the entire history to see strokes appear progressively
  4. Changesets – Group multiple mutations into a single atomic undo step using beginChangeset() / endChangeset()

Source code

main.ts
/**
 * Time Machine — Time-Travel Debugging
 *
 * Drawing canvas where each stroke is a fact mutation. Full time-travel:
 * undo/redo, export/import JSON, replay animation, changesets, snapshot slider.
 */

import {
  createModule,
  createSystem,
  t,
  type ModuleSchema,
} from "@directive-run/core";
import { devtoolsPlugin } from "@directive-run/core/plugins";

// ============================================================================
// Types
// ============================================================================

interface Stroke {
  id: string;
  x: number;
  y: number;
  color: string;
  size: number;
}

interface TimelineEntry {
  time: number;
  event: string;
  detail: string;
  type: "stroke" | "undo" | "redo" | "changeset" | "export" | "import" | "replay" | "goto";
}

// ============================================================================
// Timeline
// ============================================================================

const timeline: TimelineEntry[] = [];

function addTimeline(event: string, detail: string, type: TimelineEntry["type"]) {
  timeline.unshift({ time: Date.now(), event, detail, type });
  if (timeline.length > 50) {
    timeline.length = 50;
  }
}

// ============================================================================
// Schema
// ============================================================================

const schema = {
  facts: {
    strokes: t.object<Stroke[]>(),
    currentColor: t.string(),
    brushSize: t.number(),
    changesetActive: t.boolean(),
    changesetLabel: t.string(),
  },
  derivations: {
    strokeCount: t.number(),
    canUndo: t.boolean(),
    canRedo: t.boolean(),
    currentIndex: t.number(),
    totalSnapshots: t.number(),
  },
  events: {
    addStroke: { x: t.number(), y: t.number() },
    setColor: { value: t.string() },
    setBrushSize: { value: t.number() },
    clearCanvas: {},
  },
  requirements: {},
} satisfies ModuleSchema;

// ============================================================================
// Module
// ============================================================================

const canvasModule = createModule("canvas", {
  schema,

  init: (facts) => {
    facts.strokes = [];
    facts.currentColor = "#5ba3a3";
    facts.brushSize = 12;
    facts.changesetActive = false;
    facts.changesetLabel = "";
  },

  derive: {
    strokeCount: (facts) => facts.strokes.length,
    // These will be updated from the time-travel manager
    canUndo: () => false,
    canRedo: () => false,
    currentIndex: () => 0,
    totalSnapshots: () => 0,
  },

  events: {
    addStroke: (facts, { x, y }) => {
      const stroke: Stroke = {
        id: `s${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
        x,
        y,
        color: facts.currentColor,
        size: facts.brushSize,
      };
      facts.strokes = [...facts.strokes, stroke];
    },
    setColor: (facts, { value }) => {
      facts.currentColor = value;
    },
    setBrushSize: (facts, { value }) => {
      facts.brushSize = value;
    },
    clearCanvas: (facts) => {
      facts.strokes = [];
    },
  },
});

// ============================================================================
// System with Time-Travel
// ============================================================================

const system = createSystem({
  module: canvasModule,
  debug: { timeTravel: true, maxSnapshots: 200 },
  plugins: [devtoolsPlugin({ name: "time-machine" })],
});
system.start();

const tt = system.debug!;

// ============================================================================
// DOM References
// ============================================================================

const canvasEl = document.getElementById("tm-canvas") as HTMLCanvasElement;
const ctx = canvasEl.getContext("2d")!;
const colorPicker = document.getElementById("tm-color") as HTMLInputElement;
const brushSlider = document.getElementById("tm-brush-size") as HTMLInputElement;
const brushVal = document.getElementById("tm-brush-val")!;
const undoBtn = document.getElementById("tm-undo") as HTMLButtonElement;
const redoBtn = document.getElementById("tm-redo") as HTMLButtonElement;
const replayBtn = document.getElementById("tm-replay") as HTMLButtonElement;
const clearBtn = document.getElementById("tm-clear") as HTMLButtonElement;
const snapshotSlider = document.getElementById("tm-snapshot-slider") as HTMLInputElement;
const snapshotInfo = document.getElementById("tm-snapshot-info")!;
const exportBtn = document.getElementById("tm-export") as HTMLButtonElement;
const importBtn = document.getElementById("tm-import") as HTMLButtonElement;
const exportArea = document.getElementById("tm-export-area") as HTMLTextAreaElement;
const beginChangesetBtn = document.getElementById("tm-begin-changeset") as HTMLButtonElement;
const endChangesetBtn = document.getElementById("tm-end-changeset") as HTMLButtonElement;
const changesetStatus = document.getElementById("tm-changeset-status")!;

// Timeline
const timelineEl = document.getElementById("tm-timeline")!;

// ============================================================================
// Canvas Rendering
// ============================================================================

function drawCanvas(): void {
  ctx.fillStyle = "#0f172a";
  ctx.fillRect(0, 0, canvasEl.width, canvasEl.height);

  const strokes = system.facts.strokes as Stroke[];
  for (const stroke of strokes) {
    ctx.beginPath();
    ctx.arc(stroke.x, stroke.y, stroke.size / 2, 0, Math.PI * 2);
    ctx.fillStyle = stroke.color;
    ctx.fill();
  }
}

// ============================================================================
// Render
// ============================================================================

function escapeHtml(text: string): string {
  const div = document.createElement("div");
  div.textContent = text;

  return div.innerHTML;
}

function render(): void {
  drawCanvas();

  const canUndo = tt.currentIndex > 0;
  const canRedo = tt.currentIndex < tt.snapshots.length - 1;

  // Buttons
  undoBtn.disabled = !canUndo;
  redoBtn.disabled = !canRedo;

  // Snapshot slider
  snapshotSlider.max = String(Math.max(0, tt.snapshots.length - 1));
  snapshotSlider.value = String(tt.currentIndex);
  snapshotInfo.textContent = `${tt.currentIndex} / ${tt.snapshots.length - 1}`;

  // Changeset status
  const isActive = system.facts.changesetActive as boolean;
  changesetStatus.textContent = isActive ? "Recording..." : "Inactive";
  changesetStatus.className = `tm-changeset-status ${isActive ? "active" : ""}`;
  beginChangesetBtn.disabled = isActive;
  endChangesetBtn.disabled = !isActive;

  // Slider label
  brushVal.textContent = `${system.facts.brushSize}px`;

  // Timeline
  if (timeline.length === 0) {
    timelineEl.innerHTML = '<div class="tm-timeline-empty">Events appear after drawing</div>';
  } else {
    timelineEl.innerHTML = "";
    for (const entry of timeline) {
      const el = document.createElement("div");
      el.className = `tm-timeline-entry ${entry.type}`;

      const time = new Date(entry.time);
      const timeStr = time.toLocaleTimeString([], {
        hour: "2-digit",
        minute: "2-digit",
        second: "2-digit",
      });

      el.innerHTML = `
        <span class="tm-timeline-time">${timeStr}</span>
        <span class="tm-timeline-event">${escapeHtml(entry.event)}</span>
        <span class="tm-timeline-detail">${escapeHtml(entry.detail)}</span>
      `;

      timelineEl.appendChild(el);
    }
  }
}

// ============================================================================
// Subscribe
// ============================================================================

const allKeys = [...Object.keys(schema.facts)];
system.subscribe(allKeys, render);

// ============================================================================
// Canvas Interaction (pointer events for mouse + touch)
// ============================================================================

let isDrawing = false;

function canvasCoords(e: PointerEvent): { x: number; y: number } {
  const rect = canvasEl.getBoundingClientRect();
  const scaleX = canvasEl.width / rect.width;
  const scaleY = canvasEl.height / rect.height;

  return {
    x: Math.round((e.clientX - rect.left) * scaleX),
    y: Math.round((e.clientY - rect.top) * scaleY),
  };
}

canvasEl.addEventListener("pointerdown", (e) => {
  isDrawing = true;
  canvasEl.setPointerCapture(e.pointerId);
  const { x, y } = canvasCoords(e);
  system.events.addStroke({ x, y });
  addTimeline("stroke", `(${x}, ${y}) ${system.facts.currentColor}`, "stroke");
});

canvasEl.addEventListener("pointermove", (e) => {
  if (!isDrawing) {
    return;
  }

  const { x, y } = canvasCoords(e);
  system.events.addStroke({ x, y });
});

canvasEl.addEventListener("pointerup", () => {
  isDrawing = false;
});

canvasEl.addEventListener("pointerleave", () => {
  isDrawing = false;
});

// ============================================================================
// Controls
// ============================================================================

colorPicker.addEventListener("input", () => {
  system.events.setColor({ value: colorPicker.value });
});

brushSlider.addEventListener("input", () => {
  system.events.setBrushSize({ value: Number(brushSlider.value) });
});

undoBtn.addEventListener("click", () => {
  tt.goBack();
  addTimeline("undo", `→ snapshot #${tt.currentIndex}`, "undo");
  render();
});

redoBtn.addEventListener("click", () => {
  tt.goForward();
  addTimeline("redo", `→ snapshot #${tt.currentIndex}`, "redo");
  render();
});

replayBtn.addEventListener("click", async () => {
  addTimeline("replay", `replaying ${tt.snapshots.length} snapshots`, "replay");
  await tt.replay();
  render();
});

clearBtn.addEventListener("click", () => {
  system.events.clearCanvas();
  addTimeline("stroke", "canvas cleared", "stroke");
});

snapshotSlider.addEventListener("input", () => {
  const idx = Number(snapshotSlider.value);
  if (idx >= 0 && idx < tt.snapshots.length) {
    tt.goTo(tt.snapshots[idx]!.id);
    addTimeline("goto", `→ snapshot #${idx}`, "goto");
    render();
  }
});

exportBtn.addEventListener("click", () => {
  const data = tt.export();
  exportArea.value = data;
  addTimeline("export", `${tt.snapshots.length} snapshots`, "export");
  render();
});

importBtn.addEventListener("click", () => {
  const data = exportArea.value.trim();
  if (!data) {
    return;
  }

  try {
    tt.import(data);
    addTimeline("import", "snapshots restored", "import");
    render();
  } catch (err) {
    addTimeline("import", `error: ${err instanceof Error ? err.message : String(err)}`, "import");
    render();
  }
});

beginChangesetBtn.addEventListener("click", () => {
  tt.beginChangeset("drawing-group");
  system.facts.changesetActive = true;
  system.facts.changesetLabel = "drawing-group";
  addTimeline("changeset", "started", "changeset");
  render();
});

endChangesetBtn.addEventListener("click", () => {
  tt.endChangeset();
  system.facts.changesetActive = false;
  system.facts.changesetLabel = "";
  addTimeline("changeset", "ended", "changeset");
  render();
});

// ============================================================================
// Initial Render
// ============================================================================

render();
document.body.setAttribute("data-time-machine-ready", "true");

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works