@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/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-input {
646
+ .ree-subject-field {
647
+ position: relative;
538
648
  flex: 1 1 360px;
539
- min-width: 240px;
540
- width: auto;
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
- flex-shrink: 0;
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
- /* Global 没有标题区,容器顶部留白会叠到第一个类目上。 */
1654
- .ree-inspector-global {
1818
+ /* Body 没有标题区,容器顶部留白会叠到第一个类目上。 */
1819
+ .ree-inspector-body {
1655
1820
  padding-top: 0;
1656
1821
  padding-bottom: 0;
1657
1822
  }
1658
1823
 
1659
- .ree-inspector-global > .ree-inspector-section:first-child {
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.