@voila.dev/ui-email-block-editor 1.1.9
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 +23 -0
- package/package.json +64 -0
- package/src/blocks/article-block.tsx +122 -0
- package/src/blocks/block-definitions.tsx +94 -0
- package/src/blocks/block-text-input.tsx +44 -0
- package/src/blocks/button-block.tsx +119 -0
- package/src/blocks/divider-block.tsx +19 -0
- package/src/blocks/email-card-shell.tsx +80 -0
- package/src/blocks/grid-block.tsx +95 -0
- package/src/blocks/heading-block.tsx +86 -0
- package/src/blocks/image-block.tsx +264 -0
- package/src/blocks/list-block.tsx +202 -0
- package/src/blocks/offer-block.tsx +267 -0
- package/src/blocks/paragraph-block.tsx +47 -0
- package/src/blocks/product-block.tsx +155 -0
- package/src/blocks/rating-block.tsx +121 -0
- package/src/blocks/rich-text-editable.tsx +88 -0
- package/src/blocks/stat-block.tsx +113 -0
- package/src/blocks/table-block.tsx +257 -0
- package/src/dnd/sortable-block-list.tsx +263 -0
- package/src/document/reducer.ts +345 -0
- package/src/document/rich-text.ts +168 -0
- package/src/document/types.ts +388 -0
- package/src/email-block-editor.tsx +163 -0
- package/src/lib/use-media-query.ts +38 -0
- package/src/sections/add-block-menu.tsx +56 -0
- package/src/sections/block-options/alignment-option.tsx +53 -0
- package/src/sections/block-options/block-option-row.tsx +79 -0
- package/src/sections/block-options/money-option.tsx +69 -0
- package/src/sections/block-options/segmented-option.tsx +62 -0
- package/src/sections/block-options/select-option.tsx +76 -0
- package/src/sections/block-options/text-option.tsx +91 -0
- package/src/sections/block-toolbar.tsx +379 -0
- package/src/sections/editor-canvas.tsx +403 -0
- package/src/sections/editor-sidebar.tsx +95 -0
- package/src/sections/link-popover.tsx +89 -0
- package/src/sections/preview-toggle.tsx +45 -0
- package/src/styles.css +10 -0
- package/src/theme.ts +26 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor document model, owned by this package. The domain's
|
|
3
|
+
* `MarketingEmailDocument` Effect Schema mirrors this shape exactly (a
|
|
4
|
+
* type-assertion test in `packages/domain` guards against drift), so a document
|
|
5
|
+
* produced here can be persisted and rendered server-side as-is.
|
|
6
|
+
*
|
|
7
|
+
* Text fields may carry `{{firstName}}` / `{{lastName}}` / `{{email}}`
|
|
8
|
+
* placeholders; substitution happens at render time, not in the editor.
|
|
9
|
+
*/
|
|
10
|
+
export const EMAIL_EDITOR_DOCUMENT_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Which rendering of the email the canvas is showing. Not part of the
|
|
14
|
+
* document — it is a view of it — but every block gets it, because what a
|
|
15
|
+
* reader sees depends on it (a grid's column count, the card's width).
|
|
16
|
+
*/
|
|
17
|
+
export type EmailEditorPreview = "desktop" | "mobile";
|
|
18
|
+
|
|
19
|
+
/** The card width each preview mirrors: the full 600px email card, or a
|
|
20
|
+
* common phone viewport. */
|
|
21
|
+
export const EMAIL_PREVIEW_WIDTH: {
|
|
22
|
+
readonly [P in EmailEditorPreview]: number;
|
|
23
|
+
} = { desktop: 600, mobile: 390 };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The shared option vocabulary. Declared once here and mirrored once in the
|
|
27
|
+
* domain schema; a new option reuses one of these rather than inventing a
|
|
28
|
+
* per-block string union (§3.2 of the editor plan).
|
|
29
|
+
*/
|
|
30
|
+
export type EmailEditorAlignment = "left" | "center" | "right";
|
|
31
|
+
export type EmailEditorButtonVariant = "primary" | "secondary";
|
|
32
|
+
export type EmailEditorHeadingLevel = 1 | 2;
|
|
33
|
+
export type EmailEditorImageWidth = "full" | "contained";
|
|
34
|
+
/** No embedded video survives an email client, so a video is a thumbnail with
|
|
35
|
+
* a play badge composited over it. */
|
|
36
|
+
export type EmailEditorImageOverlay = "none" | "play";
|
|
37
|
+
|
|
38
|
+
export interface EmailEditorHeadingBlock {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly type: "heading";
|
|
41
|
+
readonly text: string;
|
|
42
|
+
/** H1 for the email's title, H2 for a section — a long email needs the
|
|
43
|
+
* hierarchy, and every heading was the same size before. */
|
|
44
|
+
readonly level: EmailEditorHeadingLevel;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A run of paragraph text sharing the same inline formatting. Rich text is a
|
|
49
|
+
* flat list of these spans — no nesting, trivially safe to render (escape the
|
|
50
|
+
* text, wrap the marks) and to extend with a new mark.
|
|
51
|
+
*/
|
|
52
|
+
export interface EmailEditorTextSpan {
|
|
53
|
+
readonly text: string;
|
|
54
|
+
readonly bold?: boolean;
|
|
55
|
+
readonly italic?: boolean;
|
|
56
|
+
readonly underline?: boolean;
|
|
57
|
+
/** Present when the run is a link. */
|
|
58
|
+
readonly href?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface EmailEditorParagraphBlock {
|
|
62
|
+
readonly id: string;
|
|
63
|
+
readonly type: "paragraph";
|
|
64
|
+
readonly spans: ReadonlyArray<EmailEditorTextSpan>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface EmailEditorButtonBlock {
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly type: "button";
|
|
70
|
+
readonly label: string;
|
|
71
|
+
readonly href: string;
|
|
72
|
+
readonly align: EmailEditorAlignment;
|
|
73
|
+
/** `primary` is the filled brand button, `secondary` the outlined one. */
|
|
74
|
+
readonly variant: EmailEditorButtonVariant;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface EmailEditorImageBlock {
|
|
78
|
+
readonly id: string;
|
|
79
|
+
readonly type: "image";
|
|
80
|
+
readonly src: string;
|
|
81
|
+
readonly alt: string;
|
|
82
|
+
/** Wraps the image in a link when non-empty. */
|
|
83
|
+
readonly href: string;
|
|
84
|
+
readonly width: EmailEditorImageWidth;
|
|
85
|
+
readonly overlay: EmailEditorImageOverlay;
|
|
86
|
+
/** Rounded corners, on by default. Outlook's Word engine squares them off
|
|
87
|
+
* regardless — the toggle says so next to it. */
|
|
88
|
+
readonly rounded: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface EmailEditorDividerBlock {
|
|
92
|
+
readonly id: string;
|
|
93
|
+
readonly type: "divider";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type EmailEditorListMarker = "bullet" | "number" | "badge";
|
|
97
|
+
|
|
98
|
+
/** One list entry: an optional bold lead-in, then the same span model the
|
|
99
|
+
* paragraph uses — so bold/italic/underline/links work inside items for free. */
|
|
100
|
+
export interface EmailEditorListItem {
|
|
101
|
+
readonly title?: string;
|
|
102
|
+
readonly spans: ReadonlyArray<EmailEditorTextSpan>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface EmailEditorListBlock {
|
|
106
|
+
readonly id: string;
|
|
107
|
+
readonly type: "list";
|
|
108
|
+
readonly marker: EmailEditorListMarker;
|
|
109
|
+
readonly items: ReadonlyArray<EmailEditorListItem>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A single figure. One stat per block on purpose: a row of three is a
|
|
114
|
+
* three-column grid of stat blocks, not a block that invents its own layout
|
|
115
|
+
* (§1.5 of the editor plan).
|
|
116
|
+
*/
|
|
117
|
+
export interface EmailEditorStatBlock {
|
|
118
|
+
readonly id: string;
|
|
119
|
+
readonly type: "stat";
|
|
120
|
+
readonly value: string;
|
|
121
|
+
readonly label: string;
|
|
122
|
+
readonly description: string;
|
|
123
|
+
readonly align: EmailEditorAlignment;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type EmailEditorRatingStyle = "filled" | "outline";
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A one-to-five satisfaction question. Each step is its own link to `href`
|
|
130
|
+
* with `?rating=N` appended, so the five variants are five separately
|
|
131
|
+
* countable tracked links — the distribution falls out of the click stats.
|
|
132
|
+
*/
|
|
133
|
+
export interface EmailEditorRatingBlock {
|
|
134
|
+
readonly id: string;
|
|
135
|
+
readonly type: "rating";
|
|
136
|
+
readonly question: ReadonlyArray<EmailEditorTextSpan>;
|
|
137
|
+
readonly style: EmailEditorRatingStyle;
|
|
138
|
+
readonly lowLabel: string;
|
|
139
|
+
readonly highLabel: string;
|
|
140
|
+
readonly href: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface EmailEditorTableColumn {
|
|
144
|
+
readonly label: string;
|
|
145
|
+
readonly align: "left" | "right";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The currencies the platform transacts in, mirroring the domain's
|
|
149
|
+
* `SupportedCurrency`. */
|
|
150
|
+
export type EmailEditorCurrency = "EUR";
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A price, stored the way the platform stores money everywhere: an integer
|
|
154
|
+
* amount in the currency's minor unit plus its currency, never a
|
|
155
|
+
* pre-formatted string — one campaign can go out in several locales, so the
|
|
156
|
+
* formatting happens at render.
|
|
157
|
+
*/
|
|
158
|
+
export interface EmailEditorMoney {
|
|
159
|
+
readonly amountInMinorUnits: number;
|
|
160
|
+
readonly currency: EmailEditorCurrency;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The visual of a card block. An empty `src` means the card has none. */
|
|
164
|
+
export interface EmailEditorCardImage {
|
|
165
|
+
readonly src: string;
|
|
166
|
+
readonly alt: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A blog post or an external resource. The field names follow the real blog
|
|
171
|
+
* model (`your blog content model`), so composing a digest from published
|
|
172
|
+
* posts is a straight mapping. Empty strings stand for the absent optionals.
|
|
173
|
+
*/
|
|
174
|
+
export interface EmailEditorArticleBlock {
|
|
175
|
+
readonly id: string;
|
|
176
|
+
readonly type: "article";
|
|
177
|
+
readonly title: string;
|
|
178
|
+
readonly description: string;
|
|
179
|
+
readonly image: EmailEditorCardImage;
|
|
180
|
+
readonly author: string;
|
|
181
|
+
/** ISO date (`YYYY-MM-DD`), formatted for the recipient at render. */
|
|
182
|
+
readonly publishDate: string;
|
|
183
|
+
readonly href: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface EmailEditorProductBlock {
|
|
187
|
+
readonly id: string;
|
|
188
|
+
readonly type: "product";
|
|
189
|
+
readonly name: string;
|
|
190
|
+
readonly description: string;
|
|
191
|
+
readonly image: EmailEditorCardImage;
|
|
192
|
+
readonly price: EmailEditorMoney;
|
|
193
|
+
/** The struck-through « tarif de base », mirroring the shop catalogue's
|
|
194
|
+
* `publicPriceHtCents`. Null when the product is not discounted. */
|
|
195
|
+
readonly compareAtPrice: EmailEditorMoney | null;
|
|
196
|
+
readonly href: string;
|
|
197
|
+
readonly buttonLabel: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface EmailEditorOfferBlock {
|
|
201
|
+
readonly id: string;
|
|
202
|
+
readonly type: "offer";
|
|
203
|
+
readonly eyebrow: string;
|
|
204
|
+
readonly name: string;
|
|
205
|
+
readonly description: string;
|
|
206
|
+
readonly image: EmailEditorCardImage;
|
|
207
|
+
readonly price: EmailEditorMoney;
|
|
208
|
+
/** « par mois », « par an »; empty for a one-off price. */
|
|
209
|
+
readonly period: string;
|
|
210
|
+
readonly features: ReadonlyArray<string>;
|
|
211
|
+
readonly buttonLabel: string;
|
|
212
|
+
readonly buttonHref: string;
|
|
213
|
+
/** Draws the card in the brand colour — the recommended plan of a row. */
|
|
214
|
+
readonly highlighted: boolean;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface EmailEditorTableBlock {
|
|
218
|
+
readonly id: string;
|
|
219
|
+
readonly type: "table";
|
|
220
|
+
readonly columns: ReadonlyArray<EmailEditorTableColumn>;
|
|
221
|
+
/** Row-major plain-text cells; a short row renders blank trailing cells. */
|
|
222
|
+
readonly rows: ReadonlyArray<ReadonlyArray<string>>;
|
|
223
|
+
readonly headerRow: boolean;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Everything that can sit inside a grid cell — that is, every block but the
|
|
227
|
+
* grid itself. */
|
|
228
|
+
export type EmailEditorLeafBlock =
|
|
229
|
+
| EmailEditorHeadingBlock
|
|
230
|
+
| EmailEditorParagraphBlock
|
|
231
|
+
| EmailEditorButtonBlock
|
|
232
|
+
| EmailEditorImageBlock
|
|
233
|
+
| EmailEditorDividerBlock
|
|
234
|
+
| EmailEditorListBlock
|
|
235
|
+
| EmailEditorStatBlock
|
|
236
|
+
| EmailEditorTableBlock
|
|
237
|
+
| EmailEditorArticleBlock
|
|
238
|
+
| EmailEditorProductBlock
|
|
239
|
+
| EmailEditorOfferBlock
|
|
240
|
+
| EmailEditorRatingBlock;
|
|
241
|
+
|
|
242
|
+
export type EmailEditorGridColumns = 1 | 2 | 3 | 4;
|
|
243
|
+
export type EmailEditorGridMobileColumns = 1 | 2;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A multi-column row. `children` is a flat list that *flows* into the columns
|
|
247
|
+
* rather than explicit per-column buckets — that covers every layout we need
|
|
248
|
+
* (a gallery, two article cards, three offers) at a fraction of the
|
|
249
|
+
* complexity, and an asymmetric layout is simply two sibling blocks.
|
|
250
|
+
*
|
|
251
|
+
* A grid cannot contain a grid: the type forbids it, which is what keeps the
|
|
252
|
+
* reducer, the drag-and-drop layer and the renderer tractable.
|
|
253
|
+
*/
|
|
254
|
+
export interface EmailEditorGridBlock {
|
|
255
|
+
readonly id: string;
|
|
256
|
+
readonly type: "grid";
|
|
257
|
+
readonly desktopColumns: EmailEditorGridColumns;
|
|
258
|
+
/** Defaults to 1 so the layout is readable in the clients that ignore the
|
|
259
|
+
* media query pinning this count (see the renderer). */
|
|
260
|
+
readonly mobileColumns: EmailEditorGridMobileColumns;
|
|
261
|
+
readonly children: ReadonlyArray<EmailEditorLeafBlock>;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export type EmailEditorBlock = EmailEditorLeafBlock | EmailEditorGridBlock;
|
|
265
|
+
|
|
266
|
+
export type EmailEditorBlockType = EmailEditorBlock["type"];
|
|
267
|
+
export type EmailEditorLeafBlockType = EmailEditorLeafBlock["type"];
|
|
268
|
+
|
|
269
|
+
export const isEmailEditorGridBlock = (
|
|
270
|
+
block: EmailEditorBlock,
|
|
271
|
+
): block is EmailEditorGridBlock => block.type === "grid";
|
|
272
|
+
|
|
273
|
+
export interface EmailEditorDocument {
|
|
274
|
+
readonly version: typeof EMAIL_EDITOR_DOCUMENT_VERSION;
|
|
275
|
+
readonly blocks: ReadonlyArray<EmailEditorBlock>;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const emptyEmailEditorDocument = (): EmailEditorDocument => ({
|
|
279
|
+
version: EMAIL_EDITOR_DOCUMENT_VERSION,
|
|
280
|
+
blocks: [],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
/** A freshly added block of the given type, with empty content fields. */
|
|
284
|
+
export const createEmailEditorBlock = (
|
|
285
|
+
type: EmailEditorBlockType,
|
|
286
|
+
id: string,
|
|
287
|
+
): EmailEditorBlock => {
|
|
288
|
+
switch (type) {
|
|
289
|
+
case "heading":
|
|
290
|
+
return { id, type, text: "", level: 1 };
|
|
291
|
+
case "paragraph":
|
|
292
|
+
return { id, type, spans: [] };
|
|
293
|
+
case "button":
|
|
294
|
+
return {
|
|
295
|
+
id,
|
|
296
|
+
type,
|
|
297
|
+
label: "",
|
|
298
|
+
href: "",
|
|
299
|
+
align: "center",
|
|
300
|
+
variant: "primary",
|
|
301
|
+
};
|
|
302
|
+
case "image":
|
|
303
|
+
return {
|
|
304
|
+
id,
|
|
305
|
+
type,
|
|
306
|
+
src: "",
|
|
307
|
+
alt: "",
|
|
308
|
+
href: "",
|
|
309
|
+
width: "full",
|
|
310
|
+
overlay: "none",
|
|
311
|
+
rounded: true,
|
|
312
|
+
};
|
|
313
|
+
case "divider":
|
|
314
|
+
return { id, type };
|
|
315
|
+
case "list":
|
|
316
|
+
return { id, type, marker: "bullet", items: [{ spans: [] }] };
|
|
317
|
+
case "stat":
|
|
318
|
+
return {
|
|
319
|
+
id,
|
|
320
|
+
type,
|
|
321
|
+
value: "",
|
|
322
|
+
label: "",
|
|
323
|
+
description: "",
|
|
324
|
+
align: "center",
|
|
325
|
+
};
|
|
326
|
+
case "table":
|
|
327
|
+
return {
|
|
328
|
+
id,
|
|
329
|
+
type,
|
|
330
|
+
columns: [
|
|
331
|
+
{ label: "", align: "left" },
|
|
332
|
+
{ label: "", align: "right" },
|
|
333
|
+
],
|
|
334
|
+
rows: [["", ""]],
|
|
335
|
+
headerRow: true,
|
|
336
|
+
};
|
|
337
|
+
case "article":
|
|
338
|
+
return {
|
|
339
|
+
id,
|
|
340
|
+
type,
|
|
341
|
+
title: "",
|
|
342
|
+
description: "",
|
|
343
|
+
image: { src: "", alt: "" },
|
|
344
|
+
author: "",
|
|
345
|
+
publishDate: "",
|
|
346
|
+
href: "",
|
|
347
|
+
};
|
|
348
|
+
case "product":
|
|
349
|
+
return {
|
|
350
|
+
id,
|
|
351
|
+
type,
|
|
352
|
+
name: "",
|
|
353
|
+
description: "",
|
|
354
|
+
image: { src: "", alt: "" },
|
|
355
|
+
price: { amountInMinorUnits: 0, currency: "EUR" },
|
|
356
|
+
compareAtPrice: null,
|
|
357
|
+
href: "",
|
|
358
|
+
buttonLabel: "",
|
|
359
|
+
};
|
|
360
|
+
case "offer":
|
|
361
|
+
return {
|
|
362
|
+
id,
|
|
363
|
+
type,
|
|
364
|
+
eyebrow: "",
|
|
365
|
+
name: "",
|
|
366
|
+
description: "",
|
|
367
|
+
image: { src: "", alt: "" },
|
|
368
|
+
price: { amountInMinorUnits: 0, currency: "EUR" },
|
|
369
|
+
period: "",
|
|
370
|
+
features: [],
|
|
371
|
+
buttonLabel: "",
|
|
372
|
+
buttonHref: "",
|
|
373
|
+
highlighted: false,
|
|
374
|
+
};
|
|
375
|
+
case "rating":
|
|
376
|
+
return {
|
|
377
|
+
id,
|
|
378
|
+
type,
|
|
379
|
+
question: [],
|
|
380
|
+
style: "filled",
|
|
381
|
+
lowLabel: "",
|
|
382
|
+
highLabel: "",
|
|
383
|
+
href: "",
|
|
384
|
+
};
|
|
385
|
+
case "grid":
|
|
386
|
+
return { id, type, desktopColumns: 2, mobileColumns: 1, children: [] };
|
|
387
|
+
}
|
|
388
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Drawer,
|
|
3
|
+
DrawerContent,
|
|
4
|
+
DrawerDescription,
|
|
5
|
+
DrawerHeader,
|
|
6
|
+
DrawerTitle,
|
|
7
|
+
} from "@voila.dev/ui/components/drawer";
|
|
8
|
+
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
|
9
|
+
import {
|
|
10
|
+
createEmailEditorReducer,
|
|
11
|
+
type EmailEditorAction,
|
|
12
|
+
type EmailEditorState,
|
|
13
|
+
} from "#/document/reducer.ts";
|
|
14
|
+
import type {
|
|
15
|
+
EmailEditorDocument,
|
|
16
|
+
EmailEditorPreview,
|
|
17
|
+
} from "#/document/types.ts";
|
|
18
|
+
import { useCompactEditorLayout } from "#/lib/use-media-query.ts";
|
|
19
|
+
import { EditorCanvas } from "#/sections/editor-canvas.tsx";
|
|
20
|
+
import {
|
|
21
|
+
BlockSettingsPanel,
|
|
22
|
+
EditorSidebar,
|
|
23
|
+
} from "#/sections/editor-sidebar.tsx";
|
|
24
|
+
import { PreviewToggle } from "#/sections/preview-toggle.tsx";
|
|
25
|
+
|
|
26
|
+
/** « Réglages du bloc » as a bottom sheet, for the viewports where a 280px
|
|
27
|
+
* column would put the options a screenful away from their block. */
|
|
28
|
+
function BlockSettingsSheet({
|
|
29
|
+
open,
|
|
30
|
+
onOpenChange,
|
|
31
|
+
state,
|
|
32
|
+
dispatch,
|
|
33
|
+
onUploadImage,
|
|
34
|
+
}: {
|
|
35
|
+
open: boolean;
|
|
36
|
+
onOpenChange: (open: boolean) => void;
|
|
37
|
+
state: EmailEditorState;
|
|
38
|
+
dispatch: (action: EmailEditorAction) => void;
|
|
39
|
+
onUploadImage?: (file: File) => Promise<string>;
|
|
40
|
+
}) {
|
|
41
|
+
return (
|
|
42
|
+
<Drawer open={open} onOpenChange={onOpenChange}>
|
|
43
|
+
<DrawerContent>
|
|
44
|
+
<DrawerHeader>
|
|
45
|
+
<DrawerTitle>Réglages du bloc</DrawerTitle>
|
|
46
|
+
<DrawerDescription className="sr-only">
|
|
47
|
+
Options du bloc sélectionné.
|
|
48
|
+
</DrawerDescription>
|
|
49
|
+
</DrawerHeader>
|
|
50
|
+
<div className="flex flex-col gap-4 overflow-y-auto px-4 pb-8">
|
|
51
|
+
<BlockSettingsPanel
|
|
52
|
+
state={state}
|
|
53
|
+
dispatch={dispatch}
|
|
54
|
+
onUploadImage={onUploadImage}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
</DrawerContent>
|
|
58
|
+
</Drawer>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The composed WYSIWYG email editor. On a wide viewport it is the canvas plus
|
|
64
|
+
* a per-block settings column; below `lg` the column would push the options a
|
|
65
|
+
* screenful away from the block they configure, so the settings move into a
|
|
66
|
+
* bottom sheet opened from the selected block's toolbar.
|
|
67
|
+
*
|
|
68
|
+
* The document is controlled (`document`/`onChange`); the block selection is
|
|
69
|
+
* internal UI state. Every building part (blocks, sections, dnd list, reducer)
|
|
70
|
+
* is also exported individually for custom compositions.
|
|
71
|
+
*/
|
|
72
|
+
// fallow-ignore-next-line complexity -- composition, not logic: the compact/wide split picks a layout and the rest is prop wiring.
|
|
73
|
+
export function EmailBlockEditor({
|
|
74
|
+
document,
|
|
75
|
+
onChange,
|
|
76
|
+
onUploadImage,
|
|
77
|
+
generateBlockId = () => crypto.randomUUID(),
|
|
78
|
+
headerSlot,
|
|
79
|
+
footerSlot,
|
|
80
|
+
}: {
|
|
81
|
+
document: EmailEditorDocument;
|
|
82
|
+
onChange: (document: EmailEditorDocument) => void;
|
|
83
|
+
/** Delegated image upload: receives the picked file, resolves with its
|
|
84
|
+
* public URL. Omit to disable image uploads. */
|
|
85
|
+
onUploadImage?: (file: File) => Promise<string>;
|
|
86
|
+
/** Block-id factory, injectable for deterministic tests. */
|
|
87
|
+
generateBlockId?: () => string;
|
|
88
|
+
headerSlot?: ReactNode;
|
|
89
|
+
footerSlot?: ReactNode;
|
|
90
|
+
}) {
|
|
91
|
+
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
|
92
|
+
const [settingsSheetOpen, setSettingsSheetOpen] = useState(false);
|
|
93
|
+
const compact = useCompactEditorLayout();
|
|
94
|
+
// Editing on a phone starts on the phone rendering; the author can still
|
|
95
|
+
// switch to the desktop one to check a multi-column row.
|
|
96
|
+
const [preview, setPreview] = useState<EmailEditorPreview>(
|
|
97
|
+
compact ? "mobile" : "desktop",
|
|
98
|
+
);
|
|
99
|
+
const reduce = useMemo(
|
|
100
|
+
() => createEmailEditorReducer(generateBlockId),
|
|
101
|
+
[generateBlockId],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const dispatch = useCallback(
|
|
105
|
+
(action: EmailEditorAction) => {
|
|
106
|
+
const state = reduce({ document, selectedBlockId }, action);
|
|
107
|
+
if (state.document !== document) {
|
|
108
|
+
onChange(state.document);
|
|
109
|
+
}
|
|
110
|
+
setSelectedBlockId(state.selectedBlockId);
|
|
111
|
+
},
|
|
112
|
+
[reduce, document, selectedBlockId, onChange],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const state = { document, selectedBlockId };
|
|
116
|
+
// Only the sheet layout needs an opener; the sidebar layout is always there.
|
|
117
|
+
const openSettingsSheet = compact
|
|
118
|
+
? () => setSettingsSheetOpen(true)
|
|
119
|
+
: undefined;
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<>
|
|
123
|
+
<div
|
|
124
|
+
className={
|
|
125
|
+
compact
|
|
126
|
+
? "grid grid-cols-1 items-start gap-6"
|
|
127
|
+
: "grid grid-cols-[minmax(0,1fr)_280px] items-start gap-6"
|
|
128
|
+
}
|
|
129
|
+
>
|
|
130
|
+
<div className="flex flex-col gap-3">
|
|
131
|
+
<div className="flex items-center justify-end">
|
|
132
|
+
<PreviewToggle value={preview} onChange={setPreview} />
|
|
133
|
+
</div>
|
|
134
|
+
<EditorCanvas
|
|
135
|
+
state={state}
|
|
136
|
+
dispatch={dispatch}
|
|
137
|
+
preview={preview}
|
|
138
|
+
onUploadImage={onUploadImage}
|
|
139
|
+
onOpenSettings={openSettingsSheet}
|
|
140
|
+
headerSlot={headerSlot}
|
|
141
|
+
footerSlot={footerSlot}
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
{compact ? null : (
|
|
145
|
+
<EditorSidebar
|
|
146
|
+
state={state}
|
|
147
|
+
dispatch={dispatch}
|
|
148
|
+
onUploadImage={onUploadImage}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
{compact ? (
|
|
153
|
+
<BlockSettingsSheet
|
|
154
|
+
open={settingsSheetOpen}
|
|
155
|
+
onOpenChange={setSettingsSheetOpen}
|
|
156
|
+
state={state}
|
|
157
|
+
dispatch={dispatch}
|
|
158
|
+
onUploadImage={onUploadImage}
|
|
159
|
+
/>
|
|
160
|
+
) : null}
|
|
161
|
+
</>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Subscribe to a CSS media query. The initial value is read synchronously so
|
|
5
|
+
* the first paint already uses the right layout (no desktop-then-mobile flash);
|
|
6
|
+
* it falls back to `false` where `window` is absent.
|
|
7
|
+
*/
|
|
8
|
+
const useMediaQuery = (query: string): boolean => {
|
|
9
|
+
const [matches, setMatches] = useState(() =>
|
|
10
|
+
typeof window === "undefined" ? false : window.matchMedia(query).matches,
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const list = window.matchMedia(query);
|
|
15
|
+
const update = () => setMatches(list.matches);
|
|
16
|
+
update();
|
|
17
|
+
list.addEventListener("change", update);
|
|
18
|
+
return () => list.removeEventListener("change", update);
|
|
19
|
+
}, [query]);
|
|
20
|
+
|
|
21
|
+
return matches;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Touch-driven pointers. Drives the 44px tap-target floor on the block
|
|
26
|
+
* toolbar, and moves that toolbar into the flow above its block instead of
|
|
27
|
+
* floating it (a 44px bar has nowhere to float on a 390px viewport).
|
|
28
|
+
*/
|
|
29
|
+
export const useCoarsePointer = (): boolean =>
|
|
30
|
+
useMediaQuery("(pointer: coarse)");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Below Tailwind's `lg`, the 280px settings column no longer fits beside the
|
|
34
|
+
* canvas, so « Réglages du bloc » moves into a bottom sheet reachable from the
|
|
35
|
+
* selected block's toolbar.
|
|
36
|
+
*/
|
|
37
|
+
export const useCompactEditorLayout = (): boolean =>
|
|
38
|
+
useMediaQuery("(max-width: 1023px)");
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { PlusIcon } from "@phosphor-icons/react";
|
|
2
|
+
import { Button } from "@voila.dev/ui/components/button";
|
|
3
|
+
import {
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuItem,
|
|
7
|
+
DropdownMenuTrigger,
|
|
8
|
+
} from "@voila.dev/ui/components/dropdown-menu";
|
|
9
|
+
import type { ReactElement } from "react";
|
|
10
|
+
import {
|
|
11
|
+
EMAIL_BLOCK_DEFINITIONS,
|
|
12
|
+
EMAIL_BLOCK_TYPES,
|
|
13
|
+
} from "#/blocks/block-definitions.tsx";
|
|
14
|
+
import type { EmailEditorBlockType } from "#/document/types.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The « Ajouter un bloc » menu, fed by the block registry. The default trigger
|
|
18
|
+
* is the outlined empty-state button; the block toolbar passes its own icon
|
|
19
|
+
* trigger instead.
|
|
20
|
+
*/
|
|
21
|
+
export function AddBlockMenu({
|
|
22
|
+
onAdd,
|
|
23
|
+
trigger,
|
|
24
|
+
types = EMAIL_BLOCK_TYPES,
|
|
25
|
+
}: {
|
|
26
|
+
onAdd: (type: EmailEditorBlockType) => void;
|
|
27
|
+
trigger?: ReactElement;
|
|
28
|
+
/** The offerable types; a grid cell passes the leaf types only. */
|
|
29
|
+
types?: ReadonlyArray<EmailEditorBlockType>;
|
|
30
|
+
}) {
|
|
31
|
+
return (
|
|
32
|
+
<DropdownMenu>
|
|
33
|
+
<DropdownMenuTrigger
|
|
34
|
+
render={
|
|
35
|
+
trigger ?? (
|
|
36
|
+
<Button variant="outline" size="sm">
|
|
37
|
+
<PlusIcon aria-hidden />
|
|
38
|
+
Ajouter un bloc
|
|
39
|
+
</Button>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
/>
|
|
43
|
+
<DropdownMenuContent align="start">
|
|
44
|
+
{types.map((type) => {
|
|
45
|
+
const definition = EMAIL_BLOCK_DEFINITIONS[type];
|
|
46
|
+
return (
|
|
47
|
+
<DropdownMenuItem key={type} onClick={() => onAdd(type)}>
|
|
48
|
+
<definition.icon aria-hidden />
|
|
49
|
+
{definition.label}
|
|
50
|
+
</DropdownMenuItem>
|
|
51
|
+
);
|
|
52
|
+
})}
|
|
53
|
+
</DropdownMenuContent>
|
|
54
|
+
</DropdownMenu>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TextAlignCenterIcon,
|
|
3
|
+
TextAlignLeftIcon,
|
|
4
|
+
TextAlignRightIcon,
|
|
5
|
+
} from "@phosphor-icons/react";
|
|
6
|
+
import type { EmailEditorAlignment } from "#/document/types.ts";
|
|
7
|
+
import { SegmentedOption } from "#/sections/block-options/segmented-option.tsx";
|
|
8
|
+
|
|
9
|
+
const ALIGNMENTS: ReadonlyArray<{
|
|
10
|
+
readonly value: EmailEditorAlignment;
|
|
11
|
+
readonly label: string;
|
|
12
|
+
readonly icon: React.ReactNode;
|
|
13
|
+
}> = [
|
|
14
|
+
{
|
|
15
|
+
value: "left",
|
|
16
|
+
label: "Aligner à gauche",
|
|
17
|
+
icon: <TextAlignLeftIcon aria-hidden />,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
value: "center",
|
|
21
|
+
label: "Centrer",
|
|
22
|
+
icon: <TextAlignCenterIcon aria-hidden />,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
value: "right",
|
|
26
|
+
label: "Aligner à droite",
|
|
27
|
+
icon: <TextAlignRightIcon aria-hidden />,
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The one alignment control. Every block that can be aligned uses this exact
|
|
33
|
+
* segmented control, so « Alignement » means the same thing and looks the same
|
|
34
|
+
* everywhere (§1.2 of the editor plan).
|
|
35
|
+
*/
|
|
36
|
+
export function AlignmentOption({
|
|
37
|
+
label = "Alignement",
|
|
38
|
+
value,
|
|
39
|
+
onChange,
|
|
40
|
+
}: {
|
|
41
|
+
label?: string;
|
|
42
|
+
value: EmailEditorAlignment;
|
|
43
|
+
onChange: (alignment: EmailEditorAlignment) => void;
|
|
44
|
+
}) {
|
|
45
|
+
return (
|
|
46
|
+
<SegmentedOption
|
|
47
|
+
label={label}
|
|
48
|
+
value={value}
|
|
49
|
+
options={ALIGNMENTS}
|
|
50
|
+
onChange={onChange}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
}
|