<?php
/**
* snippet.php — keyboard shortcut chord ladder + max-history stepper.
* Reactive Undo / Redo / step-counter / Rewind via the
* `history:change` listener; max-stepper flips
* `builder.history.maxSize` on the fly.
*
* Drop alongside `dist/` + `themes/default/`, run a PHP server. Cmd-Z
* undoes; Cmd-Shift-Z (or Cmd-Y) redoes; Cmd-Shift-Backspace rewinds
* to the baseline. Number input flips depth limit (5-200, engine
* default 50).
*
* IDs `c17UndoBtn` / `c17RedoBtn` / `c17RewindBtn` / `c17StepCounter`
* / `c17MaxStepper` match the live demo on
* /examples/flows/3-history-keyboard-shortcuts/ AND Steps 1-3 of the
* walkthrough (BUILDER.md RULE H, D13.B.parity).
*/
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 keyboard shortcuts — chord ladder + max stepper</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: 8px; padding: 0 16px; background: #f8f9fa; border-bottom: 1px solid #e5e7eb; }
.my-header__title { font-weight: 600; font-size: 14px; margin-right: auto; }
.my-btn { padding: 6px 12px; background: #fff; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; font: inherit; font-size: 12px; }
.my-btn:hover:not(:disabled) { background: #f3f4f6; }
.my-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.my-counter { font-size: 11px; padding: 4px 10px; border-radius: 999px; background: #f4f4f5; color: #71717a; font-weight: 600; min-width: 80px; text-align: center; }
.my-stepper-label { font-size: 11px; color: #71717a; }
.my-stepper { width: 56px; padding: 4px 6px; font-size: 12px; border: 1px solid #d1d5db; border-radius: 4px; }
.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 — chord ladder + max stepper</span>
<button type="button" id="c17UndoBtn" class="my-btn" disabled>↶ Undo</button>
<button type="button" id="c17RedoBtn" class="my-btn" disabled>↷ Redo</button>
<button type="button" id="c17RewindBtn" class="my-btn">⤺ Rewind</button>
<span class="my-counter" id="c17StepCounter" aria-live="polite">Step 1 / 1</span>
<span class="my-stepper-label">Depth limit:</span>
<input type="number" id="c17MaxStepper" class="my-stepper" min="5" max="200" step="5" value="50" aria-label="Maximum history stack size">
</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>
// historyShortcuts: true is the engine default — Cmd-Z / Cmd-Shift-Z /
// Cmd-Y are wired without buyer code.
const builder = new Builder({
mainContainer: '#MyCanvas',
widgetsContainer: '#MyWidgets',
settingsContainer: '#MySettings',
});
const undoBtn = document.getElementById('c17UndoBtn');
const redoBtn = document.getElementById('c17RedoBtn');
const rewindBtn = document.getElementById('c17RewindBtn');
const counter = document.getElementById('c17StepCounter');
const stepper = document.getElementById('c17MaxStepper');
function renderHistoryUi(state) {
// state: { canUndo, canRedo, size, currentIndex, currentEntry }
undoBtn.disabled = !state.canUndo;
redoBtn.disabled = !state.canRedo;
counter.textContent = `Step ${state.currentIndex + 1} / ${state.size}`;
}
// Listener-as-source-of-truth: every undo/redo/commit/clear updates UI here.
builder.events.on('history:change', renderHistoryUi);
// Boot snapshot — engine doesn't re-emit on subscribe.
builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL, () => {
renderHistoryUi({
canUndo: builder.history.canUndo(),
canRedo: builder.history.canRedo(),
size: builder.history.size(),
currentIndex: builder.history.getCurrentIndex(),
currentEntry: null,
});
stepper.value = builder.history.maxSize;
});
// Click handlers — one line each. Listener above owns repaint.
undoBtn.addEventListener('click', () => builder.undo());
redoBtn.addEventListener('click', () => builder.redo());
rewindBtn.addEventListener('click', () => {
const entries = builder.history.getEntries();
if (entries.length > 0) builder.history.jumpTo(entries[0].id);
});
// Custom chord — Cmd-Shift-Backspace rewinds to baseline.
document.addEventListener('keydown', (e) => {
const mod = e.metaKey || e.ctrlKey;
if (!mod || !e.shiftKey) return;
if (e.key !== 'Backspace') return;
e.preventDefault();
const entries = builder.history.getEntries();
if (entries.length > 0) builder.history.jumpTo(entries[0].id);
});
// Max-history stepper — flips maxSize live.
stepper.addEventListener('change', (e) => {
const next = Math.max(5, Math.min(200, parseInt(e.target.value, 10) || 50));
builder.history.maxSize = next;
stepper.value = next;
});
</script>
</body>
</html>
History keyboard shortcuts — chord ladder + max-history stepper
Three layers in 30 lines of buyer code: (1) the engine wires Cmd-Z / Cmd-Shift-Z / Cmd-Y for free, (2) extend the ladder with any custom chord (Cmd-Shift-Backspace = rewind to start), (3) flip the stack ceiling on the fly via `history.maxSize`. Reactive Undo/Redo + step counter all driven by the `history:change` listener — single source of truth.
Walkthrough
-
The engine ships with Cmd-Z / Cmd-Shift-Z / Cmd-Y wired — buyer has to do nothing
Builder.js:746-767attaches akeydownlistener ondocument(capture phase) that converts (Cmd|Ctrl)+Z →builder.history.undo(); (Cmd|Ctrl)+Shift+Z and (Cmd|Ctrl)+Y →builder.history.redo(). The handler skips inputs / textareas / contenteditable so text editors handle their own undo natively. Mac and Windows / Linux both work — the engine checksmetaKey || ctrlKey.Try it now in the live demo. Click any block on the canvas, change something via the settings panel, then press ⌘Z (or Ctrl+Z). The Undo / Redo buttons in the header reflect the new state (driven by the same
history:changelistener wired below). Boot state is at the baseline — Undo greys out; Redo greys out; once you make ONE change both light up.Want to opt OUT of the built-in chords (e.g., your app needs Cmd-Z for a different action)? Pass
{ historyShortcuts: false }into the Builder constructor. The engine then leaves the keyboard alone — wire your own listener that callsbuilder.history.undo()+builder.history.redo()on whatever chord you pick.JavaScript// Default — engine wires Cmd-Z / Cmd-Shift-Z / Cmd-Y for free. const builder = new Builder({ mainContainer: '#MyCanvas', widgetsContainer: '#MyWidgets', settingsContainer: '#MySettings', // historyShortcuts: true ← default, no need to set }); // Opt-out — engine ignores the keyboard, you wire your own. const builderManual = new Builder({ mainContainer: '#MyCanvas', widgetsContainer: '#MyWidgets', settingsContainer: '#MySettings', historyShortcuts: false, }); // Then wire whatever chord you want: document.addEventListener('keydown', (e) => { const mod = e.metaKey || e.ctrlKey; if (!mod) return; if (e.key === 'z' && !e.shiftKey) builderManual.undo(); if (e.key === 'z' && e.shiftKey) builderManual.redo(); }); -
Reactive Undo / Redo buttons + step counter — `history:change` is the single source of truth
The header carries an Undo button + Redo button + a step-counter pill ("Step 3 / 7"). All three reflect engine state via ONE
history:changelistener. Engine emits the event fromHistoryManager._notifyChange()on every undo / redo / commit / clear with this payload:{\n canUndo: boolean, // pointer > 0\n canRedo: boolean, // pointer < size - 1\n size: number, // total stack length\n currentIndex: number, // 0-based pointer position\n currentEntry: { labelKey, timestamp, ... }\n}Mutating button state in the click handler instead of the listener is the easiest way to desync — keyboard shortcuts, programmatic
builder.undo()calls, and `Cmd-Shift-Backspace` rewinds all skip the click path but still emit the event. The listener-as-UI-source-of-truth pattern (locked in D13.C.8) means: click handlers stay one-line (builder.undo()); listener owns repaint; programmatic flips update through SAME path with zero per-caller wiring.This example is the SECOND consumer of
history:changein the demo (the first was W6.C.3 events tier). Locks the rule: "any UI surface that reflects history state subscribes tohistory:change+ reads payload fields, never reads engine state imperatively in a render-loop."JavaScriptconst undoBtn = document.getElementById('c17UndoBtn'); const redoBtn = document.getElementById('c17RedoBtn'); const counter = document.getElementById('c17StepCounter'); function renderHistoryUi(state) { // state shape: { canUndo, canRedo, size, currentIndex, currentEntry } undoBtn.disabled = !state.canUndo; redoBtn.disabled = !state.canRedo; // currentIndex is 0-based; show 1-based for buyers. counter.textContent = `Step ${state.currentIndex + 1} / ${state.size}`; } // Subscribe — engine emits on every undo/redo/commit/clear. builder.events.on('history:change', renderHistoryUi); // Boot snapshot — engine doesn't re-emit history:change on subscribe. // Read once after load() resolves. renderHistoryUi({ canUndo: builder.history.canUndo(), canRedo: builder.history.canRedo(), size: builder.history.size(), currentIndex: builder.history.getCurrentIndex(), currentEntry: null, }); // Click handlers — one-line each; the listener owns the repaint. undoBtn.addEventListener('click', () => builder.undo()); redoBtn.addEventListener('click', () => builder.redo()); -
Extend the ladder + flip the stack ceiling — Cmd-Shift-Backspace rewinds, max-stepper changes maxSize
Custom chord — rewind to start. Cmd-Shift-Backspace (or Ctrl-Shift-Backspace) jumps the pointer back to entry 0 in one move via
builder.history.jumpTo(builder.history.getEntries()[0].id). Useful when the buyer wants a "reset to baseline" affordance without nuking the stack (Cmd-Z still walks back through individual edits afterwards). Wire any chord this way: listen on document, check modifiers, look up the entry id, call jumpTo.Max-history stepper — `history.maxSize`. Engine default is 50 entries. Above this the stack trims oldest-first on each push. Reduce to 10 for memory-constrained embeds (an in-app email composer that ships in a tiny iframe) or raise to 200 for power-users on a full-screen editor. The setting takes effect on the NEXT push — already-stored entries above the new ceiling stay until the next commit trims them. The stepper in the header is a
<input type="number">bound tohistory.maxSizewith a 5-200 range so a fat-fingered 1 doesn't lose the engine's baseline.Surface the depth pill. The step counter from Step 2 already shows current/max. Add a "Depth limit: N" pill so the buyer's power-users see when they're at the ceiling + can raise the limit before losing entries. The whole UI surface is <30 lines of host code; the engine handles the trimming + payload semantics.
JavaScript// Custom chord — Cmd-Shift-Backspace rewinds to entry 0. document.addEventListener('keydown', (e) => { const mod = e.metaKey || e.ctrlKey; if (!mod || !e.shiftKey) return; // Backspace key — Mac users typically have Delete labelled Backspace. if (e.key !== 'Backspace') return; e.preventDefault(); const entries = builder.history.getEntries(); if (entries.length > 0) builder.history.jumpTo(entries[0].id); }); // Optional: surface the chord as a button too — keyboard discovery is hard. document.getElementById('c17RewindBtn').addEventListener('click', () => { const entries = builder.history.getEntries(); if (entries.length > 0) builder.history.jumpTo(entries[0].id); }); // Max-history stepper — flips builder.history.maxSize on the fly. const stepper = document.getElementById('c17MaxStepper'); stepper.value = builder.history.maxSize; // boot value (engine default = 50) stepper.addEventListener('change', (e) => { const next = Math.max(5, Math.min(200, parseInt(e.target.value, 10) || 50)); builder.history.maxSize = next; stepper.value = next; // clamp visible value }); // Listener already wired in Step 2 covers updates — no extra subscribe needed. // jumpTo emits 'history:change' so Undo/Redo buttons + step counter follow.
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
The chord ladder vs the engine\'s built-in handler. The engine\'s built-in (`Builder.js:746-767`) attaches at document in CAPTURE phase + skips text-edit contexts. Custom chords you wire on top can also attach at document — they fire in document order. If your chord conflicts with the engine\'s default (e.g., you also want Cmd-Z for something else), opt out via { historyShortcuts: false } in the constructor, then re-implement whichever defaults you still want from scratch.
Why jumpTo(entries[0].id) and not history.clear()? Both walk to the baseline visually, but clear() NUKES every entry except the current one — your buyer can\'t Cmd-Z back through their work after rewinding. jumpTo moves the pointer; the entries stay in place, ready for the next Cmd-Z to step through. Pick clear() only when you want a hard reset (e.g., loading a fresh page; the engine already calls clear() internally on `builder.load()`).
Step counter is 1-based for the buyer; engine is 0-based internally. history.getCurrentIndex() returns the 0-based pointer position (0 = baseline, size-1 = newest). The pill shows `Step currentIndex + 1 / size` because "Step 1 of 1" reads more naturally than "Index 0 of 1". Keep the ENGINE side consistent with the rest of the array world (0-based) + present 1-based to humans only at display time.
The maxSize stepper is for power-users — don\'t surface it casually. Most buyers should never see this control; the engine default of 50 covers >95% of editing sessions without exhausting memory. Expose maxSize ONLY in two scenarios: (1) memory-constrained embeds where 50 is too high (in-app composers shipping in tiny iframes; Slack-thread editors); (2) power-user "settings" panels where the user wants explicit control. Don\'t make it the default UI — the cognitive overhead is greater than the typical benefit.
Engine emits BUT doesn\'t re-emit on subscribe. The history:change listener fires only on subsequent state changes — boot state needs an explicit imperative read (the renderHistoryUi({ canUndo: ..., canRedo: ..., size: ..., currentIndex: ... }) call after subscribing). Same pattern as document:changed in W6.C.1 + mode:changed in W6.A.A.4 — engine events fire on TRANSITIONS, not on initial subscribe. The buyer is responsible for the boot snapshot read.
Phase C closes here at 17/17. Five events + five api + four extensions + three flows. The next 17 examples in the buyer-facing docs (counting backwards) cover most of what a SaaS buyer needs to know to ship a real BuilderJS-powered product. The chord ladder pattern transfers to ANY engine surface — switch builder.history for builder.events / builder.theme / etc. and the (listen on document → check modifiers → call engine method) recipe stays identical. After Phase C closes: Phase D 3-item multi-tenant cookbook lands → F.B story-driven layers (6) → F.C 6-round audit + close-out (9) → Phase E close-out → W7 Gallery.