Custom tools: registerElementsTool renders editor custom tools from code#37
Merged
Conversation
Accepts the same tool definition embedders already write for the
editor's registerTool (docs.unlayer.com Custom Tools) and returns a
React component. One definition powers both runtimes: the Builder uses
the panel/canvas half (label, icon, options, Viewer, validator — all
accepted and ignored here), while Elements calls renderer.exporters per
mode and collects renderer.head, so code output matches editor exports
by construction. renderToJson emits { type: 'custom', slug, values }
with u_content_custom_<slug> meta ids — the shape custom-tool designs
already use — so trees round-trip into the Builder.
Details: document mode falls back to the web exporter and email-only
tools render via email everywhere (matching export fallbacks); tool
output passes through toSafeHtml when configured (matching editor
exports, which sanitize custom tool HTML); the content wrapper, head
extraction, and JSON emission honor an explicit meta base so ids match
editor-saved designs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds option-group default seeding — the common real-world custom tool
shape keeps values: {} and defines defaults on each property widget, so
registerElementsTool now folds those in (disabled properties skipped,
tool-level values winning, matching the editor's precedence).
22 edge-case tests modeled on realistic tools: a product card (nested
object values, rich-text defaults, editor-only fields at every level)
and a head-heavy tool (css keyed on values._meta.htmlID, per-mode
branching, js emission, undefined heads, tag dedupe). Failure modes:
throwing exporters drop only their block with a logged error, non-string
returns coerce, special characters pass through raw, the sanitizer sees
each mode's output. Composition: per-slug counters, containerPadding,
base props excluded from values, plaintext extraction.
Round-trip verified against a live editor: a renderToJson design
containing a custom tool loads with the tool registered via customJS,
saveDesign returns type/slug/values/_meta intact, and exportHtml
renders through the tool's own exporter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds first-class support for rendering Unlayer editor Custom Tools from React Elements by introducing registerElementsTool(toolConfig), enabling consistent HTML export, head collection, and design JSON round-tripping using the same exporter functions embedders already register with the editor.
Changes:
- Introduces
registerElementsToolto convert an Unlayer custom-tool definition into a React component backed by the tool’srenderer.exportersandrenderer.head. - Extends JSON rendering/head extraction to support custom-tool meta/id bases and emit
{ type: "custom", slug, values }for round-trip compatibility. - Adds documentation and comprehensive tests for rendering modes, fallbacks, head contributions, sanitization, and edge cases.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/shared/src/types.ts | Extends DesignContent to optionally carry slug for custom-tool content. |
| packages/react/src/utils/render-to-json.ts | Emits custom-tool JSON shape and uses configurable meta/id bases per tool. |
| packages/react/src/utils/register-tool.tsx | Adds registerElementsTool API that wraps editor tool definitions as Elements components. |
| packages/react/src/utils/register-tool.test.tsx | Adds core tests for per-mode rendering, head, sanitization, and JSON shape. |
| packages/react/src/utils/register-tool-edge-cases.test.tsx | Adds coverage for real-world tool shapes, head behaviors, failure modes, and composition. |
| packages/react/src/utils/extract-head.ts | Allows components to supply head builders directly and aligns meta naming with custom tools. |
| packages/react/src/utils/create-component.tsx | Threads contentType/slug/metaName/head through item config and meta generation. |
| packages/react/src/index.ts | Exports registerElementsTool and its public types. |
| packages/react/src/components/Column.tsx | Uses item config metaName to generate correct wrapper ids/classes for custom tools. |
| packages/react/README.md | Documents the new Custom Tools workflow and key behaviors. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ories
Review feedback: exporter returns are coerced to a string before a
configured sanitizer sees them (a non-string return would crash a
sanitizer calling string methods), and the metaName doc now describes
the suffix rather than the full prefixed id.
Adding Storybook coverage surfaced a real parity gap the smoke test
caught: tools written for the editor read link-widget values in render
shape (values.<option>.url), but Elements only normalized fields named
href/action. Options declared with widget: 'link' are now normalized
to { url, target } before each render, matching what editor exporters
receive.
Four new stories under Custom Tools/Registered Tools: a countdown with
option-widget defaults, a customized variant, a product card with
nested image/link values, and both tools composed inside a full design.
Visual baseline extended (130 renders; all pre-existing renders
byte-identical to main).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stories had no visible code: the meta lacked the autodocs tag the other component stories carry, and even autodocs would only show the JSX usage — for custom tools the definition object is the code worth copying. Each story now ships an explicit source snippet with the tool definition, the registerElementsTool call, and the usage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the accordion and tab tools from the public examples gallery as stories, with the upgrades the originals lack: head css scoped per instance via values._meta.htmlID, idempotent head js, and email exporters (panels render expanded; tabs become stacked sections — email has no JS). Stories render through renderToHtmlParts with a head injector so the interactivity works in the canvas. Building the two-instance story exposed a real bug: the value-level _meta.htmlID came from the factory's index default, so every instance in a Column rendered as _1 while head extraction numbered _1.._N — instance >= 2's scoped css targeted an id that didn't exist. Column now allocates the content id before rendering and threads it into the item, so the value meta, wrapper id, and head/json numbering all agree. Verified in-browser: two accordions with different colors style and toggle independently. All snapshots byte-identical — built-in output is unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three more tools from the public examples gallery, completing the set: - product_library — the gallery's flagship card, ported faithfully: nested image/link values, per-part colors, rich-text description, and the split price/CTA footer, in both div (web) and table (email) markup. The link-widget action arrives in render shape (.url/.target) exactly as editor exporters receive it. - qr_tool — in the Builder a property-editor widget generates the code client-side; from code any generator URL works. Adds the email exporter the original leaves empty (tickets and menus are the prime QR use case). - map_tool — the original builds a Google Static Maps URL around an API key, which a public example should not ship. This port computes OpenStreetMap tile coordinates from lat/lon/zoom in the exporter (slippy-map math), keyless, with a marker overlay on web and the same tile table rendering in email. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per the naming discussion: terms stay the same across the Builder and Elements (that shared vocabulary is the point of the one-definition contract), and the docs bridge the mental model for Elements-first users — a custom tool is the custom component you create. registerElementsTool remains exported as an alias. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
brunolemos
approved these changes
Jul 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
New
registerTool(toolConfig)API — the same term and the same config object as the editor'sunlayer.registerTool(Custom Tools docs). A custom tool is the custom component you create; one definition powers both runtimes. (registerElementsToolis exported as an alias.)renderToHtml/renderToHtmlPartscall the tool's ownrenderer.exporters[mode](values)and collectrenderer.headcss/js like built-in components. Output matches editor exports by construction.renderToJsonemits{ type: "custom", slug, values }withu_content_custom_<slug>meta ids, the exact shape editor-saved custom-tool designs use.valueswin); link-widget values reach exporters in render shape ({ url, target });documentfalls back to thewebexporter; tool output passes throughtoSafeHtmlwhen configured. Editor-only fields (label,icon,optionswidgets,Viewer,validator,transformer) are accepted and ignored._meta.htmlID, wrapper id, and head/json numbering agree — per-instance scoped head css works with N instances.Verified end-to-end
Live editor round-trip: a
renderToJsondesign containing a custom tool loads with the tool registered viacustomJS;saveDesignreturns type/slug/values/_metaintact;exportHtmlrenders through the tool's own exporter.Storybook
Eleven documented stories with full source snippets: Registered Tools (countdown, product card, composition) and Examples Gallery — the complex tools from examples.unlayer.com ported: accordion + tabs (head css/js interactivity, per-instance scoping, idempotent js, email adaptations), product library (faithful), QR (plus the email exporter the original leaves empty), and map (keyless OpenStreetMap tile math instead of the original's hardcoded Google API key). Stories render through
renderToHtmlPartswith a head injector, so interactivity works in the canvas.Tests
37 dedicated tests (rendering, fallbacks, defaults, sanitization incl. non-string coercion, JSON shape, multi-instance id alignment, hostile input). Full suite 449 green with zero snapshot changes — built-in output byte-identical. Browser E2E gate, story visual-drift gate (137 renders, zero pre-existing changes), and smoke 137/137 all pass.
🤖 Generated with Claude Code
📖 Storybook Preview: https://unlayer.github.io/elements/pr/37/