unlayer-custom-tools
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseBuild 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#在初始化时使用custom#
前缀注册:
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:
| Widget | Default Value | Access in |
|---|---|---|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
Dropdown options — pass via under the tool's properties config:
unlayer.init()javascript
unlayer.init({
tools: {
'custom#product_card': {
properties: {
department: {
editor: {
data: {
options: [
{ label: 'Sales', value: 'sales' },
{ label: 'Support', value: 'support' },
],
},
},
},
},
},
},
});如何在渲染器中读取各组件类型的值:
| 组件(Widget) | 默认值 | 在 |
|---|---|---|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
下拉选项——在中通过工具的properties配置传递:
unlayer.init()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 , , , , and :
idiconseveritytitledescriptionjavascript
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
}每个错误必须包含、、、和:
idiconseveritytitledescriptionjavascript
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: , , , , , , , , , , , .
colorbackground-colorfont-sizefont-familyfont-weighttext-alignpaddingmarginborderwidthmax-widthdisplay: block/inline-blockUnsafe (avoid in email): , , , , , (partial support), .
flexboxgridpositionfloatbox-shadowborder-radiuscalc()邮件客户端(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属性: 、、、、、、、、、、、。
colorbackground-colorfont-sizefont-familyfont-weighttext-alignpaddingmarginborderwidthmax-widthdisplay: block/inline-block不安全属性(邮件中避免使用): 、、、、、(部分支持)、。
flexboxgridpositionfloatbox-shadowborder-radiuscalc()Common Mistakes
常见错误
| Mistake | Fix |
|---|---|
Missing | Tools MUST use |
| Div-based email exporter | Email exporters MUST return table-based HTML |
Forgetting | Scope CSS: |
| Hardcoded values in renderer | Use |
| Wrong dropdown options format | Pass options via |
| 错误 | 修复方案 |
|---|---|
缺少 | 在初始化的 |
| 邮件导出器使用div布局 | 邮件导出器必须返回基于表格的HTML |
忘记使用 | 作用域CSS: |
| 渲染器中使用硬编码值 | 使用 |
| 下拉选项格式错误 | 通过 |
Troubleshooting
故障排除
| Problem | Fix |
|---|---|
| Tool doesn't appear in editor | Check |
| Properties panel is empty | Check |
| Custom editor doesn't update | Ensure |
| Exported HTML looks different | Check both |
| 问题 | 修复方案 |
|---|---|
| 工具未在编辑器中显示 | 检查 |
| 属性面板为空 | 检查 |
| 自定义编辑器未更新 | 确保调用 |
| 导出的HTML显示不一致 | 检查 |