@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
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # @voila.dev/ui-email-block-editor
2
+
3
+ WYSIWYG block editor for marketing emails. The canvas mirrors the transactional
4
+ email chrome (600px card, email theme) so what the admin composes looks like the
5
+ email that will be sent — the server-rendered preview stays the source of truth.
6
+
7
+ - `document/types.ts` — the editor document model (`EmailEditorDocument`,
8
+ blocks: heading, paragraph, button, image, divider). Pure TypeScript; the
9
+ domain's `MarketingEmailDocument` schema validates exactly this shape.
10
+ - `document/reducer.ts` — pure reducer (add/update/remove/move/duplicate/
11
+ select/replace) with an injected block-id factory.
12
+ - `blocks/*` — one WYSIWYG component per block, edited in place.
13
+ - `sections/*` — canvas chrome, block toolbar, add-block menu, settings sidebar.
14
+ - `dnd/sortable-block-list.tsx` — keyboard-accessible dnd-kit reordering.
15
+ - `email-block-editor.tsx` — the composed `<EmailBlockEditor />`.
16
+
17
+ Image uploads are delegated: the host passes `onUploadImage(file) → Promise<url>`.
18
+
19
+ Consumers must add this package to their Tailwind sources:
20
+
21
+ ```css
22
+ @source "../../../../packages/ui-email-block-editor/src";
23
+ ```
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@voila.dev/ui-email-block-editor",
3
+ "version": "1.1.9",
4
+ "type": "module",
5
+ "description": "The email template editor that lives in your app, not someone else's SaaS.",
6
+ "license": "MIT",
7
+ "homepage": "https://ui.voila.dev/components/email-block-editor",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/voila-voila-dev/ui.git",
11
+ "directory": "packages/ui-email-block-editor"
12
+ },
13
+ "keywords": [
14
+ "react",
15
+ "email",
16
+ "editor",
17
+ "block-editor",
18
+ "dnd-kit"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "!src/**/*.test.ts",
26
+ "!src/**/*.test.tsx",
27
+ "!src/**/*.stories.ts",
28
+ "!src/**/*.stories.tsx"
29
+ ],
30
+ "exports": {
31
+ "./styles.css": "./src/styles.css",
32
+ "./email-block-editor": "./src/email-block-editor.tsx",
33
+ "./theme": "./src/theme.ts",
34
+ "./document/*": "./src/document/*.ts",
35
+ "./blocks/*": "./src/blocks/*.tsx",
36
+ "./sections/*": "./src/sections/*.tsx",
37
+ "./dnd/*": "./src/dnd/*.tsx"
38
+ },
39
+ "imports": {
40
+ "#/*": "./src/*"
41
+ },
42
+ "scripts": {
43
+ "check": "biome check",
44
+ "check-types": "tsc --noEmit",
45
+ "format": "biome format",
46
+ "lint": "biome lint",
47
+ "test": "vitest run"
48
+ },
49
+ "dependencies": {
50
+ "@dnd-kit/core": "^6.3.1",
51
+ "@dnd-kit/sortable": "^10.0.0",
52
+ "@dnd-kit/utilities": "^3.2.2",
53
+ "@phosphor-icons/react": "2.1.10",
54
+ "@voila.dev/ui": "0.0.0"
55
+ },
56
+ "peerDependencies": {
57
+ "react": "^19.0.0",
58
+ "react-dom": "^19.0.0",
59
+ "tailwindcss": "^4.0.0"
60
+ },
61
+ "sideEffects": [
62
+ "**/*.css"
63
+ ]
64
+ }
@@ -0,0 +1,122 @@
1
+ import { NewspaperIcon } 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 { EmailCardMeta, EmailCardShell } from "#/blocks/email-card-shell.tsx";
8
+ import type { EmailEditorArticleBlock } from "#/document/types.ts";
9
+ import { BlockOptionSection } from "#/sections/block-options/block-option-row.tsx";
10
+ import {
11
+ LinkOption,
12
+ TextAreaOption,
13
+ TextOption,
14
+ } from "#/sections/block-options/text-option.tsx";
15
+ import { EMAIL_COLOR } from "#/theme.ts";
16
+
17
+ /** The author/date line, with a separator only when both are present. */
18
+ const metaLine = (block: EmailEditorArticleBlock): string =>
19
+ [block.author, block.publishDate].filter((part) => part !== "").join(" · ");
20
+
21
+ /**
22
+ * A blog post or an external resource, on the shared card shell. Field names
23
+ * follow the real blog model, so a digest built from published posts maps
24
+ * straight across. The title and description are edited in place; the image,
25
+ * the byline and the link live in the settings.
26
+ */
27
+ function ArticleBlockView({
28
+ block,
29
+ onChange,
30
+ }: EmailBlockComponentProps<EmailEditorArticleBlock>) {
31
+ const meta = metaLine(block);
32
+ return (
33
+ <EmailCardShell image={block.image}>
34
+ <BlockTextInput
35
+ ariaLabel="Titre de l'article"
36
+ value={block.title}
37
+ placeholder="Titre de l'article"
38
+ onChange={(title) => onChange({ ...block, title })}
39
+ className="font-bold text-[17px] leading-[1.3]"
40
+ style={{ color: EMAIL_COLOR.brand }}
41
+ />
42
+ {meta === "" ? null : <EmailCardMeta>{meta}</EmailCardMeta>}
43
+ <textarea
44
+ aria-label="Résumé de l'article"
45
+ value={block.description}
46
+ placeholder="Le résumé de l'article."
47
+ rows={2}
48
+ onChange={(event) =>
49
+ onChange({ ...block, description: event.target.value })
50
+ }
51
+ className="w-full resize-none border-none bg-transparent p-0 text-[15px] leading-[1.5] outline-none [field-sizing:content] placeholder:opacity-40"
52
+ style={{ color: EMAIL_COLOR.ink }}
53
+ />
54
+ </EmailCardShell>
55
+ );
56
+ }
57
+
58
+ function ArticleBlockSettings({
59
+ block,
60
+ onChange,
61
+ }: EmailBlockComponentProps<EmailEditorArticleBlock>) {
62
+ return (
63
+ <>
64
+ <BlockOptionSection title="Contenu">
65
+ <TextOption
66
+ label="Titre"
67
+ value={block.title}
68
+ onChange={(title) => onChange({ ...block, title })}
69
+ />
70
+ <TextAreaOption
71
+ label="Résumé"
72
+ value={block.description}
73
+ onChange={(description) => onChange({ ...block, description })}
74
+ />
75
+ <TextOption
76
+ label="Auteur"
77
+ value={block.author}
78
+ onChange={(author) => onChange({ ...block, author })}
79
+ />
80
+ <TextOption
81
+ label="Date de publication"
82
+ value={block.publishDate}
83
+ onChange={(publishDate) => onChange({ ...block, publishDate })}
84
+ placeholder="2026-07-20"
85
+ description="Format AAAA-MM-JJ ; la date est écrite dans la langue du destinataire à l'envoi."
86
+ />
87
+ </BlockOptionSection>
88
+ <BlockOptionSection title="Apparence">
89
+ <TextOption
90
+ label="Adresse de l'image"
91
+ value={block.image.src}
92
+ onChange={(src) =>
93
+ onChange({ ...block, image: { ...block.image, src } })
94
+ }
95
+ placeholder="https://"
96
+ />
97
+ <TextOption
98
+ label="Texte alternatif"
99
+ value={block.image.alt}
100
+ onChange={(alt) =>
101
+ onChange({ ...block, image: { ...block.image, alt } })
102
+ }
103
+ />
104
+ </BlockOptionSection>
105
+ <BlockOptionSection title="Lien">
106
+ <LinkOption
107
+ value={block.href}
108
+ onChange={(href) => onChange({ ...block, href })}
109
+ description="La carte entière renvoie vers cette adresse."
110
+ />
111
+ </BlockOptionSection>
112
+ </>
113
+ );
114
+ }
115
+
116
+ export const articleBlockDefinition: EmailBlockDefinition<EmailEditorArticleBlock> =
117
+ {
118
+ label: "Article",
119
+ icon: NewspaperIcon,
120
+ View: ArticleBlockView,
121
+ Settings: ArticleBlockSettings,
122
+ };
@@ -0,0 +1,94 @@
1
+ import type { Icon } from "@phosphor-icons/react";
2
+ import type { ComponentType, ReactNode } from "react";
3
+ import { articleBlockDefinition } from "#/blocks/article-block.tsx";
4
+ import { buttonBlockDefinition } from "#/blocks/button-block.tsx";
5
+ import { dividerBlockDefinition } from "#/blocks/divider-block.tsx";
6
+ import { gridBlockDefinition } from "#/blocks/grid-block.tsx";
7
+ import { headingBlockDefinition } from "#/blocks/heading-block.tsx";
8
+ import { imageBlockDefinition } from "#/blocks/image-block.tsx";
9
+ import { listBlockDefinition } from "#/blocks/list-block.tsx";
10
+ import { offerBlockDefinition } from "#/blocks/offer-block.tsx";
11
+ import { paragraphBlockDefinition } from "#/blocks/paragraph-block.tsx";
12
+ import { productBlockDefinition } from "#/blocks/product-block.tsx";
13
+ import { ratingBlockDefinition } from "#/blocks/rating-block.tsx";
14
+ import { statBlockDefinition } from "#/blocks/stat-block.tsx";
15
+ import { tableBlockDefinition } from "#/blocks/table-block.tsx";
16
+ import type {
17
+ EmailEditorBlock,
18
+ EmailEditorBlockType,
19
+ EmailEditorLeafBlockType,
20
+ EmailEditorPreview,
21
+ } from "#/document/types.ts";
22
+
23
+ /** What every block component receives, in the canvas and in the sidebar. */
24
+ export interface EmailBlockComponentProps<
25
+ B extends EmailEditorBlock = EmailEditorBlock,
26
+ > {
27
+ block: B;
28
+ selected: boolean;
29
+ onChange: (block: B) => void;
30
+ onUploadImage?: (file: File) => Promise<string>;
31
+ /** Which rendering the canvas is showing; a block whose layout differs
32
+ * between a phone and a desktop client reads it. Defaults to `desktop` in
33
+ * the settings panel, where it is irrelevant. */
34
+ preview?: EmailEditorPreview;
35
+ /** Container blocks only (the grid): the child rows the canvas has already
36
+ * wired for selection, drag-and-drop and their own toolbars. */
37
+ children?: ReactNode;
38
+ }
39
+
40
+ /**
41
+ * Everything the editor needs to know about one block type. Adding a block =
42
+ * add its interface to `document/types.ts` (type union + `createEmailEditorBlock`
43
+ * case), write one `blocks/<type>-block.tsx` exporting a definition, and
44
+ * register it below — the mapped type makes a missing entry a compile error,
45
+ * and the menu, canvas and sidebar all pick it up from here.
46
+ */
47
+ export interface EmailBlockDefinition<
48
+ B extends EmailEditorBlock = EmailEditorBlock,
49
+ > {
50
+ /** French label shown in the add-block menu. */
51
+ readonly label: string;
52
+ readonly icon: Icon;
53
+ /** The WYSIWYG rendering, edited in place on the canvas. */
54
+ readonly View: ComponentType<EmailBlockComponentProps<B>>;
55
+ /** The per-block settings panel; null when the block has none. */
56
+ readonly Settings: ComponentType<EmailBlockComponentProps<B>> | null;
57
+ }
58
+
59
+ export const EMAIL_BLOCK_DEFINITIONS: {
60
+ readonly [T in EmailEditorBlockType]: EmailBlockDefinition<
61
+ Extract<EmailEditorBlock, { type: T }>
62
+ >;
63
+ } = {
64
+ heading: headingBlockDefinition,
65
+ paragraph: paragraphBlockDefinition,
66
+ button: buttonBlockDefinition,
67
+ image: imageBlockDefinition,
68
+ divider: dividerBlockDefinition,
69
+ list: listBlockDefinition,
70
+ stat: statBlockDefinition,
71
+ table: tableBlockDefinition,
72
+ article: articleBlockDefinition,
73
+ product: productBlockDefinition,
74
+ offer: offerBlockDefinition,
75
+ rating: ratingBlockDefinition,
76
+ grid: gridBlockDefinition,
77
+ };
78
+
79
+ export const EMAIL_BLOCK_TYPES = Object.keys(
80
+ EMAIL_BLOCK_DEFINITIONS,
81
+ ) as ReadonlyArray<EmailEditorBlockType>;
82
+
83
+ /** The types offered inside a grid cell: everything but the grid, which cannot
84
+ * nest. Derived from the registry so a new leaf block appears automatically. */
85
+ export const EMAIL_LEAF_BLOCK_TYPES = EMAIL_BLOCK_TYPES.filter(
86
+ (type): type is EmailEditorLeafBlockType => type !== "grid",
87
+ );
88
+
89
+ /** Look up a block's definition with the union narrowed away — the registry
90
+ * guarantees the definition matches the block's own type. */
91
+ export const emailBlockDefinition = (
92
+ block: EmailEditorBlock,
93
+ ): EmailBlockDefinition =>
94
+ EMAIL_BLOCK_DEFINITIONS[block.type] as EmailBlockDefinition;
@@ -0,0 +1,44 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ /**
4
+ * A single-line field that *wraps* instead of clipping. An `<input>` scrolls
5
+ * its overflow out of sight, which is wrong on a canvas that has to mirror the
6
+ * email: inside a 178px grid cell a heading or a stat label has to run onto a
7
+ * second line exactly as the reader will see it.
8
+ *
9
+ * So it is a one-row `<textarea>` sized to its content, with Enter suppressed
10
+ * — the block model holds a plain string, and a line break in it would mean
11
+ * nothing to the renderer.
12
+ */
13
+ export function BlockTextInput({
14
+ value,
15
+ onChange,
16
+ ariaLabel,
17
+ placeholder,
18
+ className,
19
+ style,
20
+ }: {
21
+ value: string;
22
+ onChange: (value: string) => void;
23
+ ariaLabel: string;
24
+ placeholder?: string;
25
+ className?: string;
26
+ style?: CSSProperties;
27
+ }) {
28
+ return (
29
+ <textarea
30
+ aria-label={ariaLabel}
31
+ rows={1}
32
+ value={value}
33
+ placeholder={placeholder}
34
+ onChange={(event) => onChange(event.target.value)}
35
+ onKeyDown={(event) => {
36
+ if (event.key === "Enter") {
37
+ event.preventDefault();
38
+ }
39
+ }}
40
+ className={`w-full resize-none overflow-hidden border-none bg-transparent p-0 outline-none [field-sizing:content] placeholder:opacity-40 ${className ?? ""}`}
41
+ style={style}
42
+ />
43
+ );
44
+ }
@@ -0,0 +1,119 @@
1
+ import { CursorClickIcon } from "@phosphor-icons/react";
2
+ import type {
3
+ EmailBlockComponentProps,
4
+ EmailBlockDefinition,
5
+ } from "#/blocks/block-definitions.tsx";
6
+ import type {
7
+ EmailEditorAlignment,
8
+ EmailEditorButtonBlock,
9
+ EmailEditorButtonVariant,
10
+ } from "#/document/types.ts";
11
+ import { AlignmentOption } from "#/sections/block-options/alignment-option.tsx";
12
+ import { BlockOptionSection } from "#/sections/block-options/block-option-row.tsx";
13
+ import { SelectOption } from "#/sections/block-options/select-option.tsx";
14
+ import {
15
+ LinkOption,
16
+ TextOption,
17
+ } from "#/sections/block-options/text-option.tsx";
18
+ import { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
19
+
20
+ const VARIANT_OPTIONS: ReadonlyArray<{
21
+ readonly value: EmailEditorButtonVariant;
22
+ readonly label: string;
23
+ }> = [
24
+ { value: "primary", label: "Plein (couleur de marque)" },
25
+ { value: "secondary", label: "Contour" },
26
+ ];
27
+
28
+ /** Flexbox equivalents of the email's `align` attribute. */
29
+ const JUSTIFY: { readonly [A in EmailEditorAlignment]: string } = {
30
+ left: "flex-start",
31
+ center: "center",
32
+ right: "flex-end",
33
+ };
34
+
35
+ /**
36
+ * The call-to-action button. The label is edited in place; the target URL, the
37
+ * alignment and the variant live in the settings sidebar (no inline chip — the
38
+ * button carries one link for the whole block, unlike a paragraph's
39
+ * per-selection links). Mirrors the domain `emailButton` component.
40
+ */
41
+ function ButtonBlockView({
42
+ block,
43
+ onChange,
44
+ }: EmailBlockComponentProps<EmailEditorButtonBlock>) {
45
+ const filled = block.variant === "primary";
46
+ return (
47
+ <div className="flex" style={{ justifyContent: JUSTIFY[block.align] }}>
48
+ <span
49
+ className="inline-block rounded-lg px-[30px] py-[13px]"
50
+ style={{
51
+ backgroundColor: filled ? EMAIL_COLOR.brand : "transparent",
52
+ border: `1px solid ${EMAIL_COLOR.brand}`,
53
+ }}
54
+ >
55
+ <input
56
+ aria-label="Libellé du bouton"
57
+ value={block.label}
58
+ onChange={(event) =>
59
+ onChange({ ...block, label: event.target.value })
60
+ }
61
+ placeholder="Votre bouton"
62
+ className="min-w-16 max-w-full border-none bg-transparent p-0 text-center font-semibold text-[15px] leading-none outline-none [field-sizing:content] placeholder:opacity-50"
63
+ style={{
64
+ fontFamily: EMAIL_FONT,
65
+ color: filled ? "#ffffff" : EMAIL_COLOR.brand,
66
+ }}
67
+ />
68
+ </span>
69
+ </div>
70
+ );
71
+ }
72
+
73
+ function ButtonBlockSettings({
74
+ block,
75
+ onChange,
76
+ }: EmailBlockComponentProps<EmailEditorButtonBlock>) {
77
+ return (
78
+ <>
79
+ <BlockOptionSection title="Contenu">
80
+ <TextOption
81
+ label="Libellé"
82
+ value={block.label}
83
+ onChange={(label) => onChange({ ...block, label })}
84
+ />
85
+ </BlockOptionSection>
86
+ <BlockOptionSection title="Apparence">
87
+ <AlignmentOption
88
+ value={block.align}
89
+ onChange={(align) => onChange({ ...block, align })}
90
+ />
91
+ <SelectOption
92
+ label="Style"
93
+ value={block.variant}
94
+ options={VARIANT_OPTIONS}
95
+ onChange={(variant) => onChange({ ...block, variant })}
96
+ description={
97
+ block.variant === "secondary"
98
+ ? "Outlook (moteur Word) ignore les coins arrondis : le contour y sera à angles droits."
99
+ : undefined
100
+ }
101
+ />
102
+ </BlockOptionSection>
103
+ <BlockOptionSection title="Lien">
104
+ <LinkOption
105
+ value={block.href}
106
+ onChange={(href) => onChange({ ...block, href })}
107
+ />
108
+ </BlockOptionSection>
109
+ </>
110
+ );
111
+ }
112
+
113
+ export const buttonBlockDefinition: EmailBlockDefinition<EmailEditorButtonBlock> =
114
+ {
115
+ label: "Bouton",
116
+ icon: CursorClickIcon,
117
+ View: ButtonBlockView,
118
+ Settings: ButtonBlockSettings,
119
+ };
@@ -0,0 +1,19 @@
1
+ import { MinusIcon } from "@phosphor-icons/react";
2
+ import type { EmailBlockDefinition } from "#/blocks/block-definitions.tsx";
3
+ import type { EmailEditorDividerBlock } from "#/document/types.ts";
4
+ import { EMAIL_COLOR } from "#/theme.ts";
5
+
6
+ /** A horizontal rule. Mirrors the domain `emailDivider` component. */
7
+ function DividerBlockView() {
8
+ return (
9
+ <hr className="my-2 border-t" style={{ borderColor: EMAIL_COLOR.border }} />
10
+ );
11
+ }
12
+
13
+ export const dividerBlockDefinition: EmailBlockDefinition<EmailEditorDividerBlock> =
14
+ {
15
+ label: "Séparateur",
16
+ icon: MinusIcon,
17
+ View: DividerBlockView,
18
+ Settings: null,
19
+ };
@@ -0,0 +1,80 @@
1
+ import { ImageIcon } from "@phosphor-icons/react";
2
+ import type { ReactNode } from "react";
3
+ import type { EmailEditorCardImage } from "#/document/types.ts";
4
+ import { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
5
+
6
+ /**
7
+ * The one card shape. The article, product and offer blocks are all
8
+ * card-shaped, so they share this shell (and its `emailCard` counterpart in
9
+ * the renderer) rather than each growing its own border, radius and padding —
10
+ * which is exactly how three cards drift into three looks.
11
+ */
12
+ export function EmailCardShell({
13
+ image,
14
+ highlighted = false,
15
+ children,
16
+ }: {
17
+ /** Omit for a card with no visual; an empty `src` renders the placeholder. */
18
+ image?: EmailEditorCardImage;
19
+ highlighted?: boolean;
20
+ children: ReactNode;
21
+ }) {
22
+ return (
23
+ <div
24
+ className="overflow-hidden rounded-[14px]"
25
+ style={{
26
+ border: `${highlighted ? 2 : 1}px solid ${highlighted ? EMAIL_COLOR.brand : EMAIL_COLOR.border}`,
27
+ backgroundColor: EMAIL_COLOR.card,
28
+ fontFamily: EMAIL_FONT,
29
+ }}
30
+ >
31
+ {image === undefined ? null : <EmailCardImage image={image} />}
32
+ <div className="flex flex-col gap-2 px-[18px] py-4">{children}</div>
33
+ </div>
34
+ );
35
+ }
36
+
37
+ function EmailCardImage({ image }: { image: EmailEditorCardImage }) {
38
+ if (image.src === "") {
39
+ return (
40
+ <div
41
+ className="flex h-28 items-center justify-center"
42
+ style={{
43
+ backgroundColor: EMAIL_COLOR.canvas,
44
+ color: EMAIL_COLOR.muted,
45
+ }}
46
+ >
47
+ <ImageIcon size={24} aria-hidden />
48
+ </div>
49
+ );
50
+ }
51
+ return <img src={image.src} alt={image.alt} className="block w-full" />;
52
+ }
53
+
54
+ /** The muted meta line a card puts under its title (author, date, period). */
55
+ export function EmailCardMeta({ children }: { children: ReactNode }) {
56
+ return (
57
+ <div
58
+ className="text-[13px] leading-[1.4]"
59
+ style={{ color: EMAIL_COLOR.muted }}
60
+ >
61
+ {children}
62
+ </div>
63
+ );
64
+ }
65
+
66
+ /** A card's call to action. The target lives in the settings, so the canvas
67
+ * only shows the pill; an empty label means the card has no button. */
68
+ export function EmailCardButton({ label }: { label: string }) {
69
+ if (label === "") {
70
+ return null;
71
+ }
72
+ return (
73
+ <span
74
+ className="mt-1 inline-block self-start rounded-lg px-[18px] py-[10px] font-semibold text-[14px] text-white"
75
+ style={{ backgroundColor: EMAIL_COLOR.brand }}
76
+ >
77
+ {label}
78
+ </span>
79
+ );
80
+ }
@@ -0,0 +1,95 @@
1
+ import { ColumnsIcon } from "@phosphor-icons/react";
2
+ import type {
3
+ EmailBlockComponentProps,
4
+ EmailBlockDefinition,
5
+ } from "#/blocks/block-definitions.tsx";
6
+ import type {
7
+ EmailEditorGridBlock,
8
+ EmailEditorGridColumns,
9
+ EmailEditorGridMobileColumns,
10
+ } from "#/document/types.ts";
11
+ import { BlockOptionSection } from "#/sections/block-options/block-option-row.tsx";
12
+ import { SegmentedOption } from "#/sections/block-options/segmented-option.tsx";
13
+
14
+ /** The gutter between two cells, mirrored by the renderer's cell padding. */
15
+ export const EMAIL_GRID_GAP_PX = 16;
16
+
17
+ const DESKTOP_COLUMN_OPTIONS: ReadonlyArray<{
18
+ readonly value: EmailEditorGridColumns;
19
+ readonly label: string;
20
+ }> = [
21
+ { value: 1, label: "1" },
22
+ { value: 2, label: "2" },
23
+ { value: 3, label: "3" },
24
+ { value: 4, label: "4" },
25
+ ];
26
+
27
+ const MOBILE_COLUMN_OPTIONS: ReadonlyArray<{
28
+ readonly value: EmailEditorGridMobileColumns;
29
+ readonly label: string;
30
+ }> = [
31
+ { value: 1, label: "1" },
32
+ { value: 2, label: "2" },
33
+ ];
34
+
35
+ /**
36
+ * The layout shell of a multi-column row. The cells themselves — the child
37
+ * block rows and the « ajouter » slot — are composed by the canvas and slotted
38
+ * in as `children`, so the grid owns the layout and nothing else.
39
+ *
40
+ * The cells mirror the count the reader will actually get in the previewed
41
+ * client, which is what makes the desktop/mobile switch meaningful.
42
+ */
43
+ function GridBlockView({
44
+ block,
45
+ preview,
46
+ children,
47
+ }: EmailBlockComponentProps<EmailEditorGridBlock>) {
48
+ const columns =
49
+ preview === "mobile" ? block.mobileColumns : block.desktopColumns;
50
+ return (
51
+ <div
52
+ className="grid items-start"
53
+ style={{
54
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
55
+ gap: `${EMAIL_GRID_GAP_PX}px`,
56
+ }}
57
+ >
58
+ {children}
59
+ </div>
60
+ );
61
+ }
62
+
63
+ function GridBlockSettings({
64
+ block,
65
+ onChange,
66
+ }: EmailBlockComponentProps<EmailEditorGridBlock>) {
67
+ return (
68
+ <BlockOptionSection title="Apparence">
69
+ <SegmentedOption
70
+ label="Colonnes (ordinateur)"
71
+ value={block.desktopColumns}
72
+ options={DESKTOP_COLUMN_OPTIONS}
73
+ onChange={(desktopColumns) => onChange({ ...block, desktopColumns })}
74
+ />
75
+ <SegmentedOption
76
+ label="Colonnes (mobile)"
77
+ value={block.mobileColumns}
78
+ options={MOBILE_COLUMN_OPTIONS}
79
+ onChange={(mobileColumns) => onChange({ ...block, mobileColumns })}
80
+ description={
81
+ block.mobileColumns === block.desktopColumns
82
+ ? undefined
83
+ : "Un nombre de colonnes différent sur mobile repose sur une media query : l'application Gmail sur compte tiers l'ignore et repasse à une colonne."
84
+ }
85
+ />
86
+ </BlockOptionSection>
87
+ );
88
+ }
89
+
90
+ export const gridBlockDefinition: EmailBlockDefinition<EmailEditorGridBlock> = {
91
+ label: "Colonnes",
92
+ icon: ColumnsIcon,
93
+ View: GridBlockView,
94
+ Settings: GridBlockSettings,
95
+ };