@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.
Files changed (39) hide show
  1. package/README.md +23 -0
  2. package/package.json +64 -0
  3. package/src/blocks/article-block.tsx +122 -0
  4. package/src/blocks/block-definitions.tsx +94 -0
  5. package/src/blocks/block-text-input.tsx +44 -0
  6. package/src/blocks/button-block.tsx +119 -0
  7. package/src/blocks/divider-block.tsx +19 -0
  8. package/src/blocks/email-card-shell.tsx +80 -0
  9. package/src/blocks/grid-block.tsx +95 -0
  10. package/src/blocks/heading-block.tsx +86 -0
  11. package/src/blocks/image-block.tsx +264 -0
  12. package/src/blocks/list-block.tsx +202 -0
  13. package/src/blocks/offer-block.tsx +267 -0
  14. package/src/blocks/paragraph-block.tsx +47 -0
  15. package/src/blocks/product-block.tsx +155 -0
  16. package/src/blocks/rating-block.tsx +121 -0
  17. package/src/blocks/rich-text-editable.tsx +88 -0
  18. package/src/blocks/stat-block.tsx +113 -0
  19. package/src/blocks/table-block.tsx +257 -0
  20. package/src/dnd/sortable-block-list.tsx +263 -0
  21. package/src/document/reducer.ts +345 -0
  22. package/src/document/rich-text.ts +168 -0
  23. package/src/document/types.ts +388 -0
  24. package/src/email-block-editor.tsx +163 -0
  25. package/src/lib/use-media-query.ts +38 -0
  26. package/src/sections/add-block-menu.tsx +56 -0
  27. package/src/sections/block-options/alignment-option.tsx +53 -0
  28. package/src/sections/block-options/block-option-row.tsx +79 -0
  29. package/src/sections/block-options/money-option.tsx +69 -0
  30. package/src/sections/block-options/segmented-option.tsx +62 -0
  31. package/src/sections/block-options/select-option.tsx +76 -0
  32. package/src/sections/block-options/text-option.tsx +91 -0
  33. package/src/sections/block-toolbar.tsx +379 -0
  34. package/src/sections/editor-canvas.tsx +403 -0
  35. package/src/sections/editor-sidebar.tsx +95 -0
  36. package/src/sections/link-popover.tsx +89 -0
  37. package/src/sections/preview-toggle.tsx +45 -0
  38. package/src/styles.css +10 -0
  39. 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
+ }