@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,86 @@
1
+ import { TextHIcon } from "@phosphor-icons/react";
2
+ import type {
3
+ EmailBlockComponentProps,
4
+ EmailBlockDefinition,
5
+ } from "#/blocks/block-definitions.tsx";
6
+ import { BlockTextInput } from "#/blocks/block-text-input.tsx";
7
+ import type {
8
+ EmailEditorHeadingBlock,
9
+ EmailEditorHeadingLevel,
10
+ } from "#/document/types.ts";
11
+ import { SelectOption } from "#/sections/block-options/select-option.tsx";
12
+ import { TextOption } from "#/sections/block-options/text-option.tsx";
13
+ import { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
14
+
15
+ /** The two heading sizes, mirroring the domain `emailHeading` component. */
16
+ export const EMAIL_HEADING_STYLE: {
17
+ readonly [Level in EmailEditorHeadingLevel]: {
18
+ readonly fontSize: string;
19
+ readonly label: string;
20
+ };
21
+ } = {
22
+ 1: { fontSize: "22px", label: "Titre principal (H1)" },
23
+ 2: { fontSize: "17px", label: "Sous-titre (H2)" },
24
+ };
25
+
26
+ const HEADING_LEVEL_OPTIONS: ReadonlyArray<{
27
+ readonly value: EmailEditorHeadingLevel;
28
+ readonly label: string;
29
+ }> = [
30
+ { value: 1, label: EMAIL_HEADING_STYLE[1].label },
31
+ { value: 2, label: EMAIL_HEADING_STYLE[2].label },
32
+ ];
33
+
34
+ /**
35
+ * A title line, edited in place. Mirrors the domain `emailHeading` component
36
+ * (bold, brand-colored) so the canvas matches the sent email; the level picks
37
+ * between the email's own title and a section heading.
38
+ */
39
+ function HeadingBlockView({
40
+ block,
41
+ onChange,
42
+ }: EmailBlockComponentProps<EmailEditorHeadingBlock>) {
43
+ return (
44
+ <BlockTextInput
45
+ ariaLabel="Titre"
46
+ value={block.text}
47
+ onChange={(text) => onChange({ ...block, text })}
48
+ placeholder="Votre titre"
49
+ className="font-bold leading-[1.3]"
50
+ style={{
51
+ fontFamily: EMAIL_FONT,
52
+ color: EMAIL_COLOR.brand,
53
+ fontSize: EMAIL_HEADING_STYLE[block.level].fontSize,
54
+ }}
55
+ />
56
+ );
57
+ }
58
+
59
+ function HeadingBlockSettings({
60
+ block,
61
+ onChange,
62
+ }: EmailBlockComponentProps<EmailEditorHeadingBlock>) {
63
+ return (
64
+ <>
65
+ <TextOption
66
+ label="Texte"
67
+ value={block.text}
68
+ onChange={(text) => onChange({ ...block, text })}
69
+ />
70
+ <SelectOption
71
+ label="Niveau"
72
+ value={block.level}
73
+ options={HEADING_LEVEL_OPTIONS}
74
+ onChange={(level) => onChange({ ...block, level })}
75
+ />
76
+ </>
77
+ );
78
+ }
79
+
80
+ export const headingBlockDefinition: EmailBlockDefinition<EmailEditorHeadingBlock> =
81
+ {
82
+ label: "Titre",
83
+ icon: TextHIcon,
84
+ View: HeadingBlockView,
85
+ Settings: HeadingBlockSettings,
86
+ };
@@ -0,0 +1,264 @@
1
+ import { ImageIcon, PlayIcon, UploadSimpleIcon } from "@phosphor-icons/react";
2
+ import { Button } from "@voila.dev/ui/components/button";
3
+ import { useRef, useState } from "react";
4
+ import type {
5
+ EmailBlockComponentProps,
6
+ EmailBlockDefinition,
7
+ } from "#/blocks/block-definitions.tsx";
8
+ import type {
9
+ EmailEditorImageBlock,
10
+ EmailEditorImageOverlay,
11
+ EmailEditorImageWidth,
12
+ } from "#/document/types.ts";
13
+ import { BlockOptionSection } from "#/sections/block-options/block-option-row.tsx";
14
+ import {
15
+ SelectOption,
16
+ ToggleOption,
17
+ } from "#/sections/block-options/select-option.tsx";
18
+ import {
19
+ LinkOption,
20
+ TextOption,
21
+ } from "#/sections/block-options/text-option.tsx";
22
+ import { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
23
+
24
+ /**
25
+ * How much of the card's inner width each option occupies. `contained` is the
26
+ * option for a visual that should not bleed edge to edge (a logo, a portrait);
27
+ * the renderer uses the same ratio against the 536px content width.
28
+ */
29
+ export const EMAIL_IMAGE_WIDTH_RATIO: {
30
+ readonly [W in EmailEditorImageWidth]: number;
31
+ } = { full: 1, contained: 0.6 };
32
+
33
+ const WIDTH_OPTIONS: ReadonlyArray<{
34
+ readonly value: EmailEditorImageWidth;
35
+ readonly label: string;
36
+ }> = [
37
+ { value: "full", label: "Pleine largeur" },
38
+ { value: "contained", label: "Largeur réduite (centrée)" },
39
+ ];
40
+
41
+ const OVERLAY_OPTIONS: ReadonlyArray<{
42
+ readonly value: EmailEditorImageOverlay;
43
+ readonly label: string;
44
+ }> = [
45
+ { value: "none", label: "Aucune" },
46
+ { value: "play", label: "Bouton lecture (vignette vidéo)" },
47
+ ];
48
+
49
+ /** The play badge composited over a video thumbnail. */
50
+ function PlayOverlay() {
51
+ return (
52
+ <span
53
+ className="-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 flex size-14 items-center justify-center rounded-full"
54
+ style={{ backgroundColor: EMAIL_COLOR.brand }}
55
+ aria-hidden
56
+ >
57
+ <PlayIcon size={24} weight="fill" color="#ffffff" />
58
+ </span>
59
+ );
60
+ }
61
+
62
+ /**
63
+ * A content image. Without a source it renders a dashed drop zone that opens a
64
+ * file picker; the actual upload is delegated to the host through
65
+ * `onUploadImage` (the admin wires its ticketed upload). Mirrors the domain
66
+ * `emailImage` component (width, radius and play overlay all follow the
67
+ * block's options).
68
+ */
69
+ function ImageBlockView({
70
+ block,
71
+ onChange,
72
+ onUploadImage,
73
+ }: EmailBlockComponentProps<EmailEditorImageBlock>) {
74
+ if (block.src !== "") {
75
+ return (
76
+ <div
77
+ className="relative mx-auto"
78
+ style={{
79
+ width: `${EMAIL_IMAGE_WIDTH_RATIO[block.width] * 100}%`,
80
+ }}
81
+ >
82
+ <img
83
+ src={block.src}
84
+ alt={block.alt}
85
+ className={block.rounded ? "block w-full rounded-lg" : "block w-full"}
86
+ />
87
+ {block.overlay === "play" ? <PlayOverlay /> : null}
88
+ </div>
89
+ );
90
+ }
91
+
92
+ return (
93
+ <ImageDropZone
94
+ onUploadImage={onUploadImage}
95
+ onUploaded={(src) => onChange({ ...block, src })}
96
+ />
97
+ );
98
+ }
99
+
100
+ /** The empty state: a dashed zone that opens the file picker. The upload
101
+ * itself is the host's job, so without `onUploadImage` it is simply inert. */
102
+ function ImageDropZone({
103
+ onUploadImage,
104
+ onUploaded,
105
+ }: {
106
+ onUploadImage?: (file: File) => Promise<string>;
107
+ onUploaded: (src: string) => void;
108
+ }) {
109
+ const fileInputRef = useRef<HTMLInputElement>(null);
110
+ const [uploading, setUploading] = useState(false);
111
+
112
+ const upload = async (file: File) => {
113
+ if (!onUploadImage) {
114
+ return;
115
+ }
116
+ setUploading(true);
117
+ try {
118
+ onUploaded(await onUploadImage(file));
119
+ } finally {
120
+ setUploading(false);
121
+ }
122
+ };
123
+
124
+ return (
125
+ <>
126
+ <button
127
+ type="button"
128
+ onClick={() => fileInputRef.current?.click()}
129
+ disabled={onUploadImage === undefined || uploading}
130
+ className="flex w-full flex-col items-center gap-2 rounded-lg border border-dashed px-4 py-10 text-[14px] disabled:cursor-not-allowed"
131
+ style={{
132
+ borderColor: EMAIL_COLOR.muted,
133
+ color: EMAIL_COLOR.muted,
134
+ fontFamily: EMAIL_FONT,
135
+ }}
136
+ >
137
+ <ImageIcon size={24} aria-hidden />
138
+ {uploading ? "Téléversement en cours…" : "Ajouter une image"}
139
+ </button>
140
+ <input
141
+ ref={fileInputRef}
142
+ type="file"
143
+ accept="image/*"
144
+ className="hidden"
145
+ onChange={(event) => {
146
+ const file = event.target.files?.[0];
147
+ if (file) {
148
+ void upload(file);
149
+ }
150
+ event.target.value = "";
151
+ }}
152
+ />
153
+ </>
154
+ );
155
+ }
156
+
157
+ function ImageUploadButton({
158
+ block,
159
+ onChange,
160
+ onUploadImage,
161
+ }: {
162
+ block: EmailEditorImageBlock;
163
+ onChange: (block: EmailEditorImageBlock) => void;
164
+ onUploadImage: (file: File) => Promise<string>;
165
+ }) {
166
+ const fileInputRef = useRef<HTMLInputElement>(null);
167
+ return (
168
+ <>
169
+ <Button
170
+ variant="outline"
171
+ size="sm"
172
+ onClick={() => fileInputRef.current?.click()}
173
+ >
174
+ <UploadSimpleIcon aria-hidden />
175
+ {block.src === "" ? "Téléverser une image" : "Remplacer l'image"}
176
+ </Button>
177
+ <input
178
+ ref={fileInputRef}
179
+ type="file"
180
+ accept="image/*"
181
+ className="hidden"
182
+ onChange={(event) => {
183
+ const file = event.target.files?.[0];
184
+ if (file) {
185
+ void onUploadImage(file).then((src) => onChange({ ...block, src }));
186
+ }
187
+ event.target.value = "";
188
+ }}
189
+ />
190
+ </>
191
+ );
192
+ }
193
+
194
+ function ImageBlockSettings({
195
+ block,
196
+ onChange,
197
+ onUploadImage,
198
+ }: EmailBlockComponentProps<EmailEditorImageBlock>) {
199
+ return (
200
+ <>
201
+ <BlockOptionSection title="Contenu">
202
+ <TextOption
203
+ label="Texte alternatif"
204
+ value={block.alt}
205
+ onChange={(alt) => onChange({ ...block, alt })}
206
+ description="Affiché quand l'image est bloquée par la messagerie."
207
+ />
208
+ <TextOption
209
+ label="Adresse de l'image"
210
+ value={block.src}
211
+ onChange={(src) => onChange({ ...block, src })}
212
+ placeholder="https://"
213
+ />
214
+ {onUploadImage ? (
215
+ <ImageUploadButton
216
+ block={block}
217
+ onChange={onChange}
218
+ onUploadImage={onUploadImage}
219
+ />
220
+ ) : null}
221
+ </BlockOptionSection>
222
+ <BlockOptionSection title="Apparence">
223
+ <SelectOption
224
+ label="Largeur"
225
+ value={block.width}
226
+ options={WIDTH_OPTIONS}
227
+ onChange={(width) => onChange({ ...block, width })}
228
+ />
229
+ <SelectOption
230
+ label="Superposition"
231
+ value={block.overlay}
232
+ options={OVERLAY_OPTIONS}
233
+ onChange={(overlay) => onChange({ ...block, overlay })}
234
+ description={
235
+ block.overlay === "play"
236
+ ? "Aucune messagerie ne lit une vidéo intégrée : la vignette renvoie vers le lien ci-dessous. Outlook affiche la pastille sous l'image."
237
+ : undefined
238
+ }
239
+ />
240
+ <ToggleOption
241
+ label="Coins arrondis"
242
+ checked={block.rounded}
243
+ onChange={(rounded) => onChange({ ...block, rounded })}
244
+ description="Outlook (moteur Word) affiche toujours des angles droits."
245
+ />
246
+ </BlockOptionSection>
247
+ <BlockOptionSection title="Lien">
248
+ <LinkOption
249
+ value={block.href}
250
+ onChange={(href) => onChange({ ...block, href })}
251
+ description="Laissez vide pour une image non cliquable."
252
+ />
253
+ </BlockOptionSection>
254
+ </>
255
+ );
256
+ }
257
+
258
+ export const imageBlockDefinition: EmailBlockDefinition<EmailEditorImageBlock> =
259
+ {
260
+ label: "Image",
261
+ icon: ImageIcon,
262
+ View: ImageBlockView,
263
+ Settings: ImageBlockSettings,
264
+ };
@@ -0,0 +1,202 @@
1
+ import { ListBulletsIcon, PlusIcon, XIcon } from "@phosphor-icons/react";
2
+ import { Button } from "@voila.dev/ui/components/button";
3
+ import type {
4
+ EmailBlockComponentProps,
5
+ EmailBlockDefinition,
6
+ } from "#/blocks/block-definitions.tsx";
7
+ import { RichTextEditable } from "#/blocks/rich-text-editable.tsx";
8
+ import type {
9
+ EmailEditorListBlock,
10
+ EmailEditorListItem,
11
+ EmailEditorListMarker,
12
+ } from "#/document/types.ts";
13
+ import { SelectOption } from "#/sections/block-options/select-option.tsx";
14
+ import { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
15
+
16
+ const MARKER_OPTIONS: ReadonlyArray<{
17
+ readonly value: EmailEditorListMarker;
18
+ readonly label: string;
19
+ }> = [
20
+ { value: "bullet", label: "Puce" },
21
+ { value: "number", label: "Numéro" },
22
+ { value: "badge", label: "Pastille numérotée" },
23
+ ];
24
+
25
+ /** The marker shown before an item, mirroring the domain `emailList`. */
26
+ function ListMarker({
27
+ marker,
28
+ index,
29
+ }: {
30
+ marker: EmailEditorListMarker;
31
+ index: number;
32
+ }) {
33
+ if (marker === "badge") {
34
+ return (
35
+ <span
36
+ className="flex size-6 shrink-0 items-center justify-center rounded-full font-semibold text-[12px] text-white"
37
+ style={{ backgroundColor: EMAIL_COLOR.brand }}
38
+ aria-hidden
39
+ >
40
+ {index + 1}
41
+ </span>
42
+ );
43
+ }
44
+ return (
45
+ <span
46
+ className="w-6 shrink-0 text-[16px] leading-[1.6]"
47
+ style={{ color: EMAIL_COLOR.muted }}
48
+ aria-hidden
49
+ >
50
+ {marker === "number" ? `${index + 1}.` : "•"}
51
+ </span>
52
+ );
53
+ }
54
+
55
+ /**
56
+ * A bulleted, numbered or badge list. Each item is the same rich-text surface
57
+ * the paragraph uses, so the toolbar's bold/italic/underline/link controls act
58
+ * on the focused item with no extra wiring. The optional bold lead-in stays
59
+ * out of the way until the item is hovered or focused.
60
+ */
61
+ function ListBlockView({
62
+ block,
63
+ selected,
64
+ onChange,
65
+ }: EmailBlockComponentProps<EmailEditorListBlock>) {
66
+ const replaceItem = (index: number, item: EmailEditorListItem) =>
67
+ onChange({
68
+ ...block,
69
+ items: block.items.map((current, at) => (at === index ? item : current)),
70
+ });
71
+
72
+ return (
73
+ <ul className="flex list-none flex-col gap-2 p-0">
74
+ {block.items.map((item, index) => (
75
+ <ListItemRow
76
+ key={index}
77
+ item={item}
78
+ index={index}
79
+ marker={block.marker}
80
+ showTitle={selected || (item.title ?? "") !== ""}
81
+ onChange={(next) => replaceItem(index, next)}
82
+ />
83
+ ))}
84
+ </ul>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * One entry. The lead-in is optional, so an empty one only takes up room while
90
+ * the block is selected — otherwise the canvas would not match the email.
91
+ * Selection (not hover) drives it, so the field is reachable on a touch screen.
92
+ */
93
+ function ListItemRow({
94
+ item,
95
+ index,
96
+ marker,
97
+ showTitle,
98
+ onChange,
99
+ }: {
100
+ item: EmailEditorListItem;
101
+ index: number;
102
+ marker: EmailEditorListMarker;
103
+ showTitle: boolean;
104
+ onChange: (item: EmailEditorListItem) => void;
105
+ }) {
106
+ return (
107
+ <li className="flex items-start gap-2">
108
+ <ListMarker marker={marker} index={index} />
109
+ <div className="flex min-w-0 flex-1 flex-col">
110
+ {showTitle ? (
111
+ <input
112
+ aria-label={`Titre de l'élément ${index + 1}`}
113
+ value={item.title ?? ""}
114
+ placeholder="Titre (optionnel)"
115
+ onChange={(event) =>
116
+ onChange({ ...item, title: event.target.value })
117
+ }
118
+ className="max-w-full border-none bg-transparent p-0 font-semibold text-[16px] leading-[1.6] outline-none [field-sizing:content] placeholder:opacity-30"
119
+ style={{ fontFamily: EMAIL_FONT, color: EMAIL_COLOR.ink }}
120
+ />
121
+ ) : null}
122
+ <RichTextEditable
123
+ spans={item.spans}
124
+ onChange={(spans) => onChange({ ...item, spans })}
125
+ ariaLabel={`Élément ${index + 1}`}
126
+ placeholder="Votre texte"
127
+ className="text-[16px] leading-[1.6]"
128
+ style={{ fontFamily: EMAIL_FONT, color: EMAIL_COLOR.ink }}
129
+ />
130
+ </div>
131
+ </li>
132
+ );
133
+ }
134
+
135
+ function ListBlockSettings({
136
+ block,
137
+ onChange,
138
+ }: EmailBlockComponentProps<EmailEditorListBlock>) {
139
+ return (
140
+ <>
141
+ <SelectOption
142
+ label="Marqueur"
143
+ value={block.marker}
144
+ options={MARKER_OPTIONS}
145
+ onChange={(marker) => onChange({ ...block, marker })}
146
+ />
147
+ <div className="flex flex-col gap-2">
148
+ <span className="font-medium text-sm">Éléments</span>
149
+ {block.items.map((item, index) => (
150
+ <div
151
+ key={index}
152
+ className="flex items-center justify-between gap-2 text-sm"
153
+ >
154
+ <span className="truncate text-muted-foreground">
155
+ {item.title?.trim() ||
156
+ item.spans
157
+ .map((span) => span.text)
158
+ .join("")
159
+ .trim() ||
160
+ `Élément ${index + 1}`}
161
+ </span>
162
+ <Button
163
+ variant="ghost"
164
+ size="icon-sm"
165
+ aria-label={`Supprimer l'élément ${index + 1}`}
166
+ disabled={block.items.length === 1}
167
+ onClick={() =>
168
+ onChange({
169
+ ...block,
170
+ items: block.items.filter((_, at) => at !== index),
171
+ })
172
+ }
173
+ >
174
+ <XIcon aria-hidden />
175
+ </Button>
176
+ </div>
177
+ ))}
178
+ <Button
179
+ variant="outline"
180
+ size="sm"
181
+ onClick={() =>
182
+ onChange({ ...block, items: [...block.items, { spans: [] }] })
183
+ }
184
+ >
185
+ <PlusIcon aria-hidden />
186
+ Ajouter un élément
187
+ </Button>
188
+ </div>
189
+ <p className="text-muted-foreground text-xs">
190
+ Le texte de chaque élément se met en forme depuis la barre d'outils du
191
+ bloc, comme un paragraphe.
192
+ </p>
193
+ </>
194
+ );
195
+ }
196
+
197
+ export const listBlockDefinition: EmailBlockDefinition<EmailEditorListBlock> = {
198
+ label: "Liste",
199
+ icon: ListBulletsIcon,
200
+ View: ListBlockView,
201
+ Settings: ListBlockSettings,
202
+ };