Skip to content

Custom tools: registerElementsTool renders editor custom tools from code#37

Merged
ivoIturrieta merged 7 commits into
mainfrom
feat/custom-tools-bridge
Jul 4, 2026
Merged

Custom tools: registerElementsTool renders editor custom tools from code#37
ivoIturrieta merged 7 commits into
mainfrom
feat/custom-tools-bridge

Conversation

@ivoIturrieta

@ivoIturrieta ivoIturrieta commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

What

New registerTool(toolConfig) API — the same term and the same config object as the editor's unlayer.registerTool (Custom Tools docs). A custom tool is the custom component you create; one definition powers both runtimes. (registerElementsTool is exported as an alias.)

  • RenderingrenderToHtml/renderToHtmlParts call the tool's own renderer.exporters[mode](values) and collect renderer.head css/js like built-in components. Output matches editor exports by construction.
  • Round-triprenderToJson emits { type: "custom", slug, values } with u_content_custom_<slug> meta ids, the exact shape editor-saved custom-tool designs use.
  • Editor parity details — option-group widget defaults seed values (tool-level values win); link-widget values reach exporters in render shape ({ url, target }); document falls back to the web exporter; tool output passes through toSafeHtml when configured. Editor-only fields (label, icon, options widgets, Viewer, validator, transformer) are accepted and ignored.
  • Multi-instance correctness — Column pre-allocates each content id and threads it into the item, so the value-level _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 renderToJson design containing a custom tool loads with the tool registered via customJS; saveDesign returns type/slug/values/_meta intact; exportHtml renders 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 renderToHtmlParts with 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/

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>
@ivoIturrieta ivoIturrieta changed the base branch from fix/paragraph-margins-and-full-document-html to main July 4, 2026 09:15
@ivoIturrieta ivoIturrieta reopened this Jul 4, 2026
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>
Copilot AI review requested due to automatic review settings July 4, 2026 09:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 registerElementsTool to convert an Unlayer custom-tool definition into a React component backed by the tool’s renderer.exporters and renderer.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.

Comment thread packages/react/src/utils/register-tool.tsx
Comment thread packages/react/src/utils/create-component.tsx Outdated
…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>
ivoIturrieta and others added 4 commits July 4, 2026 16:53
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>
@ivoIturrieta ivoIturrieta merged commit ae9e346 into main Jul 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants