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