@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,113 @@
|
|
|
1
|
+
import { ChartBarIcon } 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
|
+
EmailEditorAlignment,
|
|
9
|
+
EmailEditorStatBlock,
|
|
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 {
|
|
14
|
+
TextAreaOption,
|
|
15
|
+
TextOption,
|
|
16
|
+
} from "#/sections/block-options/text-option.tsx";
|
|
17
|
+
import { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
|
|
18
|
+
|
|
19
|
+
const TEXT_ALIGN: {
|
|
20
|
+
readonly [A in EmailEditorAlignment]: "left" | "center" | "right";
|
|
21
|
+
} = { left: "left", center: "center", right: "right" };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One figure with its caption. A row of three is a three-column grid of stat
|
|
25
|
+
* blocks — the block never invents its own multi-column layout (§1.5 of the
|
|
26
|
+
* editor plan). Every field is edited in place.
|
|
27
|
+
*/
|
|
28
|
+
function StatBlockView({
|
|
29
|
+
block,
|
|
30
|
+
selected,
|
|
31
|
+
onChange,
|
|
32
|
+
}: EmailBlockComponentProps<EmailEditorStatBlock>) {
|
|
33
|
+
const textAlign = TEXT_ALIGN[block.align];
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className="flex flex-col gap-1"
|
|
37
|
+
style={{ textAlign, fontFamily: EMAIL_FONT }}
|
|
38
|
+
>
|
|
39
|
+
<BlockTextInput
|
|
40
|
+
ariaLabel="Valeur"
|
|
41
|
+
value={block.value}
|
|
42
|
+
placeholder="128"
|
|
43
|
+
onChange={(value) => onChange({ ...block, value })}
|
|
44
|
+
className="font-bold text-[30px] leading-[1.1]"
|
|
45
|
+
style={{ color: EMAIL_COLOR.brand, textAlign }}
|
|
46
|
+
/>
|
|
47
|
+
<BlockTextInput
|
|
48
|
+
ariaLabel="Libellé"
|
|
49
|
+
value={block.label}
|
|
50
|
+
placeholder="Missions pourvues"
|
|
51
|
+
onChange={(label) => onChange({ ...block, label })}
|
|
52
|
+
className="font-semibold text-[12px] uppercase leading-[1.4] tracking-[0.04em]"
|
|
53
|
+
style={{ color: EMAIL_COLOR.muted, textAlign }}
|
|
54
|
+
/>
|
|
55
|
+
{/* The description is optional, so an empty one only takes up room
|
|
56
|
+
while the block is selected — otherwise the canvas would show a
|
|
57
|
+
line the email will not have. */}
|
|
58
|
+
{selected || block.description !== "" ? (
|
|
59
|
+
<BlockTextInput
|
|
60
|
+
ariaLabel="Description"
|
|
61
|
+
value={block.description}
|
|
62
|
+
placeholder="Description (optionnelle)"
|
|
63
|
+
onChange={(description) => onChange({ ...block, description })}
|
|
64
|
+
className="text-[14px] leading-[1.5]"
|
|
65
|
+
style={{ color: EMAIL_COLOR.ink, textAlign }}
|
|
66
|
+
/>
|
|
67
|
+
) : null}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function StatBlockSettings({
|
|
73
|
+
block,
|
|
74
|
+
onChange,
|
|
75
|
+
}: EmailBlockComponentProps<EmailEditorStatBlock>) {
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
<BlockOptionSection title="Contenu">
|
|
79
|
+
<TextOption
|
|
80
|
+
label="Valeur"
|
|
81
|
+
value={block.value}
|
|
82
|
+
onChange={(value) => onChange({ ...block, value })}
|
|
83
|
+
placeholder="128"
|
|
84
|
+
/>
|
|
85
|
+
<TextOption
|
|
86
|
+
label="Libellé"
|
|
87
|
+
value={block.label}
|
|
88
|
+
onChange={(label) => onChange({ ...block, label })}
|
|
89
|
+
placeholder="Missions pourvues"
|
|
90
|
+
/>
|
|
91
|
+
<TextAreaOption
|
|
92
|
+
label="Description"
|
|
93
|
+
value={block.description}
|
|
94
|
+
onChange={(description) => onChange({ ...block, description })}
|
|
95
|
+
rows={2}
|
|
96
|
+
/>
|
|
97
|
+
</BlockOptionSection>
|
|
98
|
+
<BlockOptionSection title="Apparence">
|
|
99
|
+
<AlignmentOption
|
|
100
|
+
value={block.align}
|
|
101
|
+
onChange={(align) => onChange({ ...block, align })}
|
|
102
|
+
/>
|
|
103
|
+
</BlockOptionSection>
|
|
104
|
+
</>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const statBlockDefinition: EmailBlockDefinition<EmailEditorStatBlock> = {
|
|
109
|
+
label: "Chiffre clé",
|
|
110
|
+
icon: ChartBarIcon,
|
|
111
|
+
View: StatBlockView,
|
|
112
|
+
Settings: StatBlockSettings,
|
|
113
|
+
};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { PlusIcon, TableIcon, XIcon } from "@phosphor-icons/react";
|
|
2
|
+
import { Button } from "@voila.dev/ui/components/button";
|
|
3
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
4
|
+
import type {
|
|
5
|
+
EmailBlockComponentProps,
|
|
6
|
+
EmailBlockDefinition,
|
|
7
|
+
} from "#/blocks/block-definitions.tsx";
|
|
8
|
+
import { BlockTextInput } from "#/blocks/block-text-input.tsx";
|
|
9
|
+
import type {
|
|
10
|
+
EmailEditorTableBlock,
|
|
11
|
+
EmailEditorTableColumn,
|
|
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 { EMAIL_COLOR, EMAIL_FONT } from "#/theme.ts";
|
|
19
|
+
|
|
20
|
+
const ALIGN_OPTIONS: ReadonlyArray<{
|
|
21
|
+
readonly value: EmailEditorTableColumn["align"];
|
|
22
|
+
readonly label: string;
|
|
23
|
+
}> = [
|
|
24
|
+
{ value: "left", label: "À gauche" },
|
|
25
|
+
{ value: "right", label: "À droite" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** Mirrors `emailLineItemsTable`'s `flushEdges`: the first and last columns
|
|
29
|
+
* lose their outer padding, so the table's text lines up with the blocks above
|
|
30
|
+
* and below it. */
|
|
31
|
+
const cellPaddingClassName = (index: number, columnCount: number): string =>
|
|
32
|
+
cn(
|
|
33
|
+
"py-2",
|
|
34
|
+
index === 0 ? "pl-0" : "pl-[10px]",
|
|
35
|
+
index === columnCount - 1 ? "pr-0" : "pr-[10px]",
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/** A row padded (or trimmed) to the current column count. */
|
|
39
|
+
const rowOfWidth = (
|
|
40
|
+
row: ReadonlyArray<string>,
|
|
41
|
+
width: number,
|
|
42
|
+
): ReadonlyArray<string> =>
|
|
43
|
+
Array.from({ length: width }, (_, index) => row[index] ?? "");
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A plain-text data table — an order recap, a schedule, a price list. Mirrors
|
|
47
|
+
* the domain `emailLineItemsTable` component: header rules, row separators and
|
|
48
|
+
* per-column alignment. Cells are edited in place.
|
|
49
|
+
*/
|
|
50
|
+
function TableBlockView({
|
|
51
|
+
block,
|
|
52
|
+
onChange,
|
|
53
|
+
}: EmailBlockComponentProps<EmailEditorTableBlock>) {
|
|
54
|
+
const setCell = (rowIndex: number, columnIndex: number, value: string) =>
|
|
55
|
+
onChange({
|
|
56
|
+
...block,
|
|
57
|
+
rows: block.rows.map((row, at) =>
|
|
58
|
+
at === rowIndex
|
|
59
|
+
? rowOfWidth(row, block.columns.length).map((cell, column) =>
|
|
60
|
+
column === columnIndex ? value : cell,
|
|
61
|
+
)
|
|
62
|
+
: row,
|
|
63
|
+
),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<table
|
|
68
|
+
className="w-full border-collapse"
|
|
69
|
+
style={{ fontFamily: EMAIL_FONT }}
|
|
70
|
+
>
|
|
71
|
+
{block.headerRow ? (
|
|
72
|
+
<thead>
|
|
73
|
+
<tr>
|
|
74
|
+
{block.columns.map((column, columnIndex) => (
|
|
75
|
+
<th
|
|
76
|
+
key={columnIndex}
|
|
77
|
+
className={cellPaddingClassName(
|
|
78
|
+
columnIndex,
|
|
79
|
+
block.columns.length,
|
|
80
|
+
)}
|
|
81
|
+
style={{
|
|
82
|
+
borderBottom: `2px solid ${EMAIL_COLOR.border}`,
|
|
83
|
+
textAlign: column.align,
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<BlockTextInput
|
|
87
|
+
ariaLabel={`Titre de la colonne ${columnIndex + 1}`}
|
|
88
|
+
value={column.label}
|
|
89
|
+
placeholder="Colonne"
|
|
90
|
+
onChange={(label) =>
|
|
91
|
+
onChange({
|
|
92
|
+
...block,
|
|
93
|
+
columns: block.columns.map((current, at) =>
|
|
94
|
+
at === columnIndex ? { ...current, label } : current,
|
|
95
|
+
),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
className="font-semibold text-[11px] uppercase leading-[1.3] tracking-[0.04em]"
|
|
99
|
+
style={{ color: EMAIL_COLOR.muted, textAlign: column.align }}
|
|
100
|
+
/>
|
|
101
|
+
</th>
|
|
102
|
+
))}
|
|
103
|
+
</tr>
|
|
104
|
+
</thead>
|
|
105
|
+
) : null}
|
|
106
|
+
<tbody>
|
|
107
|
+
{block.rows.map((row, rowIndex) => (
|
|
108
|
+
<tr key={rowIndex}>
|
|
109
|
+
{rowOfWidth(row, block.columns.length).map((cell, columnIndex) => (
|
|
110
|
+
<td
|
|
111
|
+
key={columnIndex}
|
|
112
|
+
className={cellPaddingClassName(
|
|
113
|
+
columnIndex,
|
|
114
|
+
block.columns.length,
|
|
115
|
+
)}
|
|
116
|
+
style={{ borderBottom: `1px solid ${EMAIL_COLOR.border}` }}
|
|
117
|
+
>
|
|
118
|
+
<BlockTextInput
|
|
119
|
+
ariaLabel={`Ligne ${rowIndex + 1}, colonne ${columnIndex + 1}`}
|
|
120
|
+
value={cell}
|
|
121
|
+
onChange={(value) => setCell(rowIndex, columnIndex, value)}
|
|
122
|
+
className="text-[14px] leading-[1.4]"
|
|
123
|
+
style={{
|
|
124
|
+
color: EMAIL_COLOR.ink,
|
|
125
|
+
textAlign: block.columns[columnIndex]?.align ?? "left",
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
</td>
|
|
129
|
+
))}
|
|
130
|
+
</tr>
|
|
131
|
+
))}
|
|
132
|
+
</tbody>
|
|
133
|
+
</table>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function TableColumnSettings({
|
|
138
|
+
block,
|
|
139
|
+
onChange,
|
|
140
|
+
}: {
|
|
141
|
+
block: EmailEditorTableBlock;
|
|
142
|
+
onChange: (block: EmailEditorTableBlock) => void;
|
|
143
|
+
}) {
|
|
144
|
+
return (
|
|
145
|
+
<>
|
|
146
|
+
{block.columns.map((column, index) => (
|
|
147
|
+
<div key={index} className="flex items-end gap-2">
|
|
148
|
+
<div className="flex-1">
|
|
149
|
+
<SelectOption
|
|
150
|
+
label={column.label.trim() || `Colonne ${index + 1}`}
|
|
151
|
+
value={column.align}
|
|
152
|
+
options={ALIGN_OPTIONS}
|
|
153
|
+
onChange={(align) =>
|
|
154
|
+
onChange({
|
|
155
|
+
...block,
|
|
156
|
+
columns: block.columns.map((current, at) =>
|
|
157
|
+
at === index ? { ...current, align } : current,
|
|
158
|
+
),
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
<Button
|
|
164
|
+
variant="ghost"
|
|
165
|
+
size="icon-sm"
|
|
166
|
+
aria-label={`Supprimer la colonne ${index + 1}`}
|
|
167
|
+
disabled={block.columns.length === 1}
|
|
168
|
+
onClick={() =>
|
|
169
|
+
onChange({
|
|
170
|
+
...block,
|
|
171
|
+
columns: block.columns.filter((_, at) => at !== index),
|
|
172
|
+
rows: block.rows.map((row) =>
|
|
173
|
+
row.filter((_, at) => at !== index),
|
|
174
|
+
),
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
>
|
|
178
|
+
<XIcon aria-hidden />
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
))}
|
|
182
|
+
<Button
|
|
183
|
+
variant="outline"
|
|
184
|
+
size="sm"
|
|
185
|
+
onClick={() =>
|
|
186
|
+
onChange({
|
|
187
|
+
...block,
|
|
188
|
+
columns: [...block.columns, { label: "", align: "left" }],
|
|
189
|
+
rows: block.rows.map((row) => [...row, ""]),
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
>
|
|
193
|
+
<PlusIcon aria-hidden />
|
|
194
|
+
Ajouter une colonne
|
|
195
|
+
</Button>
|
|
196
|
+
</>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function TableBlockSettings({
|
|
201
|
+
block,
|
|
202
|
+
onChange,
|
|
203
|
+
}: EmailBlockComponentProps<EmailEditorTableBlock>) {
|
|
204
|
+
return (
|
|
205
|
+
<>
|
|
206
|
+
<BlockOptionSection title="Contenu">
|
|
207
|
+
<div className="flex items-center justify-between gap-2 text-sm">
|
|
208
|
+
<span className="text-muted-foreground">
|
|
209
|
+
{block.rows.length} ligne{block.rows.length > 1 ? "s" : ""}
|
|
210
|
+
</span>
|
|
211
|
+
<div className="flex gap-1">
|
|
212
|
+
<Button
|
|
213
|
+
variant="ghost"
|
|
214
|
+
size="icon-sm"
|
|
215
|
+
aria-label="Supprimer la dernière ligne"
|
|
216
|
+
disabled={block.rows.length === 1}
|
|
217
|
+
onClick={() =>
|
|
218
|
+
onChange({ ...block, rows: block.rows.slice(0, -1) })
|
|
219
|
+
}
|
|
220
|
+
>
|
|
221
|
+
<XIcon aria-hidden />
|
|
222
|
+
</Button>
|
|
223
|
+
<Button
|
|
224
|
+
variant="outline"
|
|
225
|
+
size="sm"
|
|
226
|
+
onClick={() =>
|
|
227
|
+
onChange({
|
|
228
|
+
...block,
|
|
229
|
+
rows: [...block.rows, block.columns.map(() => "")],
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
>
|
|
233
|
+
<PlusIcon aria-hidden />
|
|
234
|
+
Ligne
|
|
235
|
+
</Button>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</BlockOptionSection>
|
|
239
|
+
<BlockOptionSection title="Apparence">
|
|
240
|
+
<ToggleOption
|
|
241
|
+
label="Ligne d'en-tête"
|
|
242
|
+
checked={block.headerRow}
|
|
243
|
+
onChange={(headerRow) => onChange({ ...block, headerRow })}
|
|
244
|
+
/>
|
|
245
|
+
<TableColumnSettings block={block} onChange={onChange} />
|
|
246
|
+
</BlockOptionSection>
|
|
247
|
+
</>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const tableBlockDefinition: EmailBlockDefinition<EmailEditorTableBlock> =
|
|
252
|
+
{
|
|
253
|
+
label: "Tableau",
|
|
254
|
+
icon: TableIcon,
|
|
255
|
+
View: TableBlockView,
|
|
256
|
+
Settings: TableBlockSettings,
|
|
257
|
+
};
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CollisionDetection,
|
|
3
|
+
DndContext,
|
|
4
|
+
type DragEndEvent,
|
|
5
|
+
type DraggableAttributes,
|
|
6
|
+
type DraggableSyntheticListeners,
|
|
7
|
+
KeyboardSensor,
|
|
8
|
+
PointerSensor,
|
|
9
|
+
pointerWithin,
|
|
10
|
+
rectIntersection,
|
|
11
|
+
useDroppable,
|
|
12
|
+
useSensor,
|
|
13
|
+
useSensors,
|
|
14
|
+
} from "@dnd-kit/core";
|
|
15
|
+
import {
|
|
16
|
+
rectSortingStrategy,
|
|
17
|
+
SortableContext,
|
|
18
|
+
sortableKeyboardCoordinates,
|
|
19
|
+
useSortable,
|
|
20
|
+
verticalListSortingStrategy,
|
|
21
|
+
} from "@dnd-kit/sortable";
|
|
22
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
23
|
+
import type { ReactNode } from "react";
|
|
24
|
+
import {
|
|
25
|
+
type EmailEditorContainerId,
|
|
26
|
+
emailEditorContainerBlocks,
|
|
27
|
+
emailEditorContainerOf,
|
|
28
|
+
} from "#/document/reducer.ts";
|
|
29
|
+
import {
|
|
30
|
+
type EmailEditorBlock,
|
|
31
|
+
isEmailEditorGridBlock,
|
|
32
|
+
} from "#/document/types.ts";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Containers are droppable in their own right — that is how a block lands in
|
|
36
|
+
* an empty grid, or below the last block of the document. Their droppable ids
|
|
37
|
+
* are namespaced so they never collide with the block ids of the sortables.
|
|
38
|
+
*/
|
|
39
|
+
const CONTAINER_PREFIX = "container:";
|
|
40
|
+
const ROOT_CONTAINER = `${CONTAINER_PREFIX}root`;
|
|
41
|
+
|
|
42
|
+
const containerDroppableId = (containerId: EmailEditorContainerId): string =>
|
|
43
|
+
containerId === null ? ROOT_CONTAINER : `${CONTAINER_PREFIX}${containerId}`;
|
|
44
|
+
|
|
45
|
+
const containerIdFromDroppable = (
|
|
46
|
+
droppableId: string,
|
|
47
|
+
): EmailEditorContainerId | undefined => {
|
|
48
|
+
if (!droppableId.startsWith(CONTAINER_PREFIX)) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
return droppableId === ROOT_CONTAINER
|
|
52
|
+
? null
|
|
53
|
+
: droppableId.slice(CONTAINER_PREFIX.length);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What a sortable item hands to its drag handle: spread `attributes` and
|
|
58
|
+
* `listeners` on the handle element and pass it `setActivatorNodeRef`, so the
|
|
59
|
+
* drag starts from the handle only (keyboard included) and the block's inputs
|
|
60
|
+
* stay freely clickable.
|
|
61
|
+
*/
|
|
62
|
+
export interface SortableBlockHandle {
|
|
63
|
+
readonly attributes: DraggableAttributes;
|
|
64
|
+
readonly listeners: DraggableSyntheticListeners;
|
|
65
|
+
readonly setActivatorNodeRef: (element: HTMLElement | null) => void;
|
|
66
|
+
readonly isDragging: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Rank a droppable by how specific it is, so the deepest target under the
|
|
71
|
+
* pointer wins: a block nested in a grid beats that grid's own drop area,
|
|
72
|
+
* which beats a root-level block (the grid's sortable node is one), which
|
|
73
|
+
* beats the document root. Without this a drop aimed inside a grid would land
|
|
74
|
+
* beside it.
|
|
75
|
+
*/
|
|
76
|
+
const droppableDepth = (
|
|
77
|
+
blocks: ReadonlyArray<EmailEditorBlock>,
|
|
78
|
+
droppableId: string,
|
|
79
|
+
): number => {
|
|
80
|
+
if (droppableId === ROOT_CONTAINER) {
|
|
81
|
+
return 3;
|
|
82
|
+
}
|
|
83
|
+
if (droppableId.startsWith(CONTAINER_PREFIX)) {
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
return emailEditorContainerOf(blocks, droppableId) === null ? 2 : 0;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The dnd-kit context for the whole document: one instance at the editor root,
|
|
91
|
+
* one `SortableContext` per container below it. Reordering is announced
|
|
92
|
+
* through `onMove` with the dragged block id and its destination container and
|
|
93
|
+
* index; the document itself stays owned by the editor reducer.
|
|
94
|
+
*/
|
|
95
|
+
export function EmailEditorDndContext({
|
|
96
|
+
blocks,
|
|
97
|
+
onMove,
|
|
98
|
+
children,
|
|
99
|
+
}: {
|
|
100
|
+
blocks: ReadonlyArray<EmailEditorBlock>;
|
|
101
|
+
onMove: (
|
|
102
|
+
blockId: string,
|
|
103
|
+
toContainerId: EmailEditorContainerId,
|
|
104
|
+
toIndex: number,
|
|
105
|
+
) => void;
|
|
106
|
+
children: ReactNode;
|
|
107
|
+
}) {
|
|
108
|
+
const sensors = useSensors(
|
|
109
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
110
|
+
useSensor(KeyboardSensor, {
|
|
111
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const collisionDetection: CollisionDetection = (args) => {
|
|
116
|
+
const found = pointerWithin(args);
|
|
117
|
+
const collisions = found.length > 0 ? found : rectIntersection(args);
|
|
118
|
+
return [...collisions].sort(
|
|
119
|
+
(left, right) =>
|
|
120
|
+
droppableDepth(blocks, String(left.id)) -
|
|
121
|
+
droppableDepth(blocks, String(right.id)),
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// fallow-ignore-next-line complexity -- one destination resolution with its guards; cognitive complexity is 7.
|
|
126
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
127
|
+
const { active, over } = event;
|
|
128
|
+
if (over === null || active.id === over.id) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const activeId = String(active.id);
|
|
132
|
+
const overId = String(over.id);
|
|
133
|
+
const overContainer = containerIdFromDroppable(overId);
|
|
134
|
+
|
|
135
|
+
const destination =
|
|
136
|
+
overContainer !== undefined
|
|
137
|
+
? {
|
|
138
|
+
containerId: overContainer,
|
|
139
|
+
index: (emailEditorContainerBlocks(blocks, overContainer) ?? [])
|
|
140
|
+
.length,
|
|
141
|
+
}
|
|
142
|
+
: resolveDropOnBlock(blocks, overId);
|
|
143
|
+
if (destination === undefined) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A grid cannot nest: aiming one at something inside another grid drops
|
|
148
|
+
// it beside that grid instead of doing nothing.
|
|
149
|
+
const activeBlock = blocks.find((block) => block.id === activeId);
|
|
150
|
+
if (
|
|
151
|
+
activeBlock !== undefined &&
|
|
152
|
+
isEmailEditorGridBlock(activeBlock) &&
|
|
153
|
+
destination.containerId !== null
|
|
154
|
+
) {
|
|
155
|
+
const rootIndex = blocks.findIndex(
|
|
156
|
+
(block) => block.id === destination.containerId,
|
|
157
|
+
);
|
|
158
|
+
onMove(activeId, null, rootIndex === -1 ? blocks.length : rootIndex);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
onMove(activeId, destination.containerId, destination.index);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<DndContext
|
|
166
|
+
sensors={sensors}
|
|
167
|
+
collisionDetection={collisionDetection}
|
|
168
|
+
onDragEnd={handleDragEnd}
|
|
169
|
+
>
|
|
170
|
+
{children}
|
|
171
|
+
</DndContext>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The container and index of a block that was dropped onto. */
|
|
176
|
+
const resolveDropOnBlock = (
|
|
177
|
+
blocks: ReadonlyArray<EmailEditorBlock>,
|
|
178
|
+
blockId: string,
|
|
179
|
+
): { containerId: EmailEditorContainerId; index: number } | undefined => {
|
|
180
|
+
const containerId = emailEditorContainerOf(blocks, blockId);
|
|
181
|
+
if (containerId === undefined) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
const siblings = emailEditorContainerBlocks(blocks, containerId) ?? [];
|
|
185
|
+
return {
|
|
186
|
+
containerId,
|
|
187
|
+
index: siblings.findIndex((block) => block.id === blockId),
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* One container's sortable children. `layout` picks the sorting strategy: a
|
|
193
|
+
* vertical list for the document root, a rectangular one for a grid's cells.
|
|
194
|
+
* The wrapper is itself droppable, which is what lets a block land in an empty
|
|
195
|
+
* grid or in the blank space under the last block.
|
|
196
|
+
*/
|
|
197
|
+
export function SortableBlockContainer({
|
|
198
|
+
containerId,
|
|
199
|
+
blockIds,
|
|
200
|
+
layout,
|
|
201
|
+
className,
|
|
202
|
+
style,
|
|
203
|
+
children,
|
|
204
|
+
}: {
|
|
205
|
+
containerId: EmailEditorContainerId;
|
|
206
|
+
blockIds: ReadonlyArray<string>;
|
|
207
|
+
layout: "list" | "grid";
|
|
208
|
+
className?: string;
|
|
209
|
+
style?: React.CSSProperties;
|
|
210
|
+
children: ReactNode;
|
|
211
|
+
}) {
|
|
212
|
+
const { setNodeRef } = useDroppable({
|
|
213
|
+
id: containerDroppableId(containerId),
|
|
214
|
+
});
|
|
215
|
+
return (
|
|
216
|
+
<SortableContext
|
|
217
|
+
items={[...blockIds]}
|
|
218
|
+
strategy={
|
|
219
|
+
layout === "grid" ? rectSortingStrategy : verticalListSortingStrategy
|
|
220
|
+
}
|
|
221
|
+
>
|
|
222
|
+
<div ref={setNodeRef} className={className} style={style}>
|
|
223
|
+
{children}
|
|
224
|
+
</div>
|
|
225
|
+
</SortableContext>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** One draggable row; renders its content through a render prop that receives
|
|
230
|
+
* the drag handle to attach to the block toolbar. */
|
|
231
|
+
export function SortableBlockItem({
|
|
232
|
+
blockId,
|
|
233
|
+
className,
|
|
234
|
+
children,
|
|
235
|
+
}: {
|
|
236
|
+
blockId: string;
|
|
237
|
+
className?: string;
|
|
238
|
+
children: (handle: SortableBlockHandle) => ReactNode;
|
|
239
|
+
}) {
|
|
240
|
+
const {
|
|
241
|
+
attributes,
|
|
242
|
+
listeners,
|
|
243
|
+
setNodeRef,
|
|
244
|
+
setActivatorNodeRef,
|
|
245
|
+
transform,
|
|
246
|
+
transition,
|
|
247
|
+
isDragging,
|
|
248
|
+
} = useSortable({ id: blockId });
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<div
|
|
252
|
+
ref={setNodeRef}
|
|
253
|
+
className={className}
|
|
254
|
+
style={{
|
|
255
|
+
transform: CSS.Transform.toString(transform),
|
|
256
|
+
transition,
|
|
257
|
+
...(isDragging && { opacity: 0.6, zIndex: 1, position: "relative" }),
|
|
258
|
+
}}
|
|
259
|
+
>
|
|
260
|
+
{children({ attributes, listeners, setActivatorNodeRef, isDragging })}
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|