unlayer-custom-tools

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Build Custom Tools

构建自定义工具

Overview

概述

Custom tools are drag-and-drop content blocks you create for the Unlayer editor. Each tool needs:
  • A renderer (what users see in the editor)
  • Exporters (HTML output — must be table-based for email)
  • Property editors (the settings panel)
自定义工具是你为Unlayer编辑器创建的可拖拽内容块。每个工具需要:
  • 渲染器(renderer):用户在编辑器中看到的内容
  • 导出器(Exporters):HTML输出——邮件场景下必须基于表格
  • 属性编辑器(Property editors):设置面板

Complete Example: Product Card

完整示例:产品卡片

This is a fully working custom tool with an image, title, price, and buy button:
javascript
unlayer.registerTool({
  name: 'product_card',
  label: 'Product Card',
  icon: 'fa-shopping-cart',
  supportedDisplayModes: ['web', 'email'],

  options: {
    content: {
      title: 'Content',
      position: 1,
      options: {
        productTitle: {
          label: 'Product Title',
          defaultValue: 'Product Name',
          widget: 'text',               // → values.productTitle = 'Product Name'
        },
        productImage: {
          label: 'Image',
          defaultValue: { url: 'https://via.placeholder.com/300x200' },
          widget: 'image',              // → values.productImage.url = 'https://...'
        },
        price: {
          label: 'Price',
          defaultValue: '$99.99',
          widget: 'text',               // → values.price = '$99.99'
        },
        buttonText: {
          label: 'Button Text',
          defaultValue: 'Buy Now',
          widget: 'text',
        },
        buttonLink: {
          label: 'Button Link',
          defaultValue: { name: 'web', values: { href: 'https://example.com', target: '_blank' } },
          widget: 'link',               // → values.buttonLink.values.href = 'https://...'
        },
      },
    },
    colors: {
      title: 'Colors',
      position: 2,
      options: {
        titleColor: {
          label: 'Title Color',
          defaultValue: '#333333',
          widget: 'color_picker',       // → values.titleColor = '#333333'
        },
        buttonBg: {
          label: 'Button Background',
          defaultValue: '#007bff',
          widget: 'color_picker',
        },
      },
    },
  },

  values: {},

  renderer: {
    Viewer: unlayer.createViewer({
      render(values) {
        return `
          <div style="text-align: center; padding: 20px;">
            <img src="${values.productImage.url}" style="max-width: 100%;" />
            <h3 style="color: ${values.titleColor};">${values.productTitle}</h3>
            <p style="font-size: 24px; font-weight: bold;">${values.price}</p>
            <a href="${values.buttonLink.values.href}"
               style="display: inline-block; background: ${values.buttonBg};
                      color: #fff; padding: 12px 24px; text-decoration: none;
                      border-radius: 4px;">
              ${values.buttonText}
            </a>
          </div>
        `;
      },
    }),

    exporters: {
      web(values) {
        return `
          <div style="text-align: center; padding: 20px;">
            <img src="${values.productImage.url}" alt="${values.productTitle}" style="max-width: 100%;" />
            <h3 style="color: ${values.titleColor};">${values.productTitle}</h3>
            <p style="font-size: 24px; font-weight: bold;">${values.price}</p>
            <a href="${values.buttonLink.values.href}" target="${values.buttonLink.values.target}"
               style="display: inline-block; background: ${values.buttonBg};
                      color: #fff; padding: 12px 24px; text-decoration: none;">
              ${values.buttonText}
            </a>
          </div>
        `;
      },
      email(values) {
        // Email MUST use tables — divs break in Outlook/Gmail
        return `
          <table width="100%" cellpadding="0" cellspacing="0" border="0">
            <tr><td align="center" style="padding: 20px;">
              <img src="${values.productImage.url}" alt="${values.productTitle}"
                   style="max-width: 100%; display: block;" />
            </td></tr>
            <tr><td align="center" style="padding: 10px;">
              <h3 style="color: ${values.titleColor}; margin: 0;">${values.productTitle}</h3>
            </td></tr>
            <tr><td align="center">
              <p style="font-size: 24px; font-weight: bold; margin: 5px 0;">${values.price}</p>
            </td></tr>
            <tr><td align="center" style="padding: 15px;">
              <table cellpadding="0" cellspacing="0" border="0">
                <tr><td style="background: ${values.buttonBg}; border-radius: 4px;">
                  <a href="${values.buttonLink.values.href}" target="${values.buttonLink.values.target}"
                     style="display: inline-block; color: #fff; padding: 12px 24px;
                            text-decoration: none;">
                    ${values.buttonText}
                  </a>
                </td></tr>
              </table>
            </td></tr>
          </table>
        `;
      },
    },

    head: {
      css(values) {
        return `#${values._meta.htmlID} img { max-width: 100%; height: auto; }`;
      },
      js(values) { return ''; },
    },
  },

  validator(data) {
    const { values, defaultErrors } = data;
    const errors = [];
    if (!values.productTitle) {
      errors.push({
        id: 'PRODUCT_TITLE_REQUIRED',
        icon: 'fa-warning',
        severity: 'ERROR',
        title: 'Missing product title',
        description: 'Product title is required',
      });
    }
    if (!values.productImage?.url) {
      errors.push({
        id: 'PRODUCT_IMAGE_REQUIRED',
        icon: 'fa-warning',
        severity: 'ERROR',
        title: 'Missing product image',
        description: 'Product image is required',
      });
    }
    return [...errors, ...defaultErrors];
  },
});
这是一个包含图片、标题、价格和购买按钮的可直接运行的自定义工具:
javascript
unlayer.registerTool({
  name: 'product_card',
  label: 'Product Card',
  icon: 'fa-shopping-cart',
  supportedDisplayModes: ['web', 'email'],

  options: {
    content: {
      title: 'Content',
      position: 1,
      options: {
        productTitle: {
          label: 'Product Title',
          defaultValue: 'Product Name',
          widget: 'text',               // → values.productTitle = 'Product Name'
        },
        productImage: {
          label: 'Image',
          defaultValue: { url: 'https://via.placeholder.com/300x200' },
          widget: 'image',              // → values.productImage.url = 'https://...'
        },
        price: {
          label: 'Price',
          defaultValue: '$99.99',
          widget: 'text',               // → values.price = '$99.99'
        },
        buttonText: {
          label: 'Button Text',
          defaultValue: 'Buy Now',
          widget: 'text',
        },
        buttonLink: {
          label: 'Button Link',
          defaultValue: { name: 'web', values: { href: 'https://example.com', target: '_blank' } },
          widget: 'link',               // → values.buttonLink.values.href = 'https://...'
        },
      },
    },
    colors: {
      title: 'Colors',
      position: 2,
      options: {
        titleColor: {
          label: 'Title Color',
          defaultValue: '#333333',
          widget: 'color_picker',       // → values.titleColor = '#333333'
        },
        buttonBg: {
          label: 'Button Background',
          defaultValue: '#007bff',
          widget: 'color_picker',
        },
      },
    },
  },

  values: {},

  renderer: {
    Viewer: unlayer.createViewer({
      render(values) {
        return `
          <div style="text-align: center; padding: 20px;">
            <img src="${values.productImage.url}" style="max-width: 100%;" />
            <h3 style="color: ${values.titleColor};">${values.productTitle}</h3>
            <p style="font-size: 24px; font-weight: bold;">${values.price}</p>
            <a href="${values.buttonLink.values.href}"
               style="display: inline-block; background: ${values.buttonBg};
                      color: #fff; padding: 12px 24px; text-decoration: none;
                      border-radius: 4px;">
              ${values.buttonText}
            </a>
          </div>
        `;
      },
    }),

    exporters: {
      web(values) {
        return `
          <div style="text-align: center; padding: 20px;">
            <img src="${values.productImage.url}" alt="${values.productTitle}" style="max-width: 100%;" />
            <h3 style="color: ${values.titleColor};">${values.productTitle}</h3>
            <p style="font-size: 24px; font-weight: bold;">${values.price}</p>
            <a href="${values.buttonLink.values.href}" target="${values.buttonLink.values.target}"
               style="display: inline-block; background: ${values.buttonBg};
                      color: #fff; padding: 12px 24px; text-decoration: none;">
              ${values.buttonText}
            </a>
          </div>
        `;
      },
      email(values) {
        // 邮件必须使用表格——div在Outlook/Gmail中会失效
        return `
          <table width="100%" cellpadding="0" cellspacing="0" border="0">
            <tr><td align="center" style="padding: 20px;">
              <img src="${values.productImage.url}" alt="${values.productTitle}"
                   style="max-width: 100%; display: block;" />
            </td></tr>
            <tr><td align="center" style="padding: 10px;">
              <h3 style="color: ${values.titleColor}; margin: 0;">${values.productTitle}</h3>
            </td></tr>
            <tr><td align="center">
              <p style="font-size: 24px; font-weight: bold; margin: 5px 0;">${values.price}</p>
            </td></tr>
            <tr><td align="center" style="padding: 15px;">
              <table cellpadding="0" cellspacing="0" border="0">
                <tr><td style="background: ${values.buttonBg}; border-radius: 4px;">
                  <a href="${values.buttonLink.values.href}" target="${values.buttonLink.values.target}"
                     style="display: inline-block; color: #fff; padding: 12px 24px;
                            text-decoration: none;">
                    ${values.buttonText}
                  </a>
                </td></tr>
              </table>
            </td></tr>
          </table>
        `;
      },
    },

    head: {
      css(values) {
        return `#${values._meta.htmlID} img { max-width: 100%; height: auto; }`;
      },
      js(values) { return ''; },
    },
  },

  validator(data) {
    const { values, defaultErrors } = data;
    const errors = [];
    if (!values.productTitle) {
      errors.push({
        id: 'PRODUCT_TITLE_REQUIRED',
        icon: 'fa-warning',
        severity: 'ERROR',
        title: 'Missing product title',
        description: 'Product title is required',
      });
    }
    if (!values.productImage?.url) {
      errors.push({
        id: 'PRODUCT_IMAGE_REQUIRED',
        icon: 'fa-warning',
        severity: 'ERROR',
        title: 'Missing product image',
        description: 'Product image is required',
      });
    }
    return [...errors, ...defaultErrors];
  },
});

Register it at init time with the
custom#
prefix:

在初始化时使用
custom#
前缀注册:

javascript
unlayer.init({
  tools: {
    'custom#product_card': {          // REQUIRED: custom# prefix
      data: {
        apiEndpoint: '/api/products', // Custom data accessible in renderer
      },
      properties: {
        // Override default property values or dropdown options
      },
    },
  },
});

javascript
unlayer.init({
  tools: {
    'custom#product_card': {          // 必填:custom#前缀
      data: {
        apiEndpoint: '/api/products', // 渲染器中可访问的自定义数据
      },
      properties: {
        // 覆盖默认属性值或下拉选项
      },
    },
  },
});

Widget Value Access Reference

组件值访问参考

How to read each widget type's value in your renderer:
WidgetDefault ValueAccess in
render(values)
text
'Hello'
values.myField
'Hello'
rich_text
'<p>Hello</p>'
values.myField
'<p>Hello</p>'
html
'<div>...</div>'
values.myField
'<div>...</div>'
color_picker
'#FF0000'
values.myField
'#FF0000'
alignment
'center'
values.myField
'center'
font_family
{label:'Arial', value:'arial'}
values.myField.value
'arial'
image
{url: 'https://...'}
values.myField.url
'https://...'
toggle
false
values.myField
false
link
{name:'web', values:{href,target}}
values.myField.values.href
'https://...'
counter
'10'
values.myField
'10'
(string!)
dropdown
'option1'
values.myField
'option1'
datetime
'2025-01-01'
values.myField
'2025-01-01'
border
{borderTopWidth:'1px',...}
values.myField.borderTopWidth
'1px'
Dropdown options — pass via
unlayer.init()
under the tool's properties config:
javascript
unlayer.init({
  tools: {
    'custom#product_card': {
      properties: {
        department: {
          editor: {
            data: {
              options: [
                { label: 'Sales', value: 'sales' },
                { label: 'Support', value: 'support' },
              ],
            },
          },
        },
      },
    },
  },
});

如何在渲染器中读取各组件类型的值:
组件(Widget)默认值
render(values)
中访问
text
'Hello'
values.myField
'Hello'
rich_text
'<p>Hello</p>'
values.myField
'<p>Hello</p>'
html
'<div>...</div>'
values.myField
'<div>...</div>'
color_picker
'#FF0000'
values.myField
'#FF0000'
alignment
'center'
values.myField
'center'
font_family
{label:'Arial', value:'arial'}
values.myField.value
'arial'
image
{url: 'https://...'}
values.myField.url
'https://...'
toggle
false
values.myField
false
link
{name:'web', values:{href,target}}
values.myField.values.href
'https://...'
counter
'10'
values.myField
'10'
(字符串类型!)
dropdown
'option1'
values.myField
'option1'
datetime
'2025-01-01'
values.myField
'2025-01-01'
border
{borderTopWidth:'1px',...}
values.myField.borderTopWidth
'1px'
下拉选项——在
unlayer.init()
中通过工具的properties配置传递:
javascript
unlayer.init({
  tools: {
    'custom#product_card': {
      properties: {
        department: {
          editor: {
            data: {
              options: [
                { label: 'Sales', value: 'sales' },
                { label: 'Support', value: 'support' },
              ],
            },
          },
        },
      },
    },
  },
});

Custom Property Editor (React)

自定义属性编辑器(React)

For controls beyond built-in widgets:
jsx
const RangeSlider = ({ label, value, updateValue, data }) => (
  <div>
    <label>{label}: {value}px</label>
    <input
      type="range"
      min={data.min || 0}
      max={data.max || 100}
      value={parseInt(value)}
      onChange={(e) => updateValue(e.target.value + 'px')}
    />
  </div>
);

unlayer.registerPropertyEditor({
  name: 'range_slider',
  Widget: RangeSlider,
});

// Use in your tool:
borderRadius: {
  label: 'Corner Radius',
  defaultValue: '4px',
  widget: 'range_slider',
  data: { min: 0, max: 50 },
},

对于内置组件无法满足的控件需求:
jsx
const RangeSlider = ({ label, value, updateValue, data }) => (
  <div>
    <label>{label}: {value}px</label>
    <input
      type="range"
      min={data.min || 0}
      max={data.max || 100}
      value={parseInt(value)}
      onChange={(e) => updateValue(e.target.value + 'px')}
    />
  </div>
);

unlayer.registerPropertyEditor({
  name: 'range_slider',
  Widget: RangeSlider,
});

// 在工具中使用:
borderRadius: {
  label: 'Corner Radius',
  defaultValue: '4px',
  widget: 'range_slider',
  data: { min: 0, max: 50 },
},

Validator Return Format

验证器返回格式

Each error must include
id
,
icon
,
severity
,
title
, and
description
:
javascript
validator(data) {
  const { values, defaultErrors } = data;
  const errors = [];

  if (!values.productTitle) {
    errors.push({
      id: 'PRODUCT_TITLE_REQUIRED',     // Unique error ID
      icon: 'fa-warning',                // FontAwesome icon
      severity: 'ERROR',                 // 'ERROR' | 'WARNING'
      title: 'Missing product title',    // Short label
      description: 'Product title is required',  // Detailed message
    });
  }

  if (values.price && !values.price.startsWith('$')) {
    errors.push({
      id: 'PRICE_MISSING_CURRENCY',
      icon: 'fa-dollar-sign',
      severity: 'WARNING',
      title: 'Missing currency symbol',
      description: 'Price should include currency symbol',
      labelPath: 'price',               // Optional — highlights the property in the panel
    });
  }

  return [...errors, ...defaultErrors];  // Merge with built-in errors
}

每个错误必须包含
id
icon
severity
title
description
javascript
validator(data) {
  const { values, defaultErrors } = data;
  const errors = [];

  if (!values.productTitle) {
    errors.push({
      id: 'PRODUCT_TITLE_REQUIRED',     // 唯一错误ID
      icon: 'fa-warning',                // FontAwesome图标
      severity: 'ERROR',                 // 'ERROR' | 'WARNING'
      title: 'Missing product title',    // 简短标题
      description: 'Product title is required',  // 详细说明
    });
  }

  if (values.price && !values.price.startsWith('$')) {
    errors.push({
      id: 'PRICE_MISSING_CURRENCY',
      icon: 'fa-dollar-sign',
      severity: 'WARNING',
      title: 'Missing currency symbol',
      description: 'Price should include currency symbol',
      labelPath: 'price',               // 可选——在面板中高亮对应属性
    });
  }

  return [...errors, ...defaultErrors];  // 合并内置错误
}

Email-Safe HTML Patterns

邮件安全HTML模板

Email clients (Outlook, Gmail) require table-based HTML. Copy-paste these patterns:
Button:
html
<table cellpadding="0" cellspacing="0" border="0">
  <tr><td style="background: #007bff; border-radius: 4px;">
    <a href="URL" style="display: inline-block; color: #fff; padding: 12px 24px; text-decoration: none;">
      Button Text
    </a>
  </td></tr>
</table>
Two columns:
html
<table width="100%" cellpadding="0" cellspacing="0">
  <tr>
    <td width="50%" valign="top" style="padding: 10px;">Left</td>
    <td width="50%" valign="top" style="padding: 10px;">Right</td>
  </tr>
</table>
Safe CSS properties:
color
,
background-color
,
font-size
,
font-family
,
font-weight
,
text-align
,
padding
,
margin
,
border
,
width
,
max-width
,
display: block/inline-block
.
Unsafe (avoid in email):
flexbox
,
grid
,
position
,
float
,
box-shadow
,
border-radius
(partial support),
calc()
.

邮件客户端(Outlook、Gmail)要求使用基于表格的HTML。可直接复制以下模板:
按钮:
html
<table cellpadding="0" cellspacing="0" border="0">
  <tr><td style="background: #007bff; border-radius: 4px;">
    <a href="URL" style="display: inline-block; color: #fff; padding: 12px 24px; text-decoration: none;">
      Button Text
    </a>
  </td></tr>
</table>
两栏布局:
html
<table width="100%" cellpadding="0" cellspacing="0">
  <tr>
    <td width="50%" valign="top" style="padding: 10px;">Left</td>
    <td width="50%" valign="top" style="padding: 10px;">Right</td>
  </tr>
</table>
安全CSS属性:
color
background-color
font-size
font-family
font-weight
text-align
padding
margin
border
width
max-width
display: block/inline-block
不安全属性(邮件中避免使用):
flexbox
grid
position
float
box-shadow
border-radius
(部分支持)、
calc()

Common Mistakes

常见错误

MistakeFix
Missing
custom#
prefix
Tools MUST use
custom#my_tool
in
tools
config at init
Div-based email exporterEmail exporters MUST return table-based HTML
Forgetting
_meta.htmlID
Scope CSS:
#${values._meta.htmlID} { ... }
Hardcoded values in rendererUse
values
object — let property editors drive content
Wrong dropdown options formatPass options via
unlayer.init()
under
tools['custom#name'].properties.prop.editor.data.options
错误修复方案
缺少
custom#
前缀
在初始化的
tools
配置中,工具必须使用
custom#my_tool
格式
邮件导出器使用div布局邮件导出器必须返回基于表格的HTML
忘记使用
_meta.htmlID
作用域CSS:
#${values._meta.htmlID} { ... }
渲染器中使用硬编码值使用
values
对象——让属性编辑器驱动内容
下拉选项格式错误通过
unlayer.init()
中的
tools['custom#name'].properties.prop.editor.data.options
传递选项

Troubleshooting

故障排除

ProblemFix
Tool doesn't appear in editorCheck
supportedDisplayModes
includes current mode
Properties panel is emptyCheck
options
structure — needs group → options nesting
Custom editor doesn't updateEnsure
updateValue()
is called with the new value
Exported HTML looks differentCheck both
Viewer.render()
and
exporters.email/web()
问题修复方案
工具未在编辑器中显示检查
supportedDisplayModes
是否包含当前模式
属性面板为空检查
options
结构——需要分组→选项的嵌套层级
自定义编辑器未更新确保调用
updateValue()
传递新值
导出的HTML显示不一致检查
Viewer.render()
exporters.email/web()
两个方法

Resources

资源