@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,267 @@
1
+ import {
2
+ CheckIcon,
3
+ PlusIcon,
4
+ SealPercentIcon,
5
+ XIcon,
6
+ } from "@phosphor-icons/react";
7
+ import { Button } from "@voila.dev/ui/components/button";
8
+ import type {
9
+ EmailBlockComponentProps,
10
+ EmailBlockDefinition,
11
+ } from "#/blocks/block-definitions.tsx";
12
+ import { BlockTextInput } from "#/blocks/block-text-input.tsx";
13
+ import {
14
+ EmailCardButton,
15
+ EmailCardMeta,
16
+ EmailCardShell,
17
+ } from "#/blocks/email-card-shell.tsx";
18
+ import type { EmailEditorOfferBlock } from "#/document/types.ts";
19
+ import { BlockOptionSection } from "#/sections/block-options/block-option-row.tsx";
20
+ import {
21
+ formatPreviewPrice,
22
+ MoneyOption,
23
+ } from "#/sections/block-options/money-option.tsx";
24
+ import { ToggleOption } from "#/sections/block-options/select-option.tsx";
25
+ import {
26
+ LinkOption,
27
+ TextAreaOption,
28
+ TextOption,
29
+ } from "#/sections/block-options/text-option.tsx";
30
+ import { EMAIL_COLOR } from "#/theme.ts";
31
+
32
+ /**
33
+ * A pricing plan on the shared card shell: an eyebrow, a name, a price with an
34
+ * optional period, a feature list and a call to action. `highlighted` draws
35
+ * the recommended plan of a row in the brand colour.
36
+ */
37
+ function OfferBlockView({
38
+ block,
39
+ onChange,
40
+ }: EmailBlockComponentProps<EmailEditorOfferBlock>) {
41
+ return (
42
+ <EmailCardShell
43
+ image={block.image.src === "" ? undefined : block.image}
44
+ highlighted={block.highlighted}
45
+ >
46
+ <OfferHeader block={block} onChange={onChange} />
47
+ {block.description === "" ? null : (
48
+ <p
49
+ className="text-[15px] leading-[1.5]"
50
+ style={{ color: EMAIL_COLOR.ink }}
51
+ >
52
+ {block.description}
53
+ </p>
54
+ )}
55
+ <OfferFeatureList features={block.features} />
56
+ <EmailCardButton label={block.buttonLabel} />
57
+ </EmailCardShell>
58
+ );
59
+ }
60
+
61
+ /** The plan's identity: its optional surtitle, its name, and the price with
62
+ * its optional recurrence. */
63
+ function OfferHeader({
64
+ block,
65
+ onChange,
66
+ }: {
67
+ block: EmailEditorOfferBlock;
68
+ onChange: (block: EmailEditorOfferBlock) => void;
69
+ }) {
70
+ return (
71
+ <>
72
+ {block.eyebrow === "" ? null : (
73
+ <span
74
+ className="font-semibold text-[11px] uppercase tracking-[0.06em]"
75
+ style={{ color: EMAIL_COLOR.brand }}
76
+ >
77
+ {block.eyebrow}
78
+ </span>
79
+ )}
80
+ <BlockTextInput
81
+ ariaLabel="Nom de l'offre"
82
+ value={block.name}
83
+ placeholder="Nom de l'offre"
84
+ onChange={(name) => onChange({ ...block, name })}
85
+ className="font-bold text-[17px] leading-[1.3]"
86
+ style={{ color: EMAIL_COLOR.brand }}
87
+ />
88
+ <div className="flex items-baseline gap-1.5">
89
+ <span
90
+ className="font-bold text-[26px] leading-[1.1]"
91
+ style={{ color: EMAIL_COLOR.ink }}
92
+ >
93
+ {formatPreviewPrice(block.price)}
94
+ </span>
95
+ {block.period === "" ? null : (
96
+ <EmailCardMeta>{block.period}</EmailCardMeta>
97
+ )}
98
+ </div>
99
+ </>
100
+ );
101
+ }
102
+
103
+ /** The ticked « points inclus », mirroring the bulleted list the renderer
104
+ * emits for them. */
105
+ function OfferFeatureList({ features }: { features: ReadonlyArray<string> }) {
106
+ if (features.length === 0) {
107
+ return null;
108
+ }
109
+ return (
110
+ <ul className="flex list-none flex-col gap-1 p-0">
111
+ {features.map((feature, index) => (
112
+ <li
113
+ key={index}
114
+ className="flex items-start gap-2 text-[14px] leading-[1.5]"
115
+ style={{ color: EMAIL_COLOR.ink }}
116
+ >
117
+ <CheckIcon
118
+ size={16}
119
+ aria-hidden
120
+ style={{ color: EMAIL_COLOR.brand, marginTop: 3 }}
121
+ />
122
+ {feature}
123
+ </li>
124
+ ))}
125
+ </ul>
126
+ );
127
+ }
128
+
129
+ function OfferFeatureSettings({
130
+ block,
131
+ onChange,
132
+ }: {
133
+ block: EmailEditorOfferBlock;
134
+ onChange: (block: EmailEditorOfferBlock) => void;
135
+ }) {
136
+ return (
137
+ <div className="flex flex-col gap-2">
138
+ <span className="font-medium text-sm">Points inclus</span>
139
+ {block.features.map((feature, index) => (
140
+ <div key={index} className="flex items-center gap-2">
141
+ <input
142
+ aria-label={`Point inclus ${index + 1}`}
143
+ value={feature}
144
+ onChange={(event) =>
145
+ onChange({
146
+ ...block,
147
+ features: block.features.map((current, at) =>
148
+ at === index ? event.target.value : current,
149
+ ),
150
+ })
151
+ }
152
+ className="h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
153
+ />
154
+ <Button
155
+ variant="ghost"
156
+ size="icon-sm"
157
+ aria-label={`Supprimer le point ${index + 1}`}
158
+ onClick={() =>
159
+ onChange({
160
+ ...block,
161
+ features: block.features.filter((_, at) => at !== index),
162
+ })
163
+ }
164
+ >
165
+ <XIcon aria-hidden />
166
+ </Button>
167
+ </div>
168
+ ))}
169
+ <Button
170
+ variant="outline"
171
+ size="sm"
172
+ onClick={() =>
173
+ onChange({ ...block, features: [...block.features, ""] })
174
+ }
175
+ >
176
+ <PlusIcon aria-hidden />
177
+ Ajouter un point
178
+ </Button>
179
+ </div>
180
+ );
181
+ }
182
+
183
+ function OfferBlockSettings({
184
+ block,
185
+ onChange,
186
+ }: EmailBlockComponentProps<EmailEditorOfferBlock>) {
187
+ return (
188
+ <>
189
+ <BlockOptionSection title="Contenu">
190
+ <TextOption
191
+ label="Surtitre"
192
+ value={block.eyebrow}
193
+ onChange={(eyebrow) => onChange({ ...block, eyebrow })}
194
+ placeholder="Le plus choisi"
195
+ />
196
+ <TextOption
197
+ label="Nom"
198
+ value={block.name}
199
+ onChange={(name) => onChange({ ...block, name })}
200
+ />
201
+ <MoneyOption
202
+ label="Prix"
203
+ value={block.price}
204
+ onChange={(price) => onChange({ ...block, price })}
205
+ />
206
+ <TextOption
207
+ label="Périodicité"
208
+ value={block.period}
209
+ onChange={(period) => onChange({ ...block, period })}
210
+ placeholder="par mois"
211
+ description="Laissez vide pour un tarif unique."
212
+ />
213
+ <TextAreaOption
214
+ label="Description"
215
+ value={block.description}
216
+ onChange={(description) => onChange({ ...block, description })}
217
+ />
218
+ <OfferFeatureSettings block={block} onChange={onChange} />
219
+ </BlockOptionSection>
220
+ <BlockOptionSection title="Apparence">
221
+ <ToggleOption
222
+ label="Mettre en avant"
223
+ checked={block.highlighted}
224
+ onChange={(highlighted) => onChange({ ...block, highlighted })}
225
+ description="Encadre la carte dans la couleur de marque. Outlook (moteur Word) affiche des angles droits."
226
+ />
227
+ <TextOption
228
+ label="Adresse de l'image"
229
+ value={block.image.src}
230
+ onChange={(src) =>
231
+ onChange({ ...block, image: { ...block.image, src } })
232
+ }
233
+ placeholder="https://"
234
+ description="Laissez vide pour une offre sans visuel."
235
+ />
236
+ <TextOption
237
+ label="Texte alternatif"
238
+ value={block.image.alt}
239
+ onChange={(alt) =>
240
+ onChange({ ...block, image: { ...block.image, alt } })
241
+ }
242
+ />
243
+ </BlockOptionSection>
244
+ <BlockOptionSection title="Lien">
245
+ <TextOption
246
+ label="Libellé du bouton"
247
+ value={block.buttonLabel}
248
+ onChange={(buttonLabel) => onChange({ ...block, buttonLabel })}
249
+ placeholder="Choisir cette offre"
250
+ description="Laissez vide pour une carte sans bouton."
251
+ />
252
+ <LinkOption
253
+ value={block.buttonHref}
254
+ onChange={(buttonHref) => onChange({ ...block, buttonHref })}
255
+ />
256
+ </BlockOptionSection>
257
+ </>
258
+ );
259
+ }
260
+
261
+ export const offerBlockDefinition: EmailBlockDefinition<EmailEditorOfferBlock> =
262
+ {
263
+ label: "Offre",
264
+ icon: SealPercentIcon,
265
+ View: OfferBlockView,
266
+ Settings: OfferBlockSettings,
267
+ };
@@ -0,0 +1,47 @@
1
+ import { TextAlignLeftIcon } from "@phosphor-icons/react";
2
+ import type {
3
+ EmailBlockComponentProps,
4
+ EmailBlockDefinition,
5
+ } from "#/blocks/block-definitions.tsx";
6
+ import { RichTextEditable } from "#/blocks/rich-text-editable.tsx";
7
+ import type { EmailEditorParagraphBlock } from "#/document/types.ts";
8
+ import { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
9
+
10
+ /**
11
+ * A rich body paragraph, edited in place (bold, italic, underline and links
12
+ * via the block toolbar). Mirrors the domain `emailParagraph` component
13
+ * (16px ink).
14
+ */
15
+ function ParagraphBlockView({
16
+ block,
17
+ onChange,
18
+ }: EmailBlockComponentProps<EmailEditorParagraphBlock>) {
19
+ return (
20
+ <RichTextEditable
21
+ spans={block.spans}
22
+ onChange={(spans) => onChange({ ...block, spans })}
23
+ ariaLabel="Paragraphe"
24
+ placeholder="Votre texte. Utilisez {{firstName}} pour personnaliser."
25
+ className="text-[16px] leading-[1.6]"
26
+ style={{ fontFamily: EMAIL_FONT, color: EMAIL_COLOR.ink }}
27
+ />
28
+ );
29
+ }
30
+
31
+ function ParagraphBlockSettings() {
32
+ return (
33
+ <p className="text-muted-foreground text-xs">
34
+ {
35
+ "Mettez en forme le texte (gras, italique, souligné, lien) depuis la barre d'outils du bloc. Personnalisez avec {{firstName}}, {{lastName}} ou {{email}} ; la valeur du contact est substituée à l'envoi."
36
+ }
37
+ </p>
38
+ );
39
+ }
40
+
41
+ export const paragraphBlockDefinition: EmailBlockDefinition<EmailEditorParagraphBlock> =
42
+ {
43
+ label: "Paragraphe",
44
+ icon: TextAlignLeftIcon,
45
+ View: ParagraphBlockView,
46
+ Settings: ParagraphBlockSettings,
47
+ };
@@ -0,0 +1,155 @@
1
+ import { TagIcon } 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 { EmailCardButton, EmailCardShell } from "#/blocks/email-card-shell.tsx";
8
+ import type { EmailEditorProductBlock } from "#/document/types.ts";
9
+ import { BlockOptionSection } from "#/sections/block-options/block-option-row.tsx";
10
+ import {
11
+ formatPreviewPrice,
12
+ MoneyOption,
13
+ } from "#/sections/block-options/money-option.tsx";
14
+ import { ToggleOption } from "#/sections/block-options/select-option.tsx";
15
+ import {
16
+ LinkOption,
17
+ TextAreaOption,
18
+ TextOption,
19
+ } from "#/sections/block-options/text-option.tsx";
20
+ import { EMAIL_COLOR } from "#/theme.ts";
21
+
22
+ /**
23
+ * A catalogue item on the shared card shell: visual, name, description, price
24
+ * with an optional struck-through « tarif de base », and a call to action.
25
+ */
26
+ function ProductBlockView({
27
+ block,
28
+ onChange,
29
+ }: EmailBlockComponentProps<EmailEditorProductBlock>) {
30
+ return (
31
+ <EmailCardShell image={block.image}>
32
+ <BlockTextInput
33
+ ariaLabel="Nom du produit"
34
+ value={block.name}
35
+ placeholder="Nom du produit"
36
+ onChange={(name) => onChange({ ...block, name })}
37
+ className="font-bold text-[17px] leading-[1.3]"
38
+ style={{ color: EMAIL_COLOR.brand }}
39
+ />
40
+ <textarea
41
+ aria-label="Description du produit"
42
+ value={block.description}
43
+ placeholder="La description du produit."
44
+ rows={2}
45
+ onChange={(event) =>
46
+ onChange({ ...block, description: event.target.value })
47
+ }
48
+ className="w-full resize-none border-none bg-transparent p-0 text-[15px] leading-[1.5] outline-none [field-sizing:content] placeholder:opacity-40"
49
+ style={{ color: EMAIL_COLOR.ink }}
50
+ />
51
+ <div className="flex items-baseline gap-2">
52
+ <span
53
+ className="font-bold text-[18px]"
54
+ style={{ color: EMAIL_COLOR.ink }}
55
+ >
56
+ {formatPreviewPrice(block.price)}
57
+ </span>
58
+ {block.compareAtPrice === null ? null : (
59
+ <span
60
+ className="text-[14px] line-through"
61
+ style={{ color: EMAIL_COLOR.muted }}
62
+ >
63
+ {formatPreviewPrice(block.compareAtPrice)}
64
+ </span>
65
+ )}
66
+ </div>
67
+ <EmailCardButton label={block.buttonLabel} />
68
+ </EmailCardShell>
69
+ );
70
+ }
71
+
72
+ function ProductBlockSettings({
73
+ block,
74
+ onChange,
75
+ }: EmailBlockComponentProps<EmailEditorProductBlock>) {
76
+ return (
77
+ <>
78
+ <BlockOptionSection title="Contenu">
79
+ <TextOption
80
+ label="Nom"
81
+ value={block.name}
82
+ onChange={(name) => onChange({ ...block, name })}
83
+ />
84
+ <TextAreaOption
85
+ label="Description"
86
+ value={block.description}
87
+ onChange={(description) => onChange({ ...block, description })}
88
+ />
89
+ <MoneyOption
90
+ label="Prix"
91
+ value={block.price}
92
+ onChange={(price) => onChange({ ...block, price })}
93
+ />
94
+ <ToggleOption
95
+ label="Tarif de base barré"
96
+ checked={block.compareAtPrice !== null}
97
+ onChange={(enabled) =>
98
+ onChange({
99
+ ...block,
100
+ compareAtPrice: enabled ? { ...block.price } : null,
101
+ })
102
+ }
103
+ description="Affiche un prix barré à côté du prix courant."
104
+ />
105
+ {block.compareAtPrice === null ? null : (
106
+ <MoneyOption
107
+ label="Tarif de base"
108
+ value={block.compareAtPrice}
109
+ onChange={(compareAtPrice) =>
110
+ onChange({ ...block, compareAtPrice })
111
+ }
112
+ />
113
+ )}
114
+ </BlockOptionSection>
115
+ <BlockOptionSection title="Apparence">
116
+ <TextOption
117
+ label="Adresse de l'image"
118
+ value={block.image.src}
119
+ onChange={(src) =>
120
+ onChange({ ...block, image: { ...block.image, src } })
121
+ }
122
+ placeholder="https://"
123
+ />
124
+ <TextOption
125
+ label="Texte alternatif"
126
+ value={block.image.alt}
127
+ onChange={(alt) =>
128
+ onChange({ ...block, image: { ...block.image, alt } })
129
+ }
130
+ />
131
+ </BlockOptionSection>
132
+ <BlockOptionSection title="Lien">
133
+ <TextOption
134
+ label="Libellé du bouton"
135
+ value={block.buttonLabel}
136
+ onChange={(buttonLabel) => onChange({ ...block, buttonLabel })}
137
+ placeholder="Commander"
138
+ description="Laissez vide pour une carte sans bouton."
139
+ />
140
+ <LinkOption
141
+ value={block.href}
142
+ onChange={(href) => onChange({ ...block, href })}
143
+ />
144
+ </BlockOptionSection>
145
+ </>
146
+ );
147
+ }
148
+
149
+ export const productBlockDefinition: EmailBlockDefinition<EmailEditorProductBlock> =
150
+ {
151
+ label: "Produit",
152
+ icon: TagIcon,
153
+ View: ProductBlockView,
154
+ Settings: ProductBlockSettings,
155
+ };
@@ -0,0 +1,121 @@
1
+ import { StarIcon } from "@phosphor-icons/react";
2
+ import type {
3
+ EmailBlockComponentProps,
4
+ EmailBlockDefinition,
5
+ } from "#/blocks/block-definitions.tsx";
6
+ import { RichTextEditable } from "#/blocks/rich-text-editable.tsx";
7
+ import type {
8
+ EmailEditorRatingBlock,
9
+ EmailEditorRatingStyle,
10
+ } from "#/document/types.ts";
11
+ import { BlockOptionSection } from "#/sections/block-options/block-option-row.tsx";
12
+ import { SelectOption } from "#/sections/block-options/select-option.tsx";
13
+ import {
14
+ LinkOption,
15
+ TextOption,
16
+ } from "#/sections/block-options/text-option.tsx";
17
+ import { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
18
+
19
+ /** The scale, mirroring the domain's `EMAIL_RATING_SCALE`. */
20
+ const RATING_SCALE = [1, 2, 3, 4, 5] as const;
21
+
22
+ const STYLE_OPTIONS: ReadonlyArray<{
23
+ readonly value: EmailEditorRatingStyle;
24
+ readonly label: string;
25
+ }> = [
26
+ { value: "filled", label: "Étoiles pleines" },
27
+ { value: "outline", label: "Étoiles contour" },
28
+ ];
29
+
30
+ /**
31
+ * A one-to-five satisfaction question. In the sent email each star is its own
32
+ * link carrying `?rating=N`, which makes the five steps five separately
33
+ * countable tracked links; on the canvas they are inert.
34
+ */
35
+ function RatingBlockView({
36
+ block,
37
+ onChange,
38
+ }: EmailBlockComponentProps<EmailEditorRatingBlock>) {
39
+ return (
40
+ <div className="flex flex-col items-center gap-3 text-center">
41
+ <RichTextEditable
42
+ spans={block.question}
43
+ onChange={(question) => onChange({ ...block, question })}
44
+ ariaLabel="Question"
45
+ placeholder="Que pensez-vous de votre dernière mission ?"
46
+ className="text-center text-[16px] leading-[1.6]"
47
+ style={{ fontFamily: EMAIL_FONT, color: EMAIL_COLOR.ink }}
48
+ />
49
+ <div className="flex items-center gap-2" aria-hidden>
50
+ {RATING_SCALE.map((rating) => (
51
+ <StarIcon
52
+ key={rating}
53
+ size={30}
54
+ weight={block.style === "filled" ? "fill" : "regular"}
55
+ color={EMAIL_COLOR.brand}
56
+ />
57
+ ))}
58
+ </div>
59
+ {block.lowLabel === "" && block.highLabel === "" ? null : (
60
+ <div
61
+ className="flex w-full max-w-[220px] justify-between text-[12px]"
62
+ style={{ color: EMAIL_COLOR.muted, fontFamily: EMAIL_FONT }}
63
+ >
64
+ <span>{block.lowLabel}</span>
65
+ <span>{block.highLabel}</span>
66
+ </div>
67
+ )}
68
+ </div>
69
+ );
70
+ }
71
+
72
+ function RatingBlockSettings({
73
+ block,
74
+ onChange,
75
+ }: EmailBlockComponentProps<EmailEditorRatingBlock>) {
76
+ return (
77
+ <>
78
+ <BlockOptionSection title="Contenu">
79
+ <p className="text-muted-foreground text-xs">
80
+ La question se saisit et se met en forme directement sur le bloc,
81
+ comme un paragraphe.
82
+ </p>
83
+ <TextOption
84
+ label="Libellé bas de l'échelle"
85
+ value={block.lowLabel}
86
+ onChange={(lowLabel) => onChange({ ...block, lowLabel })}
87
+ placeholder="Pas du tout"
88
+ />
89
+ <TextOption
90
+ label="Libellé haut de l'échelle"
91
+ value={block.highLabel}
92
+ onChange={(highLabel) => onChange({ ...block, highLabel })}
93
+ placeholder="Tout à fait"
94
+ />
95
+ </BlockOptionSection>
96
+ <BlockOptionSection title="Apparence">
97
+ <SelectOption
98
+ label="Style"
99
+ value={block.style}
100
+ options={STYLE_OPTIONS}
101
+ onChange={(style) => onChange({ ...block, style })}
102
+ />
103
+ </BlockOptionSection>
104
+ <BlockOptionSection title="Lien">
105
+ <LinkOption
106
+ value={block.href}
107
+ onChange={(href) => onChange({ ...block, href })}
108
+ description="Chaque étoile pointe vers cette adresse avec « rating=1 » à « rating=5 » ajouté : les cinq notes sont donc comptées séparément dans les statistiques de clic."
109
+ />
110
+ </BlockOptionSection>
111
+ </>
112
+ );
113
+ }
114
+
115
+ export const ratingBlockDefinition: EmailBlockDefinition<EmailEditorRatingBlock> =
116
+ {
117
+ label: "Notation",
118
+ icon: StarIcon,
119
+ View: RatingBlockView,
120
+ Settings: RatingBlockSettings,
121
+ };
@@ -0,0 +1,88 @@
1
+ import { useLayoutEffect, useRef } from "react";
2
+ import {
3
+ editorElementToTextSpans,
4
+ textSpansToEditorHtml,
5
+ textSpansToPlainText,
6
+ } from "#/document/rich-text.ts";
7
+ import type { EmailEditorTextSpan } from "#/document/types.ts";
8
+
9
+ /**
10
+ * The in-place rich-text surface shared by every block that holds spans (the
11
+ * paragraph, a list item). Bold, italic, underline and links are applied from
12
+ * the block toolbar, which acts on the live selection — so this component only
13
+ * has to keep the DOM and the span model in sync.
14
+ *
15
+ * The DOM is rewritten only when the document changes from *outside* the
16
+ * editable, which is what keeps the caret from jumping while typing.
17
+ */
18
+ export function RichTextEditable({
19
+ spans,
20
+ onChange,
21
+ ariaLabel,
22
+ placeholder,
23
+ className,
24
+ style,
25
+ }: {
26
+ spans: ReadonlyArray<EmailEditorTextSpan>;
27
+ onChange: (spans: ReadonlyArray<EmailEditorTextSpan>) => void;
28
+ ariaLabel: string;
29
+ placeholder: string;
30
+ className?: string;
31
+ style?: React.CSSProperties;
32
+ }) {
33
+ const editableRef = useRef<HTMLDivElement>(null);
34
+ const renderedHtmlRef = useRef<string | null>(null);
35
+ const html = textSpansToEditorHtml(spans);
36
+
37
+ useLayoutEffect(() => {
38
+ const editable = editableRef.current;
39
+ if (editable && html !== renderedHtmlRef.current) {
40
+ editable.innerHTML = html;
41
+ renderedHtmlRef.current = html;
42
+ }
43
+ }, [html]);
44
+
45
+ const handleInput = () => {
46
+ const editable = editableRef.current;
47
+ if (!editable) {
48
+ return;
49
+ }
50
+ const next = editorElementToTextSpans(editable);
51
+ // Remember the normalized serialization: when the parent echoes the
52
+ // change back, the effect sees the same html and leaves the DOM (and
53
+ // the caret) alone.
54
+ renderedHtmlRef.current = textSpansToEditorHtml(next);
55
+ onChange(next);
56
+ };
57
+
58
+ const empty = textSpansToPlainText(spans).trim() === "";
59
+
60
+ return (
61
+ // biome-ignore lint/a11y/useSemanticElements: rich text needs contentEditable; the div IS the text box.
62
+ // biome-ignore lint/a11y/useFocusableInteractive: contentEditable elements are natively focusable.
63
+ <div
64
+ ref={editableRef}
65
+ role="textbox"
66
+ aria-multiline="true"
67
+ aria-label={ariaLabel}
68
+ contentEditable
69
+ suppressContentEditableWarning
70
+ data-placeholder={placeholder}
71
+ onInput={handleInput}
72
+ className={
73
+ // `min-h-[1lh]` keeps one line of height while the box is empty: a
74
+ // collapsed contentEditable would let the absolutely-positioned
75
+ // placeholder spill over whatever follows the block.
76
+ `relative min-h-[1lh] w-full whitespace-pre-wrap break-words outline-none [&_a]:text-[#151b77] [&_a]:underline ${className ?? ""} ` +
77
+ (empty
78
+ ? // `inset-x-0` matters: without horizontal offsets the pseudo-element
79
+ // takes its static position, which under a centred block starts at
80
+ // the middle of the line and wraps. Spanning the full width lets it
81
+ // inherit the block's own text alignment instead.
82
+ "before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:opacity-40 before:content-[attr(data-placeholder)]"
83
+ : "")
84
+ }
85
+ style={style}
86
+ />
87
+ );
88
+ }