<?php
/**
* snippet.php — Custom Widget by subclassing `BaseWidget`.
*
* Drop alongside `dist/` + `themes/default/`, run a PHP server.
* The CalloutWidget appears in the left palette under "Custom"; drag
* onto the canvas to drop a `BlockElement` carrying a `HeadingElement`
* + `PElement`. Both child elements are inline-editable on click.
*
* Class name `CalloutWidget` matches the live demo on
* /examples/extensions/1-custom-widget/ 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/Minimal');
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Custom Widget — subclass BaseWidget, register, ship</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; }
.my-header__hint { margin-left: auto; font-size: 12px; color: #6b7280; }
.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; }
/* Per-widget palette polish — engine writes widget-class="CalloutWidget"
* on the palette item's root div. We tint its icon-box amber so the
* Callout widget visually owns its identity in the palette. */
[widget-class="CalloutWidget"] .widget-icon-box {
background: rgba(245, 158, 11, 0.16);
color: #d97706;
border-radius: 8px;
}
[widget-class="CalloutWidget"]:hover .widget-icon-box {
background: rgba(245, 158, 11, 0.28);
}
</style>
</head>
<body>
<header class="my-header">
<span class="my-header__title">Custom Widget — Callout</span>
<span class="my-header__hint">Drag <strong>Callout</strong> from the left palette → drop on canvas</span>
</header>
<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>
// 1) Subclass BaseWidget — getName / getIcon + a constructor that
// builds `this.block` (the BlockElement that drops on the canvas).
class CalloutWidget extends BaseWidget {
getName() { return 'Callout'; }
getIcon() { return 'campaign'; }
constructor() {
super(); // creates this.block = new BlockElement('Block')
const heading = new HeadingElement('Heading', 'h2', '✨ Pro tip');
const paragraph = new PElement('Paragraph',
'Drop this into any layout to highlight a key insight or call out an important detail for your readers.'
);
this.block.appendElements([heading, paragraph]);
}
}
const builder = new Builder({
mainContainer: '#MyCanvas',
widgetsContainer: '#MyWidgets',
settingsContainer: '#MySettings',
});
// 2) Register inside the load callback — guarantees widgetsBox is mounted.
builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL, () => {
builder.widgetsBox.addWidget(new CalloutWidget(), { group: 'Custom' });
// Add more custom widgets here, all under the same `group` for one heading:
// builder.widgetsBox.addWidget(new TestimonialWidget(), { group: 'Custom' });
// 3) ONE render() pass paints all the additions at once.
builder.widgetsBox.render();
});
</script>
</body>
</html>
Custom Widget — subclass BaseWidget, register, ship
Three engine touchpoints — `extends BaseWidget` (with `getName` / `getIcon` + a constructor that builds `this.block`), `widgetsBox.addWidget(new MyWidget(), { group })`, and `widgetsBox.render()`. Custom palette items drag onto the canvas the same way stock widgets do; child elements become inline-editable from first drop.
Walkthrough
-
Subclass BaseWidget — getName, getIcon, build this.block
BaseWidgetexposes three override hooks:getName()(palette label, REQUIRED — no default),getIcon()(Material Symbol Rounded name, defaults to'extension'), and the constructor where you build thethis.blockshape that drops onto the canvas.this.block = new BlockElement('Block')is auto-created by the parent constructor — your constructor callssuper()first, then appends child elements viathis.block.appendElements([heading, paragraph, ...]). The block + child elements use the engine's standard Element classes (HeadingElement, PElement, ImageElement, etc.) so every standard format / inline-edit / drag-drop hook just works.Engine globals exposed by
dist/builder.js:BaseWidget·BlockElement·HeadingElement·PElement·ImageElement·ButtonElement· plus the full element catalogue (seedocs/core/ELEMENT_DEFINITION.md).JavaScriptclass CalloutWidget extends BaseWidget { // Palette label — REQUIRED (no default). getName() { return 'Callout'; } // Material Symbol Rounded name. Defaults to 'extension'. getIcon() { return 'campaign'; } constructor() { super(); // creates this.block = new BlockElement('Block') // Build the shape the widget drops onto the canvas: a Heading + // a Paragraph nested inside the block. const heading = new HeadingElement('Heading', 'h2', '✨ Pro tip'); const paragraph = new PElement('Paragraph', 'Drop this into any layout to highlight a key insight.' ); this.block.appendElements([heading, paragraph]); } } -
Register via widgetsBox.addWidget + render
builder.widgetsBoxis the live palette manager.addWidget(instance, options)registers a widget;options.groupplaces it under a named heading in the palette ("Custom" / "Layout" / "Marketing" — anything you want). Multiple custom widgets share the same group string.After all widgets are registered, call
widgetsBox.render()ONCE to paint the palette. Calling render mid-load() is wasteful — the engine'sload()already paints once when the palette mount completes; tuckingaddWidget()calls into the load callback (or right after load resolves) means a single render covers all your customizations.The widget instance you register IS the palette item (it owns the palette DOM through
render()). On drop, the engine clones the underlying block viarenderBlock()(BaseWidget default:BlockElement.parse(this.block.getData())) — so dropping the same widget multiple times produces independent copies.JavaScript// Mount + load the engine first; widgets register AFTER load() so the // palette mount is ready to receive them. const builder = new Builder({ mainContainer: '#MainContainer', widgetsContainer: '#WidgetsContainer', settingsContainer: '#SettingsContainer', }); builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL, () => { // Register inside the load callback — guarantees widgetsBox exists. builder.widgetsBox.addWidget(new CalloutWidget(), { group: 'Custom' }); // Add more custom widgets in the same group: // builder.widgetsBox.addWidget(new TestimonialWidget(), { group: 'Custom' }); // builder.widgetsBox.addWidget(new HeroBannerWidget(), { group: 'Custom' }); // ONE render() pass paints all custom widgets at once. builder.widgetsBox.render(); }); -
Drag from palette → drop on canvas → inline-edit
Once registered + rendered, the widget drags exactly like a stock widget. Drop targets are any droppable container (PageElement, BlockElement's child slots, GridElement's cells). The engine handles the drag-drop overlay, drop-zone visualisers, container highlight, and post-drop selection out of the box.
The dropped block + child elements are LIVE Element instances — heading text is inline-editable on click, paragraph text is inline-editable, the block has its own background / padding settings panel, every nested element gets its own settings panel when selected. No additional wiring needed.
To distinguish your widget's palette item in the DOM (for spec / styling): the engine adds
widget-class="to the palette item's root div. Your stylesheet can target" [widget-class="CalloutWidget"] .widget-icon-box { background: var(--brand-accent); }for per-widget polish.JavaScript// Custom CSS to style your widget in the palette (optional). // The engine writes widget-class="<ClassName>" on each palette item's // root div, giving you a stable hook for per-widget styling. // In your stylesheet: // [widget-class="CalloutWidget"] .widget-icon-box { // background: rgba(245, 158, 11, 0.12); // color: #d97706; // } // Buyer-side composition — register multiple widgets up-front, // each in their own group for natural palette organization. builder.widgetsBox.addWidget(new CalloutWidget(), { group: 'Custom' }); builder.widgetsBox.addWidget(new HeroBannerWidget(), { group: 'Custom' }); builder.widgetsBox.addWidget(new TestimonialWidget(), { group: 'Marketing' }); builder.widgetsBox.addWidget(new PricingTableWidget(),{ group: 'Marketing' }); builder.widgetsBox.render(); // ONE render covers all four.
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
Where the engine globals live. dist/builder.js exposes window.BaseWidget, window.BlockElement, window.HeadingElement, window.PElement, window.ImageElement, window.ButtonElement and the full element catalogue (see source src/builder.js lines 261-401). ESM consumers can import { BaseWidget, BlockElement, HeadingElement, PElement } from 'builderjs' instead — same surface, no globals.
Why register inside the load callback? builder.widgetsBox is created during load() setup. Calling addWidget() BEFORE load() resolves means widgetsBox may be undefined. The 5th arg of load(themeJson, themeTemplates, themeConfigData, mediaUrl, callback) is a callback that fires after the palette mount completes — that's the canonical hook for buyer customizations.
Group string is a free-form key. The options.group argument groups widgets under a heading in the palette. Use any string — "Custom", "Marketing", "Forms", "Pricing", "AI". Widgets sharing the same group render under the same heading; new groups append at the bottom of the palette in registration order.
The widget IS the palette item; renderBlock() drops the canvas instance. When the buyer drags the palette item, the engine calls widget.renderBlock() to clone the block — BaseWidget's default implementation parses this.block.getData() via BlockElement.parse, producing a fresh BlockElement instance with the same shape as the widget's prototype. Each drop is independent — editing one Callout instance doesn't affect the palette item or other dropped instances.
Per-widget styling via widget-class attr. The engine writes widget-class=" on the palette item's root div. Your stylesheet can target it for per-widget polish — e.g. [widget-class="CalloutWidget"] .widget-icon-box { background: var(--brand-accent); }. The live demo above uses this hook to tint the Callout widget's icon box amber + match the "campaign" icon's marketing connotation.
What about element-level extensions? Custom widgets are about adding NEW palette items that drop existing element types in a pre-shaped block. To extend the engine with a brand-NEW element type (e.g. a Map element, a Chart element), you subclass BaseElement + register via Builder.registerElement(name, klass) — see extensions/2-custom-element/ (W6.C.12, ships next). For custom controls in the settings panel, see extensions/3-custom-control/ (W6.C.13). For locale packs, see extensions/4-i18n-locale-pack/ (W6.C.14).