自定义物料开发
当你需要一个新的模板元素,并且它要同时出现在 Designer 和 Viewer 里,就进入自定义物料这一层。
先看一版最小实现:
import type { DesignerMaterialBundle, DesignerStore, MaterialDesignerExtension } from '@easyink/designer'
import type { MaterialNode } from '@easyink/schema'
import type { ViewerRuntime } from '@easyink/viewer'
import { trustedViewerHtml } from '@easyink/core'
import { registerMaterialBundle } from '@easyink/designer'
import { IconText } from '@easyink/icons'
export const PRICE_TAG_TYPE = 'price-tag'
export const priceTagLocaleMessages = {
messages: {
materials: {
catalog: {
enterprise: '企业物料',
},
priceTag: {
name: '价格签',
property: {
label: '标题',
amount: '金额',
},
data: {
category: '分类字段',
value: '数值字段',
},
},
},
},
locales: {
'zh-CN': {
materials: {
catalog: {
enterprise: '企业物料',
},
priceTag: {
name: '价格签',
property: {
label: '标题',
amount: '金额',
},
data: {
category: '分类字段',
value: '数值字段',
},
},
},
},
'en-US': {
materials: {
catalog: {
enterprise: 'Enterprise',
},
priceTag: {
name: 'Price Tag',
property: {
label: 'Label',
amount: 'Amount',
},
data: {
category: 'Category Field',
value: 'Value Field',
},
},
},
},
},
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
export function createPriceTagNode(input: Partial<MaterialNode> = {}): MaterialNode {
return {
id: input.id ?? `price-tag-${Date.now()}`,
type: PRICE_TAG_TYPE,
x: input.x ?? 20,
y: input.y ?? 20,
width: input.width ?? 48,
height: input.height ?? 18,
props: {
label: '价格',
amount: '¥ 99.00',
...input.props,
},
} as MaterialNode
}
export function createPriceTagDesignerExtension(): MaterialDesignerExtension {
return {
renderContent(nodeSignal, container) {
const render = (node: MaterialNode) => {
const props = node.props as Record<string, unknown>
container.textContent = `${String(props.label ?? '')}: ${String(props.amount ?? '')}`
}
render(nodeSignal.get())
return nodeSignal.subscribe(render)
},
}
}
export const priceTagDesignerBundle = {
materials: [
{
type: PRICE_TAG_TYPE,
name: 'materials.priceTag.name',
icon: IconText,
category: 'basic',
capabilities: {
bindable: true,
resizable: true,
rotatable: true,
},
binding: {
kind: 'ordinary',
primaryProp: 'amount',
formatEditor: { tabs: ['preset', 'custom'], defaultTab: 'preset' },
},
createDefaultNode: createPriceTagNode,
factory: createPriceTagDesignerExtension,
localeMessages: priceTagLocaleMessages,
propSchemas: [
{ key: 'label', label: 'materials.priceTag.property.label', type: 'string', group: 'content' },
{ key: 'amount', label: 'materials.priceTag.property.amount', type: 'string', group: 'content' },
],
},
],
catalogs: [
{
id: 'enterprise',
label: 'materials.catalog.enterprise',
items: [{ type: PRICE_TAG_TYPE }],
},
],
} satisfies DesignerMaterialBundle
export function registerPriceTagDesigner(store: DesignerStore) {
registerMaterialBundle(store, priceTagDesignerBundle)
}
export function registerPriceTagViewer(viewer: ViewerRuntime) {
viewer.registerMaterial(PRICE_TAG_TYPE, {
kind: 'ordinary',
primaryProp: 'amount',
formatEditor: { tabs: ['preset', 'custom'], defaultTab: 'preset' },
}, {
render(_node, context) {
const props = context.resolvedProps
return {
html: trustedViewerHtml(
`<div>${escapeHtml(String(props.label ?? ''))}: ${escapeHtml(String(props.amount ?? ''))}</div>`,
),
}
},
})
}这段代码做了三件事:定义一个稳定的 type,把它注册到 Designer 的物料面板,再用同一个 type 注册 Viewer 渲染器。
如果这些概念看起来有点密,没关系。先记住一句话:Designer 负责拖入和编辑,Viewer 负责最终渲染,Schema 负责保存中间结果。
判断它是不是物料
先用这个判断表分流:
// 需要新节点类型:自定义物料
node.type = 'price-tag'
// 只是给现有节点补属性:propSchemas
propSchemas.push({ key: 'label', label: '标题', type: 'string' })
// 只是加按钮、面板或命令:Contribution
ctx.registerToolbarAction(...)三条路都能扩展 Designer,但它们解决的问题不同。
- 新节点类型、新设计态、新预览态:写自定义物料。
- 现有物料多几个属性:先用
propSchemas。 - 宿主要挂面板、按钮、命令:看 贡献扩展开发。
注册到 Designer
Designer 接收的是 DesignerMaterialBundle。你可以把这个 bundle 直接交给 runtimeConfig.materials.bundles:
<script setup lang="ts">
import { builtinDesignerMaterialBundle } from '@easyink/builtin/basic'
import { EasyInkDesigner } from '@easyink/designer'
import { priceTagDesignerBundle } from './price-tag'
const runtimeConfig = {
materials: {
bundles: [builtinDesignerMaterialBundle, priceTagDesignerBundle],
},
}
</script>
<template>
<EasyInkDesigner
v-model:schema="schema"
:runtime-config="runtimeConfig"
/>
</template>这段代码保留了内置基础物料,同时追加你的 price-tag 物料。如果你想保留全部内置物料,把导入路径换成 @easyink/builtin/all。
如果你已经在项目里用 setupStore 注册物料,也不用改 bundle 结构。setupStore 仍然适合需要直接操作 store 的初始化逻辑:
<script setup lang="ts">
import { EasyInkDesigner } from '@easyink/designer'
import { registerPriceTagDesigner } from './price-tag'
function setupStore(store) {
registerPriceTagDesigner(store)
}
</script>
<template>
<EasyInkDesigner
v-model:schema="schema"
:setup-store="setupStore"
/>
</template>两种方式都注册同一份 priceTagDesignerBundle,区别只在接入位置:
runtimeConfig.materials.bundles:更适合声明“这个 Designer 要有哪些物料”。setupStore:更适合同时做其它 store 初始化,比如注册纸张、读取宿主状态或挂接调试逻辑。
registerMaterialBundle() 会同时处理四类信息:
materials:物料定义、属性面板字段、设计态 factory。localeMessages:随物料注册的多语言文案,通常放在 material entry 上。catalogs:物料面板分类,包含分类 id、标题翻译 key、排序和分类内物料。
提示
物料面板里的图标来自 MaterialCatalogEntry.icon。如果你只在 materials 里传 icon,注册器会自动把它带到 catalogs 项里;如果某个分类里的入口想用另一个图标,也可以在 catalogs[].items 项里单独传 icon。
物料面板分类
catalogs 只负责物料面板怎么展示,不负责定义物料能力。
export const priceTagDesignerBundle = {
materials: [
// 这里注册 price-tag 的能力、属性、设计态渲染器
],
catalogs: [
{
id: 'enterprise',
label: 'materials.catalog.enterprise',
order: 60,
items: [
{ type: PRICE_TAG_TYPE },
],
},
],
localeMessages: {
messages: {
materials: {
catalog: {
enterprise: '企业物料',
},
},
},
locales: {
'en-US': {
materials: {
catalog: {
enterprise: 'Enterprise',
},
},
},
},
},
} satisfies DesignerMaterialBundle这段配置会新增一个“企业物料”分类,并把 price-tag 放进去。
catalogs 里几个字段的分工是固定的:
id:分类稳定标识。相同id会合并到同一个分类,所以你可以把自定义物料追加到内置data、svg、utility分类里。label:分类标题的翻译 key。新增分类时,记得在localeMessages里一起注册翻译。order:分类排序。分类里的物料也可以在items上写order。items:分类里的入口。最小只需要{ type },其它字段默认从materials里的物料定义继承。
如果你只是想把物料追加到已有分类,可以复用内置分类 id:
catalogs: [
{
id: 'utility',
label: 'materials.catalog.utility',
items: [{ type: PRICE_TAG_TYPE }],
},
]提示
如果你没有注册内置物料包,materials.catalog.utility 这类分类翻译也不会自动注册。复用内置分类 id 时,如果页面仍要显示这个分类标题,请在自己的 bundle localeMessages 里一起注册对应翻译。
关于分类,目前知道这些就够了。物料能不能被拖入、默认节点怎么创建,仍然由 materials 里的注册信息决定。
懒加载设计态渲染器
如果物料的设计态依赖很重,例如完整 ECharts 包、复杂代码编辑器或大型交互内核,可以在 Designer 注册里使用 lazyFactory:
registerMaterialBundle(store, {
materials: [
{
type: 'sales-echarts',
name: 'materials.salesEcharts.name',
icon: IconChart,
category: 'chart',
capabilities: { bindable: true, resizable: true },
binding: {
kind: 'ordinary',
primaryProp: 'option',
formatEditor: { tabs: ['custom'], defaultTab: 'custom' },
},
createDefaultNode: createSalesEchartsNode,
factory: () => ({ renderContent: () => () => {} }),
lazyFactory: async () => (await import('./sales-echarts-designer')).createSalesEchartsExtension,
propSchemas: salesEchartsPropSchemas,
localeMessages: salesEchartsLocaleMessages,
},
],
catalogs: [
{
id: 'chart',
label: 'materials.catalog.chart',
items: [{ type: 'sales-echarts' }],
},
],
})lazyFactory 只延迟加载 MaterialDesignerExtension。物料面板、属性面板、绑定面板和 AI manifest 需要的元数据仍然同步注册,所以 type、name、icon、category、capabilities、binding、createDefaultNode、propSchemas 和 localeMessages 不要放到懒加载 chunk 里。
懒加载过程中,画布会先显示内置 loading 状态;加载完成后 Designer 会重新 mount 对应物料。Viewer 仍然需要在渲染前同步 viewer.registerMaterial(),不要假设 Viewer 会自动跟随 Designer 的懒加载注册。
属性值输入增强
propSchemas 负责声明属性如何编辑。对于直接保存到 node.props 的属性,如果用户更常从宿主资产库或本地文件获得值,可以在 editorOptions.valueInput 上声明输入通道。
propSchemas: [
{
key: 'content',
label: 'materials.logoSvg.property.content',
type: 'code',
group: 'content',
editorOptions: {
language: 'html',
valueInput: {
kind: 'text-file',
id: 'designer.logoSvg.importFile',
source: 'logo-svg-content',
accept: ['.svg', 'image/svg+xml'],
pickTitle: 'materials.logoSvg.action.importFile',
maxBytes: 262144,
},
},
},
]图片 URL 字段使用同一套声明,只是 kind 为 asset-url:
editorOptions: {
valueInput: {
kind: 'asset-url',
id: 'designer.logoImage.pickImage',
source: 'logo-image',
accept: ['image/*'],
pickTitle: 'materials.logoImage.action.pick',
},
}选择成功后,PropertiesPanel 会把返回内容当作普通属性值提交。Schema 里仍然只保存最终值,例如 props.content 或 props.src,不要额外保存 File、本地路径、文件名或“来源类型”。如果宿主需要接自己的文件库,可以通过 <EasyInkDesigner :interaction-provider> 提供 pickFileText(request) 或 pickAsset(request);不提供时,Designer shell 会使用浏览器内置选择能力。
渲染设计态
设计态最小只需要实现 renderContent():
export function createPriceTagDesignerExtension(): MaterialDesignerExtension {
return {
renderContent(nodeSignal, container) {
const render = (node: MaterialNode) => {
const props = node.props as Record<string, unknown>
container.innerHTML = ''
const label = document.createElement('span')
label.textContent = `${String(props.label ?? '')}: ${String(props.amount ?? '')}`
container.appendChild(label)
}
render(nodeSignal.get())
return nodeSignal.subscribe(render)
},
}
}nodeSignal 是 Designer 给物料的响应式节点快照。节点变化时重新渲染,返回的函数负责取消订阅或清理 DOM。
关于 geometry、behaviors、resize 和 datasourceDrop,目前知道它们是高级能力就够了。第一次做自定义物料,先让画布上能稳定显示。
注册到 Viewer
Viewer 需要按同一个 type 再注册一次:
import { trustedViewerHtml } from '@easyink/core'
import { createViewer } from '@easyink/viewer'
const viewer = createViewer({ container })
viewer.registerMaterial(PRICE_TAG_TYPE, {
kind: 'ordinary',
primaryProp: 'amount',
formatEditor: { tabs: ['preset', 'custom'], defaultTab: 'preset' },
}, {
render(_node, context) {
const props = context.resolvedProps
return {
html: trustedViewerHtml(
`<div class="price-tag">${escapeHtml(String(props.amount ?? ''))}</div>`,
),
}
},
})这里最值得记住的是 context.resolvedProps。绑定、默认值和运行时属性会在 Viewer 渲染前合成好,你的渲染器直接消费它就行。
注意
html 必须用 trustedViewerHtml() 包装。不要直接返回裸字符串。
接入数据绑定
大多数物料不需要自己解析字段路径。让 Designer 保存绑定,让 Viewer 解析绑定:
viewer.open({
schema,
data: {
product: {
name: '热敏标签纸',
price: '¥ 99.00',
},
},
})当节点保存了 binding 后,Viewer 会把绑定结果写进 context.resolvedProps。你的物料渲染器继续读 resolvedProps,不用手写 getByPath(context.data, fieldPath)。
普通绑定不只适合字符串或数字。只要物料的 primaryProp 指向某个 props 字段,Viewer 就会把绑定结果投影到这个字段。例如自定义 ECharts 物料可以声明 primaryProp: 'option',运行时数据源直接返回完整 option 对象:
const node = {
type: 'sales-echarts',
props: {
optionCode: 'return { series: [{ type: "bar", data: [1, 2, 3] }] }',
option: null,
},
binding: {
sourceId: 'report',
fieldPath: 'echartsOption',
},
}
viewer.open({
schema,
data: {
echartsOption: {
tooltip: {},
xAxis: { type: 'category', data: ['A', 'B'] },
yAxis: { type: 'value' },
series: [{ type: 'bar', data: [12, 20] }],
},
},
})如果数据源返回 JSON 字符串,物料可以在自己的 Viewer renderer 中解析它;如果要执行 JavaScript 代码生成 option,应明确把它作为可信模板代码处理,不要把它当成不可信脚本沙箱。
如果你的物料想接管数据源拖放,比如表格单元格那样落到内部区域,再实现 datasourceDrop。
结构化数据物料
如果物料消费的不是单个字段,而是一组目标 records,例如柱状图、折线图、透视卡片,可以声明 binding.kind='data-contract' 并在其中放入 contract。此时物料说清楚自己需要什么目标模型,节点 binding 只保存用户从数据源拖来的映射。
import type { MaterialDataContract } from '@easyink/core'
export const SALES_CHART_CONTRACT = {
version: 3,
model: {
kind: 'tabular',
fields: {
category: {
labelKey: 'materials.salesChart.data.category',
type: 'string',
required: true,
format: 'display',
},
value: {
labelKey: 'materials.salesChart.data.value',
type: 'number',
required: true,
format: 'raw',
},
},
},
} satisfies MaterialDataContract注册物料时把 contract 放到 MaterialDefinition.binding:
registerMaterialBundle(store, {
materials: [{
type: 'sales-chart',
name: 'materials.salesChart.name',
icon: IconChart,
category: 'chart',
capabilities: { bindable: true, resizable: true },
binding: {
kind: 'data-contract',
contract: SALES_CHART_CONTRACT,
formatEditor: { tabs: ['custom'], defaultTab: 'custom' },
},
propSchemas: [],
localeMessages: salesChartLocaleMessages,
createDefaultNode: createSalesChartNode,
factory: createSalesChartDesignerExtension,
}],
catalogs: [{
id: 'chart',
label: 'materials.catalog.chart',
items: [{ type: 'sales-chart' }],
}],
})Viewer 渲染器里由物料自己消费 contract 解析结果:
import { resolveMaterialDataContract } from '@easyink/core'
viewer.registerMaterial('sales-chart', {
kind: 'data-contract',
contract: SALES_CHART_CONTRACT,
formatEditor: { tabs: ['custom'], defaultTab: 'custom' },
}, {
render(node, context) {
const resolution = resolveMaterialDataContract(
SALES_CHART_CONTRACT,
node.binding,
context.data ?? {},
)
for (const diagnostic of resolution.diagnostics)
context.reportDiagnostic?.({ ...diagnostic, nodeId: node.id })
const points = resolution.records.map(record => ({
label: String(record.category ?? ''),
value: Number(record.value ?? 0),
}))
return renderSalesChart(points)
},
})保持这几个边界会让物料更容易扩展:
props保存视觉设置,不保存运行时数据。binding.contract.model描述目标数据模型,不描述数据源长什么样。DataContractBinding.mappings保存完整 source path,不截断集合内路径。- 默认使用
relation: { kind: 'auto' }交给 Resolver 推导 record/index 对齐关系。 formatEditor是物料注册能力声明,决定属性面板显示哪些格式 tab;Schema 里的BindingDisplayFormat只保存实际格式配置。- svg、chart 这类非普通文本值投影的物料应优先声明
formatEditor: { tabs: ['custom'], defaultTab: 'custom' },只在运行时确实消费预设格式时才开放preset。
何时实现 measure
固定尺寸物料不需要 measure()。只有最终尺寸依赖运行时内容时才加:
viewer.registerMaterial(PRICE_TAG_TYPE, {
kind: 'ordinary',
primaryProp: 'amount',
formatEditor: { tabs: ['preset', 'custom'], defaultTab: 'preset' },
}, {
render(_node, context) {
return {
html: trustedViewerHtml(`<div>${escapeHtml(String(context.resolvedProps.amount ?? ''))}</div>`),
}
},
measure(node) {
return {
width: node.width,
height: node.height,
}
},
})上面只是接口形状。对于固定宽高的价格签,删掉 measure() 更合适。
适合实现 measure() 的场景通常是:
- 文本根据内容自动增高。
- 表格根据数据行数增高。
- 容器根据子项数量展开。
何时使用 pageAware
pageAware 表示这个物料要复制到每一页:
viewer.registerMaterial('page-badge', { kind: 'none' }, {
pageAware: true,
render(_node, context) {
const props = context.resolvedProps
return {
html: trustedViewerHtml(
`<div>第 ${String(props.__pageNumber ?? '')} / ${String(props.__totalPages ?? '')} 页</div>`,
),
}
},
})它适合页码、页眉、页脚、水印这类“每页都出现”的元素。它不负责重新分页,也不会替你改变 layout 或 reflow。
Viewer 会给复制后的节点注入 __pageNumber 和 __totalPages,所以页码类物料应该从 context.resolvedProps 读取它们。
完成前检查
写完后按这个顺序验证:
// 1. Designer 能拖入
registerPriceTagDesigner(store)
// 2. Schema 里有稳定 type
schema.elements.some(node => node.type === PRICE_TAG_TYPE)
// 3. Viewer 能渲染同一个 type
viewer.registerMaterial(PRICE_TAG_TYPE, priceTagViewerExtension)然后再检查这些结果:
- 从物料面板点击或拖入后,画布上能看到元素。
- 修改属性后,Schema 保存出稳定的
type和props。 - 同一份 Schema 交给 Viewer 后,不会出现
[Unknown: price-tag]。 - 绑定数据后,Viewer 能从
context.resolvedProps读到结果。 - 打印和导出结果与 Viewer 预览一致。
关于物料,目前知道这些就够用了。接下来可以继续看 贡献扩展开发 或 Schema 参考。
声明 AI 知识
如果你希望 AI 助手能理解你的自定义物料并在生成模板时正确使用它,需要在 aiDescriptor 中声明 knowledge 字段:
import type { AIMaterialDescriptor } from '@easyink/shared'
export const priceTagAIMaterialDescriptor = {
type: 'price-tag',
description: 'Price tag for displaying product price with label.',
properties: ['label', 'amount'],
requiredProps: ['label', 'amount'],
binding: 'single',
usage: ['Use for product price display in retail labels.'],
knowledge: {
category: 'typography',
composability: {
canBeChildOf: ['*'],
canContain: [],
exclusiveWith: [],
preferredCompanions: ['barcode', 'image'],
},
bindingSpec: {
mode: 'scalar',
accepts: { types: ['number', 'string'] },
produces: { kind: 'scalar-field', fieldCount: 'single' },
},
sizing: {
minWidth: 20,
minHeight: 10,
defaultSize: { width: 48, height: 18 },
},
fitness: [
{ scenario: 'product-label', score: 0.95, reason: 'price display' },
{ scenario: 'retail-shelf', score: 0.9, reason: 'shelf price tag' },
],
},
} satisfies AIMaterialDescriptor然后在注册物料时传入:
registerMaterialBundle(store, {
materials: [{
type: PRICE_TAG_TYPE,
name: 'materials.priceTag.name',
icon: IconText,
category: 'basic',
capabilities: { bindable: true, resizable: true, rotatable: true },
aiDescriptor: priceTagAIMaterialDescriptor, // ← 传入 AI 描述
localeMessages: priceTagLocaleMessages,
createDefaultNode: createPriceTagNode,
factory: createPriceTagDesignerExtension,
}],
catalogs: [{
id: 'enterprise',
label: 'materials.catalog.enterprise',
items: [{ type: PRICE_TAG_TYPE }],
}],
})knowledge 是可选的。没有它 AI 仍然能使用你的物料,但有了它 AI 会更精确地选择物料、设置尺寸和绑定数据。