@griddle/react 0.1.0

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.
@@ -0,0 +1,709 @@
1
+ // GriddleLoopGrid: loop-mode renderer ("object looping").
2
+ //
3
+ // The packed content (bounding box of the in-flow tiles) repeats infinitely
4
+ // in both axes. There is NO native scrolling: the viewport is an
5
+ // overflow-hidden box and an unbounded PanController camera translates a
6
+ // zero-sized "plane" element via CSS transform. Wheel deltas and (optionally)
7
+ // drag-to-pan feed the camera. Because nothing has scrollable overflow, no
8
+ // scrollbars appear and the component can never affect page layout.
9
+ //
10
+ // Interactions:
11
+ // - 'pan' — drag anywhere pans the camera with configurable physics. Tiles
12
+ // are view-only (no drag / resize / selection / draw-create).
13
+ // - 'edit' — "ghost edit": only the base copy (kx=0, ky=0) is interactive,
14
+ // with ordinary non-wrapped grid semantics (drag, resize,
15
+ // selection). The surrounding repeats render live but are
16
+ // pointer-transparent ghosts (data-griddle-ghost, dimmed), so
17
+ // seams preview in real time while editing stays plain-grid.
18
+ // Wheel pans, and dragging anywhere outside the base copy pans.
19
+
20
+ import {
21
+ CSSProperties,
22
+ useCallback,
23
+ useEffect,
24
+ useLayoutEffect,
25
+ useMemo,
26
+ useRef,
27
+ useState,
28
+ } from 'react';
29
+ import type { Corner, Tile, CameraState, LoopTileInstance } from '@griddle/core';
30
+ import {
31
+ DragController,
32
+ PanController,
33
+ loopInstances,
34
+ loopPeriod,
35
+ resolveLoop,
36
+ } from '@griddle/core';
37
+ import type { GriddleGridProps } from './GriddleGrid.js';
38
+ import { useGridVersion } from './useGriddle.js';
39
+
40
+ const DEFAULT_DRAG_IGNORE = 'a, button, input, textarea, select, [contenteditable]';
41
+ const PAN_THRESHOLD_PX = 4;
42
+
43
+ interface LoopView {
44
+ /** Quantized camera position, in whole cells (world space). */
45
+ cxCell: number;
46
+ cyCell: number;
47
+ /** Viewport pixel size. */
48
+ vw: number;
49
+ vh: number;
50
+ }
51
+
52
+ interface LoopDragVisual {
53
+ tileId: string;
54
+ /** World-space origin of the grabbed instance at pickup. */
55
+ instanceLeft: number;
56
+ instanceTop: number;
57
+ pickupCol: number;
58
+ pickupRow: number;
59
+ deltaX: number;
60
+ deltaY: number;
61
+ indicatorCol: number | null;
62
+ indicatorRow: number | null;
63
+ }
64
+
65
+ interface LoopResizeState {
66
+ tileId: string;
67
+ /** Key of the grabbed instance — only this copy previews live. */
68
+ instanceKey: string;
69
+ /** World-space period offset of the grabbed instance. */
70
+ instanceDx: number;
71
+ instanceDy: number;
72
+ corner: Corner;
73
+ startPointerX: number;
74
+ startPointerY: number;
75
+ startW: number;
76
+ startH: number;
77
+ startCol: number;
78
+ startRow: number;
79
+ previewW: number;
80
+ previewH: number;
81
+ previewCol: number;
82
+ previewRow: number;
83
+ }
84
+
85
+ export function GriddleLoopGrid(props: GriddleGridProps) {
86
+ const {
87
+ api, renderTile, className, style, height = '100%', showGrid = true,
88
+ selection: controlledSelection, onSelectionChange,
89
+ onDragStart, onDragEnd, onResizeStart, onResizeEnd, onCameraChange,
90
+ } = props;
91
+ const config = api.config;
92
+ const version = useGridVersion(api.grid);
93
+ const loop = useMemo(() => resolveLoop(config), [config]);
94
+ const interaction = loop?.interaction ?? 'pan';
95
+ const editable = interaction === 'edit';
96
+
97
+ const gap = config.gap ?? 0;
98
+ const halfGap = gap / 2;
99
+ const colSize = config.unitWidth + gap;
100
+ const rowSize = config.unitHeight + gap;
101
+
102
+ const tiles = api.tiles;
103
+ const _ = version; // subscribe
104
+ const period = useMemo(() => loopPeriod(config, tiles), [config, tiles]);
105
+ const periodRef = useRef(period);
106
+ periodRef.current = period;
107
+
108
+ // Selection (edit mode only).
109
+ const [internalSelection, setInternalSelection] = useState<Set<string>>(new Set());
110
+ const selection = controlledSelection ?? internalSelection;
111
+ const setSelection = useCallback((next: Set<string>) => {
112
+ if (!controlledSelection) setInternalSelection(next);
113
+ onSelectionChange?.(next);
114
+ }, [controlledSelection, onSelectionChange]);
115
+
116
+ // Camera plumbing: the PanController is the single source of truth; the
117
+ // rAF loop applies it to the plane transform imperatively (no re-render)
118
+ // and publishes a cell-quantized view for virtualization.
119
+ const viewportRef = useRef<HTMLDivElement | null>(null);
120
+ const planeRef = useRef<HTMLDivElement | null>(null);
121
+ const panRef = useRef<PanController | null>(null);
122
+ if (!panRef.current) panRef.current = new PanController();
123
+ const [view, setView] = useState<LoopView>({ cxCell: 0, cyCell: 0, vw: 1000, vh: 800 });
124
+ const viewRef = useRef(view);
125
+ viewRef.current = view;
126
+
127
+ const onCameraChangeRef = useRef(onCameraChange);
128
+ onCameraChangeRef.current = onCameraChange;
129
+ const lastCamRef = useRef<CameraState | null>(null);
130
+
131
+ useEffect(() => {
132
+ if (loop) {
133
+ panRef.current!.setPhysics({
134
+ friction: loop.friction,
135
+ ease: loop.ease,
136
+ maxVelocity: loop.maxVelocity,
137
+ });
138
+ }
139
+ }, [loop]);
140
+
141
+ // Wheel pans the camera ("native scroll" feel without scroll).
142
+ useEffect(() => {
143
+ const el = viewportRef.current;
144
+ if (!el) return;
145
+ const onWheel = (e: WheelEvent) => {
146
+ e.preventDefault();
147
+ const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1;
148
+ panRef.current!.scrollBy(e.deltaX * k, e.deltaY * k);
149
+ };
150
+ el.addEventListener('wheel', onWheel, { passive: false });
151
+ return () => el.removeEventListener('wheel', onWheel);
152
+ }, []);
153
+
154
+ // Track viewport size.
155
+ useEffect(() => {
156
+ const el = viewportRef.current;
157
+ if (!el) return;
158
+ const measure = () => {
159
+ const vw = el.clientWidth;
160
+ const vh = el.clientHeight;
161
+ const cur = viewRef.current;
162
+ if (vw !== cur.vw || vh !== cur.vh) setView({ ...cur, vw, vh });
163
+ };
164
+ measure();
165
+ const ro = new ResizeObserver(measure);
166
+ ro.observe(el);
167
+ return () => ro.disconnect();
168
+ }, []);
169
+
170
+ // rAF loop: advance physics, move the plane, publish the quantized view.
171
+ useEffect(() => {
172
+ let raf = 0;
173
+ const frame = (now: number) => {
174
+ raf = requestAnimationFrame(frame);
175
+ const pan = panRef.current!;
176
+ const st = pan.tick(now);
177
+
178
+ const plane = planeRef.current;
179
+ if (plane) {
180
+ plane.style.transform = `translate3d(${-st.x}px, ${-st.y}px, 0)`;
181
+ }
182
+ const el = viewportRef.current;
183
+ if (el && showGrid) {
184
+ el.style.backgroundPosition = `${-st.x % colSize}px ${-st.y % rowSize}px`;
185
+ }
186
+
187
+ const cxCell = Math.floor(st.x / colSize);
188
+ const cyCell = Math.floor(st.y / rowSize);
189
+ const cur = viewRef.current;
190
+ if (cxCell !== cur.cxCell || cyCell !== cur.cyCell) {
191
+ setView({ ...cur, cxCell, cyCell });
192
+ }
193
+
194
+ const last = lastCamRef.current;
195
+ if (
196
+ !last || last.x !== st.x || last.y !== st.y ||
197
+ last.isMoving !== st.isMoving || last.isDragging !== st.isDragging
198
+ ) {
199
+ lastCamRef.current = st;
200
+ onCameraChangeRef.current?.(st);
201
+ }
202
+ };
203
+ raf = requestAnimationFrame(frame);
204
+ return () => cancelAnimationFrame(raf);
205
+ }, [colSize, rowSize, showGrid]);
206
+
207
+ // Visible instances: camera window padded by a 2-cell buffer.
208
+ const instances = useMemo(() => {
209
+ const bufX = 2 * colSize;
210
+ const bufY = 2 * rowSize;
211
+ return loopInstances(config, tiles, {
212
+ x: view.cxCell * colSize - bufX,
213
+ y: view.cyCell * rowSize - bufY,
214
+ width: view.vw + colSize + 2 * bufX,
215
+ height: view.vh + rowSize + 2 * bufY,
216
+ });
217
+ }, [config, tiles, view, colSize, rowSize, version]);
218
+
219
+ // ---- pan gesture --------------------------------------------------------
220
+ const panGestureRef = useRef<{
221
+ pointerId: number;
222
+ startX: number;
223
+ startY: number;
224
+ moved: boolean;
225
+ } | null>(null);
226
+ const suppressClickRef = useRef(false);
227
+
228
+ const onContainerPointerDown = useCallback(
229
+ (e: React.PointerEvent<HTMLDivElement>) => {
230
+ if (!loop?.dragPan) return;
231
+ if (e.button !== 0) return;
232
+ const ignoreSelector = config.dragIgnoreFrom ?? DEFAULT_DRAG_IGNORE;
233
+ if (ignoreSelector && (e.target as HTMLElement).closest(ignoreSelector)) return;
234
+ panGestureRef.current = {
235
+ pointerId: e.pointerId,
236
+ startX: e.clientX,
237
+ startY: e.clientY,
238
+ moved: false,
239
+ };
240
+ },
241
+ [loop?.dragPan, config.dragIgnoreFrom],
242
+ );
243
+
244
+ const onContainerClickCapture = useCallback((e: React.MouseEvent) => {
245
+ if (suppressClickRef.current) {
246
+ suppressClickRef.current = false;
247
+ e.preventDefault();
248
+ e.stopPropagation();
249
+ }
250
+ }, []);
251
+
252
+ // ---- tile drag / resize ('edit' interaction) ----------------------------
253
+ const dragControllerRef = useRef<DragController | null>(null);
254
+ if (!dragControllerRef.current) dragControllerRef.current = new DragController(api.grid);
255
+ const [drag, setDrag] = useState<LoopDragVisual | null>(null);
256
+ const [resize, setResize] = useState<LoopResizeState | null>(null);
257
+ const dragRef = useRef<LoopDragVisual | null>(null);
258
+ const resizeRef = useRef<LoopResizeState | null>(null);
259
+ dragRef.current = drag;
260
+ resizeRef.current = resize;
261
+ const dragStartPointerRef = useRef({ x: 0, y: 0 });
262
+
263
+ const onDragStartRef = useRef(onDragStart);
264
+ onDragStartRef.current = onDragStart;
265
+ const onDragEndRef = useRef(onDragEnd);
266
+ onDragEndRef.current = onDragEnd;
267
+ const onResizeStartRef = useRef(onResizeStart);
268
+ onResizeStartRef.current = onResizeStart;
269
+ const onResizeEndRef = useRef(onResizeEnd);
270
+ onResizeEndRef.current = onResizeEnd;
271
+
272
+ const onTilePointerDown = useCallback(
273
+ (e: React.PointerEvent<HTMLDivElement>, inst: LoopTileInstance) => {
274
+ if (!editable) return; // pan mode: bubble up to the container pan handler
275
+ if (inst.kx !== 0 || inst.ky !== 0) return; // ghosts are display-only
276
+ const tile = inst.tile;
277
+ if (tile.draggable === false) return;
278
+ if ((e.target as HTMLElement).dataset.griddleHandle) return;
279
+ const ignoreSelector = config.dragIgnoreFrom ?? DEFAULT_DRAG_IGNORE;
280
+ if (ignoreSelector && (e.target as HTMLElement).closest(ignoreSelector)) return;
281
+
282
+ if (e.metaKey || e.ctrlKey) {
283
+ e.preventDefault();
284
+ const next = new Set(selection);
285
+ if (next.has(tile.id)) next.delete(tile.id);
286
+ else next.add(tile.id);
287
+ setSelection(next);
288
+ e.stopPropagation();
289
+ return;
290
+ }
291
+ setSelection(new Set([tile.id]));
292
+
293
+ // No pointer capture here: the grabbed instance unmounts (the drag
294
+ // renders as an overlay), which would release the capture mid-gesture.
295
+ // Window-level listeners track the pointer instead.
296
+ const ctrl = dragControllerRef.current!;
297
+ if (!ctrl.start(tile.id)) return;
298
+ dragStartPointerRef.current = { x: e.clientX, y: e.clientY };
299
+ setDrag({
300
+ tileId: tile.id,
301
+ instanceLeft: inst.left,
302
+ instanceTop: inst.top,
303
+ pickupCol: tile.col,
304
+ pickupRow: tile.row,
305
+ deltaX: 0,
306
+ deltaY: 0,
307
+ indicatorCol: tile.col,
308
+ indicatorRow: tile.row,
309
+ });
310
+ onDragStartRef.current?.(tile.id);
311
+ e.stopPropagation();
312
+ },
313
+ [editable, config, selection, setSelection],
314
+ );
315
+
316
+ const onResizeHandleDown = useCallback(
317
+ (e: React.PointerEvent<HTMLDivElement>, inst: LoopTileInstance, corner: Corner) => {
318
+ if (!editable) return;
319
+ const tile = inst.tile;
320
+ if (tile.resizable === false) return;
321
+ (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
322
+ const baseLeft = tile.col * colSize + halfGap;
323
+ const baseTop = tile.row * rowSize + halfGap;
324
+ setResize({
325
+ tileId: tile.id,
326
+ instanceKey: inst.key,
327
+ instanceDx: inst.left - baseLeft,
328
+ instanceDy: inst.top - baseTop,
329
+ corner,
330
+ startPointerX: e.clientX,
331
+ startPointerY: e.clientY,
332
+ startW: tile.w,
333
+ startH: tile.h,
334
+ startCol: tile.col,
335
+ startRow: tile.row,
336
+ previewW: tile.w,
337
+ previewH: tile.h,
338
+ previewCol: tile.col,
339
+ previewRow: tile.row,
340
+ });
341
+ onResizeStartRef.current?.(tile.id);
342
+ e.stopPropagation();
343
+ },
344
+ [editable, colSize, rowSize, halfGap],
345
+ );
346
+
347
+ const onPointerMove = useCallback(
348
+ (e: PointerEvent) => {
349
+ const pg = panGestureRef.current;
350
+ if (pg && e.pointerId === pg.pointerId) {
351
+ const now = performance.now();
352
+ const pan = panRef.current!;
353
+ if (!pg.moved) {
354
+ const dist = Math.hypot(e.clientX - pg.startX, e.clientY - pg.startY);
355
+ if (dist >= PAN_THRESHOLD_PX) {
356
+ pg.moved = true;
357
+ pan.dragStart(pg.startX, pg.startY, now);
358
+ pan.dragMove(e.clientX, e.clientY, now);
359
+ }
360
+ } else {
361
+ pan.dragMove(e.clientX, e.clientY, now);
362
+ }
363
+ return;
364
+ }
365
+
366
+ const d = dragRef.current;
367
+ if (d) {
368
+ // Ghost edit: plain grid semantics in the base copy — no wrapping.
369
+ const dx = e.clientX - dragStartPointerRef.current.x;
370
+ const dy = e.clientY - dragStartPointerRef.current.y;
371
+ const candidate = {
372
+ col: d.pickupCol + Math.round(dx / colSize),
373
+ row: d.pickupRow + Math.round(dy / rowSize),
374
+ };
375
+ const result = dragControllerRef.current!.update(candidate);
376
+ setDrag({
377
+ ...d,
378
+ deltaX: dx,
379
+ deltaY: dy,
380
+ indicatorCol: result.indicatorCell ? result.indicatorCell.col : null,
381
+ indicatorRow: result.indicatorCell ? result.indicatorCell.row : null,
382
+ });
383
+ }
384
+
385
+ const r = resizeRef.current;
386
+ if (r) {
387
+ const dx = e.clientX - r.startPointerX;
388
+ const dy = e.clientY - r.startPointerY;
389
+ let dw = 0, dh = 0, dcol = 0, drow = 0;
390
+ const stepsX = Math.round(dx / colSize);
391
+ const stepsY = Math.round(dy / rowSize);
392
+ if (r.corner === 'se' || r.corner === 'ne') dw = stepsX;
393
+ if (r.corner === 'se' || r.corner === 'sw') dh = stepsY;
394
+ if (r.corner === 'sw' || r.corner === 'nw') { dw = -stepsX; dcol = stepsX; }
395
+ if (r.corner === 'ne' || r.corner === 'nw') { dh = -stepsY; drow = stepsY; }
396
+ const tile = api.grid.getTile(r.tileId);
397
+ const minW = tile?.minW ?? 1;
398
+ const minH = tile?.minH ?? 1;
399
+ const maxW = Math.min(tile?.maxW ?? Infinity, config.cols);
400
+ const maxH = Math.min(tile?.maxH ?? Infinity, config.rows);
401
+ const newW = Math.min(maxW, Math.max(minW, r.startW + dw));
402
+ const newH = Math.min(maxH, Math.max(minH, r.startH + dh));
403
+ const newCol = r.startCol + dcol;
404
+ const newRow = r.startRow + drow;
405
+ if (newW !== r.previewW || newH !== r.previewH || newCol !== r.previewCol || newRow !== r.previewRow) {
406
+ setResize({ ...r, previewW: newW, previewH: newH, previewCol: newCol, previewRow: newRow });
407
+ }
408
+ }
409
+ },
410
+ [colSize, rowSize, config, api],
411
+ );
412
+
413
+ const onPointerUp = useCallback(
414
+ (e: PointerEvent) => {
415
+ const pg = panGestureRef.current;
416
+ if (pg && e.pointerId === pg.pointerId) {
417
+ if (pg.moved) {
418
+ panRef.current!.dragEnd(performance.now());
419
+ suppressClickRef.current = true;
420
+ }
421
+ panGestureRef.current = null;
422
+ return;
423
+ }
424
+
425
+ const d = dragRef.current;
426
+ if (d) {
427
+ const tileId = d.tileId;
428
+ const { committed } = dragControllerRef.current!.end();
429
+ setDrag(null);
430
+ onDragEndRef.current?.(tileId, committed);
431
+ }
432
+ const r = resizeRef.current;
433
+ if (r) {
434
+ const tile = api.grid.getTile(r.tileId);
435
+ let committed = false;
436
+ if (tile) {
437
+ const target = { col: r.previewCol, row: r.previewRow };
438
+ if (target.col !== tile.col || target.row !== tile.row) {
439
+ api.moveTile(r.tileId, target);
440
+ }
441
+ if (r.previewW !== tile.w || r.previewH !== tile.h) {
442
+ committed = api.resizeTile(r.tileId, { w: r.previewW, h: r.previewH });
443
+ } else {
444
+ committed = r.previewCol !== r.startCol || r.previewRow !== r.startRow
445
+ || r.previewW !== r.startW || r.previewH !== r.startH;
446
+ }
447
+ }
448
+ setResize(null);
449
+ onResizeEndRef.current?.(r.tileId, committed);
450
+ }
451
+ },
452
+ [api, config],
453
+ );
454
+
455
+ useEffect(() => {
456
+ window.addEventListener('pointermove', onPointerMove);
457
+ window.addEventListener('pointerup', onPointerUp);
458
+ window.addEventListener('pointercancel', onPointerUp);
459
+ return () => {
460
+ window.removeEventListener('pointermove', onPointerMove);
461
+ window.removeEventListener('pointerup', onPointerUp);
462
+ window.removeEventListener('pointercancel', onPointerUp);
463
+ };
464
+ }, [onPointerMove, onPointerUp]);
465
+
466
+ useEffect(() => {
467
+ if (!editable) return;
468
+ const onKeyDown = (e: KeyboardEvent) => {
469
+ if (e.key === 'Escape') setSelection(new Set());
470
+ };
471
+ window.addEventListener('keydown', onKeyDown);
472
+ return () => window.removeEventListener('keydown', onKeyDown);
473
+ }, [editable, setSelection]);
474
+
475
+ const onBackgroundPointerDown = useCallback(
476
+ (e: React.PointerEvent<HTMLDivElement>) => {
477
+ if ((e.target as HTMLElement).closest('[data-griddle-tile]')) return;
478
+ if (editable) setSelection(new Set());
479
+ },
480
+ [editable, setSelection],
481
+ );
482
+
483
+ // FLIP for edit-mode repacks, keyed by world key. Skips the active dragger
484
+ // (overlay) and any apparent jump of >= half a period (period-size changes
485
+ // can re-home instances).
486
+ const prevRectsRef = useRef(new Map<string, { x: number; y: number }>());
487
+ const tileNodes = useRef(new Map<string, HTMLDivElement>());
488
+
489
+ useLayoutEffect(() => {
490
+ const prev = prevRectsRef.current;
491
+ const next = new Map<string, { x: number; y: number }>();
492
+ const draggerId = dragRef.current?.tileId ?? null;
493
+ for (const inst of instances) {
494
+ const key = inst.key;
495
+ next.set(key, { x: inst.left, y: inst.top });
496
+ if (!editable || inst.tile.id === draggerId) continue;
497
+ const p = prev.get(key);
498
+ const node = tileNodes.current.get(key);
499
+ if (p && node) {
500
+ const dx = p.x - inst.left;
501
+ const dy = p.y - inst.top;
502
+ if (
503
+ (dx !== 0 || dy !== 0) &&
504
+ Math.abs(dx) < periodRef.current.width / 2 &&
505
+ Math.abs(dy) < periodRef.current.height / 2
506
+ ) {
507
+ node.style.transition = 'none';
508
+ node.style.transform = `translate(${dx}px, ${dy}px)`;
509
+ requestAnimationFrame(() => {
510
+ node.style.transition = 'transform 220ms cubic-bezier(.2,.7,.2,1)';
511
+ node.style.transform = 'translate(0,0)';
512
+ });
513
+ }
514
+ }
515
+ }
516
+ prevRectsRef.current = next;
517
+ }, [instances, editable]);
518
+
519
+ // Drop indicator: rendered in the base copy (ghost edit is plain-grid).
520
+ let indicatorRect: { left: number; top: number; width: number; height: number } | null = null;
521
+ if (drag && drag.indicatorCol !== null && drag.indicatorRow !== null) {
522
+ const t = api.grid.getTile(drag.tileId);
523
+ if (t) {
524
+ indicatorRect = {
525
+ left: drag.indicatorCol * colSize + halfGap,
526
+ top: drag.indicatorRow * rowSize + halfGap,
527
+ width: t.w * config.unitWidth + (t.w - 1) * gap,
528
+ height: t.h * config.unitHeight + (t.h - 1) * gap,
529
+ };
530
+ }
531
+ }
532
+
533
+ // Dragged tile renders as a single overlay copy following the cursor; its
534
+ // grid instances are hidden while the gesture is live (avoids key churn as
535
+ // the live preview wraps its cell across the seam).
536
+ const draggedTile = drag ? api.grid.getTile(drag.tileId) : null;
537
+
538
+ const bgStyle: CSSProperties = showGrid
539
+ ? {
540
+ backgroundImage: `linear-gradient(to right, rgba(0,0,0,0.08) 1px, transparent 1px), linear-gradient(to bottom, rgba(0,0,0,0.08) 1px, transparent 1px)`,
541
+ backgroundSize: `${colSize}px ${rowSize}px`,
542
+ backgroundPosition: '0 0',
543
+ }
544
+ : {};
545
+
546
+ const handles = config.resizeHandles ?? ['se'];
547
+
548
+ return (
549
+ <div
550
+ ref={viewportRef}
551
+ className={className}
552
+ onPointerDown={(e) => {
553
+ onContainerPointerDown(e);
554
+ onBackgroundPointerDown(e);
555
+ }}
556
+ onClickCapture={onContainerClickCapture}
557
+ style={{
558
+ position: 'relative',
559
+ overflow: 'hidden',
560
+ height,
561
+ touchAction: 'none',
562
+ userSelect: 'none',
563
+ cursor: !editable && loop?.dragPan ? 'grab' : undefined,
564
+ ['--griddle-tile-radius' as never]: `${config.tileRadius ?? 4}px`,
565
+ ...bgStyle,
566
+ ...style,
567
+ }}
568
+ >
569
+ <div
570
+ ref={planeRef}
571
+ style={{
572
+ position: 'absolute',
573
+ top: 0,
574
+ left: 0,
575
+ width: 0,
576
+ height: 0,
577
+ willChange: 'transform',
578
+ }}
579
+ >
580
+ {indicatorRect && (
581
+ <div
582
+ style={{
583
+ position: 'absolute',
584
+ left: indicatorRect.left,
585
+ top: indicatorRect.top,
586
+ width: indicatorRect.width,
587
+ height: indicatorRect.height,
588
+ boxSizing: 'border-box',
589
+ border: '2px dashed rgba(59, 91, 219, 0.55)',
590
+ background: 'rgba(59, 91, 219, 0.08)',
591
+ borderRadius: config.tileRadius ?? 4,
592
+ pointerEvents: 'none',
593
+ zIndex: 5,
594
+ }}
595
+ />
596
+ )}
597
+ {instances.map((inst) => {
598
+ const tile = inst.tile;
599
+ if (drag && tile.id === drag.tileId) return null;
600
+ const key = inst.key;
601
+ const isBase = inst.kx === 0 && inst.ky === 0;
602
+ const isGhost = editable && !isBase;
603
+ const isResizingInst = resize?.instanceKey === inst.key;
604
+ const isSelected = editable && isBase && selection.has(tile.id);
605
+ const tileHandles = tile.resizeHandles ?? handles;
606
+
607
+ let left = inst.left;
608
+ let top = inst.top;
609
+ let width = inst.width;
610
+ let heightPx = inst.height;
611
+ if (isResizingInst && resize) {
612
+ left = resize.previewCol * colSize + halfGap + resize.instanceDx;
613
+ top = resize.previewRow * rowSize + halfGap + resize.instanceDy;
614
+ width = resize.previewW * config.unitWidth + (resize.previewW - 1) * gap;
615
+ heightPx = resize.previewH * config.unitHeight + (resize.previewH - 1) * gap;
616
+ }
617
+
618
+ const tileStyle: CSSProperties = {
619
+ position: 'absolute',
620
+ left,
621
+ top,
622
+ width,
623
+ height: heightPx,
624
+ boxSizing: 'border-box',
625
+ cursor: editable && isBase ? 'grab' : undefined,
626
+ userSelect: 'none',
627
+ // Ghosts are display-only: pointer-transparent (so the gesture
628
+ // falls through to drag-pan) and dimmed to mark the editable copy.
629
+ pointerEvents: isGhost ? 'none' : undefined,
630
+ zIndex: isResizingInst ? 10 : 1,
631
+ opacity: isResizingInst ? 0.85 : isGhost ? 0.55 : 1,
632
+ boxShadow: isSelected
633
+ ? '0 0 0 3px rgba(59, 91, 219, 0.85), inset 0 0 0 1px rgba(59, 91, 219, 0.3)'
634
+ : undefined,
635
+ borderRadius: config.tileRadius ?? 4,
636
+ };
637
+
638
+ return (
639
+ <div
640
+ key={key}
641
+ ref={(el) => {
642
+ if (el) tileNodes.current.set(key, el);
643
+ else tileNodes.current.delete(key);
644
+ }}
645
+ data-griddle-tile={tile.id}
646
+ data-griddle-instance={key}
647
+ data-griddle-ghost={isGhost ? '' : undefined}
648
+ aria-hidden={isBase ? undefined : true}
649
+ onPointerDown={(e) => onTilePointerDown(e, inst)}
650
+ style={tileStyle}
651
+ >
652
+ {renderTile(tile, isSelected)}
653
+ {editable && isBase && tile.resizable !== false &&
654
+ tileHandles.map((c) => (
655
+ <div
656
+ key={c}
657
+ data-griddle-handle={c}
658
+ onPointerDown={(e) => onResizeHandleDown(e, inst, c)}
659
+ style={handleStyle(c)}
660
+ />
661
+ ))}
662
+ </div>
663
+ );
664
+ })}
665
+ {drag && draggedTile && (
666
+ <div
667
+ data-griddle-tile={draggedTile.id}
668
+ style={{
669
+ position: 'absolute',
670
+ left: drag.instanceLeft,
671
+ top: drag.instanceTop,
672
+ width: draggedTile.w * config.unitWidth + (draggedTile.w - 1) * gap,
673
+ height: draggedTile.h * config.unitHeight + (draggedTile.h - 1) * gap,
674
+ boxSizing: 'border-box',
675
+ cursor: 'grabbing',
676
+ userSelect: 'none',
677
+ zIndex: 20,
678
+ opacity: 0.85,
679
+ transform: `translate(${drag.deltaX}px, ${drag.deltaY}px)`,
680
+ filter: 'drop-shadow(0 8px 16px rgba(0,0,0,0.18))',
681
+ borderRadius: config.tileRadius ?? 4,
682
+ pointerEvents: 'none',
683
+ }}
684
+ >
685
+ {renderTile(draggedTile, selection.has(draggedTile.id))}
686
+ </div>
687
+ )}
688
+ </div>
689
+ </div>
690
+ );
691
+ }
692
+
693
+ function handleStyle(c: Corner): CSSProperties {
694
+ const size = 12;
695
+ const base: CSSProperties = {
696
+ position: 'absolute',
697
+ width: size,
698
+ height: size,
699
+ background: 'rgba(60,60,60,0.7)',
700
+ border: '2px solid white',
701
+ borderRadius: 3,
702
+ cursor: `${c}-resize`,
703
+ touchAction: 'none',
704
+ };
705
+ if (c === 'nw') return { ...base, top: -size / 2, left: -size / 2 };
706
+ if (c === 'ne') return { ...base, top: -size / 2, right: -size / 2 };
707
+ if (c === 'sw') return { ...base, bottom: -size / 2, left: -size / 2 };
708
+ return { ...base, bottom: -size / 2, right: -size / 2 };
709
+ }