@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,379 @@
1
+ import {
2
+ ColumnsIcon,
3
+ CopyIcon,
4
+ DotsSixVerticalIcon,
5
+ LinkIcon,
6
+ PlusIcon,
7
+ SlidersHorizontalIcon,
8
+ TextBIcon,
9
+ TextItalicIcon,
10
+ TextUnderlineIcon,
11
+ TrashIcon,
12
+ } from "@phosphor-icons/react";
13
+ import { Button } from "@voila.dev/ui/components/button";
14
+ import { cn } from "@voila.dev/ui/lib/utils";
15
+ import { useEffect, useRef, useState } from "react";
16
+ import type { SortableBlockHandle } from "#/dnd/sortable-block-list.tsx";
17
+ import type { EmailEditorBlockType } from "#/document/types.ts";
18
+ import { AddBlockMenu } from "#/sections/add-block-menu.tsx";
19
+ import { LinkPopover } from "#/sections/link-popover.tsx";
20
+
21
+ /**
22
+ * Run a document.execCommand-based inline formatting command against the
23
+ * current text selection, then poke the surrounding contentEditable's `input`
24
+ * handler so the paragraph block re-reads the DOM into the span model. The
25
+ * toolbar buttons prevent mousedown default so the text selection (and focus)
26
+ * survive the click.
27
+ */
28
+ const applyInlineFormat = (command: string, value?: string) => {
29
+ document.execCommand("styleWithCSS", false, "false");
30
+ document.execCommand(command, false, value);
31
+ const node = window.getSelection()?.anchorNode;
32
+ const element = node instanceof Element ? node : node?.parentElement;
33
+ element
34
+ ?.closest("[contenteditable]")
35
+ ?.dispatchEvent(new Event("input", { bubbles: true }));
36
+ };
37
+
38
+ const keepSelection = (event: { preventDefault: () => void }) =>
39
+ event.preventDefault();
40
+
41
+ /** The <a> the caret or selection currently sits in, if any. */
42
+ // fallow-ignore-next-line complexity -- a few null guards on the live selection; cognitive complexity is 3.
43
+ const selectedAnchorElement = (): HTMLAnchorElement | null => {
44
+ const selection = window.getSelection();
45
+ const node = selection?.anchorNode;
46
+ if (!node) {
47
+ return null;
48
+ }
49
+ const element = node instanceof Element ? node : node.parentElement;
50
+ return element?.closest("a") ?? null;
51
+ };
52
+
53
+ const INLINE_MARKS = ["bold", "italic", "underline"] as const;
54
+
55
+ /** Track which marks are active at the caret, following the live selection. */
56
+ const useActiveInlineMarks = (): ReadonlySet<string> => {
57
+ const [active, setActive] = useState<ReadonlySet<string>>(new Set());
58
+ useEffect(() => {
59
+ const readSelection = () => {
60
+ const next = new Set<string>();
61
+ for (const command of INLINE_MARKS) {
62
+ if (document.queryCommandState(command)) {
63
+ next.add(command);
64
+ }
65
+ }
66
+ if (selectedAnchorElement() !== null) {
67
+ next.add("link");
68
+ }
69
+ setActive(next);
70
+ };
71
+ document.addEventListener("selectionchange", readSelection);
72
+ readSelection();
73
+ return () => document.removeEventListener("selectionchange", readSelection);
74
+ }, []);
75
+ return active;
76
+ };
77
+
78
+ /**
79
+ * Tap targets reach the 44px floor on touch pointers and stay compact under a
80
+ * mouse. `size-11` overrides the size variant's `size-*` through
81
+ * tailwind-merge, so the two never fight.
82
+ */
83
+ const toolbarButtonClassName = (coarsePointer: boolean, active = false) =>
84
+ cn(
85
+ "shrink-0",
86
+ coarsePointer && "size-11",
87
+ active && "bg-accent text-accent-foreground",
88
+ );
89
+
90
+ function ToolbarIconButton({
91
+ label,
92
+ active = false,
93
+ coarsePointer,
94
+ onClick,
95
+ onMouseDown,
96
+ children,
97
+ }: {
98
+ label: string;
99
+ active?: boolean;
100
+ coarsePointer: boolean;
101
+ onClick?: () => void;
102
+ onMouseDown?: (event: { preventDefault: () => void }) => void;
103
+ children: React.ReactNode;
104
+ }) {
105
+ return (
106
+ <Button
107
+ variant="ghost"
108
+ size={coarsePointer ? "icon" : "icon-sm"}
109
+ aria-label={label}
110
+ aria-pressed={active || undefined}
111
+ className={toolbarButtonClassName(coarsePointer, active)}
112
+ onMouseDown={onMouseDown}
113
+ onClick={onClick}
114
+ >
115
+ {children}
116
+ </Button>
117
+ );
118
+ }
119
+
120
+ /** Where the block sits in the document: add a sibling below, drag it, and —
121
+ * for a block in a grid cell — reach the row it belongs to. */
122
+ function StructureControls({
123
+ handle,
124
+ coarsePointer,
125
+ addableTypes,
126
+ onAddBelow,
127
+ onSelectContainer,
128
+ }: {
129
+ handle: SortableBlockHandle;
130
+ coarsePointer: boolean;
131
+ addableTypes?: ReadonlyArray<EmailEditorBlockType>;
132
+ onAddBelow: (type: EmailEditorBlockType) => void;
133
+ onSelectContainer?: () => void;
134
+ }) {
135
+ return (
136
+ <>
137
+ <AddBlockMenu
138
+ onAdd={onAddBelow}
139
+ types={addableTypes}
140
+ trigger={
141
+ <Button
142
+ variant="ghost"
143
+ size={coarsePointer ? "icon" : "icon-sm"}
144
+ aria-label="Ajouter un bloc"
145
+ className={toolbarButtonClassName(coarsePointer)}
146
+ >
147
+ <PlusIcon aria-hidden />
148
+ </Button>
149
+ }
150
+ />
151
+ <Button
152
+ variant="ghost"
153
+ size={coarsePointer ? "icon" : "icon-sm"}
154
+ aria-label="Déplacer le bloc"
155
+ className={cn(
156
+ toolbarButtonClassName(coarsePointer),
157
+ "cursor-grab touch-none active:cursor-grabbing",
158
+ )}
159
+ ref={handle.setActivatorNodeRef}
160
+ {...handle.attributes}
161
+ {...handle.listeners}
162
+ >
163
+ <DotsSixVerticalIcon aria-hidden />
164
+ </Button>
165
+ {onSelectContainer ? (
166
+ <ToolbarIconButton
167
+ label="Sélectionner la ligne de colonnes"
168
+ coarsePointer={coarsePointer}
169
+ onClick={onSelectContainer}
170
+ >
171
+ <ColumnsIcon aria-hidden />
172
+ </ToolbarIconButton>
173
+ ) : null}
174
+ </>
175
+ );
176
+ }
177
+
178
+ function ToolbarSeparator() {
179
+ return <div className="mx-0.5 h-5 w-px shrink-0 bg-border" />;
180
+ }
181
+
182
+ function RichTextControls({
183
+ active,
184
+ coarsePointer,
185
+ }: {
186
+ active: ReadonlySet<string>;
187
+ coarsePointer: boolean;
188
+ }) {
189
+ return (
190
+ <>
191
+ <ToolbarSeparator />
192
+ <ToolbarIconButton
193
+ label="Gras"
194
+ active={active.has("bold")}
195
+ coarsePointer={coarsePointer}
196
+ onMouseDown={keepSelection}
197
+ onClick={() => applyInlineFormat("bold")}
198
+ >
199
+ <TextBIcon aria-hidden />
200
+ </ToolbarIconButton>
201
+ <ToolbarIconButton
202
+ label="Italique"
203
+ active={active.has("italic")}
204
+ coarsePointer={coarsePointer}
205
+ onMouseDown={keepSelection}
206
+ onClick={() => applyInlineFormat("italic")}
207
+ >
208
+ <TextItalicIcon aria-hidden />
209
+ </ToolbarIconButton>
210
+ <ToolbarIconButton
211
+ label="Souligné"
212
+ active={active.has("underline")}
213
+ coarsePointer={coarsePointer}
214
+ onMouseDown={keepSelection}
215
+ onClick={() => applyInlineFormat("underline")}
216
+ >
217
+ <TextUnderlineIcon aria-hidden />
218
+ </ToolbarIconButton>
219
+ <SelectionLinkButton
220
+ active={active.has("link")}
221
+ coarsePointer={coarsePointer}
222
+ />
223
+ </>
224
+ );
225
+ }
226
+
227
+ /** Link editing on the current text selection. Opening on a caret inside an
228
+ * existing link edits that whole link (URL prefilled); the selection is saved
229
+ * while the popover holds focus and restored before the command applies. */
230
+ function SelectionLinkButton({
231
+ active,
232
+ coarsePointer,
233
+ }: {
234
+ active: boolean;
235
+ coarsePointer: boolean;
236
+ }) {
237
+ const savedRangeRef = useRef<Range | null>(null);
238
+
239
+ const restoreSelection = (): boolean => {
240
+ const range = savedRangeRef.current;
241
+ if (!range) {
242
+ return false;
243
+ }
244
+ const selection = window.getSelection();
245
+ selection?.removeAllRanges();
246
+ selection?.addRange(range);
247
+ return true;
248
+ };
249
+
250
+ return (
251
+ <LinkPopover
252
+ trigger={
253
+ <Button
254
+ variant="ghost"
255
+ size={coarsePointer ? "icon" : "icon-sm"}
256
+ aria-label="Insérer un lien"
257
+ aria-pressed={active || undefined}
258
+ className={toolbarButtonClassName(coarsePointer, active)}
259
+ onMouseDown={keepSelection}
260
+ >
261
+ <LinkIcon aria-hidden />
262
+ </Button>
263
+ }
264
+ initialHref={() => selectedAnchorElement()?.getAttribute("href") ?? ""}
265
+ // fallow-ignore-next-line complexity -- selection bookkeeping guards; cognitive complexity is 4.
266
+ onOpen={() => {
267
+ // A caret inside a link edits the whole link, not an empty range.
268
+ const anchor = selectedAnchorElement();
269
+ const selection = window.getSelection();
270
+ if (anchor && selection?.isCollapsed) {
271
+ const range = document.createRange();
272
+ range.selectNodeContents(anchor);
273
+ selection.removeAllRanges();
274
+ selection.addRange(range);
275
+ }
276
+ savedRangeRef.current =
277
+ selection && selection.rangeCount > 0
278
+ ? selection.getRangeAt(0).cloneRange()
279
+ : null;
280
+ }}
281
+ onApply={(href) => {
282
+ if (restoreSelection()) {
283
+ applyInlineFormat("createLink", href);
284
+ }
285
+ }}
286
+ onRemove={() => {
287
+ if (restoreSelection()) {
288
+ applyInlineFormat("unlink");
289
+ }
290
+ }}
291
+ />
292
+ );
293
+ }
294
+
295
+ /**
296
+ * The controls of the selected block: add-below, drag handle, inline
297
+ * formatting when the block holds rich text, duplicate, delete, plus a
298
+ * « Réglages » button when the settings live in a bottom sheet rather than in
299
+ * the sidebar. App-chrome styling on purpose — the toolbar is editor UI, not
300
+ * part of the email. Only ever rendered for the selected block, so a single
301
+ * toolbar is visible at a time.
302
+ *
303
+ * At 44px per target the full set is wider than a 390px viewport, so the row
304
+ * wraps under a touch pointer rather than scrolling: it sits in the flow there
305
+ * (see the canvas), so a second line costs nothing, while a scrolling row
306
+ * would hide « Supprimer » behind an edge with no affordance.
307
+ */
308
+ export function BlockToolbar({
309
+ handle,
310
+ richText,
311
+ coarsePointer,
312
+ addableTypes,
313
+ onAddBelow,
314
+ onDuplicate,
315
+ onRemove,
316
+ onOpenSettings,
317
+ onSelectContainer,
318
+ }: {
319
+ handle: SortableBlockHandle;
320
+ /** Show the bold/italic/underline/link group (paragraph blocks). */
321
+ richText: boolean;
322
+ /** Touch pointer: grow every target to 44px. */
323
+ coarsePointer: boolean;
324
+ /** Restricts the add-below menu; a grid cell offers the leaf types only. */
325
+ addableTypes?: ReadonlyArray<EmailEditorBlockType>;
326
+ onAddBelow: (type: EmailEditorBlockType) => void;
327
+ onDuplicate: () => void;
328
+ onRemove: () => void;
329
+ /** Present when the settings are in a sheet; opens it for this block. */
330
+ onOpenSettings?: () => void;
331
+ /** Present for a block inside a grid: selects the grid, which is otherwise
332
+ * only reachable through the thin band around its cells. */
333
+ onSelectContainer?: () => void;
334
+ }) {
335
+ const activeMarks = useActiveInlineMarks();
336
+ return (
337
+ <div
338
+ className={cn(
339
+ "flex max-w-full items-center gap-0.5 rounded-md border bg-background p-0.5 shadow-sm",
340
+ coarsePointer ? "flex-wrap justify-start" : "flex-nowrap",
341
+ )}
342
+ >
343
+ <StructureControls
344
+ handle={handle}
345
+ coarsePointer={coarsePointer}
346
+ addableTypes={addableTypes}
347
+ onAddBelow={onAddBelow}
348
+ onSelectContainer={onSelectContainer}
349
+ />
350
+ {richText ? (
351
+ <RichTextControls active={activeMarks} coarsePointer={coarsePointer} />
352
+ ) : null}
353
+ <ToolbarSeparator />
354
+ {onOpenSettings ? (
355
+ <ToolbarIconButton
356
+ label="Réglages du bloc"
357
+ coarsePointer={coarsePointer}
358
+ onClick={onOpenSettings}
359
+ >
360
+ <SlidersHorizontalIcon aria-hidden />
361
+ </ToolbarIconButton>
362
+ ) : null}
363
+ <ToolbarIconButton
364
+ label="Dupliquer le bloc"
365
+ coarsePointer={coarsePointer}
366
+ onClick={onDuplicate}
367
+ >
368
+ <CopyIcon aria-hidden />
369
+ </ToolbarIconButton>
370
+ <ToolbarIconButton
371
+ label="Supprimer le bloc"
372
+ coarsePointer={coarsePointer}
373
+ onClick={onRemove}
374
+ >
375
+ <TrashIcon aria-hidden />
376
+ </ToolbarIconButton>
377
+ </div>
378
+ );
379
+ }