<?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>
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
-
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 thehistory:changeevent so the buttons reflect engine state without polling.Heads-up:
history:changeis emitted fromHistoryManager.jsbut does NOT currently have aBuilder.EVENTS.*constant — pass the raw string. A future Phase A.A scope addition will exposeBuilder.EVENTS.HISTORY_CHANGEalongside the existingDOCUMENT_CHANGED/ELEMENT_ADDED/ELEMENT_REMOVED/MODE_CHANGEDconstants.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}`; }); -
Why `history:change` and not `document:changed`?
Both events fire on every commit, but they answer different questions.
document:changedis "the page tree mutated, time to autosave or update a dirty pill" — debounced 120 ms by the bus, no payload.history:changeis "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 fromhistory: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; }); -
Read state on first paint — the bus is hot from `new Builder({...})`
The bus is alive from construction time, but
history:changeonly fires on a state transition (commit, undo, redo, clear). On first paint afterbuilder.load(), the buttons need their initial state set explicitly — read it directly viabuilder.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(); });
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.
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.