@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/README.md +26 -132
- package/README.zh-CN.md +26 -132
- package/dist/index.d.ts +355 -37
- package/dist/index.js +573 -433
- package/dist/styles.css +41 -31
- package/docs/advanced-examples.md +265 -0
- package/docs/advanced-examples.zh-CN.md +265 -0
- package/docs/api.md +277 -0
- package/docs/api.zh-CN.md +277 -0
- package/docs/assets/architecture.svg +84 -0
- package/docs/assets/architecture.zh-CN.svg +84 -0
- package/docs/basic-examples.md +113 -0
- package/docs/basic-examples.zh-CN.md +113 -0
- package/docs/changelog.md +125 -0
- package/{CHANGELOG.md → docs/changelog.zh-CN.md} +62 -0
- package/docs/custom-blocks.md +27 -31
- package/docs/custom-blocks.zh-CN.md +26 -30
- package/docs/release.md +38 -0
- package/docs/release.zh-CN.md +38 -0
- package/package.json +20 -5
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-
|
|
1668
|
+
.ree-input-with-variable {
|
|
1674
1669
|
position: relative;
|
|
1675
1670
|
}
|
|
1676
1671
|
|
|
1677
|
-
.ree-
|
|
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:
|
|
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-
|
|
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 语法。
|