Events · 2 of 5

element:added / element:removed — live activity log

Subscribe to both element:added and element:removed on the same bus, render a bounded list of the last N mutations. Each event carries { elementUid, elementType } — enough to identify what changed without serializing the page tree.

Walkthrough

  1. Two listeners, same shape — subscribe to both events

    Both events emit through builder.events with the same payload — { elementUid, elementType }. Subscribe with two events.on(...) calls or, if you prefer, route both through a single dispatcher. The two-listener form keeps each event's side effect separate; the dispatcher form is tighter when the only difference is a verb.

    Unlike document:changed, neither element:added nor element:removed is debounced — drag-removing a row that contains 3 cells fires three element:removed events back-to-back. The bus handles a tight burst without coalescing; if your handler is expensive, debounce in your own code (one requestAnimationFrame coalesce is usually enough).

    JavaScript
    // Two listeners — each handler is single-purpose.
    builder.events.on(Builder.EVENTS.ELEMENT_ADDED, ({ elementUid, elementType }) => {
      log.push({ verb: 'added',   elementUid, elementType, at: Date.now() });
      render();
    });
    builder.events.on(Builder.EVENTS.ELEMENT_REMOVED, ({ elementUid, elementType }) => {
      log.push({ verb: 'removed', elementUid, elementType, at: Date.now() });
      render();
    });
    
    // Or — single dispatcher.
    for (const verb of ['added', 'removed']) {
      builder.events.on(`element:${verb}`, (payload) => {
        log.push({ verb, ...payload, at: Date.now() });
        render();
      });
    }
  2. Bounded log — push, slice the tail, render

    The host owns history bounding. A real activity log can be unbounded if you persist it (an analytics endpoint, a CRDT op log) — but a UI list only needs the last N entries. Push to the array, slice the tail, render. Array.prototype.slice(-N) is the cleanest expression: returns a fresh array of the last N items without mutating the source.

    The live demo above caps at 8 entries. Tune MAX_LOG at the top of your handler — anything beyond ~50 entries in a DOM list begins to thrash if events fire fast (multi-block paste, undo of a deep delete). Persist the rest server-side if you need the full trace.

    JavaScript
    const MAX_LOG = 8;
    const log = [];
    
    function render() {
      const tail = log.slice(-MAX_LOG);
      document.getElementById('c2ActivityLog').innerHTML = tail
        .map(({ verb, elementType, elementUid, at }) =>
          `<li class="log__row log__row--${verb}">
            <span class="log__verb">${verb}</span>
            <span class="log__type">${elementType ?? 'unknown'}</span>
            <code class="log__uid">${elementUid}</code>
            <time>${new Date(at).toLocaleTimeString()}</time>
          </li>`
        )
        .join('');
    }
  3. Wire a Clear button + an empty state

    The host UI also owns "clear" semantics. Empty the array, re-render. Buyers expect a Clear button on any feed-like surface, and the empty state should read as "ready" not "broken" — same lesson as the cookbook 5-sidebar-only floating-panel placeholder (D13.C.B.5).

    Per BUILDER.md RULE H, every code line shown here is in the live demo above. The c2ClearLog button at the right of the panel runs the exact handler below; the empty-state pseudo-content reads "No mutations yet — drag a widget into the canvas, or delete a row." until the first event fires.

    JavaScript
    document.getElementById('c2ClearLog').addEventListener('click', () => {
      log.length = 0;
      render();
    });
    
    // CSS-only empty state — no JS needed for the placeholder copy.
    // .c2-log:empty::before {
    //   content: 'No mutations yet — drag a widget into the canvas, or delete a row.';
    //   color: var(--demo-text-muted);
    // }

Live demo

demo-mini-builder--rich-3col-header
element:added · element:removed

    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 — `element:added` + `element:removed` event subscription.
     *
     * Drop alongside `dist/` + `themes/default/`, run a PHP server.
     * Drag a widget into the canvas → "added" entry appears in the
     * activity-log panel. Delete a row → multiple "removed" entries
     * (depth-first traversal: contained blocks remove first, then the
     * row itself).
     *
     * `c2ActivityLog` + `c2ClearLog` IDs match the live demo on
     * /examples/events/2-element-added-removed/ AND Steps 2-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>element:added / element:removed — activity log</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; }
            .c2-log { list-style: none; margin: 0; padding: 0; flex: 1; min-width: 0; max-height: 36px; overflow-y: auto; scroll-behavior: smooth; display: flex; flex-direction: column; gap: 2px; }
            .c2-log__row { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: #6b7280; }
            .c2-log__row--added .c2-log__verb { color: #10b981; }
            .c2-log__row--removed .c2-log__verb { color: #d32f2f; }
            .c2-log__verb { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; font-size: 10px; }
            .c2-log__type { font-weight: 600; color: #1f2937; }
            .c2-log__uid { font-family: ui-monospace, monospace; font-size: 10px; color: #9ca3af; }
            .c2-log:empty::before { content: 'No mutations yet — drag a widget into the canvas, or delete a row.'; color: #9ca3af; font-size: 11px; }
            .my-clear { margin-left: auto; padding: 4px 10px; background: #fff; border: 1px solid #d1d5db; border-radius: 4px; cursor: pointer; font: inherit; font-size: 12px; }
            .my-clear:hover { background: #f3f4f6; }
            .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">element:added · element:removed</span>
        <ul id="c2ActivityLog" class="c2-log" aria-live="polite" aria-label="Last 8 element mutations"></ul>
        <button type="button" id="c2ClearLog" class="my-clear">Clear</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 MAX_LOG = 8;
        const log = [];
        const listEl = document.getElementById('c2ActivityLog');
    
        function escapeHtml(s) {
            return String(s).replace(/[&<>"']/g, ch => ({
                '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
            }[ch]));
        }
    
        function render() {
            const tail = log.slice(-MAX_LOG);
            listEl.innerHTML = tail.map(({ verb, elementType, elementUid, at }) => {
                const ts = new Date(at);
                const hh = String(ts.getHours()).padStart(2, '0');
                const mm = String(ts.getMinutes()).padStart(2, '0');
                const ss = String(ts.getSeconds()).padStart(2, '0');
                return `<li class="c2-log__row c2-log__row--${escapeHtml(verb)}">
                  <span class="c2-log__verb">${escapeHtml(verb)}</span>
                  <span class="c2-log__type">${escapeHtml(elementType || 'unknown')}</span>
                  <code class="c2-log__uid">#${escapeHtml(String(elementUid))}</code>
                  <time>${hh}:${mm}:${ss}</time>
                </li>`;
            }).join('');
        }
    
        function pushAndRender(verb, payload) {
            log.push({ verb, elementUid: payload.elementUid, elementType: payload.elementType, at: Date.now() });
            render();
            listEl.scrollTop = listEl.scrollHeight; // Newest at the bottom; auto-scroll into view.
        }
    
        // Two listeners — same payload shape `{ elementUid, elementType }`.
        builder.events.on(Builder.EVENTS.ELEMENT_ADDED,   (p) => pushAndRender('added', p));
        builder.events.on(Builder.EVENTS.ELEMENT_REMOVED, (p) => pushAndRender('removed', p));
    
        document.getElementById('c2ClearLog').addEventListener('click', () => {
            log.length = 0;
            render();
        });
    
        builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
    </script>
    
    </body>
    </html>
    

    Notes

    Why two events instead of one "element:changed"? Drag-add and drag-remove are different mutations with different downstream side effects — analytics fires "user added a Block of type X", autosave fires once per change, the audit log records destruction. Forcing a single event would push the verb into the payload and lose the ability to filter at subscription time. The bus exposes both verbs so your handler can be specific without parsing.

    Payload guarantees. Both events emit { elementUid, elementType } — the UID is the engine-assigned ID (stable across sessions if you persisted it via builder.save()), the type is the element's class name from getName(). Type is null for elements that don't implement getName() — defensive fallback to 'unknown' in the renderer.

    Sequence on a multi-block delete. Removing a row that contains a Block fires element:removed for each contained block before the row itself, depth-first. The activity log shows that order — useful for understanding the engine's tree-walk on undo / paste / delete-with-children operations. history:change (Phase C.3) covers the parallel "the page tree mutated" signal that bundles a full diff.

    What about edits to existing elements? Format changes (text colour, padding, alignment) don't fire element:added / element:removed — they fire document:changed only (see events/1-document-changed). The two events are about tree-shape mutations; format changes leave the tree intact and only flip a property. Combine both events for a full activity feed.