@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,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
|
+
}
|