@standhigher/react-email-editor 0.2.0 → 0.4.1
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/CHANGELOG.md +60 -0
- package/README.md +24 -104
- package/README.zh-CN.md +24 -104
- package/dist/index.d.ts +641 -120
- package/dist/index.js +4797 -4221
- package/dist/styles.css +216 -39
- package/docs/advanced-examples.md +265 -0
- package/docs/advanced-examples.zh-CN.md +265 -0
- package/docs/api.md +275 -0
- package/docs/api.zh-CN.md +275 -0
- package/docs/assets/architecture.png +0 -0
- package/docs/assets/architecture.svg +84 -0
- package/docs/assets/architecture.zh-CN.png +0 -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/custom-blocks.md +487 -0
- package/docs/custom-blocks.zh-CN.md +486 -0
- package/package.json +3 -1
package/dist/styles.css
CHANGED
|
@@ -248,6 +248,46 @@
|
|
|
248
248
|
cursor: default;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
.ree-switch-control {
|
|
252
|
+
width: 100%;
|
|
253
|
+
justify-content: space-between;
|
|
254
|
+
flex-direction: row-reverse;
|
|
255
|
+
font-size: 13px;
|
|
256
|
+
color: var(--ree-muted, #6d7175);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.ree-switch-control input {
|
|
260
|
+
display: flex;
|
|
261
|
+
align-items: center;
|
|
262
|
+
width: 32px;
|
|
263
|
+
height: 20px;
|
|
264
|
+
padding: 2px;
|
|
265
|
+
border: 0;
|
|
266
|
+
border-radius: 999px;
|
|
267
|
+
background: #b5b5b5;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.ree-switch-control input::before,
|
|
271
|
+
.ree-switch-control input:checked::before {
|
|
272
|
+
content: "";
|
|
273
|
+
width: 16px;
|
|
274
|
+
height: 16px;
|
|
275
|
+
margin: 0;
|
|
276
|
+
border: 0;
|
|
277
|
+
border-radius: 50%;
|
|
278
|
+
background: #ffffff;
|
|
279
|
+
transform: none;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.ree-switch-control input:checked::before {
|
|
283
|
+
transform: translateX(12px);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.ree-switch-control input:focus-visible {
|
|
287
|
+
border: 2px solid #202223;
|
|
288
|
+
padding: 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
251
291
|
.ree-range {
|
|
252
292
|
-webkit-appearance: none;
|
|
253
293
|
appearance: none;
|
|
@@ -375,6 +415,75 @@
|
|
|
375
415
|
padding: calc(var(--ree-button-padding-y) - 1px) calc(var(--ree-button-padding-x) - 1px);
|
|
376
416
|
}
|
|
377
417
|
|
|
418
|
+
.ree-btn-icon {
|
|
419
|
+
display: inline-flex;
|
|
420
|
+
align-items: center;
|
|
421
|
+
justify-content: center;
|
|
422
|
+
flex-shrink: 0;
|
|
423
|
+
width: 30px;
|
|
424
|
+
height: 30px;
|
|
425
|
+
--ree-button-padding-y: 0px;
|
|
426
|
+
--ree-button-padding-x: 0px;
|
|
427
|
+
border-color: transparent;
|
|
428
|
+
border-radius: 6px;
|
|
429
|
+
background: transparent;
|
|
430
|
+
color: var(--ree-muted);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.ree-btn-icon:hover {
|
|
434
|
+
border-color: transparent;
|
|
435
|
+
color: var(--ree-text);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.ree-btn-icon:focus-visible {
|
|
439
|
+
padding: 0;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.ree-btn-has-tooltip {
|
|
443
|
+
position: relative;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
.ree-block-tooltip,
|
|
447
|
+
.ree-btn-tooltip {
|
|
448
|
+
position: absolute;
|
|
449
|
+
top: calc(100% + 8px);
|
|
450
|
+
left: 0;
|
|
451
|
+
z-index: 10;
|
|
452
|
+
display: none;
|
|
453
|
+
padding: 6px 9px;
|
|
454
|
+
border-radius: var(--ree-radius-sm);
|
|
455
|
+
background: var(--ree-text);
|
|
456
|
+
color: var(--ree-panel);
|
|
457
|
+
box-shadow: var(--ree-shadow-md);
|
|
458
|
+
font-size: 12px;
|
|
459
|
+
font-weight: 500;
|
|
460
|
+
line-height: 1.4;
|
|
461
|
+
white-space: nowrap;
|
|
462
|
+
cursor: default;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.ree-block-tooltip::before,
|
|
466
|
+
.ree-btn-tooltip::before {
|
|
467
|
+
content: "";
|
|
468
|
+
position: absolute;
|
|
469
|
+
top: -8px;
|
|
470
|
+
left: 0;
|
|
471
|
+
width: 100%;
|
|
472
|
+
height: 8px;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
.ree-btn-tooltip {
|
|
476
|
+
text-transform: none;
|
|
477
|
+
letter-spacing: normal;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
.ree-block-btn:hover .ree-block-tooltip,
|
|
481
|
+
.ree-block-btn:focus-visible .ree-block-tooltip,
|
|
482
|
+
.ree-btn-has-tooltip:hover .ree-btn-tooltip,
|
|
483
|
+
.ree-btn-has-tooltip:focus-visible .ree-btn-tooltip {
|
|
484
|
+
display: block;
|
|
485
|
+
}
|
|
486
|
+
|
|
378
487
|
.ree-reset {
|
|
379
488
|
appearance: none;
|
|
380
489
|
display: inline-flex;
|
|
@@ -534,17 +643,28 @@
|
|
|
534
643
|
color: var(--ree-text);
|
|
535
644
|
}
|
|
536
645
|
|
|
537
|
-
.ree-subject-
|
|
646
|
+
.ree-subject-field {
|
|
647
|
+
position: relative;
|
|
538
648
|
flex: 1 1 360px;
|
|
539
|
-
min-width: 240px;
|
|
540
|
-
|
|
649
|
+
min-width: min(240px, 100%);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.ree-subject-input {
|
|
653
|
+
--ree-control-padding-end: 76px;
|
|
541
654
|
}
|
|
542
655
|
|
|
543
656
|
.ree-subject-count {
|
|
544
|
-
|
|
657
|
+
position: absolute;
|
|
658
|
+
top: 0;
|
|
659
|
+
right: 12px;
|
|
660
|
+
bottom: 0;
|
|
661
|
+
display: flex;
|
|
662
|
+
align-items: center;
|
|
545
663
|
color: var(--ree-muted);
|
|
546
664
|
font-size: 13px;
|
|
547
665
|
font-variant-numeric: tabular-nums;
|
|
666
|
+
white-space: nowrap;
|
|
667
|
+
pointer-events: none;
|
|
548
668
|
}
|
|
549
669
|
|
|
550
670
|
.ree-personalization {
|
|
@@ -741,6 +861,47 @@
|
|
|
741
861
|
flex-shrink: 0;
|
|
742
862
|
}
|
|
743
863
|
|
|
864
|
+
.ree-library-head {
|
|
865
|
+
justify-content: space-between;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
.ree-col-left.is-collapsed {
|
|
869
|
+
width: 52px;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
.ree-col-left.is-collapsed .ree-library-head {
|
|
873
|
+
justify-content: center;
|
|
874
|
+
padding-left: 7px;
|
|
875
|
+
padding-right: 7px;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
.ree-col-left:not(.is-collapsed) .ree-library-expand,
|
|
879
|
+
.ree-col-left.is-collapsed .ree-library-collapse,
|
|
880
|
+
.ree-col-left.is-collapsed .ree-library-label {
|
|
881
|
+
display: none;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
@media (width < 1375px) {
|
|
885
|
+
.ree-col-left:not(.is-expanded) {
|
|
886
|
+
width: 52px;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
.ree-col-left:not(.is-expanded) .ree-library-head {
|
|
890
|
+
justify-content: center;
|
|
891
|
+
padding-left: 7px;
|
|
892
|
+
padding-right: 7px;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
.ree-col-left:not(.is-expanded) .ree-library-label,
|
|
896
|
+
.ree-col-left:not(.is-expanded) .ree-library-collapse {
|
|
897
|
+
display: none;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
.ree-col-left:not(.is-expanded) .ree-library-expand {
|
|
901
|
+
display: inline-flex;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
744
905
|
.ree-col-center {
|
|
745
906
|
flex: 1;
|
|
746
907
|
min-width: 0;
|
|
@@ -812,6 +973,42 @@
|
|
|
812
973
|
font-weight: 500;
|
|
813
974
|
}
|
|
814
975
|
|
|
976
|
+
.ree-col-left.is-collapsed .ree-lib {
|
|
977
|
+
grid-template-columns: 1fr;
|
|
978
|
+
gap: 8px;
|
|
979
|
+
padding: 12px 7px;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
.ree-col-left.is-collapsed .ree-lib-item {
|
|
983
|
+
justify-content: center;
|
|
984
|
+
min-height: 38px;
|
|
985
|
+
gap: 0;
|
|
986
|
+
padding: 8px;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
.ree-col-left.is-collapsed .ree-lib-title {
|
|
990
|
+
display: none;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
@media (width < 1375px) {
|
|
994
|
+
.ree-col-left:not(.is-expanded) .ree-lib {
|
|
995
|
+
grid-template-columns: 1fr;
|
|
996
|
+
gap: 8px;
|
|
997
|
+
padding: 12px 7px;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
.ree-col-left:not(.is-expanded) .ree-lib-item {
|
|
1001
|
+
justify-content: center;
|
|
1002
|
+
min-height: 38px;
|
|
1003
|
+
gap: 0;
|
|
1004
|
+
padding: 8px;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
.ree-col-left:not(.is-expanded) .ree-lib-title {
|
|
1008
|
+
display: none;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
815
1012
|
|
|
816
1013
|
/* ---------- 画布 ---------- */
|
|
817
1014
|
|
|
@@ -1093,38 +1290,6 @@
|
|
|
1093
1290
|
color: var(--ree-danger);
|
|
1094
1291
|
}
|
|
1095
1292
|
|
|
1096
|
-
.ree-block-tooltip {
|
|
1097
|
-
position: absolute;
|
|
1098
|
-
top: calc(100% + 8px);
|
|
1099
|
-
left: 0;
|
|
1100
|
-
z-index: 10;
|
|
1101
|
-
display: none;
|
|
1102
|
-
padding: 6px 9px;
|
|
1103
|
-
border-radius: var(--ree-radius-sm);
|
|
1104
|
-
background: var(--ree-text);
|
|
1105
|
-
color: var(--ree-panel);
|
|
1106
|
-
box-shadow: var(--ree-shadow-md);
|
|
1107
|
-
font-size: 12px;
|
|
1108
|
-
font-weight: 500;
|
|
1109
|
-
line-height: 1.4;
|
|
1110
|
-
white-space: nowrap;
|
|
1111
|
-
cursor: default;
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
.ree-block-tooltip::before {
|
|
1115
|
-
content: "";
|
|
1116
|
-
position: absolute;
|
|
1117
|
-
top: -8px;
|
|
1118
|
-
left: 0;
|
|
1119
|
-
width: 100%;
|
|
1120
|
-
height: 8px;
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
.ree-block-btn:hover .ree-block-tooltip,
|
|
1124
|
-
.ree-block-btn:focus-visible .ree-block-tooltip {
|
|
1125
|
-
display: block;
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
1293
|
.ree-block-content {
|
|
1129
1294
|
width: 100%;
|
|
1130
1295
|
overflow: hidden;
|
|
@@ -1650,13 +1815,13 @@
|
|
|
1650
1815
|
padding-bottom: var(--ree-inspector-section-space);
|
|
1651
1816
|
}
|
|
1652
1817
|
|
|
1653
|
-
/*
|
|
1654
|
-
.ree-inspector-
|
|
1818
|
+
/* Body 没有标题区,容器顶部留白会叠到第一个类目上。 */
|
|
1819
|
+
.ree-inspector-body {
|
|
1655
1820
|
padding-top: 0;
|
|
1656
1821
|
padding-bottom: 0;
|
|
1657
1822
|
}
|
|
1658
1823
|
|
|
1659
|
-
.ree-inspector-
|
|
1824
|
+
.ree-inspector-body > .ree-inspector-section:first-child {
|
|
1660
1825
|
border-top: 0;
|
|
1661
1826
|
}
|
|
1662
1827
|
|
|
@@ -1904,3 +2069,15 @@
|
|
|
1904
2069
|
cursor: grabbing;
|
|
1905
2070
|
font-weight: 500;
|
|
1906
2071
|
}
|
|
2072
|
+
|
|
2073
|
+
.ree-plugin-field { min-width: 0; }
|
|
2074
|
+
.ree-plugin-error { margin: 8px 0; padding: 8px; color: #b42318; font-size: 13px; overflow-wrap: anywhere; }
|
|
2075
|
+
.ree-plugin-empty { margin: 6px 0; color: #737373; font-size: 12px; }
|
|
2076
|
+
.ree-plugin-list { min-width: 0; margin: 0; padding: 10px; border: 1px solid #e3e3e5; border-radius: 6px; }
|
|
2077
|
+
.ree-plugin-list-item { display: grid; gap: 12px; padding: 12px 0; border-bottom: 1px solid #e3e3e5; }
|
|
2078
|
+
.ree-plugin-list-actions { display: flex; align-items: center; gap: 4px; }
|
|
2079
|
+
.ree-plugin-list-actions > span { margin-right: auto; }
|
|
2080
|
+
.ree-plugin-image { display: block; max-width: 100%; max-height: 120px; object-fit: contain; }
|
|
2081
|
+
.ree-plugin-richtext { min-height: 64px; padding: 8px; border: 1px solid #e3e3e5; border-radius: 4px; }
|
|
2082
|
+
.ree-plugin-richtext:focus-within { border-color: #000000; }
|
|
2083
|
+
.ree-plugin-richtext .ProseMirror { min-height: 48px; outline: none; }
|
|
@@ -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.
|