react-native-smart-grid 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.
Files changed (80) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +554 -0
  3. package/lib/module/components/DragLayer.js +71 -0
  4. package/lib/module/components/DragLayer.js.map +1 -0
  5. package/lib/module/components/DraggableTile.js +79 -0
  6. package/lib/module/components/DraggableTile.js.map +1 -0
  7. package/lib/module/components/GhostTile.js +37 -0
  8. package/lib/module/components/GhostTile.js.map +1 -0
  9. package/lib/module/components/GridTile.js +25 -0
  10. package/lib/module/components/GridTile.js.map +1 -0
  11. package/lib/module/components/ResizeHandle.js +72 -0
  12. package/lib/module/components/ResizeHandle.js.map +1 -0
  13. package/lib/module/components/SmartGrid.js +363 -0
  14. package/lib/module/components/SmartGrid.js.map +1 -0
  15. package/lib/module/context/GridDragContext.js +130 -0
  16. package/lib/module/context/GridDragContext.js.map +1 -0
  17. package/lib/module/engine/GridEngine.js +148 -0
  18. package/lib/module/engine/GridEngine.js.map +1 -0
  19. package/lib/module/engine/autoArrange.js +54 -0
  20. package/lib/module/engine/autoArrange.js.map +1 -0
  21. package/lib/module/engine/collisions.js +67 -0
  22. package/lib/module/engine/collisions.js.map +1 -0
  23. package/lib/module/hooks/useTileGesture.js +62 -0
  24. package/lib/module/hooks/useTileGesture.js.map +1 -0
  25. package/lib/module/index.js +9 -0
  26. package/lib/module/index.js.map +1 -0
  27. package/lib/module/layout/LayoutCalculator.js +29 -0
  28. package/lib/module/layout/LayoutCalculator.js.map +1 -0
  29. package/lib/module/package.json +1 -0
  30. package/lib/module/types.js +2 -0
  31. package/lib/module/types.js.map +1 -0
  32. package/lib/module/utils/pixelToGrid.js +22 -0
  33. package/lib/module/utils/pixelToGrid.js.map +1 -0
  34. package/lib/typescript/package.json +1 -0
  35. package/lib/typescript/src/components/DragLayer.d.ts +11 -0
  36. package/lib/typescript/src/components/DragLayer.d.ts.map +1 -0
  37. package/lib/typescript/src/components/DraggableTile.d.ts +14 -0
  38. package/lib/typescript/src/components/DraggableTile.d.ts.map +1 -0
  39. package/lib/typescript/src/components/GhostTile.d.ts +9 -0
  40. package/lib/typescript/src/components/GhostTile.d.ts.map +1 -0
  41. package/lib/typescript/src/components/GridTile.d.ts +9 -0
  42. package/lib/typescript/src/components/GridTile.d.ts.map +1 -0
  43. package/lib/typescript/src/components/ResizeHandle.d.ts +9 -0
  44. package/lib/typescript/src/components/ResizeHandle.d.ts.map +1 -0
  45. package/lib/typescript/src/components/SmartGrid.d.ts +214 -0
  46. package/lib/typescript/src/components/SmartGrid.d.ts.map +1 -0
  47. package/lib/typescript/src/context/GridDragContext.d.ts +44 -0
  48. package/lib/typescript/src/context/GridDragContext.d.ts.map +1 -0
  49. package/lib/typescript/src/engine/GridEngine.d.ts +35 -0
  50. package/lib/typescript/src/engine/GridEngine.d.ts.map +1 -0
  51. package/lib/typescript/src/engine/autoArrange.d.ts +4 -0
  52. package/lib/typescript/src/engine/autoArrange.d.ts.map +1 -0
  53. package/lib/typescript/src/engine/collisions.d.ts +3 -0
  54. package/lib/typescript/src/engine/collisions.d.ts.map +1 -0
  55. package/lib/typescript/src/hooks/useTileGesture.d.ts +13 -0
  56. package/lib/typescript/src/hooks/useTileGesture.d.ts.map +1 -0
  57. package/lib/typescript/src/index.d.ts +10 -0
  58. package/lib/typescript/src/index.d.ts.map +1 -0
  59. package/lib/typescript/src/layout/LayoutCalculator.d.ts +15 -0
  60. package/lib/typescript/src/layout/LayoutCalculator.d.ts.map +1 -0
  61. package/lib/typescript/src/types.d.ts +105 -0
  62. package/lib/typescript/src/types.d.ts.map +1 -0
  63. package/lib/typescript/src/utils/pixelToGrid.d.ts +9 -0
  64. package/lib/typescript/src/utils/pixelToGrid.d.ts.map +1 -0
  65. package/package.json +161 -0
  66. package/src/components/DragLayer.tsx +71 -0
  67. package/src/components/DraggableTile.tsx +88 -0
  68. package/src/components/GhostTile.tsx +42 -0
  69. package/src/components/GridTile.tsx +27 -0
  70. package/src/components/ResizeHandle.tsx +74 -0
  71. package/src/components/SmartGrid.tsx +506 -0
  72. package/src/context/GridDragContext.tsx +191 -0
  73. package/src/engine/GridEngine.ts +148 -0
  74. package/src/engine/autoArrange.ts +59 -0
  75. package/src/engine/collisions.ts +87 -0
  76. package/src/hooks/useTileGesture.ts +88 -0
  77. package/src/index.tsx +29 -0
  78. package/src/layout/LayoutCalculator.ts +50 -0
  79. package/src/types.ts +113 -0
  80. package/src/utils/pixelToGrid.ts +31 -0
@@ -0,0 +1,148 @@
1
+ import type { LayoutItem, TilePosition, TileSize } from '../types';
2
+
3
+ // Sentinel value marking a cell as occupied by a tile with the given id.
4
+ // The matrix stores tile ids so we can look up who owns a cell in O(1).
5
+ type Cell = string | null; // null = empty, string = tile id
6
+
7
+ export class GridEngine {
8
+ private matrix: Cell[][];
9
+ private columns: number;
10
+
11
+ constructor(columns: number, initialRows = 20) {
12
+ this.columns = columns;
13
+ this.matrix = GridEngine.createMatrix(columns, initialRows);
14
+ }
15
+
16
+ private static createMatrix(columns: number, rows: number): Cell[][] {
17
+ return Array.from({ length: rows }, () => Array(columns).fill(null));
18
+ }
19
+
20
+ // ── Accessors ────────────────────────────────────────────────────────────
21
+
22
+ get rowCount(): number {
23
+ return this.matrix.length;
24
+ }
25
+
26
+ get columnCount(): number {
27
+ return this.columns;
28
+ }
29
+
30
+ // ── Core operations ──────────────────────────────────────────────────────
31
+
32
+ isOccupied(pos: TilePosition, size: TileSize, ignoreId?: string): boolean {
33
+ for (let row = pos.y; row < pos.y + size.h; row++) {
34
+ for (let col = pos.x; col < pos.x + size.w; col++) {
35
+ if (col < 0 || col >= this.columns) return true; // out of bounds
36
+ this.growIfNeeded(row);
37
+ const cell = this.matrix[row]![col]!;
38
+ if (cell !== null && cell !== ignoreId) return true;
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+
44
+ isInBounds(pos: TilePosition, size: TileSize): boolean {
45
+ return pos.x >= 0 && pos.x + size.w <= this.columns && pos.y >= 0;
46
+ }
47
+
48
+ placeAt(id: string, pos: TilePosition, size: TileSize): void {
49
+ for (let row = pos.y; row < pos.y + size.h; row++) {
50
+ this.growIfNeeded(row);
51
+ for (let col = pos.x; col < pos.x + size.w; col++) {
52
+ this.matrix[row]![col] = id;
53
+ }
54
+ }
55
+ }
56
+
57
+ removeFrom(pos: TilePosition, size: TileSize): void {
58
+ for (let row = pos.y; row < pos.y + size.h; row++) {
59
+ if (row >= this.matrix.length) break;
60
+ for (let col = pos.x; col < pos.x + size.w; col++) {
61
+ if (col < this.columns) this.matrix[row]![col] = null;
62
+ }
63
+ }
64
+ }
65
+
66
+ removeById(id: string): void {
67
+ for (let row = 0; row < this.matrix.length; row++) {
68
+ for (let col = 0; col < this.columns; col++) {
69
+ if (this.matrix[row]![col] === id) {
70
+ this.matrix[row]![col] = null;
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ // ── Placement search ─────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Scan top-left to bottom-right for the first position where `size` fits.
80
+ * Returns null if the grid (up to maxRows) is full.
81
+ */
82
+ findFirstFit(size: TileSize, maxRows = this.matrix.length + size.h): TilePosition | null {
83
+ for (let row = 0; row < maxRows; row++) {
84
+ this.growIfNeeded(row + size.h - 1);
85
+ for (let col = 0; col <= this.columns - size.w; col++) {
86
+ const pos: TilePosition = { x: col, y: row };
87
+ if (!this.isOccupied(pos, size)) return pos;
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Returns ids of all tiles that overlap the given region, excluding ignoreId.
95
+ */
96
+ getCollisions(pos: TilePosition, size: TileSize, ignoreId?: string): Set<string> {
97
+ const hits = new Set<string>();
98
+ for (let row = pos.y; row < pos.y + size.h; row++) {
99
+ if (row >= this.matrix.length) break;
100
+ for (let col = pos.x; col < pos.x + size.w; col++) {
101
+ if (col >= this.columns) break;
102
+ const cell = this.matrix[row]![col]!;
103
+ if (cell !== null && cell !== ignoreId) hits.add(cell);
104
+ }
105
+ }
106
+ return hits;
107
+ }
108
+
109
+ // ── Serialization ────────────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Rebuild the entire matrix from a layout snapshot.
113
+ * Call this when restoring a saved layout.
114
+ */
115
+ loadLayout(items: LayoutItem[]): void {
116
+ const maxRow = items.reduce((m, item) => Math.max(m, item.position.y + item.size.h), 0);
117
+ this.matrix = GridEngine.createMatrix(this.columns, Math.max(maxRow + 5, 20));
118
+ for (const item of items) {
119
+ this.placeAt(item.id, item.position, item.size);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Dump the positions of all occupied cells grouped by tile id.
125
+ * Useful for debugging — not the same as a layout snapshot.
126
+ */
127
+ debugDump(): Record<string, TilePosition[]> {
128
+ const out: Record<string, TilePosition[]> = {};
129
+ for (let row = 0; row < this.matrix.length; row++) {
130
+ for (let col = 0; col < this.columns; col++) {
131
+ const id = this.matrix[row]![col];
132
+ if (id) {
133
+ if (!out[id]) out[id] = [];
134
+ out[id]!.push({ x: col, y: row });
135
+ }
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+
141
+ // ── Internal helpers ─────────────────────────────────────────────────────
142
+
143
+ private growIfNeeded(rowIndex: number): void {
144
+ while (this.matrix.length <= rowIndex) {
145
+ this.matrix.push(Array(this.columns).fill(null));
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,59 @@
1
+ import { GridEngine } from './GridEngine';
2
+ import type { PlacedTile, TilePosition, Gravity } from '../types';
3
+
4
+ export function autoArrange<TData>(tiles: PlacedTile<TData>[], columns: number): PlacedTile<TData>[] {
5
+ const engine = new GridEngine(columns);
6
+
7
+ const sorted = [...tiles].sort(
8
+ (a, b) => b.size.w * b.size.h - a.size.w * a.size.h
9
+ );
10
+
11
+ const newPositions = new Map<string, TilePosition>();
12
+ for (const tile of sorted) {
13
+ const pos = engine.findFirstFit(tile.size) ?? tile.position;
14
+ engine.placeAt(tile.id, pos, tile.size);
15
+ newPositions.set(tile.id, pos);
16
+ }
17
+
18
+ return tiles.map((t) => ({ ...t, position: newPositions.get(t.id) ?? t.position }));
19
+ }
20
+
21
+ export function applyGravity<TData>(
22
+ tiles: PlacedTile<TData>[],
23
+ columns: number,
24
+ gravity: Gravity
25
+ ): PlacedTile<TData>[] {
26
+ if (gravity === 'none') return tiles;
27
+
28
+ const engine = new GridEngine(columns);
29
+
30
+ const sorted = [...tiles].sort(
31
+ gravity === 'up'
32
+ ? (a, b) => a.position.y - b.position.y || a.position.x - b.position.x
33
+ : (a, b) => a.position.x - b.position.x || a.position.y - b.position.y
34
+ );
35
+
36
+ const newPositions = new Map<string, TilePosition>();
37
+ for (const tile of sorted) {
38
+ const pos = slideToward(tile.position, tile.size, engine, gravity);
39
+ engine.placeAt(tile.id, pos, tile.size);
40
+ newPositions.set(tile.id, pos);
41
+ }
42
+
43
+ return tiles.map((t) => ({ ...t, position: newPositions.get(t.id) ?? t.position }));
44
+ }
45
+
46
+ function slideToward(
47
+ start: TilePosition,
48
+ size: { w: number; h: number },
49
+ engine: GridEngine,
50
+ gravity: 'up' | 'left'
51
+ ): TilePosition {
52
+ let { x, y } = start;
53
+ if (gravity === 'up') {
54
+ while (y > 0 && !engine.isOccupied({ x, y: y - 1 }, size)) y--;
55
+ } else {
56
+ while (x > 0 && !engine.isOccupied({ x: x - 1, y }, size)) x--;
57
+ }
58
+ return { x, y };
59
+ }
@@ -0,0 +1,87 @@
1
+ import { GridEngine } from './GridEngine';
2
+ import type { PlacedTile, TilePosition, CollisionBehavior } from '../types';
3
+
4
+ export function resolveCollisions<TData>(
5
+ tiles: PlacedTile<TData>[],
6
+ draggedId: string,
7
+ targetPosition: TilePosition,
8
+ behavior: CollisionBehavior,
9
+ columns: number
10
+ ): PlacedTile<TData>[] {
11
+ if (behavior === 'swap') return resolveSwap(tiles, draggedId, targetPosition);
12
+ return resolvePush(tiles, draggedId, targetPosition, columns);
13
+ }
14
+
15
+ function resolveSwap<TData>(
16
+ tiles: PlacedTile<TData>[],
17
+ draggedId: string,
18
+ targetPosition: TilePosition
19
+ ): PlacedTile<TData>[] {
20
+ const dragged = tiles.find((t) => t.id === draggedId);
21
+ if (!dragged) return tiles;
22
+
23
+ const cx = targetPosition.x + dragged.size.w / 2;
24
+ const cy = targetPosition.y + dragged.size.h / 2;
25
+
26
+ const swapTarget = tiles.find(
27
+ (t) =>
28
+ t.id !== draggedId &&
29
+ cx > t.position.x &&
30
+ cx < t.position.x + t.size.w &&
31
+ cy > t.position.y &&
32
+ cy < t.position.y + t.size.h
33
+ );
34
+
35
+ const fromPos = dragged.position;
36
+ return tiles.map((t) => {
37
+ if (t.id === draggedId) return { ...t, position: targetPosition };
38
+ if (swapTarget && t.id === swapTarget.id) return { ...t, position: fromPos };
39
+ return t;
40
+ });
41
+ }
42
+
43
+ function resolvePush<TData>(
44
+ tiles: PlacedTile<TData>[],
45
+ draggedId: string,
46
+ targetPosition: TilePosition,
47
+ columns: number
48
+ ): PlacedTile<TData>[] {
49
+ const dragged = tiles.find((t) => t.id === draggedId);
50
+ if (!dragged) return tiles;
51
+
52
+ const engine = new GridEngine(columns);
53
+ const others = tiles.filter((t) => t.id !== draggedId);
54
+
55
+ for (const t of others) {
56
+ engine.placeAt(t.id, t.position, t.size);
57
+ }
58
+
59
+ const collisions = engine.getCollisions(targetPosition, dragged.size);
60
+
61
+ if (collisions.size === 0) {
62
+ return tiles.map((t) => (t.id === draggedId ? { ...t, position: targetPosition } : t));
63
+ }
64
+
65
+ // Claim the target space, then re-home displaced tiles in top-left order.
66
+ for (const id of collisions) {
67
+ engine.removeById(id);
68
+ }
69
+ engine.placeAt(draggedId, targetPosition, dragged.size);
70
+
71
+ const displaced = others
72
+ .filter((t) => collisions.has(t.id))
73
+ .sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x);
74
+
75
+ const newPositions = new Map<string, TilePosition>();
76
+ for (const t of displaced) {
77
+ const pos = engine.findFirstFit(t.size) ?? t.position;
78
+ engine.placeAt(t.id, pos, t.size);
79
+ newPositions.set(t.id, pos);
80
+ }
81
+
82
+ return tiles.map((t) => {
83
+ if (t.id === draggedId) return { ...t, position: targetPosition };
84
+ const pos = newPositions.get(t.id);
85
+ return pos ? { ...t, position: pos } : t;
86
+ });
87
+ }
@@ -0,0 +1,88 @@
1
+ import { Gesture } from 'react-native-gesture-handler';
2
+ import { runOnJS } from 'react-native-reanimated';
3
+ import type { PlacedTile, GridConfig } from '../types';
4
+ import type { PixelRect } from '../layout/LayoutCalculator';
5
+ import { useGridDrag } from '../context/GridDragContext';
6
+ import { pixelToGrid } from '../utils/pixelToGrid';
7
+
8
+ type Options = {
9
+ tile: PlacedTile;
10
+ rect: PixelRect;
11
+ config: GridConfig;
12
+ containerWidth: number;
13
+ };
14
+
15
+ export function useTileGesture({ tile, rect, config, containerWidth }: Options) {
16
+ const drag = useGridDrag();
17
+
18
+ const canDrag = drag.draggable && tile.draggable !== false;
19
+ const canSelect = drag.selectable && tile.selectable !== false;
20
+
21
+ function onDragStart() {
22
+ drag.startDrag(tile, rect);
23
+ }
24
+
25
+ function onDragMove(absX: number, absY: number) {
26
+ const pos = pixelToGrid(
27
+ absX,
28
+ absY,
29
+ drag.containerPageX.current,
30
+ drag.containerPageY.current,
31
+ drag.scrollYRef.current,
32
+ config,
33
+ tile.size,
34
+ containerWidth
35
+ );
36
+ drag.updateGhost(pos);
37
+ }
38
+
39
+ function onDragEnd() {
40
+ drag.endDrag(drag.ghostPosition ?? tile.position);
41
+ }
42
+
43
+ function onLongPressSelect() {
44
+ drag.selectTile(tile);
45
+ }
46
+
47
+ function onPress() {
48
+ drag.onTilePress(tile);
49
+ }
50
+
51
+ const tap = Gesture.Tap()
52
+ .maxDuration(200) // must complete before the 300ms long-press activates drag
53
+ .onEnd(() => {
54
+ runOnJS(onPress)();
55
+ });
56
+
57
+ const pan = Gesture.Pan()
58
+ .enabled(canDrag || canSelect)
59
+ .activateAfterLongPress(300)
60
+ .onStart(() => {
61
+ if (canDrag) {
62
+ runOnJS(onDragStart)();
63
+ } else {
64
+ // draggable=false: select immediately on long-press activation, not on release
65
+ runOnJS(onLongPressSelect)();
66
+ }
67
+ })
68
+ .onUpdate((e) => {
69
+ if (!canDrag) return;
70
+ drag.dragAbsX.value = e.absoluteX;
71
+ drag.dragAbsY.value = e.absoluteY;
72
+ runOnJS(onDragMove)(e.absoluteX, e.absoluteY);
73
+ })
74
+ .onEnd(() => {
75
+ if (canDrag) {
76
+ runOnJS(onDragEnd)();
77
+ }
78
+ })
79
+ .onFinalize(() => {
80
+ if (canDrag && drag.isDragging.value) {
81
+ runOnJS(onDragEnd)();
82
+ }
83
+ });
84
+
85
+ const gesture = Gesture.Simultaneous(pan, tap);
86
+
87
+ return { gesture };
88
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,29 @@
1
+ export type {
2
+ Tile,
3
+ PlacedTile,
4
+ TileSize,
5
+ TilePosition,
6
+ GridConfig,
7
+ CollisionBehavior,
8
+ Gravity,
9
+ HapticEvent,
10
+ LayoutItem,
11
+ SerializedLayout,
12
+ } from './types';
13
+
14
+ export { GridEngine } from './engine/GridEngine';
15
+ export { resolveCollisions } from './engine/collisions';
16
+ export { autoArrange, applyGravity } from './engine/autoArrange';
17
+
18
+ export type { PixelRect } from './layout/LayoutCalculator';
19
+ export {
20
+ columnWidth,
21
+ tileToPixelRect,
22
+ gridTotalHeight,
23
+ isInViewport,
24
+ } from './layout/LayoutCalculator';
25
+
26
+ export { pixelToGrid } from './utils/pixelToGrid';
27
+
28
+ export type { SmartGridRef, RenderTileInfo, SmartGridProps } from './components/SmartGrid';
29
+ export { SmartGrid } from './components/SmartGrid';
@@ -0,0 +1,50 @@
1
+ import type { TilePosition, TileSize, GridConfig } from '../types';
2
+
3
+ export type PixelRect = {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ };
9
+
10
+ export function columnWidth(config: GridConfig, containerWidth: number): number {
11
+ const available = containerWidth - config.padding * 2 - config.gap * (config.columns - 1);
12
+ return available / config.columns;
13
+ }
14
+
15
+ export function tileToPixelRect(
16
+ position: TilePosition,
17
+ size: TileSize,
18
+ config: GridConfig,
19
+ containerWidth: number
20
+ ): PixelRect {
21
+ const colW = columnWidth(config, containerWidth);
22
+ const { gap, padding, rowHeight } = config;
23
+ return {
24
+ x: padding + position.x * (colW + gap),
25
+ y: padding + position.y * (rowHeight + gap),
26
+ width: size.w * colW + (size.w - 1) * gap,
27
+ height: size.h * rowHeight + (size.h - 1) * gap,
28
+ };
29
+ }
30
+
31
+ export function gridTotalHeight(
32
+ tiles: Array<{ position: TilePosition; size: TileSize }>,
33
+ config: GridConfig
34
+ ): number {
35
+ if (tiles.length === 0) return config.padding * 2;
36
+ const maxRow = tiles.reduce((m, t) => Math.max(m, t.position.y + t.size.h), 0);
37
+ return config.padding * 2 + maxRow * config.rowHeight + Math.max(0, maxRow - 1) * config.gap;
38
+ }
39
+
40
+ export function isInViewport(
41
+ rect: PixelRect,
42
+ scrollY: number,
43
+ viewportHeight: number,
44
+ overscan = 200
45
+ ): boolean {
46
+ return (
47
+ rect.y + rect.height >= scrollY - overscan &&
48
+ rect.y <= scrollY + viewportHeight + overscan
49
+ );
50
+ }
package/src/types.ts ADDED
@@ -0,0 +1,113 @@
1
+ /** Width and height of a tile in grid units. */
2
+ export type TileSize = {
3
+ /** Number of grid columns spanned. */
4
+ w: number;
5
+ /** Number of grid rows spanned. */
6
+ h: number;
7
+ };
8
+
9
+ /** Column/row position of a tile's top-left corner (0-based). */
10
+ export type TilePosition = {
11
+ /** Column index (0-based). */
12
+ x: number;
13
+ /** Row index (0-based). */
14
+ y: number;
15
+ };
16
+
17
+ /**
18
+ * A single tile in the grid.
19
+ *
20
+ * `position` and `size` are both optional:
21
+ * - Both provided → tile is placed exactly where specified (use when restoring a saved layout).
22
+ * - `size` only → tile is auto-placed via bin-packing.
23
+ * - Neither → tile defaults to 1×1 and is auto-placed in order.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * // Explicit position + size (restored from storage)
28
+ * { id: '1', position: { x: 0, y: 0 }, size: { w: 2, h: 2 }, data: { label: 'Music' } }
29
+ *
30
+ * // Size only — grid chooses placement
31
+ * { id: '2', size: { w: 2, h: 1 }, data: { label: 'Photos' } }
32
+ *
33
+ * // No position, no size — defaults to 1×1, auto-placed in order
34
+ * { id: '3', data: { label: 'Notes' } }
35
+ *
36
+ * // Pinned tile — cannot be dragged
37
+ * { id: '4', data: { label: 'Header' }, draggable: false }
38
+ *
39
+ * // Non-selectable tile — long press fires onTilePress instead of entering selection
40
+ * { id: '5', data: { label: 'Info' }, selectable: false }
41
+ * ```
42
+ */
43
+ export type Tile<TData = unknown> = {
44
+ /** Unique identifier. Must be stable across re-renders. */
45
+ id: string;
46
+ /** Tile dimensions in grid units. Omit to default to 1×1. */
47
+ size?: TileSize;
48
+ /** Top-left position in the grid. Omit to let the grid auto-place. */
49
+ position?: TilePosition;
50
+ /** Your custom data — passed back to `renderTile` as `item.data`. */
51
+ data: TData;
52
+ /** When `true`, prevents both dragging and resizing. @default false */
53
+ locked?: boolean;
54
+ /** Smallest size the tile can be resized to. Only enforced when `isEditing` is true. */
55
+ minSize?: TileSize;
56
+ /** Largest size the tile can be resized to. Only enforced when `isEditing` is true. */
57
+ maxSize?: TileSize;
58
+ /** When `false`, the tile ignores the drag gesture and stays in place. @default true */
59
+ draggable?: boolean;
60
+ /** When `false`, long-pressing this tile fires `onTilePress` instead of entering selection. @default true */
61
+ selectable?: boolean;
62
+ };
63
+
64
+ /** A `Tile` with `position` and `size` guaranteed to be present. Used internally after auto-placement. */
65
+ export type PlacedTile<TData = unknown> = Tile<TData> & {
66
+ position: TilePosition;
67
+ size: TileSize;
68
+ };
69
+
70
+ /** Internal grid configuration passed through context. */
71
+ export type GridConfig = {
72
+ columns: number;
73
+ rowHeight: number;
74
+ gap: number;
75
+ padding: number;
76
+ };
77
+
78
+ /**
79
+ * How a dragged tile interacts with tiles it overlaps on drop.
80
+ * - `'push'` — displaced tiles are moved to the next available slot.
81
+ * - `'swap'` — the dragged tile and the tile at the drop center exchange positions.
82
+ */
83
+ export type CollisionBehavior = 'push' | 'swap';
84
+
85
+ /**
86
+ * Interaction moments passed to `onHaptic` so you can trigger device feedback.
87
+ * - `'pick-up'` — tile long-press activated (drag started or tile selected).
88
+ * - `'snap'` — ghost tile snapped to a new grid position mid-drag.
89
+ * - `'drop'` — tile released.
90
+ * - `'resize'` — tile resize committed.
91
+ */
92
+ export type HapticEvent = 'pick-up' | 'snap' | 'drop' | 'resize';
93
+
94
+ /**
95
+ * Direction tiles compact toward after every drop.
96
+ * - `'none'` — tiles stay exactly where dropped.
97
+ * - `'up'` — tiles slide upward to fill empty rows.
98
+ * - `'left'` — tiles slide left to fill empty columns.
99
+ */
100
+ export type Gravity = 'none' | 'up' | 'left';
101
+
102
+ /** Position and size of a single tile — the serializable unit returned by `serializeLayout`. */
103
+ export type LayoutItem = {
104
+ /** Tile id — matches the `id` on the original `Tile`. */
105
+ id: string;
106
+ /** Current top-left grid position. */
107
+ position: TilePosition;
108
+ /** Current size in grid units. */
109
+ size: TileSize;
110
+ };
111
+
112
+ /** Array of `LayoutItem` — the format used by `serializeLayout` and `restoreLayout`. */
113
+ export type SerializedLayout = LayoutItem[];
@@ -0,0 +1,31 @@
1
+ import type { GridConfig, TilePosition, TileSize } from '../types';
2
+ import { columnWidth } from '../layout/LayoutCalculator';
3
+
4
+ /**
5
+ * Convert an absolute screen position (from gesture handler) to a grid cell,
6
+ * accounting for the container's page offset and the current scroll position.
7
+ *
8
+ * The returned position is clamped so the tile stays within grid bounds.
9
+ */
10
+ export function pixelToGrid(
11
+ absX: number,
12
+ absY: number,
13
+ containerPageX: number,
14
+ containerPageY: number,
15
+ scrollY: number,
16
+ config: GridConfig,
17
+ size: TileSize,
18
+ containerWidth: number
19
+ ): TilePosition {
20
+ const colW = columnWidth(config, containerWidth);
21
+ const relX = absX - containerPageX - config.padding;
22
+ const relY = absY - containerPageY + scrollY - config.padding;
23
+
24
+ const x = Math.floor(relX / (colW + config.gap));
25
+ const y = Math.floor(relY / (config.rowHeight + config.gap));
26
+
27
+ return {
28
+ x: Math.max(0, Math.min(x, config.columns - size.w)),
29
+ y: Math.max(0, y),
30
+ };
31
+ }