Events · 3 of 5

history:change — external Undo/Redo buttons

Subscribe to history:change and drive your own Undo/Redo buttons OUTSIDE the engine chrome. The payload carries { canUndo, canRedo, size, currentIndex } — enough to drive button state and a "step N of M" pill from a single signal.

Walkthrough

  1. Mount your own buttons + subscribe to `history:change`

    The engine's public history surface is small: builder.undo(), builder.redo(), builder.canUndo(), builder.canRedo(). Wire two ordinary <button>s to those, then subscribe to the history:change event so the buttons reflect engine state without polling.

    Heads-up: history:change is emitted from HistoryManager.js but does NOT currently have a Builder.EVENTS.* constant — pass the raw string. A future Phase A.A scope addition will expose Builder.EVENTS.HISTORY_CHANGE alongside the existing DOCUMENT_CHANGED / ELEMENT_ADDED / ELEMENT_REMOVED / MODE_CHANGED constants.

    JavaScript
    // External buttons — live in your host page, NOT in the engine wrapper.
    const undoBtn = document.getElementById('c3UndoBtn');
    const redoBtn = document.getElementById('c3RedoBtn');
    
    undoBtn.addEventListener('click', () => builder.undo());
    redoBtn.addEventListener('click', () => builder.redo());
    
    // Subscribe via raw string — no Builder.EVENTS.HISTORY_CHANGE constant
    // is exposed today (HistoryManager fires the event regardless).
    builder.events.on('history:change', ({ canUndo, canRedo, size, currentIndex }) => {
      undoBtn.disabled = !canUndo;
      redoBtn.disabled = !canRedo;
      // currentIndex is 0-based; "step N of M" reads more naturally one-based.
      document.getElementById('c3SizePill').textContent =
        size === 0 ? 'Empty' : `Step ${currentIndex + 1} of ${size}`;
    });
  2. Why `history:change` and not `document:changed`?

    Both events fire on every commit, but they answer different questions. document:changed is "the page tree mutated, time to autosave or update a dirty pill" — debounced 120 ms by the bus, no payload. history:change is "the undo stack moved, time to update Undo/Redo button state" — fires raw (no debounce) with the full {canUndo, canRedo, size, currentIndex} shape.

    Drive autosave from document:changed. Drive button state from history:change. Use both for a complete activity feed — see events/1-document-changed + events/2-element-added-removed for the companion patterns.

    JavaScript
    // Two events, two purposes — same bus, no coupling.
    
    // 1. document:changed — debounced. Drive autosave + dirty indicator.
    builder.events.on(Builder.EVENTS.DOCUMENT_CHANGED, () => {
      fetch('/backend/save.php', { method: 'POST', body: serialize(builder) });
      document.getElementById('lastModified').textContent =
        'Last modified: ' + new Date().toLocaleTimeString();
    });
    
    // 2. history:change — raw. Drive button state + step pill.
    builder.events.on('history:change', ({ canUndo, canRedo, size, currentIndex }) => {
      document.getElementById('c3UndoBtn').disabled = !canUndo;
      document.getElementById('c3RedoBtn').disabled = !canRedo;
    });
  3. Read state on first paint — the bus is hot from `new Builder({...})`

    The bus is alive from construction time, but history:change only fires on a state transition (commit, undo, redo, clear). On first paint after builder.load(), the buttons need their initial state set explicitly — read it directly via builder.canUndo() / builder.canRedo().

    Pattern: subscribe FIRST so any commit during load() flows through the listener; then run a one-shot sync after load resolves to seed the initial state. Same shape works for any "snapshot now, update on event" UI — toolbar pin states, region-focus indicators, locale labels.

    JavaScript
    // Sync initial state — the bus has no fire history,
    // so seed buttons explicitly after load() resolves.
    function syncHistoryButtons() {
      undoBtn.disabled = !builder.canUndo();
      redoBtn.disabled = !builder.canRedo();
      document.getElementById('c3SizePill').textContent =
        builder.history.size() === 0 ? 'Empty' : `Step 1 of ${builder.history.size()}`;
    }
    
    builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL, () => {
      syncHistoryButtons();
    });

Live demo

demo-mini-builder--rich-3col-header
history:change demo — try edits, then undo/redoEmpty

The whole snippet

Click Copy on any file to grab it byte-identical, or Expand to read inline (capped at ~520 px so the page stays scannable). Multi-file snippets surface a side-by-side grid — copy only the files you need.

<?php
/**
 * snippet.php — `history:change` event subscription with external
 * Undo/Redo buttons.
 *
 * Drop alongside `dist/` + `themes/default/`, run a PHP server.
 * Edit anything → Undo lights up. Click Undo → Redo lights up.
 * The "Step N of M" pill reads the stack position from the same
 * payload — no separate query needed.
 *
 * IDs `c3UndoBtn` / `c3RedoBtn` / `c3SizePill` match the live demo
 * on /examples/events/3-history-change/ AND Steps 1-3 of the
 * walkthrough (BUILDER.md RULE H, D13.B.parity).
 *
 * NOTE: Builder.EVENTS does NOT currently expose a HISTORY_CHANGE
 * constant. Subscription is via raw string `'history:change'` —
 * the event itself fires reliably from HistoryManager.js. When the
 * constant lands (future Phase A.A scope addition), swap the
 * string for `Builder.EVENTS.HISTORY_CHANGE` — same behaviour.
 */
declare(strict_types=1);

require_once __DIR__ . '/../../../backend/_lib/ThemeRegistry.php';

$bundle = (new \DemoBuilder\ThemeRegistry(__DIR__ . '/../../../themes'))
    ->resolveBundle('default', 'master/sample/email/Minimal');
?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>history:change — external Undo/Redo</title>
    <link rel="stylesheet" href="/dist/builder.css">
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block">
    <style>
        body { margin: 0; font-family: system-ui, sans-serif; display: grid; grid-template-rows: 56px 1fr; height: 100vh; }
        .my-header  { display: flex; align-items: center; gap: 12px; padding: 0 16px; background: #f8f9fa; border-bottom: 1px solid #e5e7eb; }
        .my-header__title { font-weight: 600; font-size: 14px; margin-right: auto; }
        .my-pill { display: inline-flex; padding: 4px 10px; border-radius: 999px; background: #f3f4f6; color: #6b7280; font-size: 12px; font-weight: 600; }
        .my-btn { padding: 6px 12px; background: #fff; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; font: inherit; font-size: 13px; }
        .my-btn:hover:not(:disabled) { background: #f3f4f6; }
        .my-btn:disabled { opacity: 0.5; cursor: not-allowed; }
        .my-builder-host { display: grid; grid-template-columns: 220px 1fr 280px; min-height: 0; }
        #MyWidgets, #MySettings { background: #f8f9fa; padding: 12px; overflow: auto; }
        #MyCanvas { overflow: auto; }
    </style>
</head>
<body>

<header class="my-header">
    <span class="my-header__title">history:change demo — try edits, then undo/redo</span>
    <span id="c3SizePill" class="my-pill">Empty</span>
    <button type="button" id="c3UndoBtn" class="my-btn" disabled>Undo</button>
    <button type="button" id="c3RedoBtn" class="my-btn" disabled>Redo</button>
</header>

<div class="my-builder-host">
    <div id="MyWidgets"></div>
    <div id="MyCanvas"></div>
    <div id="MySettings"></div>
</div>

<script>
    window.THEME_JSON        = <?= json_encode($bundle->themeJson,      JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.THEME_TEMPLATES   = <?= json_encode($bundle->themeTemplates, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.THEME_CONFIG_DATA = <?= json_encode($bundle->configData,     JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.MEDIA_URL         = <?= json_encode($bundle->mediaUrl,       JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
</script>

<script src="/dist/builder.js"></script>

<script>
    const builder = new Builder({
        mainContainer:     '#MyCanvas',
        widgetsContainer:  '#MyWidgets',
        settingsContainer: '#MySettings',
    });

    const undoBtn = document.getElementById('c3UndoBtn');
    const redoBtn = document.getElementById('c3RedoBtn');
    const pill    = document.getElementById('c3SizePill');

    undoBtn.addEventListener('click', () => builder.undo());
    redoBtn.addEventListener('click', () => builder.redo());

    // Subscribe via raw string — no Builder.EVENTS.HISTORY_CHANGE
    // constant is exposed today. The event itself fires reliably.
    builder.events.on('history:change', ({ canUndo, canRedo, size, currentIndex }) => {
        undoBtn.disabled = !canUndo;
        redoBtn.disabled = !canRedo;
        pill.textContent = size === 0
            ? 'Empty'
            : `Step ${currentIndex + 1} of ${size}`;
    });

    builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL, () => {
        // Seed first-paint state — the bus has no fire history
        // before any commit lands.
        undoBtn.disabled = !builder.canUndo();
        redoBtn.disabled = !builder.canRedo();
        const size = builder.history.size();
        pill.textContent = size === 0 ? 'Empty' : `Step 1 of ${size}`;
    });
</script>

</body>
</html>

Notes

Why expose Undo/Redo OUTSIDE the engine? Three buyer scenarios make external buttons the right choice: (1) the host app already has a top app-bar where Undo lives next to Save / Share / Settings; (2) keyboard-first integrations want global shortcuts (Cmd-Z / Cmd-Shift-Z) that fire builder.undo() regardless of focus; (3) embedded contexts (Builder-as-card on a dashboard) hide the engine chrome entirely. The cookbook variant rich-3col-header ships the in-wrapper pattern; C.3 ships the host-side pattern. They compose — wire both for buyers who want the "main" toolbar inside the engine and a "secondary" one outside.

Why no Builder.EVENTS.HISTORY_CHANGE? The constant just hasn't been registered yet — the event itself fires reliably from HistoryManager.js. Phase A.A is the place to add it (gated by an L1.a design-confirm question). Until then, the raw string is canonical: builder.events.on('history:change', handler). Your code will keep working when the constant lands — just swap the string for the constant.

Stack-state semantics. currentIndex is 0-based and points at the entry that produced the current canvas state. size is the total number of entries on the stack. After 3 edits + 1 undo: currentIndex=1, size=3 — you're on step 2 of 3, with one redo available. After builder.history.clear(): size=0, currentIndex=-1 — both buttons disabled.

Production touch — keyboard shortcuts. Add a global keydown listener that fires builder.undo() on Ctrl/Cmd-Z and builder.redo() on Ctrl/Cmd-Shift-Z. The engine's INSIDE-canvas shortcuts already work (cookbook variants prove it); host-page shortcuts give buyers the OS-native UX they expect. flows/3-history-keyboard-shortcuts (Phase C.flows.3) covers the chord-ladder pattern.