<?php
/**
* snippet.php — `document:changed` event subscription.
*
* Drop this alongside `dist/` and `themes/default/`, run a PHP server,
* open the page — every edit on the canvas updates the "Last modified"
* pill in the header. Engine debounces the event at 120 ms internally,
* so a typing burst collapses into one pill update.
*
* The `c1LastModifiedPill` ID matches the live demo on
* /examples/events/1-document-changed/ AND Step 3 of the walkthrough —
* triple-surface parity (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>document:changed — last-modified pill</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: 48px 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 { margin-right: auto; font-weight: 600; font-size: 14px; }
.pill { display: inline-flex; padding: 4px 10px; border-radius: 999px; background: #f3f4f6; color: #6b7280; font-size: 12px; font-weight: 600; transition: background 240ms ease, color 240ms ease; }
.pill.is-active { background: #e0e7ff; color: #4338ca; }
.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">My builder</span>
<span id="c1LastModifiedPill" class="pill">Last modified: never</span>
</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',
});
// Subscribe BEFORE load() so the very first paint also fires through.
// The bus debounces 'document:changed' internally at 120 ms — a typing
// burst collapses into ONE pill update. Override the debounce at
// construction time with `new Builder({ eventDebounceMs: 250 })`.
const pill = document.getElementById('c1LastModifiedPill');
builder.events.on(Builder.EVENTS.DOCUMENT_CHANGED, () => {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
pill.textContent = `Last modified: ${hh}:${mm}:${ss}`;
pill.classList.add('is-active');
});
builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
</script>
</body>
</html>
document:changed — last-modified pill
Subscribe to the engine's `document:changed` event and update a host-side pill on every edit. The bus debounces internally at 120 ms — one host-visible signal per typing burst, not per keystroke.
Walkthrough
-
Subscribe via `builder.events.on(...)` after instantiation
Every Builder instance owns a pub/sub
eventsbus. Subscribe withbuilder.events.on(eventName, handler); unsubscribe withbuilder.events.off(eventName, handler). Use theBuilder.EVENTS.*constants instead of typing the string — your editor catches typos and the constant is stable across versions.Listeners attach after
new Builder({...})— the bus exists from construction time, so registering beforebuilder.load()means the very firstdocument:changedafter first paint also flows through.JavaScriptconst builder = new Builder({ mainContainer: '#MyCanvas', widgetsContainer: '#MyWidgets', settingsContainer: '#MySettings', }); // Subscribe BEFORE load() so the very first edit fires through. builder.events.on(Builder.EVENTS.DOCUMENT_CHANGED, () => { document.getElementById('c1LastModifiedPill').textContent = 'Last modified: ' + new Date().toLocaleTimeString(); }); builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL); -
The bus debounces `document:changed` internally — one signal per burst
A typing burst is dozens of mutations per second. Surfacing every one of those to the host would burn battery and triple-paint your pill. The engine wraps
document:changedin a 120 ms trailing-edge debounce insideEventEmitter— your handler fires once when the burst settles, with the latest state.120 ms is two frames at 60 Hz: long enough to coalesce a sentence of typing, short enough to feel instant. Override per-instance with
new Builder({ eventDebounceMs: 250 })when you want quieter signals (e.g. autosave). Set to0for raw fire-on-every-mutation if you need it (you almost never do).JavaScript// Default: 120 ms debounce. One pill update per typing burst. const builder = new Builder({ /* ...containers... */ }); // Quieter — autosave fires once every 1.5 seconds of inactivity. const builderQuiet = new Builder({ /* ...containers... */ eventDebounceMs: 1500, }); builderQuiet.events.on(Builder.EVENTS.DOCUMENT_CHANGED, () => { fetch('/backend/save.php', { method: 'POST', body: serialize(builderQuiet) }); }); -
Render the pill — your call where + how
The host owns the DOM around the canvas. The pill in the live demo above is one
<span>with the same#c1LastModifiedPillID this step's code targets. The handler readsnew Date().toLocaleTimeString()on every fire — locale formatting + 12/24h conventions come free from the platform.For a real autosave UX, the same listener fires the network call:
fetch(saveUrl, …), then on the response set the pill to "Saved 14:23:45 ✓". You don't need a separatesave:clickevent — letdocument:changeddrive both the indicator and the persistence.HTML<header class="my-app__header"> <h1>My CMS</h1> <span id="c1LastModifiedPill" class="pill pill--muted"> Last modified: never </span> </header> <style> .pill { display: inline-flex; padding: 4px 10px; border-radius: 999px; background: #eef2ff; color: #4338ca; font-size: 12px; font-weight: 600; } .pill--muted { background: #f3f4f6; color: #6b7280; } </style>
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 subscribe instead of polling? A polling loop (setInterval(..., 500)) reads stale state and burns CPU when nothing changed. The bus pushes only on real mutations (block move, format change, image swap, text edit) and the 120 ms debounce already coalesces typing bursts. Subscribe once, get the right cadence for free.
Where the event fires from. Every internal mutation that flips the page tree (HistoryManager commit, image upload completion, format-control change, drag-reorder) closes with this.events.emit(Builder.EVENTS.DOCUMENT_CHANGED). The bus then trailing-edge-debounces the host-visible signal at the configured eventDebounceMs. See src/includes/Builder.js for the canonical emit sites.
Pair it with `document:saved`. The companion event fires after a successful builder.save() resolves. Use both: document:changed for the dirty indicator, document:saved for the "Saved ✓" confirmation. Element added/removed (Phase C.2) and getData / load round-trip (Phase C.7) cover the next two patterns.
Unsubscribe on teardown. If your host page tears down the Builder (route change in a SPA, modal close), call builder.events.off(Builder.EVENTS.DOCUMENT_CHANGED, handler) with the same handler reference. Keep the function in a const so it stays referenceable. Otherwise the listener leaks across re-mounts.