@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.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/GriddleGrid.d.ts +45 -0
- package/dist/GriddleGrid.d.ts.map +1 -0
- package/dist/GriddleGrid.js +646 -0
- package/dist/GriddleGrid.js.map +1 -0
- package/dist/LoopGrid.d.ts +3 -0
- package/dist/LoopGrid.d.ts.map +1 -0
- package/dist/LoopGrid.js +569 -0
- package/dist/LoopGrid.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/useGriddle.d.ts +30 -0
- package/dist/useGriddle.d.ts.map +1 -0
- package/dist/useGriddle.js +39 -0
- package/dist/useGriddle.js.map +1 -0
- package/package.json +60 -0
- package/src/GriddleGrid.tsx +825 -0
- package/src/LoopGrid.tsx +709 -0
- package/src/index.ts +5 -0
- package/src/useGriddle.ts +70 -0
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
// GriddleGrid: the React rendering + interaction layer.
|
|
2
|
+
// - Virtualized tile rendering (only visible tiles are mounted)
|
|
3
|
+
// - Live-preview drag: as the cursor crosses cell boundaries the engine
|
|
4
|
+
// continuously rewinds and re-attempts the move, so victims animate into
|
|
5
|
+
// their new positions while the user is still dragging. The dragged tile
|
|
6
|
+
// itself follows the cursor freely (no snap); a separate drop indicator
|
|
7
|
+
// renders at the snapped candidate cell.
|
|
8
|
+
// - Corner resize handles
|
|
9
|
+
// - FLIP animations for repack — skipped for the active dragger.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
CSSProperties,
|
|
13
|
+
ReactNode,
|
|
14
|
+
useCallback,
|
|
15
|
+
useEffect,
|
|
16
|
+
useLayoutEffect,
|
|
17
|
+
useMemo,
|
|
18
|
+
useRef,
|
|
19
|
+
useState,
|
|
20
|
+
useSyncExternalStore,
|
|
21
|
+
} from 'react';
|
|
22
|
+
import type { GriddleApi } from './useGriddle.js';
|
|
23
|
+
import type { CameraState, Corner, Tile } from '@griddle/core';
|
|
24
|
+
import { GriddleLoopGrid } from './LoopGrid.js';
|
|
25
|
+
import {
|
|
26
|
+
visibleRange,
|
|
27
|
+
visibleTiles,
|
|
28
|
+
gridContentSize,
|
|
29
|
+
DragController,
|
|
30
|
+
GroupDragController,
|
|
31
|
+
computeTileLayout,
|
|
32
|
+
resolveStickyStacking,
|
|
33
|
+
isOutOfFlow,
|
|
34
|
+
pixelsToPin,
|
|
35
|
+
} from '@griddle/core';
|
|
36
|
+
import type { TileLayout } from '@griddle/core';
|
|
37
|
+
import { useGridVersion } from './useGriddle.js';
|
|
38
|
+
|
|
39
|
+
export interface GriddleGridProps {
|
|
40
|
+
api: GriddleApi;
|
|
41
|
+
renderTile: (tile: Tile, selected: boolean) => ReactNode;
|
|
42
|
+
className?: string;
|
|
43
|
+
style?: CSSProperties;
|
|
44
|
+
/** Optional container height; default '100%'. */
|
|
45
|
+
height?: number | string;
|
|
46
|
+
/** Whether to render the background grid lines. Default true. */
|
|
47
|
+
showGrid?: boolean;
|
|
48
|
+
/** Controlled selection state. If omitted, selection is managed internally. */
|
|
49
|
+
selection?: Set<string>;
|
|
50
|
+
/** Called whenever the selection changes. */
|
|
51
|
+
onSelectionChange?: (selection: Set<string>) => void;
|
|
52
|
+
/** Called when the user draw-creates a new tile rect on empty space. */
|
|
53
|
+
onDrawCreate?: (rect: { col: number; row: number; w: number; h: number }) => void;
|
|
54
|
+
/** Called when a drag gesture begins (pointer down on a tile). */
|
|
55
|
+
onDragStart?: (tileId: string) => void;
|
|
56
|
+
/** Called when a drag gesture ends. `committed` is true if the tile moved. */
|
|
57
|
+
onDragEnd?: (tileId: string, committed: boolean) => void;
|
|
58
|
+
/** Called when a resize gesture begins (pointer down on a handle). */
|
|
59
|
+
onResizeStart?: (tileId: string) => void;
|
|
60
|
+
/** Called when a resize gesture ends. `committed` is true if the tile resized. */
|
|
61
|
+
onResizeEnd?: (tileId: string, committed: boolean) => void;
|
|
62
|
+
/**
|
|
63
|
+
* Loop mode only: fires whenever the camera moves (offset, velocity,
|
|
64
|
+
* isMoving, isDragging). Use it to drive velocity-based visual effects.
|
|
65
|
+
* Called from the render loop — keep the handler cheap.
|
|
66
|
+
*/
|
|
67
|
+
onCameraChange?: (camera: CameraState) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface DragVisual {
|
|
71
|
+
tileId: string;
|
|
72
|
+
pickupCol: number;
|
|
73
|
+
pickupRow: number;
|
|
74
|
+
deltaX: number;
|
|
75
|
+
deltaY: number;
|
|
76
|
+
indicatorCol: number | null;
|
|
77
|
+
indicatorRow: number | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface PinDragState {
|
|
81
|
+
tileId: string;
|
|
82
|
+
startPinPx: { x: number; y: number };
|
|
83
|
+
startPointerX: number;
|
|
84
|
+
startPointerY: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface GroupDragVisual {
|
|
88
|
+
tileIds: string[];
|
|
89
|
+
/** Pixel delta from pointer pickup */
|
|
90
|
+
deltaX: number;
|
|
91
|
+
deltaY: number;
|
|
92
|
+
/** Last committed cell delta (for drop indicators) */
|
|
93
|
+
committedDcol: number;
|
|
94
|
+
committedDrow: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface ResizeState {
|
|
98
|
+
tileId: string;
|
|
99
|
+
corner: Corner;
|
|
100
|
+
startPointerX: number;
|
|
101
|
+
startPointerY: number;
|
|
102
|
+
startW: number;
|
|
103
|
+
startH: number;
|
|
104
|
+
startCol: number;
|
|
105
|
+
startRow: number;
|
|
106
|
+
previewW: number;
|
|
107
|
+
previewH: number;
|
|
108
|
+
previewCol: number;
|
|
109
|
+
previewRow: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface DrawState {
|
|
113
|
+
anchorCol: number;
|
|
114
|
+
anchorRow: number;
|
|
115
|
+
currentCol: number;
|
|
116
|
+
currentRow: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const DEFAULT_DRAG_IGNORE = 'a, button, input, textarea, select, [contenteditable]';
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Renders the grid. When `config.loop.enabled` is set, delegates to the
|
|
123
|
+
* loop-mode renderer (infinitely repeating plane); otherwise renders the
|
|
124
|
+
* standard scrollable grid. Toggling loop at runtime remounts the surface.
|
|
125
|
+
*/
|
|
126
|
+
export function GriddleGrid(props: GriddleGridProps) {
|
|
127
|
+
const loopOn = useSyncExternalStore(
|
|
128
|
+
(cb) => props.api.grid.changes.on(() => cb()),
|
|
129
|
+
() => props.api.config.loop?.enabled === true,
|
|
130
|
+
() => props.api.config.loop?.enabled === true,
|
|
131
|
+
);
|
|
132
|
+
if (loopOn) return <GriddleLoopGrid {...props} />;
|
|
133
|
+
return <GriddleStaticGrid {...props} />;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function GriddleStaticGrid(props: GriddleGridProps) {
|
|
137
|
+
const {
|
|
138
|
+
api, renderTile, className, style, height = '100%', showGrid = true,
|
|
139
|
+
selection: controlledSelection, onSelectionChange, onDrawCreate,
|
|
140
|
+
onDragStart, onDragEnd, onResizeStart, onResizeEnd,
|
|
141
|
+
} = props;
|
|
142
|
+
const config = api.config;
|
|
143
|
+
const version = useGridVersion(api.grid);
|
|
144
|
+
|
|
145
|
+
// Selection state (internal or controlled)
|
|
146
|
+
const [internalSelection, setInternalSelection] = useState<Set<string>>(new Set());
|
|
147
|
+
const selection = controlledSelection ?? internalSelection;
|
|
148
|
+
const setSelection = useCallback((next: Set<string>) => {
|
|
149
|
+
if (!controlledSelection) setInternalSelection(next);
|
|
150
|
+
onSelectionChange?.(next);
|
|
151
|
+
}, [controlledSelection, onSelectionChange]);
|
|
152
|
+
|
|
153
|
+
// Virtualization viewport
|
|
154
|
+
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
155
|
+
const [viewport, setViewport] = useState({ scrollX: 0, scrollY: 0, width: 1000, height: 800 });
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
const el = scrollRef.current;
|
|
159
|
+
if (!el) return;
|
|
160
|
+
const update = () => {
|
|
161
|
+
setViewport({
|
|
162
|
+
scrollX: el.scrollLeft,
|
|
163
|
+
scrollY: el.scrollTop,
|
|
164
|
+
width: el.clientWidth,
|
|
165
|
+
height: el.clientHeight,
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
update();
|
|
169
|
+
el.addEventListener('scroll', update, { passive: true });
|
|
170
|
+
const ro = new ResizeObserver(update);
|
|
171
|
+
ro.observe(el);
|
|
172
|
+
return () => {
|
|
173
|
+
el.removeEventListener('scroll', update);
|
|
174
|
+
ro.disconnect();
|
|
175
|
+
};
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
const tiles = api.tiles;
|
|
179
|
+
const _ = version; // subscribe
|
|
180
|
+
|
|
181
|
+
const range = useMemo(() => visibleRange(config, viewport, 4), [config, viewport]);
|
|
182
|
+
const rendered = useMemo(() => visibleTiles(tiles, range), [tiles, range]);
|
|
183
|
+
const contentSize = useMemo(() => gridContentSize(config, tiles), [config, tiles]);
|
|
184
|
+
|
|
185
|
+
const gap = config.gap ?? 0;
|
|
186
|
+
const halfGap = gap / 2;
|
|
187
|
+
const colSize = config.unitWidth + gap;
|
|
188
|
+
const rowSize = config.unitHeight + gap;
|
|
189
|
+
|
|
190
|
+
// Drag controller (lives across drags) + ephemeral visual state
|
|
191
|
+
const dragControllerRef = useRef<DragController | null>(null);
|
|
192
|
+
if (!dragControllerRef.current) {
|
|
193
|
+
dragControllerRef.current = new DragController(api.grid);
|
|
194
|
+
}
|
|
195
|
+
const groupDragControllerRef = useRef<GroupDragController | null>(null);
|
|
196
|
+
if (!groupDragControllerRef.current) {
|
|
197
|
+
groupDragControllerRef.current = new GroupDragController(api.grid);
|
|
198
|
+
}
|
|
199
|
+
const [drag, setDrag] = useState<DragVisual | null>(null);
|
|
200
|
+
const [groupDrag, setGroupDrag] = useState<GroupDragVisual | null>(null);
|
|
201
|
+
const [resize, setResize] = useState<ResizeState | null>(null);
|
|
202
|
+
const [pinDrag, setPinDrag] = useState<PinDragState | null>(null);
|
|
203
|
+
const dragRef = useRef<DragVisual | null>(null);
|
|
204
|
+
const groupDragRef = useRef<GroupDragVisual | null>(null);
|
|
205
|
+
const resizeRef = useRef<ResizeState | null>(null);
|
|
206
|
+
const pinDragRef = useRef<PinDragState | null>(null);
|
|
207
|
+
dragRef.current = drag;
|
|
208
|
+
groupDragRef.current = groupDrag;
|
|
209
|
+
resizeRef.current = resize;
|
|
210
|
+
pinDragRef.current = pinDrag;
|
|
211
|
+
const dragStartPointerRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
212
|
+
|
|
213
|
+
const [drawState, setDrawState] = useState<DrawState | null>(null);
|
|
214
|
+
const drawStateRef = useRef<DrawState | null>(null);
|
|
215
|
+
drawStateRef.current = drawState;
|
|
216
|
+
|
|
217
|
+
const onDragStartRef = useRef(onDragStart);
|
|
218
|
+
onDragStartRef.current = onDragStart;
|
|
219
|
+
const onDragEndRef = useRef(onDragEnd);
|
|
220
|
+
onDragEndRef.current = onDragEnd;
|
|
221
|
+
const onResizeStartRef = useRef(onResizeStart);
|
|
222
|
+
onResizeStartRef.current = onResizeStart;
|
|
223
|
+
const onResizeEndRef = useRef(onResizeEnd);
|
|
224
|
+
onResizeEndRef.current = onResizeEnd;
|
|
225
|
+
|
|
226
|
+
// FLIP animation state: previousRects by id. Skip the dragger during drag —
|
|
227
|
+
// it has its own cursor-driven inline transform.
|
|
228
|
+
const prevRectsRef = useRef(new Map<string, { x: number; y: number; w: number; h: number }>());
|
|
229
|
+
const tileNodes = useRef(new Map<string, HTMLDivElement>());
|
|
230
|
+
|
|
231
|
+
useLayoutEffect(() => {
|
|
232
|
+
const prev = prevRectsRef.current;
|
|
233
|
+
const next = new Map<string, { x: number; y: number; w: number; h: number }>();
|
|
234
|
+
const draggerId = dragRef.current?.tileId ?? null;
|
|
235
|
+
const groupDragIds = new Set(groupDragRef.current?.tileIds ?? []);
|
|
236
|
+
for (const t of tiles) {
|
|
237
|
+
const x = t.col * colSize + halfGap;
|
|
238
|
+
const y = t.row * rowSize + halfGap;
|
|
239
|
+
const w = t.w * config.unitWidth + (t.w - 1) * gap;
|
|
240
|
+
const h = t.h * config.unitHeight + (t.h - 1) * gap;
|
|
241
|
+
next.set(t.id, { x, y, w, h });
|
|
242
|
+
if (t.id === draggerId || groupDragIds.has(t.id)) continue;
|
|
243
|
+
const p = prev.get(t.id);
|
|
244
|
+
const node = tileNodes.current.get(t.id);
|
|
245
|
+
if (p && node) {
|
|
246
|
+
const dx = p.x - x;
|
|
247
|
+
const dy = p.y - y;
|
|
248
|
+
if (dx !== 0 || dy !== 0) {
|
|
249
|
+
node.style.transition = 'none';
|
|
250
|
+
node.style.transform = `translate(${dx}px, ${dy}px)`;
|
|
251
|
+
requestAnimationFrame(() => {
|
|
252
|
+
node.style.transition = 'transform 220ms cubic-bezier(.2,.7,.2,1)';
|
|
253
|
+
node.style.transform = 'translate(0,0)';
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
prevRectsRef.current = next;
|
|
259
|
+
}, [tiles, version, colSize, rowSize, config.unitWidth, config.unitHeight, config.gap]);
|
|
260
|
+
|
|
261
|
+
// --- drag handlers ---
|
|
262
|
+
const onTilePointerDown = useCallback(
|
|
263
|
+
(e: React.PointerEvent<HTMLDivElement>, tile: Tile) => {
|
|
264
|
+
if (tile.draggable === false) return;
|
|
265
|
+
if ((e.target as HTMLElement).dataset.griddleHandle) return;
|
|
266
|
+
|
|
267
|
+
const ignoreSelector = config.dragIgnoreFrom ?? DEFAULT_DRAG_IGNORE;
|
|
268
|
+
if (ignoreSelector && (e.target as HTMLElement).closest(ignoreSelector)) return;
|
|
269
|
+
|
|
270
|
+
const metaKey = e.metaKey || e.ctrlKey;
|
|
271
|
+
|
|
272
|
+
// Out-of-flow tiles get free-pixel drag (no selection behavior).
|
|
273
|
+
if (config.enablePositioning && isOutOfFlow(tile)) {
|
|
274
|
+
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
|
275
|
+
const layout = computeTileLayout({
|
|
276
|
+
tile,
|
|
277
|
+
config,
|
|
278
|
+
scrollX: viewport.scrollX,
|
|
279
|
+
scrollY: viewport.scrollY,
|
|
280
|
+
viewportWidth: viewport.width,
|
|
281
|
+
viewportHeight: viewport.height,
|
|
282
|
+
});
|
|
283
|
+
const startPinPx =
|
|
284
|
+
tile.position === 'fixed'
|
|
285
|
+
? { x: layout.left - viewport.scrollX, y: layout.top - viewport.scrollY }
|
|
286
|
+
: { x: layout.left, y: layout.top };
|
|
287
|
+
setPinDrag({
|
|
288
|
+
tileId: tile.id,
|
|
289
|
+
startPinPx,
|
|
290
|
+
startPointerX: e.clientX,
|
|
291
|
+
startPointerY: e.clientY,
|
|
292
|
+
});
|
|
293
|
+
onDragStartRef.current?.(tile.id);
|
|
294
|
+
e.stopPropagation();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Cmd/Ctrl+click toggles the tile in/out of the selection without
|
|
299
|
+
// starting any drag. No pointer capture needed.
|
|
300
|
+
if (metaKey) {
|
|
301
|
+
e.preventDefault();
|
|
302
|
+
const next = new Set(selection);
|
|
303
|
+
if (next.has(tile.id)) {
|
|
304
|
+
next.delete(tile.id);
|
|
305
|
+
} else {
|
|
306
|
+
next.add(tile.id);
|
|
307
|
+
}
|
|
308
|
+
setSelection(next);
|
|
309
|
+
e.stopPropagation();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const tileIsSelected = selection.has(tile.id);
|
|
314
|
+
|
|
315
|
+
if (!tileIsSelected) {
|
|
316
|
+
setSelection(new Set([tile.id]));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Capture pointer only when starting a drag.
|
|
320
|
+
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
|
321
|
+
|
|
322
|
+
// Determine if this should be a group drag (2+ selected tiles including this one).
|
|
323
|
+
const effectiveSelection = tileIsSelected ? selection : new Set([tile.id]);
|
|
324
|
+
if (effectiveSelection.size > 1) {
|
|
325
|
+
const ids = Array.from(effectiveSelection);
|
|
326
|
+
const ctrl = groupDragControllerRef.current!;
|
|
327
|
+
if (!ctrl.start(ids)) return;
|
|
328
|
+
dragStartPointerRef.current = { x: e.clientX, y: e.clientY };
|
|
329
|
+
setGroupDrag({
|
|
330
|
+
tileIds: ids,
|
|
331
|
+
deltaX: 0,
|
|
332
|
+
deltaY: 0,
|
|
333
|
+
committedDcol: 0,
|
|
334
|
+
committedDrow: 0,
|
|
335
|
+
});
|
|
336
|
+
onDragStartRef.current?.(tile.id);
|
|
337
|
+
e.stopPropagation();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Single tile drag.
|
|
342
|
+
const ctrl = dragControllerRef.current!;
|
|
343
|
+
if (!ctrl.start(tile.id)) return;
|
|
344
|
+
dragStartPointerRef.current = { x: e.clientX, y: e.clientY };
|
|
345
|
+
setDrag({
|
|
346
|
+
tileId: tile.id,
|
|
347
|
+
pickupCol: tile.col,
|
|
348
|
+
pickupRow: tile.row,
|
|
349
|
+
deltaX: 0,
|
|
350
|
+
deltaY: 0,
|
|
351
|
+
indicatorCol: tile.col,
|
|
352
|
+
indicatorRow: tile.row,
|
|
353
|
+
});
|
|
354
|
+
onDragStartRef.current?.(tile.id);
|
|
355
|
+
e.stopPropagation();
|
|
356
|
+
},
|
|
357
|
+
[config, viewport, selection, setSelection],
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const onPointerMove = useCallback(
|
|
361
|
+
(e: PointerEvent) => {
|
|
362
|
+
if (drawStateRef.current) {
|
|
363
|
+
const el = scrollRef.current;
|
|
364
|
+
if (el) {
|
|
365
|
+
const rect = el.getBoundingClientRect();
|
|
366
|
+
const x = e.clientX - rect.left + el.scrollLeft;
|
|
367
|
+
const y = e.clientY - rect.top + el.scrollTop;
|
|
368
|
+
const col = Math.max(0, Math.floor(x / colSize));
|
|
369
|
+
const row = Math.max(0, Math.floor(y / rowSize));
|
|
370
|
+
setDrawState({ ...drawStateRef.current, currentCol: col, currentRow: row });
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const pd = pinDragRef.current;
|
|
375
|
+
const d = dragRef.current;
|
|
376
|
+
const gd = groupDragRef.current;
|
|
377
|
+
const r = resizeRef.current;
|
|
378
|
+
if (pd) {
|
|
379
|
+
const dx = e.clientX - pd.startPointerX;
|
|
380
|
+
const dy = e.clientY - pd.startPointerY;
|
|
381
|
+
const newPinPx = {
|
|
382
|
+
x: pd.startPinPx.x + dx,
|
|
383
|
+
y: pd.startPinPx.y + dy,
|
|
384
|
+
};
|
|
385
|
+
const newPin = pixelsToPin(newPinPx, config);
|
|
386
|
+
api.grid.setTilePinned(pd.tileId, newPin);
|
|
387
|
+
}
|
|
388
|
+
if (gd) {
|
|
389
|
+
const dx = e.clientX - dragStartPointerRef.current.x;
|
|
390
|
+
const dy = e.clientY - dragStartPointerRef.current.y;
|
|
391
|
+
const dcol = Math.round(dx / colSize);
|
|
392
|
+
const drow = Math.round(dy / rowSize);
|
|
393
|
+
const result = groupDragControllerRef.current!.update({ dcol, drow });
|
|
394
|
+
setGroupDrag({
|
|
395
|
+
...gd,
|
|
396
|
+
deltaX: dx,
|
|
397
|
+
deltaY: dy,
|
|
398
|
+
committedDcol: result.indicatorDelta?.dcol ?? gd.committedDcol,
|
|
399
|
+
committedDrow: result.indicatorDelta?.drow ?? gd.committedDrow,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
if (d) {
|
|
403
|
+
const dx = e.clientX - dragStartPointerRef.current.x;
|
|
404
|
+
const dy = e.clientY - dragStartPointerRef.current.y;
|
|
405
|
+
const candidateCol = d.pickupCol + Math.round(dx / colSize);
|
|
406
|
+
const candidateRow = d.pickupRow + Math.round(dy / rowSize);
|
|
407
|
+
const result = dragControllerRef.current!.update({
|
|
408
|
+
col: candidateCol,
|
|
409
|
+
row: candidateRow,
|
|
410
|
+
});
|
|
411
|
+
setDrag({
|
|
412
|
+
...d,
|
|
413
|
+
deltaX: dx,
|
|
414
|
+
deltaY: dy,
|
|
415
|
+
indicatorCol: result.indicatorCell ? result.indicatorCell.col : null,
|
|
416
|
+
indicatorRow: result.indicatorCell ? result.indicatorCell.row : null,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
if (r) {
|
|
420
|
+
const dx = e.clientX - r.startPointerX;
|
|
421
|
+
const dy = e.clientY - r.startPointerY;
|
|
422
|
+
let dw = 0, dh = 0, dcol = 0, drow = 0;
|
|
423
|
+
const stepsX = Math.round(dx / colSize);
|
|
424
|
+
const stepsY = Math.round(dy / rowSize);
|
|
425
|
+
if (r.corner === 'se' || r.corner === 'ne') dw = stepsX;
|
|
426
|
+
if (r.corner === 'se' || r.corner === 'sw') dh = stepsY;
|
|
427
|
+
if (r.corner === 'sw' || r.corner === 'nw') { dw = -stepsX; dcol = stepsX; }
|
|
428
|
+
if (r.corner === 'ne' || r.corner === 'nw') { dh = -stepsY; drow = stepsY; }
|
|
429
|
+
const tile = api.grid.getTile(r.tileId);
|
|
430
|
+
const minW = tile?.minW ?? 1;
|
|
431
|
+
const minH = tile?.minH ?? 1;
|
|
432
|
+
const maxW = tile?.maxW ?? Infinity;
|
|
433
|
+
const maxH = tile?.maxH ?? Infinity;
|
|
434
|
+
const newW = Math.min(maxW, Math.max(minW, r.startW + dw));
|
|
435
|
+
const newH = Math.min(maxH, Math.max(minH, r.startH + dh));
|
|
436
|
+
const newCol = r.startCol + dcol;
|
|
437
|
+
const newRow = r.startRow + drow;
|
|
438
|
+
if (newW !== r.previewW || newH !== r.previewH || newCol !== r.previewCol || newRow !== r.previewRow) {
|
|
439
|
+
setResize({ ...r, previewW: newW, previewH: newH, previewCol: newCol, previewRow: newRow });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
[colSize, rowSize, api, config],
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
const onDrawCreateRef = useRef(onDrawCreate);
|
|
447
|
+
onDrawCreateRef.current = onDrawCreate;
|
|
448
|
+
|
|
449
|
+
const onPointerUp = useCallback(() => {
|
|
450
|
+
if (drawStateRef.current) {
|
|
451
|
+
const ds = drawStateRef.current;
|
|
452
|
+
const col = Math.min(ds.anchorCol, ds.currentCol);
|
|
453
|
+
const row = Math.min(ds.anchorRow, ds.currentRow);
|
|
454
|
+
const w = Math.max(1, Math.abs(ds.currentCol - ds.anchorCol) + 1);
|
|
455
|
+
const h = Math.max(1, Math.abs(ds.currentRow - ds.anchorRow) + 1);
|
|
456
|
+
setDrawState(null);
|
|
457
|
+
onDrawCreateRef.current?.({ col, row, w, h });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const pd = pinDragRef.current;
|
|
461
|
+
const d = dragRef.current;
|
|
462
|
+
const gd = groupDragRef.current;
|
|
463
|
+
const r = resizeRef.current;
|
|
464
|
+
if (pd) {
|
|
465
|
+
const tileId = pd.tileId;
|
|
466
|
+
setPinDrag(null);
|
|
467
|
+
onDragEndRef.current?.(tileId, true);
|
|
468
|
+
}
|
|
469
|
+
if (gd) {
|
|
470
|
+
const primaryId = gd.tileIds[0];
|
|
471
|
+
const { committed } = groupDragControllerRef.current!.end();
|
|
472
|
+
setGroupDrag(null);
|
|
473
|
+
if (primaryId) onDragEndRef.current?.(primaryId, committed);
|
|
474
|
+
}
|
|
475
|
+
if (d) {
|
|
476
|
+
const tileId = d.tileId;
|
|
477
|
+
const { committed } = dragControllerRef.current!.end();
|
|
478
|
+
setDrag(null);
|
|
479
|
+
onDragEndRef.current?.(tileId, committed);
|
|
480
|
+
}
|
|
481
|
+
if (r) {
|
|
482
|
+
const tile = api.grid.getTile(r.tileId);
|
|
483
|
+
let committed = false;
|
|
484
|
+
if (tile) {
|
|
485
|
+
if (r.previewCol !== tile.col || r.previewRow !== tile.row) {
|
|
486
|
+
api.moveTile(r.tileId, { col: r.previewCol, row: r.previewRow });
|
|
487
|
+
}
|
|
488
|
+
if (r.previewW !== tile.w || r.previewH !== tile.h) {
|
|
489
|
+
committed = api.resizeTile(r.tileId, { w: r.previewW, h: r.previewH });
|
|
490
|
+
} else {
|
|
491
|
+
committed = r.previewCol !== r.startCol || r.previewRow !== r.startRow
|
|
492
|
+
|| r.previewW !== r.startW || r.previewH !== r.startH;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
setResize(null);
|
|
496
|
+
onResizeEndRef.current?.(r.tileId, committed);
|
|
497
|
+
}
|
|
498
|
+
}, [api]);
|
|
499
|
+
|
|
500
|
+
useEffect(() => {
|
|
501
|
+
window.addEventListener('pointermove', onPointerMove);
|
|
502
|
+
window.addEventListener('pointerup', onPointerUp);
|
|
503
|
+
window.addEventListener('pointercancel', onPointerUp);
|
|
504
|
+
return () => {
|
|
505
|
+
window.removeEventListener('pointermove', onPointerMove);
|
|
506
|
+
window.removeEventListener('pointerup', onPointerUp);
|
|
507
|
+
window.removeEventListener('pointercancel', onPointerUp);
|
|
508
|
+
};
|
|
509
|
+
}, [onPointerMove, onPointerUp]);
|
|
510
|
+
|
|
511
|
+
// Escape key clears selection.
|
|
512
|
+
useEffect(() => {
|
|
513
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
514
|
+
if (e.key === 'Escape') {
|
|
515
|
+
setSelection(new Set());
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
window.addEventListener('keydown', onKeyDown);
|
|
519
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
520
|
+
}, [setSelection]);
|
|
521
|
+
|
|
522
|
+
// Click on empty space clears selection and starts draw-to-create.
|
|
523
|
+
const onBackgroundPointerDown = useCallback(
|
|
524
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
525
|
+
if ((e.target as HTMLElement).closest('[data-griddle-tile]')) return;
|
|
526
|
+
setSelection(new Set());
|
|
527
|
+
|
|
528
|
+
const el = scrollRef.current;
|
|
529
|
+
if (!el) return;
|
|
530
|
+
const rect = el.getBoundingClientRect();
|
|
531
|
+
const x = e.clientX - rect.left + el.scrollLeft;
|
|
532
|
+
const y = e.clientY - rect.top + el.scrollTop;
|
|
533
|
+
const col = Math.floor(x / colSize);
|
|
534
|
+
const row = Math.floor(y / rowSize);
|
|
535
|
+
setDrawState({ anchorCol: col, anchorRow: row, currentCol: col, currentRow: row });
|
|
536
|
+
el.setPointerCapture(e.pointerId);
|
|
537
|
+
},
|
|
538
|
+
[setSelection, colSize, rowSize],
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
const onResizeHandleDown = useCallback(
|
|
542
|
+
(e: React.PointerEvent<HTMLDivElement>, tile: Tile, corner: Corner) => {
|
|
543
|
+
if (tile.resizable === false) return;
|
|
544
|
+
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
|
545
|
+
setResize({
|
|
546
|
+
tileId: tile.id,
|
|
547
|
+
corner,
|
|
548
|
+
startPointerX: e.clientX,
|
|
549
|
+
startPointerY: e.clientY,
|
|
550
|
+
startW: tile.w,
|
|
551
|
+
startH: tile.h,
|
|
552
|
+
startCol: tile.col,
|
|
553
|
+
startRow: tile.row,
|
|
554
|
+
previewW: tile.w,
|
|
555
|
+
previewH: tile.h,
|
|
556
|
+
previewCol: tile.col,
|
|
557
|
+
previewRow: tile.row,
|
|
558
|
+
});
|
|
559
|
+
onResizeStartRef.current?.(tile.id);
|
|
560
|
+
e.stopPropagation();
|
|
561
|
+
},
|
|
562
|
+
[],
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
// Background grid lines via CSS
|
|
566
|
+
const bgStyle: CSSProperties = showGrid
|
|
567
|
+
? {
|
|
568
|
+
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)`,
|
|
569
|
+
backgroundSize: `${colSize}px ${rowSize}px`,
|
|
570
|
+
backgroundPosition: '0 0',
|
|
571
|
+
}
|
|
572
|
+
: {};
|
|
573
|
+
|
|
574
|
+
const handles = config.resizeHandles ?? ['se'];
|
|
575
|
+
|
|
576
|
+
// Compute layouts for all rendered tiles in one pass so resolveStickyStacking
|
|
577
|
+
// can see every sticky tile and adjust them as a group. Recomputes whenever
|
|
578
|
+
// the relevant inputs change.
|
|
579
|
+
const groupDragSet = useMemo(
|
|
580
|
+
() => new Set(groupDrag?.tileIds ?? []),
|
|
581
|
+
[groupDrag?.tileIds],
|
|
582
|
+
);
|
|
583
|
+
const tileLayouts = useMemo(() => {
|
|
584
|
+
const out = new Map<string, TileLayout>();
|
|
585
|
+
const stickyEntries: { tile: Tile; layout: TileLayout }[] = [];
|
|
586
|
+
for (const tile of rendered) {
|
|
587
|
+
let layout: TileLayout;
|
|
588
|
+
if (resize?.tileId === tile.id) {
|
|
589
|
+
const w = resize.previewW;
|
|
590
|
+
const h = resize.previewH;
|
|
591
|
+
layout = {
|
|
592
|
+
left: resize.previewCol * colSize + halfGap,
|
|
593
|
+
top: resize.previewRow * rowSize + halfGap,
|
|
594
|
+
width: w * config.unitWidth + (w - 1) * gap,
|
|
595
|
+
height: h * config.unitHeight + (h - 1) * gap,
|
|
596
|
+
zIndex: 10,
|
|
597
|
+
effective: 'static',
|
|
598
|
+
};
|
|
599
|
+
} else if (drag?.tileId === tile.id) {
|
|
600
|
+
layout = {
|
|
601
|
+
left: drag.pickupCol * colSize + halfGap,
|
|
602
|
+
top: drag.pickupRow * rowSize + halfGap,
|
|
603
|
+
width: tile.w * config.unitWidth + (tile.w - 1) * gap,
|
|
604
|
+
height: tile.h * config.unitHeight + (tile.h - 1) * gap,
|
|
605
|
+
transform: `translate(${drag.deltaX}px, ${drag.deltaY}px)`,
|
|
606
|
+
zIndex: 20,
|
|
607
|
+
effective: 'static',
|
|
608
|
+
};
|
|
609
|
+
} else if (groupDrag && groupDragSet.has(tile.id)) {
|
|
610
|
+
const pickup = groupDragControllerRef.current!.pickupCell(tile.id);
|
|
611
|
+
const left = (pickup ? pickup.col * colSize : tile.col * colSize) + halfGap;
|
|
612
|
+
const top = (pickup ? pickup.row * rowSize : tile.row * rowSize) + halfGap;
|
|
613
|
+
layout = {
|
|
614
|
+
left,
|
|
615
|
+
top,
|
|
616
|
+
width: tile.w * config.unitWidth + (tile.w - 1) * gap,
|
|
617
|
+
height: tile.h * config.unitHeight + (tile.h - 1) * gap,
|
|
618
|
+
transform: `translate(${groupDrag.deltaX}px, ${groupDrag.deltaY}px)`,
|
|
619
|
+
zIndex: 20,
|
|
620
|
+
effective: 'static',
|
|
621
|
+
};
|
|
622
|
+
} else {
|
|
623
|
+
layout = computeTileLayout({
|
|
624
|
+
tile,
|
|
625
|
+
config,
|
|
626
|
+
scrollX: viewport.scrollX,
|
|
627
|
+
scrollY: viewport.scrollY,
|
|
628
|
+
viewportWidth: viewport.width,
|
|
629
|
+
viewportHeight: viewport.height,
|
|
630
|
+
});
|
|
631
|
+
if (layout.effective === 'sticky') {
|
|
632
|
+
stickyEntries.push({ tile, layout });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
out.set(tile.id, layout);
|
|
636
|
+
}
|
|
637
|
+
if (stickyEntries.length > 1) resolveStickyStacking(stickyEntries);
|
|
638
|
+
return out;
|
|
639
|
+
}, [rendered, drag, groupDrag, groupDragSet, resize, config, viewport, colSize, rowSize]);
|
|
640
|
+
|
|
641
|
+
// Drop indicator rect (snapped to candidate cell, sized to dragger).
|
|
642
|
+
let indicatorRect: { left: number; top: number; width: number; height: number } | null = null;
|
|
643
|
+
if (drag && drag.indicatorCol !== null && drag.indicatorRow !== null) {
|
|
644
|
+
const t = api.grid.getTile(drag.tileId);
|
|
645
|
+
if (t) {
|
|
646
|
+
indicatorRect = {
|
|
647
|
+
left: drag.indicatorCol * colSize + halfGap,
|
|
648
|
+
top: drag.indicatorRow * rowSize + halfGap,
|
|
649
|
+
width: t.w * config.unitWidth + (t.w - 1) * gap,
|
|
650
|
+
height: t.h * config.unitHeight + (t.h - 1) * gap,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Draw-to-create ghost rect.
|
|
656
|
+
let drawGhost: { left: number; top: number; width: number; height: number; label: string } | null = null;
|
|
657
|
+
if (drawState) {
|
|
658
|
+
const col = Math.min(drawState.anchorCol, drawState.currentCol);
|
|
659
|
+
const row = Math.min(drawState.anchorRow, drawState.currentRow);
|
|
660
|
+
const w = Math.max(1, Math.abs(drawState.currentCol - drawState.anchorCol) + 1);
|
|
661
|
+
const h = Math.max(1, Math.abs(drawState.currentRow - drawState.anchorRow) + 1);
|
|
662
|
+
drawGhost = {
|
|
663
|
+
left: col * colSize + halfGap,
|
|
664
|
+
top: row * rowSize + halfGap,
|
|
665
|
+
width: w * config.unitWidth + (w - 1) * gap,
|
|
666
|
+
height: h * config.unitHeight + (h - 1) * gap,
|
|
667
|
+
label: `${w}\u00d7${h}`,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return (
|
|
672
|
+
<div
|
|
673
|
+
ref={scrollRef}
|
|
674
|
+
className={className}
|
|
675
|
+
onPointerDown={onBackgroundPointerDown}
|
|
676
|
+
style={{
|
|
677
|
+
position: 'relative',
|
|
678
|
+
overflow: 'auto',
|
|
679
|
+
height,
|
|
680
|
+
touchAction: 'none',
|
|
681
|
+
...style,
|
|
682
|
+
}}
|
|
683
|
+
>
|
|
684
|
+
<div
|
|
685
|
+
style={{
|
|
686
|
+
position: 'relative',
|
|
687
|
+
width: contentSize.width || '100%',
|
|
688
|
+
height: contentSize.height || '100%',
|
|
689
|
+
minWidth: '100%',
|
|
690
|
+
minHeight: '100%',
|
|
691
|
+
// Expose the tile radius as a CSS var so user tile components can
|
|
692
|
+
// read it with var(--griddle-tile-radius) and stay in sync.
|
|
693
|
+
['--griddle-tile-radius' as never]: `${config.tileRadius ?? 4}px`,
|
|
694
|
+
...bgStyle,
|
|
695
|
+
}}
|
|
696
|
+
>
|
|
697
|
+
{indicatorRect && (
|
|
698
|
+
<div
|
|
699
|
+
style={{
|
|
700
|
+
position: 'absolute',
|
|
701
|
+
left: indicatorRect.left,
|
|
702
|
+
top: indicatorRect.top,
|
|
703
|
+
width: indicatorRect.width,
|
|
704
|
+
height: indicatorRect.height,
|
|
705
|
+
boxSizing: 'border-box',
|
|
706
|
+
border: '2px dashed rgba(59, 91, 219, 0.55)',
|
|
707
|
+
background: 'rgba(59, 91, 219, 0.08)',
|
|
708
|
+
borderRadius: config.tileRadius ?? 4,
|
|
709
|
+
pointerEvents: 'none',
|
|
710
|
+
zIndex: 5,
|
|
711
|
+
}}
|
|
712
|
+
/>
|
|
713
|
+
)}
|
|
714
|
+
{drawGhost && (
|
|
715
|
+
<div
|
|
716
|
+
style={{
|
|
717
|
+
position: 'absolute',
|
|
718
|
+
left: drawGhost.left,
|
|
719
|
+
top: drawGhost.top,
|
|
720
|
+
width: drawGhost.width,
|
|
721
|
+
height: drawGhost.height,
|
|
722
|
+
boxSizing: 'border-box',
|
|
723
|
+
border: '2px dashed rgba(59, 91, 219, 0.55)',
|
|
724
|
+
background: 'rgba(59, 91, 219, 0.08)',
|
|
725
|
+
borderRadius: config.tileRadius ?? 4,
|
|
726
|
+
pointerEvents: 'none',
|
|
727
|
+
zIndex: 5,
|
|
728
|
+
display: 'flex',
|
|
729
|
+
alignItems: 'center',
|
|
730
|
+
justifyContent: 'center',
|
|
731
|
+
fontSize: 14,
|
|
732
|
+
fontWeight: 600,
|
|
733
|
+
color: 'rgba(59, 91, 219, 0.8)',
|
|
734
|
+
userSelect: 'none',
|
|
735
|
+
}}
|
|
736
|
+
>
|
|
737
|
+
{drawGhost.label}
|
|
738
|
+
</div>
|
|
739
|
+
)}
|
|
740
|
+
{rendered.map((tile) => {
|
|
741
|
+
const isDragging = drag?.tileId === tile.id;
|
|
742
|
+
const isGroupDragging = groupDragSet.has(tile.id);
|
|
743
|
+
const isPinDragging = pinDrag?.tileId === tile.id;
|
|
744
|
+
const isResizing = resize?.tileId === tile.id;
|
|
745
|
+
const isSelected = selection.has(tile.id);
|
|
746
|
+
const tileHandles = tile.resizeHandles ?? handles;
|
|
747
|
+
|
|
748
|
+
const layout = tileLayouts.get(tile.id) ?? {
|
|
749
|
+
left: 0, top: 0, width: 0, height: 0, zIndex: 1, effective: 'static' as const,
|
|
750
|
+
};
|
|
751
|
+
const left = layout.left;
|
|
752
|
+
const top = layout.top;
|
|
753
|
+
const width = layout.width;
|
|
754
|
+
const heightPx = layout.height;
|
|
755
|
+
const transform = layout.transform;
|
|
756
|
+
const zIndex = layout.zIndex;
|
|
757
|
+
|
|
758
|
+
const lifted = isDragging || isPinDragging || (isGroupDragging && groupDrag !== null);
|
|
759
|
+
const tileStyle: CSSProperties = {
|
|
760
|
+
position: 'absolute',
|
|
761
|
+
left,
|
|
762
|
+
top,
|
|
763
|
+
width,
|
|
764
|
+
height: heightPx,
|
|
765
|
+
boxSizing: 'border-box',
|
|
766
|
+
cursor: lifted ? 'grabbing' : 'grab',
|
|
767
|
+
userSelect: 'none',
|
|
768
|
+
zIndex,
|
|
769
|
+
opacity: lifted || isResizing ? 0.85 : 1,
|
|
770
|
+
willChange: 'transform',
|
|
771
|
+
transform,
|
|
772
|
+
filter: lifted ? 'drop-shadow(0 8px 16px rgba(0,0,0,0.18))' : undefined,
|
|
773
|
+
transition: lifted ? 'filter 120ms ease-out, opacity 120ms ease-out' : undefined,
|
|
774
|
+
boxShadow: isSelected
|
|
775
|
+
? '0 0 0 3px rgba(59, 91, 219, 0.85), inset 0 0 0 1px rgba(59, 91, 219, 0.3)'
|
|
776
|
+
: undefined,
|
|
777
|
+
borderRadius: config.tileRadius ?? 4,
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
return (
|
|
781
|
+
<div
|
|
782
|
+
key={tile.id}
|
|
783
|
+
ref={(el) => {
|
|
784
|
+
if (el) tileNodes.current.set(tile.id, el);
|
|
785
|
+
else tileNodes.current.delete(tile.id);
|
|
786
|
+
}}
|
|
787
|
+
data-griddle-tile={tile.id}
|
|
788
|
+
onPointerDown={(e) => onTilePointerDown(e, tile)}
|
|
789
|
+
style={tileStyle}
|
|
790
|
+
>
|
|
791
|
+
{renderTile(tile, isSelected)}
|
|
792
|
+
{tile.resizable !== false &&
|
|
793
|
+
tileHandles.map((c) => (
|
|
794
|
+
<div
|
|
795
|
+
key={c}
|
|
796
|
+
data-griddle-handle={c}
|
|
797
|
+
onPointerDown={(e) => onResizeHandleDown(e, tile, c)}
|
|
798
|
+
style={handleStyle(c)}
|
|
799
|
+
/>
|
|
800
|
+
))}
|
|
801
|
+
</div>
|
|
802
|
+
);
|
|
803
|
+
})}
|
|
804
|
+
</div>
|
|
805
|
+
</div>
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function handleStyle(c: Corner): CSSProperties {
|
|
810
|
+
const size = 12;
|
|
811
|
+
const base: CSSProperties = {
|
|
812
|
+
position: 'absolute',
|
|
813
|
+
width: size,
|
|
814
|
+
height: size,
|
|
815
|
+
background: 'rgba(60,60,60,0.7)',
|
|
816
|
+
border: '2px solid white',
|
|
817
|
+
borderRadius: 3,
|
|
818
|
+
cursor: `${c}-resize`,
|
|
819
|
+
touchAction: 'none',
|
|
820
|
+
};
|
|
821
|
+
if (c === 'nw') return { ...base, top: -size / 2, left: -size / 2 };
|
|
822
|
+
if (c === 'ne') return { ...base, top: -size / 2, right: -size / 2 };
|
|
823
|
+
if (c === 'sw') return { ...base, bottom: -size / 2, left: -size / 2 };
|
|
824
|
+
return { ...base, bottom: -size / 2, right: -size / 2 };
|
|
825
|
+
}
|