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