@unlayer/react-elements 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -333,6 +333,32 @@ The document shell matches the mode: `<Email>` gets the XHTML transitional docty
333
333
 
334
334
  If your app owns the document shell (an existing page template, an ESP template, an iframe `srcdoc`), use `renderToHtmlParts` instead — it returns `{ head, body }` chunks for you to place yourself.
335
335
 
336
+ ## Custom Tools
337
+
338
+ **A custom tool is the custom component you create.** We keep the editor's term — Custom Tools — because it's the same API across the Builder and Elements, and that shared vocabulary is where the power is: the same definition you pass to the editor's [`unlayer.registerTool`](https://docs.unlayer.com/builder/tools/custom) renders from code too. Elements uses the `renderer.exporters` and `renderer.head` halves of the definition; the editor-only parts (`label`, `icon`, `options`, `Viewer`, `validator`) are accepted and ignored:
339
+
340
+ ```tsx
341
+ import { registerTool, Email, Row, Column, renderToHtml, renderToJson } from '@unlayer/react-elements';
342
+ import { countdownTool } from './countdown-tool'; // the same object you register in the editor
343
+
344
+ const Countdown = registerTool(countdownTool); // same term, same config as the editor
345
+
346
+ const tree = (
347
+ <Email>
348
+ <Row><Column>
349
+ <Countdown endTime="2026-08-01T00:00:00Z" digitColor="#e11d48" />
350
+ </Column></Row>
351
+ </Email>
352
+ );
353
+
354
+ renderToHtml(tree); // renders via YOUR exporters — identical output to editor exports
355
+ renderToJson(tree); // emits { type: "custom", slug: "countdown", values } — opens in the Builder as the real tool
356
+ ```
357
+
358
+ - Props are the tool's `values` (flat), merged over the definition's defaults; `values={{...}}` works as an escape hatch.
359
+ - Modes fall back like the editor: `document` uses the `web` exporter; an email-only tool renders via its `email` exporter everywhere.
360
+ - **Sanitization:** when your config provides `toSafeHtml`, the tool's HTML output is passed through it (matching editor exports). The default config has no sanitizer — configure one if tool values can contain untrusted content.
361
+
336
362
  ## UnlayerProvider
337
363
 
338
364
  Configure global settings like CDN base URL, merge tags, text direction, and rendering mode:
package/dist/index.cjs CHANGED
@@ -620,16 +620,6 @@ function nextHtmlId(config, prefix) {
620
620
  ids[prefix] = (ids[prefix] || 0) + 1;
621
621
  return `${prefix}_${ids[prefix]}`;
622
622
  }
623
- function ensureMeta(values, type, index = 0) {
624
- return {
625
- ...values,
626
- _meta: {
627
- htmlID: `u_content_${type.toLowerCase()}_${index + 1}`,
628
- htmlClassNames: `u_content_${type.toLowerCase()}`,
629
- ...values._meta || {}
630
- }
631
- };
632
- }
633
623
  function renderComponent(config) {
634
624
  const { type, values, className, style, args = [], innerHTML, _config, exporter, metaContext } = config;
635
625
  try {
@@ -682,6 +672,7 @@ function createItemComponent(config) {
682
672
  style,
683
673
  // Internal props
684
674
  index = 0,
675
+ _metaHtmlId,
685
676
  colIndex = 0,
686
677
  cells = [],
687
678
  bodyValues = {},
@@ -699,11 +690,15 @@ function createItemComponent(config) {
699
690
  ...restProps
700
691
  });
701
692
  const finalValues = mergeValues(config.defaultValues, mappedValues);
702
- const valuesWithMeta = ensureMeta(
703
- finalValues,
704
- config.name.toLowerCase(),
705
- index
706
- );
693
+ const metaName = config.metaName ?? config.name.toLowerCase();
694
+ const valuesWithMeta = {
695
+ ...finalValues,
696
+ _meta: {
697
+ htmlID: _metaHtmlId ?? `u_content_${metaName}_${index + 1}`,
698
+ htmlClassNames: `u_content_${metaName}`,
699
+ ...finalValues._meta || {}
700
+ }
701
+ };
707
702
  const safeBodyValues = {
708
703
  contentWidth: "500px",
709
704
  ...bodyValues
@@ -752,7 +747,11 @@ function createItemComponent(config) {
752
747
  ItemComponent[UNLAYER_CONFIG_KEY] = {
753
748
  name: config.name,
754
749
  defaultValues: config.defaultValues,
755
- propMapper: config.propMapper
750
+ propMapper: config.propMapper,
751
+ contentType: config.contentType,
752
+ slug: config.slug,
753
+ metaName: config.metaName,
754
+ head: config.head
756
755
  };
757
756
  return ItemComponent;
758
757
  }
@@ -1374,9 +1373,12 @@ var Column = (props) => {
1374
1373
  if (typeof child.type === "function") {
1375
1374
  const ComponentType = child.type;
1376
1375
  const renderFn = ComponentType[UNLAYER_RENDER_KEY] || ComponentType;
1376
+ const componentName = ComponentType?.[UNLAYER_CONFIG_KEY]?.metaName ?? (ComponentType?.displayName || ComponentType?.name || "component").toLowerCase();
1377
+ const contentHtmlId = nextHtmlId(_config, `u_content_${componentName}`);
1377
1378
  const rendered = renderFn({
1378
1379
  ...child.props,
1379
1380
  _config,
1381
+ _metaHtmlId: contentHtmlId,
1380
1382
  colIndex: index,
1381
1383
  cells,
1382
1384
  bodyValues,
@@ -1385,15 +1387,13 @@ var Column = (props) => {
1385
1387
  });
1386
1388
  if (rendered && typeof rendered === "object" && rendered.props && rendered.props.dangerouslySetInnerHTML) {
1387
1389
  const componentHTML = rendered.props.dangerouslySetInnerHTML.__html;
1388
- const componentType = child.type;
1389
- const componentName = (componentType?.displayName || componentType?.name || "component").toLowerCase();
1390
1390
  const childProps = child.props;
1391
1391
  const rawContainerPadding = childProps.containerPadding ?? childProps.values?.containerPadding ?? DEFAULT_CONTAINER_PADDING2;
1392
1392
  const containerPadding = typeof rawContainerPadding === "number" ? `${rawContainerPadding}px` : rawContainerPadding;
1393
1393
  const contentValues = {
1394
1394
  containerPadding,
1395
1395
  _meta: {
1396
- htmlID: nextHtmlId(_config, `u_content_${componentName}`),
1396
+ htmlID: contentHtmlId,
1397
1397
  htmlClassNames: `u_content_${componentName}`
1398
1398
  }
1399
1399
  };
@@ -1564,7 +1564,7 @@ UnlayerProvider.displayName = "UnlayerProvider";
1564
1564
  function useUnlayerConfig() {
1565
1565
  return React.useContext(getUnlayerContext());
1566
1566
  }
1567
- function ensureMeta2(values, type, index = 0) {
1567
+ function ensureMeta(values, type, index = 0) {
1568
1568
  return {
1569
1569
  ...values,
1570
1570
  _meta: {
@@ -1645,17 +1645,17 @@ function walkItem(element, bodyValues, displayMode, headConfig, counters, styles
1645
1645
  const { children, ...restProps } = element.props;
1646
1646
  const mappedValues = propMapper({ children, ...restProps });
1647
1647
  const finalValues = mergeValues(defaultValues, mappedValues);
1648
- const contentType = name.toLowerCase();
1648
+ const contentType = config.metaName ?? name.toLowerCase();
1649
1649
  const count = nextCounter(counters, `u_content_${contentType}`);
1650
- const valuesWithMeta = ensureMeta2(finalValues, contentType, count - 1);
1651
- const head = exporters.heads[name];
1650
+ const valuesWithMeta = ensureMeta(finalValues, contentType, count - 1);
1651
+ const head = config.head ?? exporters.heads[name];
1652
1652
  callHead(head, valuesWithMeta, bodyValues, displayMode, headConfig, styles, scripts, tags);
1653
1653
  }
1654
1654
  function walkColumn(element, bodyValues, rowValues, displayMode, headConfig, counters, styles, scripts, tags) {
1655
1655
  const semanticProps = extractSemanticProps(element.props);
1656
1656
  const mapped = mapSemanticProps(semanticProps, COLUMN_DEFAULTS, "Column");
1657
1657
  const count = nextCounter(counters, "u_column");
1658
- const columnValues = ensureMeta2(mergeValues(COLUMN_DEFAULTS, mapped), "column", count - 1);
1658
+ const columnValues = ensureMeta(mergeValues(COLUMN_DEFAULTS, mapped), "column", count - 1);
1659
1659
  const columnHead = exporters.heads["Column"];
1660
1660
  callHead(columnHead, columnValues, bodyValues, displayMode, headConfig, styles, scripts, tags);
1661
1661
  const children = collectChildren(element.props.children);
@@ -1667,7 +1667,7 @@ function walkRow(element, bodyValues, displayMode, headConfig, counters, styles,
1667
1667
  const semanticProps = extractSemanticProps(element.props, ["layout"]);
1668
1668
  const mapped = mapSemanticProps(semanticProps, ROW_DEFAULTS, "Row");
1669
1669
  const count = nextCounter(counters, "u_row");
1670
- const rowValues = ensureMeta2(mergeValues(ROW_DEFAULTS, mapped), "row", count - 1);
1670
+ const rowValues = ensureMeta(mergeValues(ROW_DEFAULTS, mapped), "row", count - 1);
1671
1671
  const rowHead = exporters.heads["Row"];
1672
1672
  callHead(rowHead, rowValues, bodyValues, displayMode, headConfig, styles, scripts, tags);
1673
1673
  const children = collectChildren(element.props.children);
@@ -1690,7 +1690,7 @@ function extractHeadFromTree(element, options) {
1690
1690
  const counters = {};
1691
1691
  const semanticProps = extractSemanticProps(element.props);
1692
1692
  const mapped = mapSemanticProps(semanticProps, BODY_DEFAULTS, "Body");
1693
- const bodyValues = ensureMeta2(mergeValues(BODY_DEFAULTS, mapped), "body", 0);
1693
+ const bodyValues = ensureMeta(mergeValues(BODY_DEFAULTS, mapped), "body", 0);
1694
1694
  const bodyHead = exporters.heads["Body"];
1695
1695
  callHead(bodyHead, bodyValues, bodyValues, displayMode, headConfig, styles, scripts, tags);
1696
1696
  const children = collectChildren(element.props.children);
@@ -1963,10 +1963,11 @@ function processItem(element, counters, layout = {}) {
1963
1963
  `[Unlayer] renderToJson: <${name2}> is not a recognized Unlayer item component. Only components created with createItemComponent are supported.`
1964
1964
  );
1965
1965
  }
1966
- const { name, defaultValues, propMapper } = config;
1967
- const contentType = toContentType(name);
1968
- const count = nextCounter2(counters, `u_content_${contentType}`);
1969
- const id = makeId(`u_content_${contentType}`, count);
1966
+ const { name, defaultValues, propMapper, slug } = config;
1967
+ const contentType = config.contentType ?? toContentType(name);
1968
+ const metaBase = `u_content_${config.metaName ?? contentType}`;
1969
+ const count = nextCounter2(counters, metaBase);
1970
+ const id = makeId(metaBase, count);
1970
1971
  const { children, ...restProps } = element.props;
1971
1972
  const mappedValues = propMapper({ children, ...restProps });
1972
1973
  const finalValues = mergeValues(defaultValues, mappedValues);
@@ -1979,7 +1980,7 @@ function processItem(element, counters, layout = {}) {
1979
1980
  ...finalValues,
1980
1981
  _meta: {
1981
1982
  htmlID: id,
1982
- htmlClassNames: `u_content_${contentType}`,
1983
+ htmlClassNames: metaBase,
1983
1984
  ...finalValues._meta || {}
1984
1985
  },
1985
1986
  selectable: true,
@@ -1996,7 +1997,7 @@ function processItem(element, counters, layout = {}) {
1996
1997
  });
1997
1998
  values.src = pinImageSrc(itemSrc, availableWidth);
1998
1999
  }
1999
- return { type: contentType, values };
2000
+ return slug ? { type: contentType, slug, values } : { type: contentType, values };
2000
2001
  }
2001
2002
  function processColumn(element, counters, layout = {}) {
2002
2003
  const count = nextCounter2(counters, "u_column");
@@ -2140,6 +2141,89 @@ function renderToJson(element) {
2140
2141
  };
2141
2142
  }
2142
2143
 
2144
+ // src/utils/register-tool.tsx
2145
+ function initialValuesFromOptions(options) {
2146
+ const result = {};
2147
+ for (const group of Object.values(options ?? {})) {
2148
+ for (const [property, option] of Object.entries(group?.options ?? {})) {
2149
+ if (!option || option.enabled === false) continue;
2150
+ if (typeof option.defaultValue !== "undefined") {
2151
+ result[property] = option.defaultValue;
2152
+ }
2153
+ }
2154
+ }
2155
+ return result;
2156
+ }
2157
+ function withSanitizer(exporter) {
2158
+ return (values, ...args) => {
2159
+ const html = String(exporter(values, ...args) ?? "");
2160
+ const meta = args[args.length - 1];
2161
+ const toSafeHtml = meta?.exporterConfig?.toSafeHtml;
2162
+ return typeof toSafeHtml === "function" ? toSafeHtml(html) : html;
2163
+ };
2164
+ }
2165
+ function withLinkNormalization(exporter, linkKeys) {
2166
+ if (linkKeys.length === 0) return exporter;
2167
+ return (values, ...args) => {
2168
+ const out = { ...values };
2169
+ for (const key of linkKeys) {
2170
+ const normalized = normalizeLinkValue(out[key]);
2171
+ if (normalized !== void 0) out[key] = normalized;
2172
+ }
2173
+ return exporter(out, ...args);
2174
+ };
2175
+ }
2176
+ function linkWidgetKeys(options) {
2177
+ const keys = [];
2178
+ for (const group of Object.values(options ?? {})) {
2179
+ for (const [key, option] of Object.entries(group?.options ?? {})) {
2180
+ if (option && option.widget === "link") keys.push(key);
2181
+ }
2182
+ }
2183
+ return keys;
2184
+ }
2185
+ function registerTool(tool) {
2186
+ if (!tool?.name || typeof tool.name !== "string") {
2187
+ throw new Error("[Unlayer] registerTool: `name` is required");
2188
+ }
2189
+ const exporters = tool.renderer?.exporters;
2190
+ if (!exporters || !exporters.web && !exporters.email) {
2191
+ throw new Error(
2192
+ `[Unlayer] registerTool("${tool.name}"): \`renderer.exporters\` needs at least a \`web\` or \`email\` exporter`
2193
+ );
2194
+ }
2195
+ const linkKeys = linkWidgetKeys(tool.options);
2196
+ const sanitized = {};
2197
+ for (const mode of ["web", "email", "document"]) {
2198
+ const exporter = exporters[mode];
2199
+ if (exporter) {
2200
+ sanitized[mode] = withSanitizer(withLinkNormalization(exporter, linkKeys));
2201
+ }
2202
+ }
2203
+ if (!sanitized.web && sanitized.email) sanitized.web = sanitized.email;
2204
+ return createItemComponent({
2205
+ name: tool.name,
2206
+ displayName: tool.displayName ?? tool.name,
2207
+ // Option-group widget defaults seed the values; tool-level `values`
2208
+ // win — the editor's precedence.
2209
+ defaultValues: {
2210
+ ...initialValuesFromOptions(tool.options),
2211
+ ...tool.values ?? {}
2212
+ },
2213
+ // Flat props are the tool's values; `values` is the escape hatch.
2214
+ propMapper: ({ children: _children, values, ...rest }) => ({
2215
+ ...values ?? {},
2216
+ ...rest
2217
+ }),
2218
+ exporters: sanitized,
2219
+ contentType: "custom",
2220
+ slug: tool.name,
2221
+ metaName: `custom_${tool.name}`,
2222
+ head: tool.renderer.head
2223
+ });
2224
+ }
2225
+ var registerElementsTool = registerTool;
2226
+
2143
2227
  exports.Body = Body_default;
2144
2228
  exports.Button = Button_default;
2145
2229
  exports.Column = Column_default;
@@ -2160,6 +2244,8 @@ exports.Table = Table_default;
2160
2244
  exports.UnlayerProvider = UnlayerProvider;
2161
2245
  exports.Video = Video_default;
2162
2246
  exports.htmlToTextJson = htmlToTextJson;
2247
+ exports.registerElementsTool = registerElementsTool;
2248
+ exports.registerTool = registerTool;
2163
2249
  exports.renderRowToJson = renderRowToJson;
2164
2250
  exports.renderToHtml = renderToHtml;
2165
2251
  exports.renderToHtmlParts = renderToHtmlParts;