@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,403 @@
|
|
|
1
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
2
|
+
import { type ReactElement, type ReactNode, useState } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import {
|
|
5
|
+
EMAIL_LEAF_BLOCK_TYPES,
|
|
6
|
+
type EmailBlockComponentProps,
|
|
7
|
+
emailBlockDefinition,
|
|
8
|
+
} from "#/blocks/block-definitions.tsx";
|
|
9
|
+
import { EMAIL_GRID_GAP_PX } from "#/blocks/grid-block.tsx";
|
|
10
|
+
import {
|
|
11
|
+
EmailEditorDndContext,
|
|
12
|
+
SortableBlockContainer,
|
|
13
|
+
type SortableBlockHandle,
|
|
14
|
+
SortableBlockItem,
|
|
15
|
+
} from "#/dnd/sortable-block-list.tsx";
|
|
16
|
+
import type {
|
|
17
|
+
EmailEditorAction,
|
|
18
|
+
EmailEditorContainerId,
|
|
19
|
+
EmailEditorState,
|
|
20
|
+
} from "#/document/reducer.ts";
|
|
21
|
+
import {
|
|
22
|
+
EMAIL_PREVIEW_WIDTH,
|
|
23
|
+
type EmailEditorBlock,
|
|
24
|
+
type EmailEditorGridBlock,
|
|
25
|
+
type EmailEditorPreview,
|
|
26
|
+
isEmailEditorGridBlock,
|
|
27
|
+
} from "#/document/types.ts";
|
|
28
|
+
import { useCoarsePointer } from "#/lib/use-media-query.ts";
|
|
29
|
+
import { AddBlockMenu } from "#/sections/add-block-menu.tsx";
|
|
30
|
+
import { BlockToolbar } from "#/sections/block-toolbar.tsx";
|
|
31
|
+
import { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
|
|
32
|
+
|
|
33
|
+
/** Render `node` into `slot`, or in place when there is no slot. */
|
|
34
|
+
const renderIn = (slot: HTMLElement | null, node: ReactElement): ReactNode =>
|
|
35
|
+
slot === null ? node : createPortal(node, slot);
|
|
36
|
+
|
|
37
|
+
/** Blocks whose content is edited through the span model, and therefore want
|
|
38
|
+
* the toolbar's bold/italic/underline/link group. */
|
|
39
|
+
const RICH_TEXT_BLOCK_TYPES: ReadonlySet<string> = new Set([
|
|
40
|
+
"paragraph",
|
|
41
|
+
"list",
|
|
42
|
+
"rating",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** Everything the block rows need from the editor, threaded down one level of
|
|
46
|
+
* nesting without turning each row into a ten-prop component. */
|
|
47
|
+
interface CanvasContext {
|
|
48
|
+
readonly state: EmailEditorState;
|
|
49
|
+
readonly dispatch: (action: EmailEditorAction) => void;
|
|
50
|
+
readonly coarsePointer: boolean;
|
|
51
|
+
readonly preview: EmailEditorPreview;
|
|
52
|
+
readonly onUploadImage?: (file: File) => Promise<string>;
|
|
53
|
+
readonly onOpenSettings?: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The dashed « ajouter » slot of a grid cell. */
|
|
57
|
+
function GridAddCell({
|
|
58
|
+
gridId,
|
|
59
|
+
context,
|
|
60
|
+
}: {
|
|
61
|
+
gridId: string;
|
|
62
|
+
context: CanvasContext;
|
|
63
|
+
}) {
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex items-center justify-center rounded-lg border border-dashed px-2 py-6">
|
|
66
|
+
<AddBlockMenu
|
|
67
|
+
types={EMAIL_LEAF_BLOCK_TYPES}
|
|
68
|
+
onAdd={(type) =>
|
|
69
|
+
context.dispatch({
|
|
70
|
+
type: "add",
|
|
71
|
+
blockType: type,
|
|
72
|
+
containerId: gridId,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function GridBlockCells({
|
|
81
|
+
block,
|
|
82
|
+
context,
|
|
83
|
+
}: {
|
|
84
|
+
block: EmailEditorGridBlock;
|
|
85
|
+
context: CanvasContext;
|
|
86
|
+
}) {
|
|
87
|
+
const definition = emailBlockDefinition(block);
|
|
88
|
+
const selected = context.state.selectedBlockId === block.id;
|
|
89
|
+
const showAddCell = selected || block.children.length === 0;
|
|
90
|
+
// Under a touch pointer a cell is far too narrow for a row of 44px targets,
|
|
91
|
+
// so a selected child's toolbar is portalled up here and gets the whole
|
|
92
|
+
// grid's width. Under a mouse the toolbar floats and needs no help.
|
|
93
|
+
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
|
|
94
|
+
return (
|
|
95
|
+
<SortableBlockContainer
|
|
96
|
+
containerId={block.id}
|
|
97
|
+
blockIds={block.children.map((child) => child.id)}
|
|
98
|
+
layout="grid"
|
|
99
|
+
>
|
|
100
|
+
<div ref={setToolbarSlot} className="mb-2 empty:mb-0" />
|
|
101
|
+
<definition.View
|
|
102
|
+
block={block}
|
|
103
|
+
selected={selected}
|
|
104
|
+
preview={context.preview}
|
|
105
|
+
onChange={(updated) =>
|
|
106
|
+
context.dispatch({ type: "update", block: updated })
|
|
107
|
+
}
|
|
108
|
+
onUploadImage={context.onUploadImage}
|
|
109
|
+
>
|
|
110
|
+
{block.children.map((child, index) => (
|
|
111
|
+
<CanvasBlockRow
|
|
112
|
+
key={child.id}
|
|
113
|
+
block={child}
|
|
114
|
+
index={index}
|
|
115
|
+
containerId={block.id}
|
|
116
|
+
context={context}
|
|
117
|
+
toolbarSlot={context.coarsePointer ? toolbarSlot : null}
|
|
118
|
+
/>
|
|
119
|
+
))}
|
|
120
|
+
{showAddCell ? (
|
|
121
|
+
<GridAddCell gridId={block.id} context={context} />
|
|
122
|
+
) : null}
|
|
123
|
+
</definition.View>
|
|
124
|
+
</SortableBlockContainer>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The controls of the selected row. It stays mounted while dragging — dnd-kit
|
|
130
|
+
* holds a reference to the drag handle it contains — but is hidden, so it
|
|
131
|
+
* stops floating over the neighbouring blocks' text.
|
|
132
|
+
*
|
|
133
|
+
* Under a touch pointer the 44px bar is too tall to float without covering the
|
|
134
|
+
* previous block, so it sits in the flow above its own block; a row inside a
|
|
135
|
+
* grid cell hands it to the grid instead, which has the width for it. The
|
|
136
|
+
* portal keeps it in this row's React tree, so dnd-kit and the selection
|
|
137
|
+
* handlers are unaffected.
|
|
138
|
+
*/
|
|
139
|
+
// fallow-ignore-next-line complexity -- prop wiring: one dispatch per toolbar action, plus the nested/root split.
|
|
140
|
+
function CanvasBlockRowToolbar({
|
|
141
|
+
block,
|
|
142
|
+
index,
|
|
143
|
+
containerId,
|
|
144
|
+
context,
|
|
145
|
+
handle,
|
|
146
|
+
toolbarSlot,
|
|
147
|
+
}: {
|
|
148
|
+
block: EmailEditorBlock;
|
|
149
|
+
index: number;
|
|
150
|
+
containerId: EmailEditorContainerId;
|
|
151
|
+
context: CanvasContext;
|
|
152
|
+
handle: SortableBlockHandle;
|
|
153
|
+
toolbarSlot: HTMLElement | null;
|
|
154
|
+
}) {
|
|
155
|
+
const { dispatch, coarsePointer } = context;
|
|
156
|
+
const nested = containerId !== null;
|
|
157
|
+
return renderIn(
|
|
158
|
+
toolbarSlot,
|
|
159
|
+
<div
|
|
160
|
+
className={cn(
|
|
161
|
+
toolbarSlot !== null || coarsePointer
|
|
162
|
+
? "mb-2"
|
|
163
|
+
: "-top-9 absolute right-0 z-10",
|
|
164
|
+
handle.isDragging && "pointer-events-none opacity-0",
|
|
165
|
+
)}
|
|
166
|
+
>
|
|
167
|
+
<BlockToolbar
|
|
168
|
+
handle={handle}
|
|
169
|
+
richText={RICH_TEXT_BLOCK_TYPES.has(block.type)}
|
|
170
|
+
coarsePointer={coarsePointer}
|
|
171
|
+
addableTypes={nested ? EMAIL_LEAF_BLOCK_TYPES : undefined}
|
|
172
|
+
onAddBelow={(type) =>
|
|
173
|
+
dispatch({
|
|
174
|
+
type: "add",
|
|
175
|
+
blockType: type,
|
|
176
|
+
containerId,
|
|
177
|
+
index: index + 1,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
onDuplicate={() => dispatch({ type: "duplicate", blockId: block.id })}
|
|
181
|
+
onRemove={() => dispatch({ type: "remove", blockId: block.id })}
|
|
182
|
+
onOpenSettings={context.onOpenSettings}
|
|
183
|
+
onSelectContainer={
|
|
184
|
+
nested
|
|
185
|
+
? () => dispatch({ type: "select", blockId: containerId })
|
|
186
|
+
: undefined
|
|
187
|
+
}
|
|
188
|
+
/>
|
|
189
|
+
</div>,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function CanvasBlockRow({
|
|
194
|
+
block,
|
|
195
|
+
index,
|
|
196
|
+
containerId,
|
|
197
|
+
context,
|
|
198
|
+
toolbarSlot = null,
|
|
199
|
+
}: {
|
|
200
|
+
block: EmailEditorBlock;
|
|
201
|
+
index: number;
|
|
202
|
+
containerId: EmailEditorContainerId;
|
|
203
|
+
context: CanvasContext;
|
|
204
|
+
/** Where to render the toolbar when the row's own column is too narrow for
|
|
205
|
+
* it; see {@link GridBlockCells}. */
|
|
206
|
+
toolbarSlot?: HTMLElement | null;
|
|
207
|
+
}) {
|
|
208
|
+
const { dispatch } = context;
|
|
209
|
+
const selected = context.state.selectedBlockId === block.id;
|
|
210
|
+
const definition = emailBlockDefinition(block);
|
|
211
|
+
const grid = isEmailEditorGridBlock(block) ? block : null;
|
|
212
|
+
const viewProps: EmailBlockComponentProps = {
|
|
213
|
+
block,
|
|
214
|
+
selected,
|
|
215
|
+
preview: context.preview,
|
|
216
|
+
onChange: (updated) => dispatch({ type: "update", block: updated }),
|
|
217
|
+
onUploadImage: context.onUploadImage,
|
|
218
|
+
};
|
|
219
|
+
// Selecting the innermost block: the child's handler runs first and stops
|
|
220
|
+
// the event, so clicking inside a grid cell never selects the grid.
|
|
221
|
+
const select = (event: { stopPropagation: () => void }) => {
|
|
222
|
+
event.stopPropagation();
|
|
223
|
+
dispatch({ type: "select", blockId: block.id });
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<SortableBlockItem
|
|
228
|
+
blockId={block.id}
|
|
229
|
+
className={cn(
|
|
230
|
+
"group relative rounded-lg py-3",
|
|
231
|
+
// Every row's *content* starts at its container's edge, root or
|
|
232
|
+
// cell — that alignment is what makes the canvas read as one email.
|
|
233
|
+
// The padding that gives the selection ring its breathing room is
|
|
234
|
+
// cancelled by an equal negative margin, so it never shifts content.
|
|
235
|
+
// A cell gets half the 16px gutter on each side, so two neighbouring
|
|
236
|
+
// rings meet exactly rather than overlapping.
|
|
237
|
+
containerId === null ? "-mx-3 px-3" : "-mx-2 px-2",
|
|
238
|
+
selected && "ring-2 ring-ring/50",
|
|
239
|
+
)}
|
|
240
|
+
>
|
|
241
|
+
{(handle) => (
|
|
242
|
+
<>
|
|
243
|
+
{selected ? (
|
|
244
|
+
<CanvasBlockRowToolbar
|
|
245
|
+
block={block}
|
|
246
|
+
index={index}
|
|
247
|
+
containerId={containerId}
|
|
248
|
+
context={context}
|
|
249
|
+
handle={handle}
|
|
250
|
+
toolbarSlot={toolbarSlot}
|
|
251
|
+
/>
|
|
252
|
+
) : null}
|
|
253
|
+
{/* Selection follows focus (most blocks host a focusable control)
|
|
254
|
+
plus plain clicks for non-editable blocks like the divider. */}
|
|
255
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: selection sugar; the real controls inside stay keyboard-accessible. */}
|
|
256
|
+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: same as above. */}
|
|
257
|
+
<div onClick={select} onFocus={select}>
|
|
258
|
+
{grid === null ? (
|
|
259
|
+
<definition.View {...viewProps} />
|
|
260
|
+
) : (
|
|
261
|
+
<GridBlockCells block={grid} context={context} />
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
</>
|
|
265
|
+
)}
|
|
266
|
+
</SortableBlockItem>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Neutral stand-in for the branded header the server prepends. Pass
|
|
272
|
+
* `headerSlot` to render your own logo instead.
|
|
273
|
+
*/
|
|
274
|
+
function CardHeaderPlaceholder() {
|
|
275
|
+
return (
|
|
276
|
+
<div
|
|
277
|
+
className="flex justify-center pt-8 pb-2 text-[13px]"
|
|
278
|
+
style={{ color: EMAIL_COLOR.muted, fontFamily: EMAIL_FONT }}
|
|
279
|
+
>
|
|
280
|
+
<div className="rounded-md border border-dashed px-4 py-3">
|
|
281
|
+
Your header
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Neutral stand-in for the branded footer the server appends. Pass
|
|
289
|
+
* `footerSlot` to render your own.
|
|
290
|
+
*/
|
|
291
|
+
function CardFooterPlaceholder() {
|
|
292
|
+
return (
|
|
293
|
+
<div
|
|
294
|
+
className="px-8 pt-6 pb-2 text-center text-[13px] leading-[1.6]"
|
|
295
|
+
style={{ color: EMAIL_COLOR.muted, fontFamily: EMAIL_FONT }}
|
|
296
|
+
>
|
|
297
|
+
<div>
|
|
298
|
+
The full footer (contact details, social links, unsubscribe) is added
|
|
299
|
+
when the email is sent.
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The editing surface, dressed as the email it produces: the grey canvas, the
|
|
307
|
+
* 600px white card, the blocks, and header/footer placeholders.
|
|
308
|
+
* The real chrome is rendered server-side — the canvas only mirrors it.
|
|
309
|
+
* Blocks are added from the selected block's toolbar (+); an empty document
|
|
310
|
+
* shows a single centered add button instead.
|
|
311
|
+
*/
|
|
312
|
+
export function EditorCanvas({
|
|
313
|
+
state,
|
|
314
|
+
dispatch,
|
|
315
|
+
preview,
|
|
316
|
+
onUploadImage,
|
|
317
|
+
onOpenSettings,
|
|
318
|
+
headerSlot,
|
|
319
|
+
footerSlot,
|
|
320
|
+
}: {
|
|
321
|
+
state: EmailEditorState;
|
|
322
|
+
dispatch: (action: EmailEditorAction) => void;
|
|
323
|
+
/** Which rendering to mirror: the 600px card, or a phone-width one where
|
|
324
|
+
* grids collapse to their mobile column count. */
|
|
325
|
+
preview: EmailEditorPreview;
|
|
326
|
+
onUploadImage?: (file: File) => Promise<string>;
|
|
327
|
+
/** Present when the settings live in a sheet rather than in the sidebar;
|
|
328
|
+
* each selected block's toolbar then offers a « Réglages » button. */
|
|
329
|
+
onOpenSettings?: () => void;
|
|
330
|
+
headerSlot?: ReactNode;
|
|
331
|
+
footerSlot?: ReactNode;
|
|
332
|
+
}) {
|
|
333
|
+
const coarsePointer = useCoarsePointer();
|
|
334
|
+
const blocks = state.document.blocks;
|
|
335
|
+
const context: CanvasContext = {
|
|
336
|
+
state,
|
|
337
|
+
dispatch,
|
|
338
|
+
coarsePointer,
|
|
339
|
+
preview,
|
|
340
|
+
onUploadImage,
|
|
341
|
+
onOpenSettings,
|
|
342
|
+
};
|
|
343
|
+
return (
|
|
344
|
+
<div
|
|
345
|
+
className="flex justify-center rounded-lg px-4 py-8"
|
|
346
|
+
style={{ backgroundColor: EMAIL_COLOR.canvas }}
|
|
347
|
+
>
|
|
348
|
+
<div
|
|
349
|
+
className="w-full"
|
|
350
|
+
style={{ maxWidth: `${EMAIL_PREVIEW_WIDTH[preview]}px` }}
|
|
351
|
+
>
|
|
352
|
+
<div
|
|
353
|
+
className="rounded-[14px] border"
|
|
354
|
+
style={{
|
|
355
|
+
backgroundColor: EMAIL_COLOR.card,
|
|
356
|
+
borderColor: EMAIL_COLOR.border,
|
|
357
|
+
}}
|
|
358
|
+
>
|
|
359
|
+
{headerSlot ?? <CardHeaderPlaceholder />}
|
|
360
|
+
<div className="px-8 pt-2 pb-8">
|
|
361
|
+
<EmailEditorDndContext
|
|
362
|
+
blocks={blocks}
|
|
363
|
+
onMove={(blockId, toContainerId, toIndex) =>
|
|
364
|
+
dispatch({ type: "move", blockId, toContainerId, toIndex })
|
|
365
|
+
}
|
|
366
|
+
>
|
|
367
|
+
<SortableBlockContainer
|
|
368
|
+
containerId={null}
|
|
369
|
+
blockIds={blocks.map((block) => block.id)}
|
|
370
|
+
layout="list"
|
|
371
|
+
style={{ minHeight: `${EMAIL_GRID_GAP_PX}px` }}
|
|
372
|
+
>
|
|
373
|
+
{blocks.map((block, index) => (
|
|
374
|
+
<CanvasBlockRow
|
|
375
|
+
key={block.id}
|
|
376
|
+
block={block}
|
|
377
|
+
index={index}
|
|
378
|
+
containerId={null}
|
|
379
|
+
context={context}
|
|
380
|
+
/>
|
|
381
|
+
))}
|
|
382
|
+
</SortableBlockContainer>
|
|
383
|
+
</EmailEditorDndContext>
|
|
384
|
+
{blocks.length === 0 ? (
|
|
385
|
+
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-10">
|
|
386
|
+
<p
|
|
387
|
+
className="text-[14px]"
|
|
388
|
+
style={{ color: EMAIL_COLOR.muted, fontFamily: EMAIL_FONT }}
|
|
389
|
+
>
|
|
390
|
+
Votre email est vide.
|
|
391
|
+
</p>
|
|
392
|
+
<AddBlockMenu
|
|
393
|
+
onAdd={(type) => dispatch({ type: "add", blockType: type })}
|
|
394
|
+
/>
|
|
395
|
+
</div>
|
|
396
|
+
) : null}
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
{footerSlot ?? <CardFooterPlaceholder />}
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
);
|
|
403
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type EmailBlockComponentProps,
|
|
3
|
+
emailBlockDefinition,
|
|
4
|
+
} from "#/blocks/block-definitions.tsx";
|
|
5
|
+
import {
|
|
6
|
+
allEmailEditorBlocks,
|
|
7
|
+
type EmailEditorAction,
|
|
8
|
+
type EmailEditorState,
|
|
9
|
+
} from "#/document/reducer.ts";
|
|
10
|
+
import type { EmailEditorBlock } from "#/document/types.ts";
|
|
11
|
+
|
|
12
|
+
function SelectedBlockSettings({
|
|
13
|
+
block,
|
|
14
|
+
dispatch,
|
|
15
|
+
onUploadImage,
|
|
16
|
+
}: {
|
|
17
|
+
block: EmailEditorBlock;
|
|
18
|
+
dispatch: (action: EmailEditorAction) => void;
|
|
19
|
+
onUploadImage?: (file: File) => Promise<string>;
|
|
20
|
+
}) {
|
|
21
|
+
const definition = emailBlockDefinition(block);
|
|
22
|
+
if (definition.Settings === null) {
|
|
23
|
+
return (
|
|
24
|
+
<p className="text-muted-foreground text-sm">
|
|
25
|
+
Aucun réglage pour ce bloc.
|
|
26
|
+
</p>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const settingsProps: EmailBlockComponentProps = {
|
|
30
|
+
block,
|
|
31
|
+
selected: true,
|
|
32
|
+
onChange: (updated) => dispatch({ type: "update", block: updated }),
|
|
33
|
+
onUploadImage,
|
|
34
|
+
};
|
|
35
|
+
return <definition.Settings key={block.id} {...settingsProps} />;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The settings of the selected block, driven by the block registry. Rendered
|
|
40
|
+
* bare so it can live either in the desktop sidebar card or in the mobile
|
|
41
|
+
* bottom sheet without either owning the other's chrome.
|
|
42
|
+
*/
|
|
43
|
+
export function BlockSettingsPanel({
|
|
44
|
+
state,
|
|
45
|
+
dispatch,
|
|
46
|
+
onUploadImage,
|
|
47
|
+
}: {
|
|
48
|
+
state: EmailEditorState;
|
|
49
|
+
dispatch: (action: EmailEditorAction) => void;
|
|
50
|
+
onUploadImage?: (file: File) => Promise<string>;
|
|
51
|
+
}) {
|
|
52
|
+
// Grid children are selectable too, so the lookup walks the whole tree.
|
|
53
|
+
const selectedBlock =
|
|
54
|
+
allEmailEditorBlocks(state.document.blocks).find(
|
|
55
|
+
(block) => block.id === state.selectedBlockId,
|
|
56
|
+
) ?? null;
|
|
57
|
+
|
|
58
|
+
if (selectedBlock === null) {
|
|
59
|
+
return (
|
|
60
|
+
<p className="text-muted-foreground text-sm">
|
|
61
|
+
Sélectionnez un bloc pour modifier ses réglages.
|
|
62
|
+
</p>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<SelectedBlockSettings
|
|
68
|
+
block={selectedBlock}
|
|
69
|
+
dispatch={dispatch}
|
|
70
|
+
onUploadImage={onUploadImage}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The desktop settings column: the panel above, in its own titled card. */
|
|
76
|
+
export function EditorSidebar({
|
|
77
|
+
state,
|
|
78
|
+
dispatch,
|
|
79
|
+
onUploadImage,
|
|
80
|
+
}: {
|
|
81
|
+
state: EmailEditorState;
|
|
82
|
+
dispatch: (action: EmailEditorAction) => void;
|
|
83
|
+
onUploadImage?: (file: File) => Promise<string>;
|
|
84
|
+
}) {
|
|
85
|
+
return (
|
|
86
|
+
<div className="flex flex-col gap-4 rounded-lg border bg-background p-4">
|
|
87
|
+
<h3 className="font-medium text-sm">Réglages du bloc</h3>
|
|
88
|
+
<BlockSettingsPanel
|
|
89
|
+
state={state}
|
|
90
|
+
dispatch={dispatch}
|
|
91
|
+
onUploadImage={onUploadImage}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Button } from "@voila.dev/ui/components/button";
|
|
2
|
+
import { Input } from "@voila.dev/ui/components/input";
|
|
3
|
+
import {
|
|
4
|
+
Popover,
|
|
5
|
+
PopoverContent,
|
|
6
|
+
PopoverTrigger,
|
|
7
|
+
} from "@voila.dev/ui/components/popover";
|
|
8
|
+
import { type ReactElement, useState } from "react";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The shared « lien » editing surface: a small popover with a URL field,
|
|
12
|
+
* apply, and an optional remove action. The paragraph toolbar drives it with
|
|
13
|
+
* text-selection callbacks; the button block edits its target through it.
|
|
14
|
+
*/
|
|
15
|
+
export function LinkPopover({
|
|
16
|
+
trigger,
|
|
17
|
+
initialHref,
|
|
18
|
+
onOpen,
|
|
19
|
+
onApply,
|
|
20
|
+
onRemove,
|
|
21
|
+
}: {
|
|
22
|
+
trigger: ReactElement;
|
|
23
|
+
/** Resolved each time the popover opens (e.g. the link under the caret). */
|
|
24
|
+
initialHref: () => string;
|
|
25
|
+
/** Runs as the popover opens (the toolbar saves the text selection here). */
|
|
26
|
+
onOpen?: () => void;
|
|
27
|
+
onApply: (href: string) => void;
|
|
28
|
+
/** « Retirer le lien » — omitted when removing makes no sense (a button). */
|
|
29
|
+
onRemove?: () => void;
|
|
30
|
+
}) {
|
|
31
|
+
const [open, setOpen] = useState(false);
|
|
32
|
+
const [href, setHref] = useState("");
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Popover
|
|
36
|
+
open={open}
|
|
37
|
+
onOpenChange={(next) => {
|
|
38
|
+
if (next) {
|
|
39
|
+
onOpen?.();
|
|
40
|
+
setHref(initialHref());
|
|
41
|
+
}
|
|
42
|
+
setOpen(next);
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
<PopoverTrigger render={trigger} />
|
|
46
|
+
<PopoverContent
|
|
47
|
+
side="bottom"
|
|
48
|
+
align="start"
|
|
49
|
+
className="w-[min(18rem,calc(100vw-2rem))] p-3"
|
|
50
|
+
>
|
|
51
|
+
<form
|
|
52
|
+
className="flex flex-col gap-2"
|
|
53
|
+
onSubmit={(event) => {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
onApply(href.trim());
|
|
56
|
+
setOpen(false);
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
<Input
|
|
60
|
+
aria-label="Adresse du lien"
|
|
61
|
+
type="url"
|
|
62
|
+
placeholder="https://"
|
|
63
|
+
value={href}
|
|
64
|
+
onChange={(event) => setHref(event.target.value)}
|
|
65
|
+
autoFocus
|
|
66
|
+
/>
|
|
67
|
+
<div className="flex items-center justify-between gap-2">
|
|
68
|
+
<Button type="submit" size="sm" disabled={href.trim() === ""}>
|
|
69
|
+
Appliquer
|
|
70
|
+
</Button>
|
|
71
|
+
{onRemove ? (
|
|
72
|
+
<Button
|
|
73
|
+
type="button"
|
|
74
|
+
variant="ghost"
|
|
75
|
+
size="sm"
|
|
76
|
+
onClick={() => {
|
|
77
|
+
onRemove();
|
|
78
|
+
setOpen(false);
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
Retirer le lien
|
|
82
|
+
</Button>
|
|
83
|
+
) : null}
|
|
84
|
+
</div>
|
|
85
|
+
</form>
|
|
86
|
+
</PopoverContent>
|
|
87
|
+
</Popover>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { DesktopIcon, DeviceMobileIcon } from "@phosphor-icons/react";
|
|
2
|
+
import {
|
|
3
|
+
SegmentedControl,
|
|
4
|
+
SegmentedControlItem,
|
|
5
|
+
} from "@voila.dev/ui/components/segmented-control";
|
|
6
|
+
import type { EmailEditorPreview } from "#/document/types.ts";
|
|
7
|
+
|
|
8
|
+
const PREVIEWS: ReadonlyArray<{
|
|
9
|
+
readonly value: EmailEditorPreview;
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly Icon: typeof DesktopIcon;
|
|
12
|
+
}> = [
|
|
13
|
+
{ value: "desktop", label: "Ordinateur", Icon: DesktopIcon },
|
|
14
|
+
{ value: "mobile", label: "Mobile", Icon: DeviceMobileIcon },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Switches the canvas between the two renderings an email actually gets: the
|
|
19
|
+
* full 600px card, and a phone-width one where grids collapse to their mobile
|
|
20
|
+
* column count. Editor chrome, so it uses app styling and sits above the
|
|
21
|
+
* canvas rather than inside the card.
|
|
22
|
+
*/
|
|
23
|
+
export function PreviewToggle({
|
|
24
|
+
value,
|
|
25
|
+
onChange,
|
|
26
|
+
}: {
|
|
27
|
+
value: EmailEditorPreview;
|
|
28
|
+
onChange: (preview: EmailEditorPreview) => void;
|
|
29
|
+
}) {
|
|
30
|
+
return (
|
|
31
|
+
<SegmentedControl
|
|
32
|
+
size="sm"
|
|
33
|
+
aria-label="Aperçu"
|
|
34
|
+
value={value}
|
|
35
|
+
onValueChange={(next) => onChange(next as EmailEditorPreview)}
|
|
36
|
+
>
|
|
37
|
+
{PREVIEWS.map((preview) => (
|
|
38
|
+
<SegmentedControlItem key={preview.value} value={preview.value}>
|
|
39
|
+
<preview.Icon aria-hidden />
|
|
40
|
+
{preview.label}
|
|
41
|
+
</SegmentedControlItem>
|
|
42
|
+
))}
|
|
43
|
+
</SegmentedControl>
|
|
44
|
+
);
|
|
45
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tailwind must scan this package or none of its classes are generated — the
|
|
3
|
+
* components ship as .tsx source, not as compiled CSS.
|
|
4
|
+
*
|
|
5
|
+
* `@source` resolves relative to the file that declares it, so this works
|
|
6
|
+
* wherever the package ends up: node_modules in an app, a symlink in a
|
|
7
|
+
* workspace. Consumers import this file and add nothing of their own.
|
|
8
|
+
*/
|
|
9
|
+
@source "./";
|
|
10
|
+
@source not "./**/*.test.*";
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email look mirrored into the editor canvas, so blocks render on screen the
|
|
3
|
+
* way the sent email will. Override these to match your own email theme: your
|
|
4
|
+
* server-side renderer stays the source of truth, and these values only need to
|
|
5
|
+
* agree with it closely enough that the canvas is an honest preview.
|
|
6
|
+
*/
|
|
7
|
+
export const EMAIL_COLOR = {
|
|
8
|
+
brand: "#151b77",
|
|
9
|
+
ink: "#2a2a33",
|
|
10
|
+
muted: "#9095a3",
|
|
11
|
+
border: "#ececf1",
|
|
12
|
+
card: "#ffffff",
|
|
13
|
+
canvas: "#f4f4f7",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export const EMAIL_FONT =
|
|
17
|
+
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The locale the canvas previews prices and dates in, mirroring
|
|
21
|
+
* `renderMarketingEmailDocument`'s default. Deliberately not the browser's
|
|
22
|
+
* locale: the canvas shows what the email will look like, and a French author
|
|
23
|
+
* on an English-configured machine would otherwise see a price the recipient
|
|
24
|
+
* never gets. The sent email formats per recipient.
|
|
25
|
+
*/
|
|
26
|
+
export const EMAIL_PREVIEW_LOCALE = "fr-FR";
|