Events · 1 of 5

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

  1. Subscribe via `builder.events.on(...)` after instantiation

    Every Builder instance owns a pub/sub events bus. Subscribe with builder.events.on(eventName, handler); unsubscribe with builder.events.off(eventName, handler). Use the Builder.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 before builder.load() means the very first document:changed after first paint also flows through.

    JavaScript
    const 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);
  2. 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:changed in a 120 ms trailing-edge debounce inside EventEmitter — 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 to 0 for 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) });
    });
  3. 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 #c1LastModifiedPill ID this step's code targets. The handler reads new 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 separate save:click event — let document:changed drive 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>

Live demo

demo-mini-builder--rich-3col-header
document:changed demo — type, drag, or change a formatLast modified: never

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

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.