Flows · 3 of 3

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

  1. The engine ships with Cmd-Z / Cmd-Shift-Z / Cmd-Y wired — buyer has to do nothing

    Builder.js:746-767 attaches a keydown listener on document (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 checks metaKey || 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:change listener 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 calls builder.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();
    });
  2. 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:change listener. Engine emits the event from HistoryManager._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:change in the demo (the first was W6.C.3 events tier). Locks the rule: "any UI surface that reflects history state subscribes to history:change + reads payload fields, never reads engine state imperatively in a render-loop."

    JavaScript
    const 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());
  3. 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 to history.maxSize with 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.

Live demo

demo-mini-builder--rich-3col-header
History — chord ladder + max-history stepperStep 1 / 1Depth limit:

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 — 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>

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.