@morphika/andami 0.1.8 → 0.1.10
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 +3 -0
- package/components/admin/nav-builder/NavBuilder.tsx +90 -14
- package/components/admin/nav-builder/NavGeneralSettings.tsx +521 -271
- package/components/admin/nav-builder/NavItemSettings.tsx +331 -312
- package/components/admin/nav-builder/NavMobileSettings.tsx +159 -140
- package/components/admin/nav-builder/NavSettingsFields.tsx +287 -21
- package/components/admin/nav-builder/NavSettingsPanel.tsx +137 -127
- package/components/blocks/TextBlockRenderer.tsx +1 -1
- package/components/builder/SettingsPanel.tsx +29 -543
- package/components/builder/editors/ButtonBlockEditor.tsx +8 -3
- package/components/builder/editors/CoverBlockEditor.tsx +14 -6
- package/components/builder/editors/ImageBlockEditor.tsx +8 -3
- package/components/builder/editors/ImageGridBlockEditor.tsx +8 -3
- package/components/builder/editors/ProjectGridEditor.tsx +7 -46
- package/components/builder/editors/SpacerBlockEditor.tsx +4 -1
- package/components/builder/editors/StaggerSettings.tsx +2 -1
- package/components/builder/editors/TextBlockEditor.tsx +8 -3
- package/components/builder/editors/VideoBlockEditor.tsx +10 -4
- package/components/builder/editors/section-icons.tsx +492 -0
- package/components/builder/editors/shared.tsx +23 -4
- package/components/builder/live-preview/GhostCard.tsx +84 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +294 -1010
- package/components/builder/live-preview/LiveTextEditor.tsx +1 -1
- package/components/builder/live-preview/ProjectCardWrapper.tsx +291 -0
- package/components/builder/live-preview/drag-utils.tsx +89 -0
- package/components/builder/live-preview/useDragReorder.ts +370 -0
- package/components/builder/settings-panel/AnimationTab.tsx +152 -0
- package/components/builder/settings-panel/BlockLayoutTab.tsx +13 -58
- package/components/builder/settings-panel/CardEntranceSection.tsx +114 -0
- package/components/builder/settings-panel/ColumnV2AnimationTab.tsx +32 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +4 -1
- package/components/builder/settings-panel/CustomSectionSettings.tsx +150 -0
- package/components/builder/settings-panel/LayoutTab.tsx +11 -47
- package/components/builder/settings-panel/PageSettings.tsx +10 -4
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +6 -2
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +8 -3
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +11 -47
- package/components/builder/settings-panel/SectionV2Settings.tsx +6 -27
- package/components/builder/settings-panel/index.ts +6 -0
- package/components/builder/settings-panel/useSettingsPanelSelection.ts +184 -0
- package/components/ui/Navbar.tsx +151 -30
- package/lib/builder/serializer/migrations.ts +107 -0
- package/lib/builder/serializer/normalizers.ts +278 -0
- package/lib/builder/serializer/serializers.ts +393 -0
- package/lib/builder/serializer/shared.ts +102 -0
- package/lib/builder/serializer.ts +11 -846
- package/lib/sanity/types.ts +22 -0
- package/package.json +13 -10
- package/styles/base.css +7 -3
|
@@ -1,1010 +1,294 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* LiveProjectGridPreview — Builder canvas preview for ProjectGrid v2.
|
|
5
|
-
*
|
|
6
|
-
* Uses the shared masonry engine (lib/builder/masonry.ts) with absolute
|
|
7
|
-
* positioning for pixel-perfect parity with the public renderer.
|
|
8
|
-
*
|
|
9
|
-
* Features:
|
|
10
|
-
* - JS masonry layout (shortest-column algorithm)
|
|
11
|
-
* - 1–6 columns, per-card aspect ratio overrides
|
|
12
|
-
* - Card selection (click → blue border, settings panel shows per-card controls)
|
|
13
|
-
* - Hover state (blue border, suppressed during drag)
|
|
14
|
-
* - Override badge (aspect ratio override indicator)
|
|
15
|
-
* - Custom drag & drop: hold card body (150ms) or grab handle (immediate)
|
|
16
|
-
* → grabbed state (darkened + icon) → dragging (ghost follows mouse,
|
|
17
|
-
* dashed placeholder, green drop targets via coordinate hit-testing)
|
|
18
|
-
* → swap on drop or cancel fly-back animation
|
|
19
|
-
*
|
|
20
|
-
* Session 105 Phase 4: Builder live preview rewrite.
|
|
21
|
-
* Session 105 Phase 5: Custom DnD system (drag initiation, ghost, coordinate
|
|
22
|
-
* hit-testing for drop targets, swap logic, cancel animation).
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
import {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
position: "relative",
|
|
296
|
-
width: cardWidth,
|
|
297
|
-
height: cardHeight,
|
|
298
|
-
cursor: "pointer",
|
|
299
|
-
borderRadius: br,
|
|
300
|
-
overflow: "hidden",
|
|
301
|
-
...dropStyle,
|
|
302
|
-
...hoverStyle,
|
|
303
|
-
...selectStyle,
|
|
304
|
-
transition: "outline 150ms ease, box-shadow 150ms ease",
|
|
305
|
-
}}
|
|
306
|
-
onPointerDown={handleCardDown}
|
|
307
|
-
onClick={handleClick}
|
|
308
|
-
onMouseEnter={() => setIsHovered(true)}
|
|
309
|
-
onMouseLeave={() => setIsHovered(false)}
|
|
310
|
-
>
|
|
311
|
-
{/* Drag handle — centered, visible on hover/selection (hidden during drag) */}
|
|
312
|
-
<div
|
|
313
|
-
className={`absolute z-10 transition-opacity ${
|
|
314
|
-
(isHovered || isSelected) && !isAnyDragActive
|
|
315
|
-
? "opacity-100"
|
|
316
|
-
: "opacity-0 pointer-events-none"
|
|
317
|
-
}`}
|
|
318
|
-
style={{
|
|
319
|
-
inset: 0,
|
|
320
|
-
display: "flex",
|
|
321
|
-
alignItems: "center",
|
|
322
|
-
justifyContent: "center",
|
|
323
|
-
}}
|
|
324
|
-
>
|
|
325
|
-
<div
|
|
326
|
-
onPointerDown={handleHandleDown}
|
|
327
|
-
onClick={(e) => e.stopPropagation()}
|
|
328
|
-
style={{
|
|
329
|
-
width: 48,
|
|
330
|
-
height: 48,
|
|
331
|
-
borderRadius: "50%",
|
|
332
|
-
backgroundColor: "rgba(255,255,255,0.92)",
|
|
333
|
-
display: "flex",
|
|
334
|
-
alignItems: "center",
|
|
335
|
-
justifyContent: "center",
|
|
336
|
-
boxShadow: "0 2px 12px rgba(0,0,0,0.25)",
|
|
337
|
-
cursor: "grab",
|
|
338
|
-
transform: `scale(${invZoom})`,
|
|
339
|
-
}}
|
|
340
|
-
title="Drag to reorder"
|
|
341
|
-
aria-label="Drag to reorder project"
|
|
342
|
-
>
|
|
343
|
-
<CrossArrowIcon size={22} color="#333" />
|
|
344
|
-
</div>
|
|
345
|
-
</div>
|
|
346
|
-
|
|
347
|
-
{/* Per-card aspect ratio override badge — bottom-right */}
|
|
348
|
-
{item.aspect_ratio_override && (
|
|
349
|
-
<div
|
|
350
|
-
className="absolute z-10"
|
|
351
|
-
style={{
|
|
352
|
-
bottom: 8,
|
|
353
|
-
right: 8,
|
|
354
|
-
transform: `scale(${invZoom})`,
|
|
355
|
-
transformOrigin: "bottom right",
|
|
356
|
-
}}
|
|
357
|
-
>
|
|
358
|
-
<span
|
|
359
|
-
className="px-1.5 py-0.5 rounded text-[9px] font-medium"
|
|
360
|
-
style={{
|
|
361
|
-
backgroundColor: "rgba(0,0,0,0.6)",
|
|
362
|
-
color: "rgba(255,255,255,0.85)",
|
|
363
|
-
backdropFilter: "blur(4px)",
|
|
364
|
-
}}
|
|
365
|
-
>
|
|
366
|
-
{item.aspect_ratio_override.replace("/", ":")}
|
|
367
|
-
</span>
|
|
368
|
-
</div>
|
|
369
|
-
)}
|
|
370
|
-
|
|
371
|
-
<ProjectGridCard
|
|
372
|
-
slug={item.project_slug}
|
|
373
|
-
thumbPath={thumbMap.get(item.project_slug)}
|
|
374
|
-
customThumb={item.custom_thumbnail}
|
|
375
|
-
borderRadius={brStr}
|
|
376
|
-
style={{ width: "100%", height: "100%" }}
|
|
377
|
-
/>
|
|
378
|
-
|
|
379
|
-
{/* Green drop-target overlay (covers the image) */}
|
|
380
|
-
{isDropTarget && (
|
|
381
|
-
<div
|
|
382
|
-
style={{
|
|
383
|
-
position: "absolute",
|
|
384
|
-
inset: 0,
|
|
385
|
-
backgroundColor: "rgba(34,197,94,0.30)",
|
|
386
|
-
borderRadius: br,
|
|
387
|
-
pointerEvents: "none",
|
|
388
|
-
zIndex: 5,
|
|
389
|
-
border: `2px solid ${DROP_GREEN}`,
|
|
390
|
-
}}
|
|
391
|
-
/>
|
|
392
|
-
)}
|
|
393
|
-
</div>
|
|
394
|
-
);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// ─── GhostCard ──────────────────────────────────────────────────────
|
|
398
|
-
|
|
399
|
-
function GhostCard({
|
|
400
|
-
item,
|
|
401
|
-
thumbMap,
|
|
402
|
-
borderRadius,
|
|
403
|
-
dragState,
|
|
404
|
-
}: {
|
|
405
|
-
item: ProjectGridItem;
|
|
406
|
-
thumbMap: Map<string, string | undefined>;
|
|
407
|
-
borderRadius: number;
|
|
408
|
-
dragState: DragState;
|
|
409
|
-
}) {
|
|
410
|
-
const isCancelling = dragState.phase === "cancelling";
|
|
411
|
-
return (
|
|
412
|
-
<div
|
|
413
|
-
style={{
|
|
414
|
-
position: "fixed",
|
|
415
|
-
left: dragState.mouseX - dragState.offsetX,
|
|
416
|
-
top: dragState.mouseY - dragState.offsetY,
|
|
417
|
-
width: dragState.cardWidth,
|
|
418
|
-
height: dragState.cardHeight,
|
|
419
|
-
zIndex: 9999,
|
|
420
|
-
pointerEvents: "none",
|
|
421
|
-
borderRadius: borderRadius > 0 ? borderRadius : 8,
|
|
422
|
-
overflow: "hidden",
|
|
423
|
-
transform: isCancelling ? "scale(1)" : "scale(1.03)",
|
|
424
|
-
outline: `2px solid ${ADMIN_BLUE}`,
|
|
425
|
-
outlineOffset: -2,
|
|
426
|
-
boxShadow: isCancelling
|
|
427
|
-
? "0 4px 16px rgba(0,0,0,0.15)"
|
|
428
|
-
: "0 16px 48px rgba(0,0,0,0.3)",
|
|
429
|
-
transition: isCancelling
|
|
430
|
-
? `left ${CANCEL_DURATION}ms ease, top ${CANCEL_DURATION}ms ease, transform ${CANCEL_DURATION}ms ease, box-shadow ${CANCEL_DURATION}ms ease`
|
|
431
|
-
: undefined,
|
|
432
|
-
}}
|
|
433
|
-
>
|
|
434
|
-
<ProjectGridCard
|
|
435
|
-
slug={item.project_slug}
|
|
436
|
-
thumbPath={thumbMap.get(item.project_slug)}
|
|
437
|
-
customThumb={item.custom_thumbnail}
|
|
438
|
-
borderRadius={borderRadius > 0 ? String(borderRadius) : undefined}
|
|
439
|
-
style={{ width: "100%", height: "100%" }}
|
|
440
|
-
/>
|
|
441
|
-
{/* Centered cross-arrow icon */}
|
|
442
|
-
<div
|
|
443
|
-
style={{
|
|
444
|
-
position: "absolute",
|
|
445
|
-
inset: 0,
|
|
446
|
-
display: "flex",
|
|
447
|
-
alignItems: "center",
|
|
448
|
-
justifyContent: "center",
|
|
449
|
-
pointerEvents: "none",
|
|
450
|
-
}}
|
|
451
|
-
>
|
|
452
|
-
<div
|
|
453
|
-
style={{
|
|
454
|
-
width: 40,
|
|
455
|
-
height: 40,
|
|
456
|
-
borderRadius: "50%",
|
|
457
|
-
backgroundColor: "rgba(255,255,255,0.92)",
|
|
458
|
-
display: "flex",
|
|
459
|
-
alignItems: "center",
|
|
460
|
-
justifyContent: "center",
|
|
461
|
-
boxShadow: "0 2px 12px rgba(0,0,0,0.2)",
|
|
462
|
-
}}
|
|
463
|
-
>
|
|
464
|
-
<CrossArrowIcon size={20} color="#333" />
|
|
465
|
-
</div>
|
|
466
|
-
</div>
|
|
467
|
-
</div>
|
|
468
|
-
);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// ─── Main Component ─────────────────────────────────────────────────
|
|
472
|
-
|
|
473
|
-
export default function LiveProjectGridPreview({
|
|
474
|
-
block,
|
|
475
|
-
viewport: frameViewport = "desktop",
|
|
476
|
-
}: {
|
|
477
|
-
block: ProjectGridBlock;
|
|
478
|
-
viewport?: DeviceViewport;
|
|
479
|
-
}) {
|
|
480
|
-
const updateBlock = useBuilderStore((s) => s.updateBlock);
|
|
481
|
-
const _pushSnapshot = useBuilderStore((s) => s._pushSnapshot);
|
|
482
|
-
const selectProjectCard = useBuilderStore((s) => s.selectProjectCard);
|
|
483
|
-
const selectedProjectCardKey = useBuilderStore((s) =>
|
|
484
|
-
s.selectedProjectCardKey,
|
|
485
|
-
);
|
|
486
|
-
const selectedBlockKey = useBuilderStore((s) => s.selectedBlockKey);
|
|
487
|
-
|
|
488
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
489
|
-
const roRef = useRef<ResizeObserver | null>(null);
|
|
490
|
-
const [containerWidth, setContainerWidth] = useState(0);
|
|
491
|
-
const [dragState, setDragState] = useState<DragState | null>(null);
|
|
492
|
-
|
|
493
|
-
const activeViewport = useBuilderStore((s) => s.activeViewport);
|
|
494
|
-
const isThisBlockSelected = selectedBlockKey === block._key;
|
|
495
|
-
|
|
496
|
-
// ── Grid config ───────────────────────────────────────────────
|
|
497
|
-
|
|
498
|
-
const columns = block.columns || 3;
|
|
499
|
-
const gapV = block.gap_v ?? 16;
|
|
500
|
-
const gapH = block.gap_h ?? 16;
|
|
501
|
-
const aspectRatios = block.aspect_ratios?.length
|
|
502
|
-
? block.aspect_ratios
|
|
503
|
-
: ["16/9"];
|
|
504
|
-
const borderRadius = block.border_radius || 0;
|
|
505
|
-
const projects = block.projects || [];
|
|
506
|
-
const slugs = useMemo(
|
|
507
|
-
() => projects.map((p) => p.project_slug),
|
|
508
|
-
[projects],
|
|
509
|
-
);
|
|
510
|
-
const thumbMap = useProjectThumbnails(slugs);
|
|
511
|
-
|
|
512
|
-
// ── Stable refs for event handlers ────────────────────────────
|
|
513
|
-
|
|
514
|
-
const blockRef = useRef(block);
|
|
515
|
-
blockRef.current = block;
|
|
516
|
-
const updateBlockRef = useRef(updateBlock);
|
|
517
|
-
updateBlockRef.current = updateBlock;
|
|
518
|
-
const pushSnapshotRef = useRef(_pushSnapshot);
|
|
519
|
-
pushSnapshotRef.current = _pushSnapshot;
|
|
520
|
-
const containerWidthRef = useRef(containerWidth);
|
|
521
|
-
containerWidthRef.current = containerWidth;
|
|
522
|
-
const dragStateRef = useRef(dragState);
|
|
523
|
-
dragStateRef.current = dragState;
|
|
524
|
-
|
|
525
|
-
// Timer & cleanup refs
|
|
526
|
-
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
527
|
-
const holdCleanupRef = useRef<(() => void) | null>(null);
|
|
528
|
-
const cancelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
529
|
-
const cancelRafRef = useRef<number | null>(null);
|
|
530
|
-
const didDragRef = useRef(false);
|
|
531
|
-
|
|
532
|
-
// ── Measure container width via ResizeObserver ────────────────
|
|
533
|
-
// Uses a callback ref so the observer is set up the instant the DOM
|
|
534
|
-
// node is attached (avoids the stale-ref problem with useEffect+[]).
|
|
535
|
-
// A rAF retry loop covers the case where the initial measurement is
|
|
536
|
-
// 0 due to CSS containment or pending layout from the canvas transform.
|
|
537
|
-
|
|
538
|
-
const containerCallbackRef = useCallback(
|
|
539
|
-
(node: HTMLDivElement | null) => {
|
|
540
|
-
// Disconnect previous observer
|
|
541
|
-
if (roRef.current) {
|
|
542
|
-
roRef.current.disconnect();
|
|
543
|
-
roRef.current = null;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
containerRef.current = node;
|
|
547
|
-
if (!node) return;
|
|
548
|
-
|
|
549
|
-
const ro = new ResizeObserver((entries) => {
|
|
550
|
-
for (const entry of entries) {
|
|
551
|
-
const w =
|
|
552
|
-
entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
|
|
553
|
-
if (w > 0) setContainerWidth(w);
|
|
554
|
-
}
|
|
555
|
-
});
|
|
556
|
-
roRef.current = ro;
|
|
557
|
-
ro.observe(node);
|
|
558
|
-
|
|
559
|
-
// Immediate measurement + rAF retry for delayed layout
|
|
560
|
-
const measure = () => {
|
|
561
|
-
const w = node.clientWidth;
|
|
562
|
-
if (w > 0) {
|
|
563
|
-
setContainerWidth(w);
|
|
564
|
-
}
|
|
565
|
-
};
|
|
566
|
-
measure();
|
|
567
|
-
// Retry once layout is flushed (covers CSS containment delay)
|
|
568
|
-
requestAnimationFrame(measure);
|
|
569
|
-
},
|
|
570
|
-
[],
|
|
571
|
-
);
|
|
572
|
-
|
|
573
|
-
// Disconnect on unmount
|
|
574
|
-
useEffect(
|
|
575
|
-
() => () => {
|
|
576
|
-
if (roRef.current) {
|
|
577
|
-
roRef.current.disconnect();
|
|
578
|
-
roRef.current = null;
|
|
579
|
-
}
|
|
580
|
-
},
|
|
581
|
-
[],
|
|
582
|
-
);
|
|
583
|
-
|
|
584
|
-
// ── Masonry computation ───────────────────────────────────────
|
|
585
|
-
|
|
586
|
-
const masonryItems: MasonryItem[] = useMemo(
|
|
587
|
-
() =>
|
|
588
|
-
projects.map((item, i) => {
|
|
589
|
-
// Resolve per-card override for the frame's viewport (not the global active one)
|
|
590
|
-
let override = item.aspect_ratio_override;
|
|
591
|
-
if (frameViewport !== "desktop") {
|
|
592
|
-
const vp = frameViewport as "tablet" | "phone";
|
|
593
|
-
const vpOverride = item.responsive?.[vp]?.aspect_ratio_override;
|
|
594
|
-
if (vpOverride !== undefined) override = vpOverride;
|
|
595
|
-
}
|
|
596
|
-
return {
|
|
597
|
-
key: item._key,
|
|
598
|
-
aspectRatio: resolveItemRatio(i, override, {
|
|
599
|
-
gridRatios: aspectRatios,
|
|
600
|
-
}),
|
|
601
|
-
};
|
|
602
|
-
}),
|
|
603
|
-
[projects, aspectRatios, frameViewport],
|
|
604
|
-
);
|
|
605
|
-
|
|
606
|
-
const masonry: MasonryOutput = useMemo(() => {
|
|
607
|
-
if (containerWidth <= 0 || masonryItems.length === 0)
|
|
608
|
-
return { items: [], totalHeight: 0 };
|
|
609
|
-
return computeMasonry(masonryItems, { columns, gapH, gapV, containerWidth });
|
|
610
|
-
}, [masonryItems, columns, gapH, gapV, containerWidth]);
|
|
611
|
-
|
|
612
|
-
const masonryRef = useRef(masonry);
|
|
613
|
-
masonryRef.current = masonry;
|
|
614
|
-
|
|
615
|
-
const masonryByKey = useMemo(() => {
|
|
616
|
-
const map = new Map<string, (typeof masonry.items)[0]>();
|
|
617
|
-
for (const item of masonry.items) map.set(item.key, item);
|
|
618
|
-
return map;
|
|
619
|
-
}, [masonry]);
|
|
620
|
-
|
|
621
|
-
// ── Drag: initiate ────────────────────────────────────────────
|
|
622
|
-
|
|
623
|
-
const initiateDrag = useCallback(
|
|
624
|
-
(
|
|
625
|
-
key: string,
|
|
626
|
-
clientX: number,
|
|
627
|
-
clientY: number,
|
|
628
|
-
cardEl: HTMLDivElement,
|
|
629
|
-
) => {
|
|
630
|
-
// Clean up any pending hold
|
|
631
|
-
if (holdCleanupRef.current) {
|
|
632
|
-
holdCleanupRef.current();
|
|
633
|
-
holdCleanupRef.current = null;
|
|
634
|
-
}
|
|
635
|
-
// Clean up any cancel animation in progress
|
|
636
|
-
if (cancelTimerRef.current) {
|
|
637
|
-
clearTimeout(cancelTimerRef.current);
|
|
638
|
-
cancelTimerRef.current = null;
|
|
639
|
-
}
|
|
640
|
-
if (cancelRafRef.current) {
|
|
641
|
-
cancelAnimationFrame(cancelRafRef.current);
|
|
642
|
-
cancelRafRef.current = null;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
const rect = cardEl.getBoundingClientRect();
|
|
646
|
-
didDragRef.current = true;
|
|
647
|
-
setDragState({
|
|
648
|
-
phase: "grabbed",
|
|
649
|
-
draggedKey: key,
|
|
650
|
-
hoverTargetKey: null,
|
|
651
|
-
mouseX: clientX,
|
|
652
|
-
mouseY: clientY,
|
|
653
|
-
startMouseX: clientX,
|
|
654
|
-
startMouseY: clientY,
|
|
655
|
-
offsetX: clientX - rect.left,
|
|
656
|
-
offsetY: clientY - rect.top,
|
|
657
|
-
cardWidth: rect.width,
|
|
658
|
-
cardHeight: rect.height,
|
|
659
|
-
origScreenX: rect.left,
|
|
660
|
-
origScreenY: rect.top,
|
|
661
|
-
});
|
|
662
|
-
},
|
|
663
|
-
[],
|
|
664
|
-
);
|
|
665
|
-
|
|
666
|
-
const handleDragStart = useCallback(
|
|
667
|
-
(
|
|
668
|
-
key: string,
|
|
669
|
-
e: React.PointerEvent,
|
|
670
|
-
cardEl: HTMLDivElement,
|
|
671
|
-
fromHandle: boolean,
|
|
672
|
-
) => {
|
|
673
|
-
if (fromHandle) {
|
|
674
|
-
e.preventDefault();
|
|
675
|
-
initiateDrag(key, e.clientX, e.clientY, cardEl);
|
|
676
|
-
return;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// Card body → hold-to-drag
|
|
680
|
-
const cx = e.clientX;
|
|
681
|
-
const cy = e.clientY;
|
|
682
|
-
didDragRef.current = false;
|
|
683
|
-
|
|
684
|
-
// Clean any previous pending hold
|
|
685
|
-
if (holdCleanupRef.current) holdCleanupRef.current();
|
|
686
|
-
|
|
687
|
-
const cleanup = () => {
|
|
688
|
-
if (holdTimerRef.current) {
|
|
689
|
-
clearTimeout(holdTimerRef.current);
|
|
690
|
-
holdTimerRef.current = null;
|
|
691
|
-
}
|
|
692
|
-
window.removeEventListener("pointerup", onEarlyRelease);
|
|
693
|
-
window.removeEventListener("pointermove", onEarlyMove);
|
|
694
|
-
holdCleanupRef.current = null;
|
|
695
|
-
};
|
|
696
|
-
|
|
697
|
-
const onEarlyRelease = () => cleanup();
|
|
698
|
-
|
|
699
|
-
const onEarlyMove = (ev: PointerEvent) => {
|
|
700
|
-
// If user moved > 10px before hold time, cancel (not a drag intent)
|
|
701
|
-
const dx = ev.clientX - cx;
|
|
702
|
-
const dy = ev.clientY - cy;
|
|
703
|
-
if (dx * dx + dy * dy > 100) cleanup();
|
|
704
|
-
};
|
|
705
|
-
|
|
706
|
-
holdTimerRef.current = setTimeout(() => {
|
|
707
|
-
holdTimerRef.current = null;
|
|
708
|
-
window.removeEventListener("pointerup", onEarlyRelease);
|
|
709
|
-
window.removeEventListener("pointermove", onEarlyMove);
|
|
710
|
-
holdCleanupRef.current = null;
|
|
711
|
-
initiateDrag(key, cx, cy, cardEl);
|
|
712
|
-
}, HOLD_DELAY);
|
|
713
|
-
|
|
714
|
-
window.addEventListener("pointerup", onEarlyRelease);
|
|
715
|
-
window.addEventListener("pointermove", onEarlyMove);
|
|
716
|
-
holdCleanupRef.current = cleanup;
|
|
717
|
-
},
|
|
718
|
-
[initiateDrag],
|
|
719
|
-
);
|
|
720
|
-
|
|
721
|
-
// ── Drag: click handler (selection) ───────────────────────────
|
|
722
|
-
|
|
723
|
-
const handleSelect = useCallback(
|
|
724
|
-
(key: string) => {
|
|
725
|
-
// Suppress click that follows a completed drag
|
|
726
|
-
if (didDragRef.current) {
|
|
727
|
-
didDragRef.current = false;
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
selectProjectCard(selectedProjectCardKey === key ? null : key);
|
|
731
|
-
},
|
|
732
|
-
[selectProjectCard, selectedProjectCardKey],
|
|
733
|
-
);
|
|
734
|
-
|
|
735
|
-
// ── Global pointer handlers during drag ───────────────────────
|
|
736
|
-
|
|
737
|
-
const hasDrag = dragState !== null;
|
|
738
|
-
|
|
739
|
-
useEffect(() => {
|
|
740
|
-
if (!hasDrag) return;
|
|
741
|
-
|
|
742
|
-
const onMove = (e: PointerEvent) => {
|
|
743
|
-
setDragState((prev) => {
|
|
744
|
-
if (!prev || prev.phase === "cancelling") return prev;
|
|
745
|
-
|
|
746
|
-
const next: DragState = {
|
|
747
|
-
...prev,
|
|
748
|
-
mouseX: e.clientX,
|
|
749
|
-
mouseY: e.clientY,
|
|
750
|
-
};
|
|
751
|
-
|
|
752
|
-
// Transition: grabbed → dragging on sufficient movement
|
|
753
|
-
if (prev.phase === "grabbed") {
|
|
754
|
-
const dx = e.clientX - prev.startMouseX;
|
|
755
|
-
const dy = e.clientY - prev.startMouseY;
|
|
756
|
-
if (dx * dx + dy * dy > MOVE_THRESHOLD_SQ) {
|
|
757
|
-
next.phase = "dragging";
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
// Coordinate-based hit-testing for drop targets
|
|
762
|
-
if (next.phase === "dragging" && containerRef.current) {
|
|
763
|
-
next.hoverTargetKey = hitTestCards(
|
|
764
|
-
e.clientX,
|
|
765
|
-
e.clientY,
|
|
766
|
-
containerRef.current,
|
|
767
|
-
containerWidthRef.current,
|
|
768
|
-
masonryRef.current,
|
|
769
|
-
prev.draggedKey,
|
|
770
|
-
);
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
return next;
|
|
774
|
-
});
|
|
775
|
-
};
|
|
776
|
-
|
|
777
|
-
const onUp = () => {
|
|
778
|
-
const prev = dragStateRef.current;
|
|
779
|
-
if (!prev) {
|
|
780
|
-
setDragState(null);
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// Grabbed with no movement → cancel (no visual action, allow click through)
|
|
785
|
-
if (prev.phase === "grabbed") {
|
|
786
|
-
setDragState(null);
|
|
787
|
-
setTimeout(() => {
|
|
788
|
-
didDragRef.current = false;
|
|
789
|
-
}, 0);
|
|
790
|
-
return;
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// Dragging with a valid drop target → swap
|
|
794
|
-
if (prev.phase === "dragging" && prev.hoverTargetKey) {
|
|
795
|
-
const blk = blockRef.current;
|
|
796
|
-
const arr = [...(blk.projects || [])];
|
|
797
|
-
const fromIdx = arr.findIndex((p) => p._key === prev.draggedKey);
|
|
798
|
-
const toIdx = arr.findIndex((p) => p._key === prev.hoverTargetKey);
|
|
799
|
-
if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) {
|
|
800
|
-
pushSnapshotRef.current();
|
|
801
|
-
[arr[fromIdx], arr[toIdx]] = [arr[toIdx], arr[fromIdx]];
|
|
802
|
-
updateBlockRef.current(blk._key, {
|
|
803
|
-
projects: arr,
|
|
804
|
-
} as Partial<ProjectGridBlock>);
|
|
805
|
-
}
|
|
806
|
-
setDragState(null);
|
|
807
|
-
setTimeout(() => {
|
|
808
|
-
didDragRef.current = false;
|
|
809
|
-
}, 0);
|
|
810
|
-
return;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// Dragging with no target → cancel with fly-back animation
|
|
814
|
-
if (prev.phase === "dragging") {
|
|
815
|
-
setDragState({ ...prev, phase: "cancelling", hoverTargetKey: null });
|
|
816
|
-
setTimeout(() => {
|
|
817
|
-
didDragRef.current = false;
|
|
818
|
-
}, 0);
|
|
819
|
-
return;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// Fallback: clear
|
|
823
|
-
setDragState(null);
|
|
824
|
-
};
|
|
825
|
-
|
|
826
|
-
window.addEventListener("pointermove", onMove);
|
|
827
|
-
window.addEventListener("pointerup", onUp);
|
|
828
|
-
return () => {
|
|
829
|
-
window.removeEventListener("pointermove", onMove);
|
|
830
|
-
window.removeEventListener("pointerup", onUp);
|
|
831
|
-
};
|
|
832
|
-
}, [hasDrag]);
|
|
833
|
-
|
|
834
|
-
// ── Cancel animation: fly ghost back to original position ─────
|
|
835
|
-
|
|
836
|
-
useEffect(() => {
|
|
837
|
-
if (dragState?.phase !== "cancelling") return;
|
|
838
|
-
|
|
839
|
-
// Double rAF ensures the transition CSS is painted before position change
|
|
840
|
-
cancelRafRef.current = requestAnimationFrame(() => {
|
|
841
|
-
cancelRafRef.current = requestAnimationFrame(() => {
|
|
842
|
-
setDragState((prev) => {
|
|
843
|
-
if (prev?.phase !== "cancelling") return prev;
|
|
844
|
-
return {
|
|
845
|
-
...prev,
|
|
846
|
-
mouseX: prev.origScreenX + prev.offsetX,
|
|
847
|
-
mouseY: prev.origScreenY + prev.offsetY,
|
|
848
|
-
};
|
|
849
|
-
});
|
|
850
|
-
cancelRafRef.current = null;
|
|
851
|
-
});
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
// Clear state after transition completes
|
|
855
|
-
cancelTimerRef.current = setTimeout(() => {
|
|
856
|
-
setDragState(null);
|
|
857
|
-
cancelTimerRef.current = null;
|
|
858
|
-
}, CANCEL_DURATION + 50);
|
|
859
|
-
|
|
860
|
-
return () => {
|
|
861
|
-
if (cancelRafRef.current) cancelAnimationFrame(cancelRafRef.current);
|
|
862
|
-
if (cancelTimerRef.current) {
|
|
863
|
-
clearTimeout(cancelTimerRef.current);
|
|
864
|
-
cancelTimerRef.current = null;
|
|
865
|
-
}
|
|
866
|
-
};
|
|
867
|
-
}, [dragState?.phase]);
|
|
868
|
-
|
|
869
|
-
// ── Cleanup on unmount ────────────────────────────────────────
|
|
870
|
-
|
|
871
|
-
useEffect(
|
|
872
|
-
() => () => {
|
|
873
|
-
if (holdTimerRef.current) clearTimeout(holdTimerRef.current);
|
|
874
|
-
if (holdCleanupRef.current) holdCleanupRef.current();
|
|
875
|
-
if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current);
|
|
876
|
-
if (cancelRafRef.current) cancelAnimationFrame(cancelRafRef.current);
|
|
877
|
-
},
|
|
878
|
-
[],
|
|
879
|
-
);
|
|
880
|
-
|
|
881
|
-
// ── Container click → clear card selection ────────────────────
|
|
882
|
-
|
|
883
|
-
const handleContainerClick = useCallback(
|
|
884
|
-
(e: React.MouseEvent) => {
|
|
885
|
-
if (e.target === e.currentTarget) selectProjectCard(null);
|
|
886
|
-
},
|
|
887
|
-
[selectProjectCard],
|
|
888
|
-
);
|
|
889
|
-
|
|
890
|
-
// ── Derived drag values ───────────────────────────────────────
|
|
891
|
-
|
|
892
|
-
const draggedKey = dragState?.draggedKey ?? null;
|
|
893
|
-
const hoverTargetKey =
|
|
894
|
-
dragState?.phase === "dragging" ? dragState.hoverTargetKey : null;
|
|
895
|
-
const dragPhase = dragState?.phase ?? null;
|
|
896
|
-
const isAnyDragActive = dragState !== null;
|
|
897
|
-
const draggedItem = dragState
|
|
898
|
-
? projects.find((p) => p._key === dragState.draggedKey)
|
|
899
|
-
: null;
|
|
900
|
-
|
|
901
|
-
// ── Empty state ───────────────────────────────────────────────
|
|
902
|
-
|
|
903
|
-
if (projects.length === 0) {
|
|
904
|
-
return (
|
|
905
|
-
<div
|
|
906
|
-
className="border-2 border-dashed border-neutral-300 rounded-lg py-12 flex flex-col items-center justify-center"
|
|
907
|
-
style={{ minHeight: 200 }}
|
|
908
|
-
>
|
|
909
|
-
<div className="text-neutral-400 mb-2">
|
|
910
|
-
<svg
|
|
911
|
-
width="32"
|
|
912
|
-
height="32"
|
|
913
|
-
viewBox="0 0 24 24"
|
|
914
|
-
fill="none"
|
|
915
|
-
stroke="currentColor"
|
|
916
|
-
strokeWidth="1.5"
|
|
917
|
-
>
|
|
918
|
-
<rect x="3" y="3" width="7" height="7" />
|
|
919
|
-
<rect x="14" y="3" width="7" height="7" />
|
|
920
|
-
<rect x="3" y="14" width="7" height="7" />
|
|
921
|
-
<rect x="14" y="14" width="7" height="7" />
|
|
922
|
-
</svg>
|
|
923
|
-
</div>
|
|
924
|
-
<span className="text-xs text-neutral-400 font-medium">
|
|
925
|
-
Project Grid
|
|
926
|
-
</span>
|
|
927
|
-
<span className="text-[10px] text-neutral-400 mt-0.5">
|
|
928
|
-
Select projects in the settings panel
|
|
929
|
-
</span>
|
|
930
|
-
</div>
|
|
931
|
-
);
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
// ── Render ────────────────────────────────────────────────────
|
|
935
|
-
|
|
936
|
-
return (
|
|
937
|
-
<div ref={containerCallbackRef} onClick={handleContainerClick}>
|
|
938
|
-
{/* Masonry container with absolute positioning */}
|
|
939
|
-
<div
|
|
940
|
-
style={{
|
|
941
|
-
position: "relative",
|
|
942
|
-
height: masonry.totalHeight > 0 ? masonry.totalHeight : undefined,
|
|
943
|
-
minHeight: masonry.totalHeight > 0 ? undefined : 100,
|
|
944
|
-
}}
|
|
945
|
-
>
|
|
946
|
-
{projects.map((item) => {
|
|
947
|
-
const mr = masonryByKey.get(item._key);
|
|
948
|
-
if (!mr) return null;
|
|
949
|
-
|
|
950
|
-
const isThisDragged = draggedKey === item._key;
|
|
951
|
-
const showGrabbed = isThisDragged && dragPhase === "grabbed";
|
|
952
|
-
const showPlaceholder =
|
|
953
|
-
isThisDragged &&
|
|
954
|
-
(dragPhase === "dragging" || dragPhase === "cancelling");
|
|
955
|
-
|
|
956
|
-
return (
|
|
957
|
-
<div
|
|
958
|
-
key={item._key}
|
|
959
|
-
style={{
|
|
960
|
-
position: "absolute",
|
|
961
|
-
left: mr.x,
|
|
962
|
-
top: mr.y,
|
|
963
|
-
width: mr.width,
|
|
964
|
-
height: mr.height,
|
|
965
|
-
// Smooth position transitions after swap (disabled during active drag)
|
|
966
|
-
transition:
|
|
967
|
-
isAnyDragActive && !showPlaceholder
|
|
968
|
-
? undefined
|
|
969
|
-
: "left 200ms ease, top 200ms ease",
|
|
970
|
-
}}
|
|
971
|
-
>
|
|
972
|
-
<ProjectCardWrapper
|
|
973
|
-
item={item}
|
|
974
|
-
thumbMap={thumbMap}
|
|
975
|
-
borderRadius={borderRadius}
|
|
976
|
-
cardWidth={mr.width}
|
|
977
|
-
cardHeight={mr.height}
|
|
978
|
-
isGrabbed={showGrabbed}
|
|
979
|
-
isDragging={showPlaceholder}
|
|
980
|
-
isDropTarget={hoverTargetKey === item._key}
|
|
981
|
-
isSelected={
|
|
982
|
-
isThisBlockSelected &&
|
|
983
|
-
selectedProjectCardKey === item._key
|
|
984
|
-
}
|
|
985
|
-
isAnyDragActive={isAnyDragActive}
|
|
986
|
-
onPointerDown={handleDragStart}
|
|
987
|
-
onSelect={handleSelect}
|
|
988
|
-
/>
|
|
989
|
-
</div>
|
|
990
|
-
);
|
|
991
|
-
})}
|
|
992
|
-
</div>
|
|
993
|
-
|
|
994
|
-
{/* Ghost card — portaled to body to escape canvas CSS transform
|
|
995
|
-
(transforms create a new containing block, breaking position:fixed) */}
|
|
996
|
-
{dragState &&
|
|
997
|
-
(dragState.phase === "dragging" || dragState.phase === "cancelling") &&
|
|
998
|
-
draggedItem &&
|
|
999
|
-
createPortal(
|
|
1000
|
-
<GhostCard
|
|
1001
|
-
item={draggedItem}
|
|
1002
|
-
thumbMap={thumbMap}
|
|
1003
|
-
borderRadius={borderRadius}
|
|
1004
|
-
dragState={dragState}
|
|
1005
|
-
/>,
|
|
1006
|
-
document.body,
|
|
1007
|
-
)}
|
|
1008
|
-
</div>
|
|
1009
|
-
);
|
|
1010
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LiveProjectGridPreview — Builder canvas preview for ProjectGrid v2.
|
|
5
|
+
*
|
|
6
|
+
* Uses the shared masonry engine (lib/builder/masonry.ts) with absolute
|
|
7
|
+
* positioning for pixel-perfect parity with the public renderer.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - JS masonry layout (shortest-column algorithm)
|
|
11
|
+
* - 1–6 columns, per-card aspect ratio overrides
|
|
12
|
+
* - Card selection (click → blue border, settings panel shows per-card controls)
|
|
13
|
+
* - Hover state (blue border, suppressed during drag)
|
|
14
|
+
* - Override badge (aspect ratio override indicator)
|
|
15
|
+
* - Custom drag & drop: hold card body (150ms) or grab handle (immediate)
|
|
16
|
+
* → grabbed state (darkened + icon) → dragging (ghost follows mouse,
|
|
17
|
+
* dashed placeholder, green drop targets via coordinate hit-testing)
|
|
18
|
+
* → swap on drop or cancel fly-back animation
|
|
19
|
+
*
|
|
20
|
+
* Session 105 Phase 4: Builder live preview rewrite.
|
|
21
|
+
* Session 105 Phase 5: Custom DnD system (drag initiation, ghost, coordinate
|
|
22
|
+
* hit-testing for drop targets, swap logic, cancel animation).
|
|
23
|
+
* Session 162: Split into focused modules (drag-utils, ProjectCardWrapper,
|
|
24
|
+
* GhostCard, useDragReorder).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
useState,
|
|
29
|
+
useCallback,
|
|
30
|
+
useRef,
|
|
31
|
+
useEffect,
|
|
32
|
+
useMemo,
|
|
33
|
+
} from "react";
|
|
34
|
+
import { createPortal } from "react-dom";
|
|
35
|
+
import { useProjectThumbnails } from "./shared";
|
|
36
|
+
import { useBuilderStore } from "../../../lib/builder/store";
|
|
37
|
+
import {
|
|
38
|
+
computeMasonry,
|
|
39
|
+
resolveItemRatio,
|
|
40
|
+
type MasonryItem,
|
|
41
|
+
type MasonryOutput,
|
|
42
|
+
} from "../../../lib/builder/masonry";
|
|
43
|
+
import type { ProjectGridBlock } from "../../../lib/sanity/types";
|
|
44
|
+
import type { DeviceViewport } from "../../../lib/builder/types";
|
|
45
|
+
import ProjectCardWrapper from "./ProjectCardWrapper";
|
|
46
|
+
import GhostCard from "./GhostCard";
|
|
47
|
+
import { useDragReorder } from "./useDragReorder";
|
|
48
|
+
|
|
49
|
+
// ─── Main Component ─────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export default function LiveProjectGridPreview({
|
|
52
|
+
block,
|
|
53
|
+
viewport: frameViewport = "desktop",
|
|
54
|
+
}: {
|
|
55
|
+
block: ProjectGridBlock;
|
|
56
|
+
viewport?: DeviceViewport;
|
|
57
|
+
}) {
|
|
58
|
+
const selectProjectCard = useBuilderStore((s) => s.selectProjectCard);
|
|
59
|
+
const selectedProjectCardKey = useBuilderStore((s) =>
|
|
60
|
+
s.selectedProjectCardKey,
|
|
61
|
+
);
|
|
62
|
+
const selectedBlockKey = useBuilderStore((s) => s.selectedBlockKey);
|
|
63
|
+
|
|
64
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
const roRef = useRef<ResizeObserver | null>(null);
|
|
66
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
67
|
+
|
|
68
|
+
const isThisBlockSelected = selectedBlockKey === block._key;
|
|
69
|
+
|
|
70
|
+
// ── Grid config ───────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
const columns = block.columns || 3;
|
|
73
|
+
const gapV = block.gap_v ?? 16;
|
|
74
|
+
const gapH = block.gap_h ?? 16;
|
|
75
|
+
const aspectRatios = block.aspect_ratios?.length
|
|
76
|
+
? block.aspect_ratios
|
|
77
|
+
: ["16/9"];
|
|
78
|
+
const borderRadius = block.border_radius || 0;
|
|
79
|
+
const projects = block.projects || [];
|
|
80
|
+
const slugs = useMemo(
|
|
81
|
+
() => projects.map((p) => p.project_slug),
|
|
82
|
+
[projects],
|
|
83
|
+
);
|
|
84
|
+
const thumbMap = useProjectThumbnails(slugs);
|
|
85
|
+
|
|
86
|
+
// ── Measure container width via ResizeObserver ────────────────
|
|
87
|
+
// Uses a callback ref so the observer is set up the instant the DOM
|
|
88
|
+
// node is attached (avoids the stale-ref problem with useEffect+[]).
|
|
89
|
+
// A rAF retry loop covers the case where the initial measurement is
|
|
90
|
+
// 0 due to CSS containment or pending layout from the canvas transform.
|
|
91
|
+
|
|
92
|
+
const containerCallbackRef = useCallback(
|
|
93
|
+
(node: HTMLDivElement | null) => {
|
|
94
|
+
// Disconnect previous observer
|
|
95
|
+
if (roRef.current) {
|
|
96
|
+
roRef.current.disconnect();
|
|
97
|
+
roRef.current = null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
containerRef.current = node;
|
|
101
|
+
if (!node) return;
|
|
102
|
+
|
|
103
|
+
const ro = new ResizeObserver((entries) => {
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
const w =
|
|
106
|
+
entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
|
|
107
|
+
if (w > 0) setContainerWidth(w);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
roRef.current = ro;
|
|
111
|
+
ro.observe(node);
|
|
112
|
+
|
|
113
|
+
// Immediate measurement + rAF retry for delayed layout
|
|
114
|
+
const measure = () => {
|
|
115
|
+
const w = node.clientWidth;
|
|
116
|
+
if (w > 0) {
|
|
117
|
+
setContainerWidth(w);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
measure();
|
|
121
|
+
// Retry once layout is flushed (covers CSS containment delay)
|
|
122
|
+
requestAnimationFrame(measure);
|
|
123
|
+
},
|
|
124
|
+
[],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Disconnect on unmount
|
|
128
|
+
useEffect(
|
|
129
|
+
() => () => {
|
|
130
|
+
if (roRef.current) {
|
|
131
|
+
roRef.current.disconnect();
|
|
132
|
+
roRef.current = null;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
[],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// ── Masonry computation ───────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
const masonryItems: MasonryItem[] = useMemo(
|
|
141
|
+
() =>
|
|
142
|
+
projects.map((item, i) => {
|
|
143
|
+
// Resolve per-card override for the frame's viewport (not the global active one)
|
|
144
|
+
let override = item.aspect_ratio_override;
|
|
145
|
+
if (frameViewport !== "desktop") {
|
|
146
|
+
const vp = frameViewport as "tablet" | "phone";
|
|
147
|
+
const vpOverride = item.responsive?.[vp]?.aspect_ratio_override;
|
|
148
|
+
if (vpOverride !== undefined) override = vpOverride;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
key: item._key,
|
|
152
|
+
aspectRatio: resolveItemRatio(i, override, {
|
|
153
|
+
gridRatios: aspectRatios,
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
}),
|
|
157
|
+
[projects, aspectRatios, frameViewport],
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const masonry: MasonryOutput = useMemo(() => {
|
|
161
|
+
if (containerWidth <= 0 || masonryItems.length === 0)
|
|
162
|
+
return { items: [], totalHeight: 0 };
|
|
163
|
+
return computeMasonry(masonryItems, { columns, gapH, gapV, containerWidth });
|
|
164
|
+
}, [masonryItems, columns, gapH, gapV, containerWidth]);
|
|
165
|
+
|
|
166
|
+
const masonryByKey = useMemo(() => {
|
|
167
|
+
const map = new Map<string, (typeof masonry.items)[0]>();
|
|
168
|
+
for (const item of masonry.items) map.set(item.key, item);
|
|
169
|
+
return map;
|
|
170
|
+
}, [masonry]);
|
|
171
|
+
|
|
172
|
+
// ── Drag reorder hook ─────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
const drag = useDragReorder({ block, containerRef, containerWidth, masonry });
|
|
175
|
+
|
|
176
|
+
// ── Container click → clear card selection ────────────────────
|
|
177
|
+
|
|
178
|
+
const handleContainerClick = useCallback(
|
|
179
|
+
(e: React.MouseEvent) => {
|
|
180
|
+
if (e.target === e.currentTarget) selectProjectCard(null);
|
|
181
|
+
},
|
|
182
|
+
[selectProjectCard],
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// ── Empty state ───────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
if (projects.length === 0) {
|
|
188
|
+
return (
|
|
189
|
+
<div
|
|
190
|
+
className="border-2 border-dashed border-neutral-300 rounded-lg py-12 flex flex-col items-center justify-center"
|
|
191
|
+
style={{ minHeight: 200 }}
|
|
192
|
+
>
|
|
193
|
+
<div className="text-neutral-400 mb-2">
|
|
194
|
+
<svg
|
|
195
|
+
width="32"
|
|
196
|
+
height="32"
|
|
197
|
+
viewBox="0 0 24 24"
|
|
198
|
+
fill="none"
|
|
199
|
+
stroke="currentColor"
|
|
200
|
+
strokeWidth="1.5"
|
|
201
|
+
>
|
|
202
|
+
<rect x="3" y="3" width="7" height="7" />
|
|
203
|
+
<rect x="14" y="3" width="7" height="7" />
|
|
204
|
+
<rect x="3" y="14" width="7" height="7" />
|
|
205
|
+
<rect x="14" y="14" width="7" height="7" />
|
|
206
|
+
</svg>
|
|
207
|
+
</div>
|
|
208
|
+
<span className="text-xs text-neutral-400 font-medium">
|
|
209
|
+
Project Grid
|
|
210
|
+
</span>
|
|
211
|
+
<span className="text-[10px] text-neutral-400 mt-0.5">
|
|
212
|
+
Select projects in the settings panel
|
|
213
|
+
</span>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Render ────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div ref={containerCallbackRef} onClick={handleContainerClick}>
|
|
222
|
+
{/* Masonry container with absolute positioning */}
|
|
223
|
+
<div
|
|
224
|
+
style={{
|
|
225
|
+
position: "relative",
|
|
226
|
+
height: masonry.totalHeight > 0 ? masonry.totalHeight : undefined,
|
|
227
|
+
minHeight: masonry.totalHeight > 0 ? undefined : 100,
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
{projects.map((item) => {
|
|
231
|
+
const mr = masonryByKey.get(item._key);
|
|
232
|
+
if (!mr) return null;
|
|
233
|
+
|
|
234
|
+
const isThisDragged = drag.draggedKey === item._key;
|
|
235
|
+
const showGrabbed = isThisDragged && drag.dragPhase === "grabbed";
|
|
236
|
+
const showPlaceholder =
|
|
237
|
+
isThisDragged &&
|
|
238
|
+
(drag.dragPhase === "dragging" || drag.dragPhase === "cancelling");
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<div
|
|
242
|
+
key={item._key}
|
|
243
|
+
style={{
|
|
244
|
+
position: "absolute",
|
|
245
|
+
left: mr.x,
|
|
246
|
+
top: mr.y,
|
|
247
|
+
width: mr.width,
|
|
248
|
+
height: mr.height,
|
|
249
|
+
// Smooth position transitions after swap (disabled during active drag)
|
|
250
|
+
transition:
|
|
251
|
+
drag.isAnyDragActive && !showPlaceholder
|
|
252
|
+
? undefined
|
|
253
|
+
: "left 200ms ease, top 200ms ease",
|
|
254
|
+
}}
|
|
255
|
+
>
|
|
256
|
+
<ProjectCardWrapper
|
|
257
|
+
item={item}
|
|
258
|
+
thumbMap={thumbMap}
|
|
259
|
+
borderRadius={borderRadius}
|
|
260
|
+
cardWidth={mr.width}
|
|
261
|
+
cardHeight={mr.height}
|
|
262
|
+
isGrabbed={showGrabbed}
|
|
263
|
+
isDragging={showPlaceholder}
|
|
264
|
+
isDropTarget={drag.hoverTargetKey === item._key}
|
|
265
|
+
isSelected={
|
|
266
|
+
isThisBlockSelected &&
|
|
267
|
+
selectedProjectCardKey === item._key
|
|
268
|
+
}
|
|
269
|
+
isAnyDragActive={drag.isAnyDragActive}
|
|
270
|
+
onPointerDown={drag.handleDragStart}
|
|
271
|
+
onSelect={drag.handleSelect}
|
|
272
|
+
/>
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
})}
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
{/* Ghost card — portaled to body to escape canvas CSS transform
|
|
279
|
+
(transforms create a new containing block, breaking position:fixed) */}
|
|
280
|
+
{drag.dragState &&
|
|
281
|
+
(drag.dragState.phase === "dragging" || drag.dragState.phase === "cancelling") &&
|
|
282
|
+
drag.draggedItem &&
|
|
283
|
+
createPortal(
|
|
284
|
+
<GhostCard
|
|
285
|
+
item={drag.draggedItem}
|
|
286
|
+
thumbMap={thumbMap}
|
|
287
|
+
borderRadius={borderRadius}
|
|
288
|
+
dragState={drag.dragState}
|
|
289
|
+
/>,
|
|
290
|
+
document.body,
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
}
|