@standhigher/react-email-editor 0.3.0 → 0.4.3

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/dist/styles.css CHANGED
@@ -693,11 +693,6 @@
693
693
  background: var(--ree-panel-secondary);
694
694
  }
695
695
 
696
- .ree-personalization-menu {
697
- left: 0;
698
- min-width: 240px;
699
- }
700
-
701
696
  .ree-subject-hint {
702
697
  flex: 0 1 auto;
703
698
  margin: 0;
@@ -1670,16 +1665,30 @@
1670
1665
 
1671
1666
  /* ---------- 变量 ---------- */
1672
1667
 
1673
- .ree-var-wrap {
1668
+ .ree-input-with-variable {
1674
1669
  position: relative;
1675
1670
  }
1676
1671
 
1677
- .ree-var-btn {
1672
+ .ree-input-with-variable > .ree-input {
1673
+ --ree-control-padding-end: 48px;
1674
+ }
1675
+
1676
+ .ree-input-with-variable > .ree-textarea {
1677
+ display: block;
1678
+ }
1679
+
1680
+ .ree-personalization-suffix {
1681
+ position: absolute;
1682
+ top: 6px;
1683
+ right: 6px;
1684
+ }
1685
+
1686
+ .ree-personalization-suffix-btn {
1678
1687
  appearance: none;
1679
1688
  border: none;
1680
1689
  background: transparent;
1681
1690
  min-width: 30px;
1682
- height: 24px;
1691
+ height: 26px;
1683
1692
  padding: 0 6px;
1684
1693
  border-radius: var(--ree-radius-sm);
1685
1694
  cursor: pointer;
@@ -1687,10 +1696,16 @@
1687
1696
  font-size: 12px;
1688
1697
  }
1689
1698
 
1690
- .ree-var-btn:hover {
1699
+ .ree-personalization-suffix-btn:hover {
1691
1700
  background: var(--ree-panel-secondary);
1692
1701
  }
1693
1702
 
1703
+ .ree-personalization-suffix-btn:focus-visible,
1704
+ .ree-personalization-btn:focus-visible {
1705
+ outline: 2px solid var(--ree-text);
1706
+ outline-offset: -2px;
1707
+ }
1708
+
1694
1709
  .ree-var-menu {
1695
1710
  position: absolute;
1696
1711
  top: 100%;
@@ -1707,6 +1722,16 @@
1707
1722
  color: var(--ree-text, #202223);
1708
1723
  }
1709
1724
 
1725
+ .ree-var-menu.ree-personalization-menu {
1726
+ position: fixed;
1727
+ inset: auto;
1728
+ margin: 0;
1729
+ z-index: 1000;
1730
+ width: 240px;
1731
+ min-width: 0;
1732
+ max-width: calc(100vw - 16px);
1733
+ }
1734
+
1710
1735
  .ree-var-item {
1711
1736
  display: flex;
1712
1737
  flex-direction: column;
@@ -1730,6 +1755,13 @@
1730
1755
  color: var(--ree-primary);
1731
1756
  }
1732
1757
 
1758
+ .ree-var-name,
1759
+ .ree-var-label {
1760
+ max-width: 100%;
1761
+ overflow-wrap: anywhere;
1762
+ white-space: pre-wrap;
1763
+ }
1764
+
1733
1765
  .ree-var-label {
1734
1766
  font-size: 12px;
1735
1767
  color: var(--ree-muted);
@@ -1862,28 +1894,6 @@
1862
1894
  margin-bottom: 14px;
1863
1895
  }
1864
1896
 
1865
- .ree-field-label:has(> .ree-var-wrap) {
1866
- position: relative;
1867
- justify-content: flex-start;
1868
- }
1869
-
1870
- .ree-field-label > .ree-var-wrap {
1871
- position: static;
1872
- }
1873
-
1874
- .ree-field-label .ree-var-menu {
1875
- left: 0;
1876
- width: min(280px, 100%);
1877
- min-width: 0;
1878
- }
1879
-
1880
- .ree-field-label .ree-var-name,
1881
- .ree-field-label .ree-var-label {
1882
- max-width: 100%;
1883
- overflow-wrap: anywhere;
1884
- white-space: pre-wrap;
1885
- }
1886
-
1887
1897
  /* ---------- 配置控件 ---------- */
1888
1898
 
1889
1899
  .ree-textarea {
@@ -0,0 +1,265 @@
1
+ # Advanced examples
2
+
3
+ [简体中文](./advanced-examples.zh-CN.md) | [API reference](./api.md) | [Basic examples](./basic-examples.md)
4
+
5
+ Save each named code block in its own file and import package CSS once. Business requests are injected through props; these examples assume no backend routes.
6
+
7
+ ## 1. Validate before async saving
8
+
9
+ Validation can expose control issues early; export still needs to catch plugin rendering errors. Implement saveDocument(data) in your app, returning a Promise. Failures remain retryable.
10
+
11
+ ```tsx
12
+ // ValidatedEditor.tsx
13
+ import { useState } from "react";
14
+ import {
15
+ EmailEditor, useEmailEditor, type EmailEditorData,
16
+ } from "@standhigher/react-email-editor";
17
+
18
+ interface ValidatedEditorProps {
19
+ saveDocument: (data: EmailEditorData) => Promise<void>;
20
+ }
21
+
22
+ export function ValidatedEditor({ saveDocument }: ValidatedEditorProps) {
23
+ const editor = useEmailEditor();
24
+ const [status, setStatus] = useState("Not saved yet");
25
+ const [saving, setSaving] = useState(false);
26
+
27
+ async function save() {
28
+ setSaving(true);
29
+ try {
30
+ const issues = editor.validate();
31
+ if (issues.length) {
32
+ setStatus(issues.map((issue) => `${issue.controlPath}: ${issue.message}`).join("; "));
33
+ return;
34
+ }
35
+ const data = editor.getData();
36
+ await saveDocument(data);
37
+ setStatus("Snapshot saved; edits made during saving need another save");
38
+ } catch (error) {
39
+ setStatus(error instanceof Error ? error.message : "Save failed; retry");
40
+ } finally {
41
+ setSaving(false);
42
+ }
43
+ }
44
+
45
+ return (
46
+ <>
47
+ <EmailEditor editor={editor} height={720} />
48
+ <button type="button" disabled={saving} onClick={save}>{saving ? "Saving…" : "Save / retry"}</button>
49
+ <output aria-live="polite">{status}</output>
50
+ </>
51
+ );
52
+ }
53
+ ```
54
+
55
+ getData() generates both template and preview HTML. For JSON-only drafts use getDocument(), which avoids blocking persistence on invalid controls or rendering failures. Editing remains enabled during saving; the result corresponds to the detached snapshot taken on click.
56
+
57
+ ## 2. Share configuration between isolated instances and subscribe locally
58
+
59
+ ```tsx
60
+ // DocumentSummary.tsx
61
+ import { useEmailEditorState, type EmailEditorInstance } from "@standhigher/react-email-editor";
62
+
63
+ interface DocumentSummaryProps {
64
+ editor: EmailEditorInstance;
65
+ }
66
+
67
+ export function DocumentSummary({ editor }: DocumentSummaryProps) {
68
+ const { document } = useEmailEditorState(editor);
69
+ return <output>{document.blocks.length} blocks</output>;
70
+ }
71
+ ```
72
+
73
+ ```tsx
74
+ // ComparisonEditors.tsx
75
+ import { EmailEditor, useEmailEditor, useEmailEditorConfig } from "@standhigher/react-email-editor";
76
+ import { DocumentSummary } from "./DocumentSummary";
77
+
78
+ const builtins = ["heading", "text", "button", "image"];
79
+
80
+ export function ComparisonEditors() {
81
+ const config = useEmailEditorConfig({ builtins });
82
+ const left = useEmailEditor({ config, body: { contentWidth: 640 } });
83
+ const right = useEmailEditor({ config, body: { contentWidth: 480 } });
84
+ return (
85
+ <>
86
+ <DocumentSummary editor={left} /><EmailEditor editor={left} height={600} />
87
+ <DocumentSummary editor={right} /><EmailEditor editor={right} height={600} />
88
+ </>
89
+ );
90
+ }
91
+ ```
92
+
93
+ Documents, selection and reset are isolated. Only summary components subscribe to document changes. Shared configuration describes block capabilities without sharing mutable documents.
94
+
95
+ ## 3. Business block with multiple rich-text regions and a list
96
+
97
+ This recommendation block combines two rich-text regions and a product list. Controls are the source of truth for types and defaults. Canvas defines editing UI; renderEmail defines email HTML.
98
+
99
+ ```ts
100
+ // recommendation-controls.ts
101
+ import { EmailControls, type ControlValues } from "@standhigher/react-email-editor";
102
+
103
+ export const recommendationControls = {
104
+ intro: EmailControls.richText({ label: "Introduction", defaultValue: "<p>This week's picks</p>" }),
105
+ footer: EmailControls.richText({ label: "Footer", defaultValue: "<p>Thanks for subscribing</p>" }),
106
+ products: EmailControls.list({
107
+ label: "Products", min: 1, max: 8,
108
+ controls: {
109
+ name: EmailControls.text({ label: "Name", validate: (value) => value.trim() ? undefined : "Enter a name" }),
110
+ image: EmailControls.image({ label: "Image" }),
111
+ url: EmailControls.url({ label: "Link" }),
112
+ },
113
+ defaultValue: [{ name: "Example product", image: "", url: "https://example.com/product" }],
114
+ }),
115
+ };
116
+ export type RecommendationValues = ControlValues<typeof recommendationControls>;
117
+ ```
118
+
119
+ ```tsx
120
+ // RecommendationCanvas.tsx
121
+ import { EditableRichText, type PluginEditorProps } from "@standhigher/react-email-editor";
122
+ import type { RecommendationValues } from "./recommendation-controls";
123
+
124
+ export function RecommendationCanvas({ controls }: PluginEditorProps<RecommendationValues>) {
125
+ return (
126
+ <div>
127
+ <EditableRichText control="intro" />
128
+ <ul>{controls.products.map((product, index) => <li key={index}>{product.name}</li>)}</ul>
129
+ <EditableRichText control="footer" />
130
+ </div>
131
+ );
132
+ }
133
+ ```
134
+
135
+ ```tsx
136
+ // RecommendationInspector.tsx
137
+ import { ControlEditor } from "@standhigher/react-email-editor";
138
+
139
+ export function RecommendationInspector() {
140
+ return <ControlEditor control="products" />;
141
+ }
142
+ ```
143
+
144
+ ```ts
145
+ // recommendation.ts
146
+ import { defineBlock } from "@standhigher/react-email-editor";
147
+ import { recommendationControls } from "./recommendation-controls";
148
+ import { RecommendationCanvas } from "./RecommendationCanvas";
149
+ import { RecommendationInspector } from "./RecommendationInspector";
150
+
151
+ export const recommendation = defineBlock({
152
+ type: "shop.recommendation",
153
+ title: "Recommendations",
154
+ controls: recommendationControls,
155
+ Canvas: RecommendationCanvas,
156
+ Inspector: RecommendationInspector,
157
+ defaultStyle: { dimension: { padding: "16px" } },
158
+ renderEmail: ({ controls, email }) => email.html`
159
+ ${email.richText(controls.intro)}
160
+ ${controls.products.map((product) => email.html`
161
+ <p><strong>${product.name}</strong></p>
162
+ ${product.image ? email.image({ src: product.image, alt: product.name }) : email.raw("")}
163
+ ${email.button({ href: product.url, label: "View product" })}
164
+ `)}
165
+ ${email.richText(controls.footer)}
166
+ `,
167
+ });
168
+ ```
169
+
170
+ ```tsx
171
+ // RecommendationEditor.tsx
172
+ import { useMemo, useState } from "react";
173
+ import {
174
+ createEmailDocument, EmailEditor, useEmailEditor, useEmailEditorConfig,
175
+ } from "@standhigher/react-email-editor";
176
+ import { recommendation } from "./recommendation";
177
+
178
+ const plugins = [recommendation];
179
+
180
+ export function RecommendationEditor() {
181
+ const config = useEmailEditorConfig({ plugins });
182
+ const initialDocument = useMemo(
183
+ () => createEmailDocument([config.createBlock(recommendation.type)]),
184
+ [config],
185
+ );
186
+ const editor = useEmailEditor({ config, initialDocument });
187
+ const [html, setHtml] = useState("");
188
+ const [error, setError] = useState("");
189
+
190
+ function exportHtml() {
191
+ try {
192
+ // The same configuration works for export without mounting the UI.
193
+ setHtml(config.toHtml(editor.getDocument()));
194
+ setError("");
195
+ } catch (cause) {
196
+ setError(cause instanceof Error ? cause.message : "Export failed");
197
+ }
198
+ }
199
+ return (
200
+ <>
201
+ <EmailEditor editor={editor} height={720} />
202
+ <button type="button" onClick={exportHtml}>Export</button>
203
+ {error ? <p role="alert">{error}</p> : null}
204
+ <pre>{html}</pre>
205
+ </>
206
+ );
207
+ }
208
+ ```
209
+
210
+ config.toHtml() and the editor share the same registry; loading saved JSON requires the plugin again. ControlEditor provides list additions, deletions and child editing; Canvas only displays product names. Business pickers can use PluginEditorProps.setControls(patch) to fill related values in one update; submit a complete array for nested lists.
211
+
212
+ ## 4. Integrate an existing image resource picker
213
+
214
+ ```tsx
215
+ // ResourceEditor.tsx
216
+ import { useCallback } from "react";
217
+ import {
218
+ EmailEditor, useEmailEditor, useEmailEditorConfig, type ImagePickerContext,
219
+ } from "@standhigher/react-email-editor";
220
+
221
+ interface ResourceEditorProps {
222
+ openLibrary: (request: { currentUrl: string; signal: AbortSignal }) => Promise<string | null>;
223
+ }
224
+
225
+ export function ResourceEditor({ openLibrary }: ResourceEditorProps) {
226
+ const pickImage = useCallback(
227
+ ({ value, signal }: ImagePickerContext) => openLibrary({ currentUrl: value, signal }),
228
+ [openLibrary],
229
+ );
230
+ const config = useEmailEditorConfig({ pickImage });
231
+ const editor = useEmailEditor({ config });
232
+ return <EmailEditor editor={editor} height={720} />;
233
+ }
234
+ ```
235
+
236
+ Your resource library implements its dialog, search, empty results, uploads and cancellation. Pass signal to requests. Return an allowed URL, null to cancel, or throw to show retry. The editor provides loading/error/retry for image requests, ignores stale results and cancels on unmount.
237
+
238
+ ## 5. Structured template data and single-block preview
239
+
240
+ ```ts
241
+ // template-preview.ts
242
+ import {
243
+ BLOCK_TYPES, blockToHtml, createBlockNode, createEmailDocument, documentToHtml,
244
+ mergeTemplateData, type Variable,
245
+ } from "@standhigher/react-email-editor";
246
+
247
+ const block = createBlockNode(BLOCK_TYPES.html);
248
+ if (!block) throw new Error("HTML block is not registered");
249
+ block.controls.html = `<p>Hello, {{.customer}}</p>
250
+ {{range .products}}<p>{{.name}}</p>{{end}}`;
251
+ const variables: Variable[] = [{ name: "{{.customer}}", value: "Sam" }];
252
+ const templateData = { products: [{ name: "Tea" }, { name: "Coffee" }] };
253
+ const document = createEmailDocument([block]);
254
+
255
+ export const template = documentToHtml(document, undefined, { variables });
256
+ export const preview = documentToHtml(document, undefined, {
257
+ variables, templateData, resolveHtmlTemplates: true,
258
+ });
259
+ export const fragment = blockToHtml(block, undefined, "email", {
260
+ resolveHtmlTemplate: true,
261
+ templateData: mergeTemplateData(variables, { ...templateData, ...block.defaultData }),
262
+ });
263
+ ```
264
+
265
+ Complete documents shallow-merge variables → templateData → block.defaultData, with later data winning. Single-block rendering does not merge variables automatically; this example merges explicitly and uses the singular resolveHtmlTemplate option. Your application chooses the actual sending engine; preview only supports a subset of Go templates.
@@ -0,0 +1,265 @@
1
+ # 复杂场景示例
2
+
3
+ [English](./advanced-examples.md) | [API 参考](./api.zh-CN.md) | [基础场景](./basic-examples.zh-CN.md)
4
+
5
+ 每段标注文件名的代码保存为独立文件;引入一次包样式。业务请求通过 Props 注入,示例不会假设你的后端路径。
6
+
7
+ ## 1. 校验后异步保存
8
+
9
+ 校验可提前展示控件错误,导出仍需捕获插件渲染异常。调用方实现 `saveDocument(data)`,返回保存完成的 Promise;失败会显示可重试状态。
10
+
11
+ ```tsx
12
+ // ValidatedEditor.tsx
13
+ import { useState } from "react";
14
+ import {
15
+ EmailEditor, useEmailEditor, type EmailEditorData,
16
+ } from "@standhigher/react-email-editor";
17
+
18
+ interface ValidatedEditorProps {
19
+ saveDocument: (data: EmailEditorData) => Promise<void>;
20
+ }
21
+
22
+ export function ValidatedEditor({ saveDocument }: ValidatedEditorProps) {
23
+ const editor = useEmailEditor();
24
+ const [status, setStatus] = useState("尚未保存");
25
+ const [saving, setSaving] = useState(false);
26
+
27
+ async function save() {
28
+ setSaving(true);
29
+ try {
30
+ const issues = editor.validate();
31
+ if (issues.length) {
32
+ setStatus(issues.map((issue) => `${issue.controlPath}: ${issue.message}`).join(";"));
33
+ return;
34
+ }
35
+ const data = editor.getData();
36
+ await saveDocument(data);
37
+ setStatus("本次快照已保存;保存期间的后续编辑需再次保存");
38
+ } catch (error) {
39
+ setStatus(error instanceof Error ? error.message : "保存失败,请重试");
40
+ } finally {
41
+ setSaving(false);
42
+ }
43
+ }
44
+
45
+ return (
46
+ <>
47
+ <EmailEditor editor={editor} height={720} />
48
+ <button type="button" disabled={saving} onClick={save}>{saving ? "保存中…" : "保存 / 重试"}</button>
49
+ <output aria-live="polite">{status}</output>
50
+ </>
51
+ );
52
+ }
53
+ ```
54
+
55
+ `getData()` 同时生成模板与预览 HTML;只需要草稿 JSON 时使用 `getDocument()`,不会因无效控件或模板渲染阻止草稿持久化。保存期间编辑器仍可编辑,保存结果对应点击时的独立快照。
56
+
57
+ ## 2. 多实例共享配置,局部订阅文档
58
+
59
+ ```tsx
60
+ // DocumentSummary.tsx
61
+ import { useEmailEditorState, type EmailEditorInstance } from "@standhigher/react-email-editor";
62
+
63
+ interface DocumentSummaryProps {
64
+ editor: EmailEditorInstance;
65
+ }
66
+
67
+ export function DocumentSummary({ editor }: DocumentSummaryProps) {
68
+ const { document } = useEmailEditorState(editor);
69
+ return <output>{document.blocks.length} 个区块</output>;
70
+ }
71
+ ```
72
+
73
+ ```tsx
74
+ // ComparisonEditors.tsx
75
+ import { EmailEditor, useEmailEditor, useEmailEditorConfig } from "@standhigher/react-email-editor";
76
+ import { DocumentSummary } from "./DocumentSummary";
77
+
78
+ const builtins = ["heading", "text", "button", "image"];
79
+
80
+ export function ComparisonEditors() {
81
+ const config = useEmailEditorConfig({ builtins });
82
+ const left = useEmailEditor({ config, body: { contentWidth: 640 } });
83
+ const right = useEmailEditor({ config, body: { contentWidth: 480 } });
84
+ return (
85
+ <>
86
+ <DocumentSummary editor={left} /><EmailEditor editor={left} height={600} />
87
+ <DocumentSummary editor={right} /><EmailEditor editor={right} height={600} />
88
+ </>
89
+ );
90
+ }
91
+ ```
92
+
93
+ 两个实例的文档、选择、重置互相独立。仅摘要组件订阅文档变化。共享配置定义区块能力,不共享可变文档。
94
+
95
+ ## 3. 业务区块、多区域富文本与列表
96
+
97
+ 以下商品推荐区块同时包含两个富文本编辑区和一个商品列表。控件声明是类型和默认值的唯一来源;Canvas 负责编辑布局,`renderEmail` 负责邮件 HTML。
98
+
99
+ ```ts
100
+ // recommendation-controls.ts
101
+ import { EmailControls, type ControlValues } from "@standhigher/react-email-editor";
102
+
103
+ export const recommendationControls = {
104
+ intro: EmailControls.richText({ label: "介绍", defaultValue: "<p>本周推荐</p>" }),
105
+ footer: EmailControls.richText({ label: "结语", defaultValue: "<p>谢谢订阅</p>" }),
106
+ products: EmailControls.list({
107
+ label: "商品", min: 1, max: 8,
108
+ controls: {
109
+ name: EmailControls.text({ label: "名称", validate: (value) => value.trim() ? undefined : "请填写名称" }),
110
+ image: EmailControls.image({ label: "图片" }),
111
+ url: EmailControls.url({ label: "链接" }),
112
+ },
113
+ defaultValue: [{ name: "示例商品", image: "", url: "https://example.com/product" }],
114
+ }),
115
+ };
116
+ export type RecommendationValues = ControlValues<typeof recommendationControls>;
117
+ ```
118
+
119
+ ```tsx
120
+ // RecommendationCanvas.tsx
121
+ import { EditableRichText, type PluginEditorProps } from "@standhigher/react-email-editor";
122
+ import type { RecommendationValues } from "./recommendation-controls";
123
+
124
+ export function RecommendationCanvas({ controls }: PluginEditorProps<RecommendationValues>) {
125
+ return (
126
+ <div>
127
+ <EditableRichText control="intro" />
128
+ <ul>{controls.products.map((product, index) => <li key={index}>{product.name}</li>)}</ul>
129
+ <EditableRichText control="footer" />
130
+ </div>
131
+ );
132
+ }
133
+ ```
134
+
135
+ ```tsx
136
+ // RecommendationInspector.tsx
137
+ import { ControlEditor } from "@standhigher/react-email-editor";
138
+
139
+ export function RecommendationInspector() {
140
+ return <ControlEditor control="products" />;
141
+ }
142
+ ```
143
+
144
+ ```ts
145
+ // recommendation.ts
146
+ import { defineBlock } from "@standhigher/react-email-editor";
147
+ import { recommendationControls } from "./recommendation-controls";
148
+ import { RecommendationCanvas } from "./RecommendationCanvas";
149
+ import { RecommendationInspector } from "./RecommendationInspector";
150
+
151
+ export const recommendation = defineBlock({
152
+ type: "shop.recommendation",
153
+ title: "商品推荐",
154
+ controls: recommendationControls,
155
+ Canvas: RecommendationCanvas,
156
+ Inspector: RecommendationInspector,
157
+ defaultStyle: { dimension: { padding: "16px" } },
158
+ renderEmail: ({ controls, email }) => email.html`
159
+ ${email.richText(controls.intro)}
160
+ ${controls.products.map((product) => email.html`
161
+ <p><strong>${product.name}</strong></p>
162
+ ${product.image ? email.image({ src: product.image, alt: product.name }) : email.raw("")}
163
+ ${email.button({ href: product.url, label: "查看商品" })}
164
+ `)}
165
+ ${email.richText(controls.footer)}
166
+ `,
167
+ });
168
+ ```
169
+
170
+ ```tsx
171
+ // RecommendationEditor.tsx
172
+ import { useMemo, useState } from "react";
173
+ import {
174
+ createEmailDocument, EmailEditor, useEmailEditor, useEmailEditorConfig,
175
+ } from "@standhigher/react-email-editor";
176
+ import { recommendation } from "./recommendation";
177
+
178
+ const plugins = [recommendation];
179
+
180
+ export function RecommendationEditor() {
181
+ const config = useEmailEditorConfig({ plugins });
182
+ const initialDocument = useMemo(
183
+ () => createEmailDocument([config.createBlock(recommendation.type)]),
184
+ [config],
185
+ );
186
+ const editor = useEmailEditor({ config, initialDocument });
187
+ const [html, setHtml] = useState("");
188
+ const [error, setError] = useState("");
189
+
190
+ function exportHtml() {
191
+ try {
192
+ // 同一配置也可供不挂载 UI 的导出流程使用。
193
+ setHtml(config.toHtml(editor.getDocument()));
194
+ setError("");
195
+ } catch (cause) {
196
+ setError(cause instanceof Error ? cause.message : "导出失败");
197
+ }
198
+ }
199
+ return (
200
+ <>
201
+ <EmailEditor editor={editor} height={720} />
202
+ <button type="button" onClick={exportHtml}>导出</button>
203
+ {error ? <p role="alert">{error}</p> : null}
204
+ <pre>{html}</pre>
205
+ </>
206
+ );
207
+ }
208
+ ```
209
+
210
+ `config.toHtml()` 和编辑器共用同一注册表,保存 JSON 后再加载也必须注册该插件。列表编辑由 `ControlEditor` 提供增加、删除及子控件编辑;Canvas 的列表只展示商品名称。业务选择器一次填充多个关联字段可使用 `PluginEditorProps.setControls(patch)`,嵌套列表提交完整数组。
211
+
212
+ ## 4. 接入已有图片资源选择器
213
+
214
+ ```tsx
215
+ // ResourceEditor.tsx
216
+ import { useCallback } from "react";
217
+ import {
218
+ EmailEditor, useEmailEditor, useEmailEditorConfig, type ImagePickerContext,
219
+ } from "@standhigher/react-email-editor";
220
+
221
+ interface ResourceEditorProps {
222
+ openLibrary: (request: { currentUrl: string; signal: AbortSignal }) => Promise<string | null>;
223
+ }
224
+
225
+ export function ResourceEditor({ openLibrary }: ResourceEditorProps) {
226
+ const pickImage = useCallback(
227
+ ({ value, signal }: ImagePickerContext) => openLibrary({ currentUrl: value, signal }),
228
+ [openLibrary],
229
+ );
230
+ const config = useEmailEditorConfig({ pickImage });
231
+ const editor = useEmailEditor({ config });
232
+ return <EmailEditor editor={editor} height={720} />;
233
+ }
234
+ ```
235
+
236
+ 调用方的资源库应处理弹窗、搜索、空结果、上传与取消;传入 signal 给网络请求。返回允许协议的地址,返回 null 表示取消,抛错由编辑器显示重试。编辑器对图片请求提供 loading/error/retry,忽略过期结果,卸载时取消请求。
237
+
238
+ ## 5. 批量模板数据与单区块预览
239
+
240
+ ```ts
241
+ // template-preview.ts
242
+ import {
243
+ BLOCK_TYPES, blockToHtml, createBlockNode, createEmailDocument, documentToHtml,
244
+ mergeTemplateData, type Variable,
245
+ } from "@standhigher/react-email-editor";
246
+
247
+ const block = createBlockNode(BLOCK_TYPES.html);
248
+ if (!block) throw new Error("HTML 区块未注册");
249
+ block.controls.html = `<p>你好,{{.customer}}</p>
250
+ {{range .products}}<p>{{.name}}</p>{{end}}`;
251
+ const variables: Variable[] = [{ name: "{{.customer}}", value: "李小明" }];
252
+ const templateData = { products: [{ name: "茶叶" }, { name: "咖啡" }] };
253
+ const document = createEmailDocument([block]);
254
+
255
+ export const template = documentToHtml(document, undefined, { variables });
256
+ export const preview = documentToHtml(document, undefined, {
257
+ variables, templateData, resolveHtmlTemplates: true,
258
+ });
259
+ export const fragment = blockToHtml(block, undefined, "email", {
260
+ resolveHtmlTemplate: true,
261
+ templateData: mergeTemplateData(variables, { ...templateData, ...block.defaultData }),
262
+ });
263
+ ```
264
+
265
+ 完整文档自动按顶层浅合并 `variables → templateData → block.defaultData`,后者优先。单区块不会自动合并变量;示例显式合并,并使用单数 `resolveHtmlTemplate`。真实发送端的模板引擎由应用决定,预览仅支持部分 Go template 语法。