<?php
/**
* snippet.php — `selectElement()` + `unselect()` driven from a custom
* outline sidebar (one button per top-level block + a Clear button).
*
* Drop alongside `dist/` + `themes/default/`, run a PHP server.
* The toolbar above the canvas walks `pageElement.getChildren()` and
* renders one button per block. Click any → engine selects + canvas
* paints the rectangle. Click "Clear selection" → engine unselects.
*
* IDs `c9Toolbar` / `c9SelectBtn-{i}` / `c9ClearBtn` match the live
* demo on /examples/api/4-select-unselect-programmatic/ AND Steps
* 1-3 of the walkthrough (BUILDER.md RULE H).
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../backend/_lib/ThemeRegistry.php';
$bundle = (new \DemoBuilder\ThemeRegistry(__DIR__ . '/../../../themes'))
->resolveBundle('default', 'master/sample/email/WelcomeCustomers');
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>selectElement / unselect — programmatic selection</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: auto 1fr; height: 100vh; }
#c9Toolbar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; padding: 8px 12px; background: #f8f9fa; border-bottom: 1px solid #e5e7eb; font-size: 12px; }
#c9Toolbar__label { font-weight: 600; margin-right: 8px; }
.my-btn { font: inherit; font-size: 11px; padding: 3px 10px; background: #fff; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; }
.my-btn:hover { background: #f3f4f6; }
.my-btn:focus-visible { outline: 2px solid #4338ca; outline-offset: 2px; }
.my-divider { width: 1px; height: 16px; background: #e5e7eb; margin: 0 4px; }
.my-builder-host { display: grid; grid-template-columns: 200px 1fr 240px; min-height: 0; }
.my-host-slot { background: #f8f9fa; padding: 8px; overflow: auto; font-size: 12px; }
.my-canvas { overflow: auto; background: #fff; }
</style>
</head>
<body>
<div id="c9Toolbar" role="toolbar" aria-label="Outline selection">
<span id="c9Toolbar__label">Outline → click to select:</span>
<!-- Block buttons get appended after load(); see <script> below. -->
<span class="my-divider" aria-hidden="true"></span>
<button type="button" id="c9ClearBtn" class="my-btn">Clear selection</button>
</div>
<div class="my-builder-host">
<div id="MyWidgets" class="my-host-slot"></div>
<div id="MyCanvas" class="my-canvas"></div>
<div id="MySettings" class="my-host-slot"></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',
});
builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
// Walk the page tree post-load and render one outline button per
// top-level block. The live block reference (NOT the UID string)
// is what gets passed to selectElement().
function buildOutline() {
const blocks = builder.pageElement.getChildren();
if (blocks.length === 0) {
// Tree not ready yet — try again on next tick.
setTimeout(buildOutline, 50);
return;
}
const toolbar = document.getElementById('c9Toolbar');
const clearBtn = document.getElementById('c9ClearBtn');
blocks.forEach((block, index) => {
const btn = document.createElement('button');
btn.id = `c9SelectBtn-${index}`;
btn.type = 'button';
btn.className = 'my-btn';
btn.textContent = `${index + 1}. ${block.getName()}`;
btn.addEventListener('click', () => {
// Pass the Element instance — NOT block.id.
builder.selectElement(block);
});
// Insert before the divider that precedes the Clear button.
toolbar.insertBefore(btn, clearBtn.previousElementSibling);
});
}
setTimeout(buildOutline, 60);
// Clear selection — idempotent, no-op when nothing is selected.
document.getElementById('c9ClearBtn').addEventListener('click', () => {
builder.unselect();
});
// Optional: read the current selection for round-trips. Returns
// the live Element instance or null.
// const current = builder.getSelectedElement();
// if (current) console.log('Selected:', current.getName(), current.id);
window.whatIsSelected = () => {
const el = builder.getSelectedElement();
return el ? `${el.getName()} (id: ${el.id})` : '(nothing)';
};
</script>
</body>
</html>
selectElement() + unselect() — programmatic selection from a custom outline
Render your own outline sidebar and call `builder.selectElement(block)` to select programmatically. The engine paints the canvas selection rectangle, opens the settings panel, and fires every internal hook the same way a click-to-select does. `builder.unselect()` clears.
Walkthrough
-
Walk the page tree to enumerate top-level blocks
The engine exposes the active page via
builder.pageElement. CallingpageElement.getChildren()returns an array ofBlockElementinstances — one per top-level block in the loaded template. Each block has a stable.id(random string assigned at construction; the same UID emitted inELEMENT_ADDED/ELEMENT_REMOVEDevents) and a.getName()method that returns the human label ("BlockElement", "ImageElement", etc.).For deeper trees, walk recursively via
block.getChildren()(every container element implements it). The shape locks via BUILDER.md "Host Injection" — every element class that owns children exposesgetChildren()so external tooling can walk the tree without per-class casing.JavaScript// Walk top-level blocks. Pre-load OK — getChildren() returns [] // before load(); call AFTER load() (or inside the load() callback) // for the live tree. function collectTopBlocks(builder) { return builder.pageElement.getChildren().map((block, index) => ({ index, id: block.id, // stable UID — same as ELEMENT_ADDED.elementUid name: block.getName(), // human label, e.g. "BlockElement" block, // live Element instance — pass to selectElement() })); } builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL); const outline = collectTopBlocks(builder); // [{index, id, name, block}, ...] -
Render an outline button per block + wire selectElement()
One button per block in your custom toolbar / sidebar. The click handler passes the live
blockreference (NOT the UID string) tobuilder.selectElement(block). The engine takes care of:- Painting the selection rectangle on the canvas
- Opening the settings panel and rendering controls for the block
- Tearing down any lingering hover / container highlight on the previously-selected element
- Mounting any "select"-trigger overlays declared by the element class
The engine's click-to-select handler inside the canvas calls the same
selectElement()— your outline buttons are exactly equivalent to clicking the block on the canvas.JavaScriptoutline.forEach(({ index, name, block }) => { const btn = document.createElement('button'); btn.id = `c9SelectBtn-${index}`; btn.type = 'button'; btn.textContent = `${index + 1}. ${name}`; btn.addEventListener('click', () => { builder.selectElement(block); // ← Element instance, not block.id }); document.getElementById('c9Toolbar').appendChild(btn); }); -
Clear with unselect() + read current selection with getSelectedElement()
builder.unselect()removes the canvas rectangle, dismounts overlays, and clears the settings panel. Idempotent: calling it when nothing is selected is a no-op (early return inside the engine).builder.getSelectedElement()returns the currently-selected Element instance (ornull). Use it to round-trip — read what the user clicked on the canvas, do something with it, then callselectElement()on a SIBLING block to navigate the outline programmatically.No SELECTION_CHANGED event today. If your outline needs reactive sync ("highlight the active button when the user clicks the canvas"), wrap
selectElement()+ listen for canvas pointer events directly, OR pollgetSelectedElement()on a microtask. The engine's public events bus carries DOCUMENT_CHANGED / DOCUMENT_SAVED / MODE_CHANGED / ELEMENT_ADDED / ELEMENT_REMOVED today; SELECTION_CHANGED is intentionally deferred until the buyer-driven need lands (see DISCOVERIES D13.C.9).JavaScriptdocument.getElementById('c9ClearBtn').addEventListener('click', () => { builder.unselect(); // idempotent — no-op when nothing selected }); // Read current selection (returns Element instance or null). function whatIsSelected() { const el = builder.getSelectedElement(); if (!el) return '(nothing)'; return `${el.getName()} (id: ${el.id})`; } // Round-trip: read → mutate → re-select a sibling. function selectNextSibling() { const current = builder.getSelectedElement(); if (!current) return; const blocks = builder.pageElement.getChildren(); const idx = blocks.findIndex(b => b.id === current.id); const next = blocks[(idx + 1) % blocks.length]; builder.selectElement(next); }
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 pass the Element instance, not the UID? selectElement(element) reads multiple fields off the instance — element.domNode for the canvas highlight, element.container for parent teardown, element.removeHoverHightlight for de-duping mouseout debounce, plus the iframe-side overlay-mount hooks. Passing a UID would force the engine to do a tree walk inside selectElement — an O(N) cost on the click path. Buyer-side lookup (via blocks.find(b => b.id === uid)) is explicit and cacheable.
Outline depth. This example only enumerates top-level blocks. Real apps with nested grids / cells / blocks may want a recursive outline tree. block.getChildren() works on every container element — recurse the same way; render an indented list. The same selectElement(deepChild) call selects a deeply-nested element and the engine scrolls the canvas to bring it into view (built into selectElement's render path).
SELECTION_CHANGED event status. The engine doesn't currently expose a public SELECTION_CHANGED event on builder.events. The four selection touchpoints (selectElement / setSelectedElement / unselect / clearElements) all mutate builder.selectedElement directly. If your chrome needs reactive sync (highlight the active outline button when the user clicks on the canvas), the workaround today is: subscribe to DOCUMENT_CHANGED + read getSelectedElement(), OR wrap selectElement with a userland decorator that fires your own event before delegating. Adding a public SELECTION_CHANGED is in the engine roadmap — see DISCOVERIES D13.C.9 for the rationale on why it shipped this row first without one.
UID lookup performance. pageElement.getChildren() returns the blocks array by reference (no copy). Repeatedly calling it is cheap. For deep trees, consider building a uid → element Map at load time and refreshing on ELEMENT_ADDED / ELEMENT_REMOVED — keeps the click handler O(1) regardless of tree depth.
Composes with C.B chrome variants. This API works inside any cookbook variant — replace the host toolbar with your own outline sidebar in compact-3col, the in-wrapper header in rich-3col-header, the floating panel in sidebar-only, etc. The API surface (selectElement / unselect / getSelectedElement) is variant-agnostic.