your personal website on atproto - mirror
blento.app
1import { COLUMNS, margin, mobileMargin } from '$lib';
2import { clamp } from '$lib/helper';
3import type { Item } from '$lib/types';
4
5export type GridPosition = {
6 x: number;
7 y: number;
8 swapWithId: string | null;
9 placement: 'above' | 'below' | null;
10};
11
12export type DragState = {
13 item: Item;
14 mouseDeltaX: number;
15 mouseDeltaY: number;
16 originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>;
17 lastTargetId: string | null;
18 lastPlacement: 'above' | 'below' | null;
19};
20
21/**
22 * Convert client coordinates to a grid position with swap detection and hysteresis.
23 * Returns undefined if container or dragState.item is missing.
24 * Mutates dragState.lastTargetId and dragState.lastPlacement for hysteresis tracking.
25 */
26export function getGridPosition(
27 clientX: number,
28 clientY: number,
29 container: HTMLElement,
30 dragState: DragState,
31 items: Item[],
32 isMobile: boolean
33): GridPosition | undefined {
34 if (!dragState.item) return;
35
36 // x, y represent the top-left corner of the dragged card
37 const x = clientX + dragState.mouseDeltaX;
38 const y = clientY + dragState.mouseDeltaY;
39
40 const rect = container.getBoundingClientRect();
41 const currentMargin = isMobile ? mobileMargin : margin;
42 const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
43
44 // Get card dimensions based on current view mode
45 const cardW = isMobile ? dragState.item.mobileW : dragState.item.w;
46 const cardH = isMobile ? dragState.item.mobileH : dragState.item.h;
47
48 // Get dragged card's original position
49 const draggedOrigPos = dragState.originalPositions.get(dragState.item.id);
50 const draggedOrigY = draggedOrigPos ? (isMobile ? draggedOrigPos.mobileY : draggedOrigPos.y) : 0;
51
52 // Calculate raw grid position based on top-left of dragged card
53 let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW);
54 gridX = Math.floor(gridX / 2) * 2;
55
56 let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0);
57
58 if (isMobile) {
59 gridX = Math.floor(gridX / 2) * 2;
60 gridY = Math.floor(gridY / 2) * 2;
61 }
62
63 // Find if we're hovering over another card (using ORIGINAL positions)
64 const centerGridY = gridY + cardH / 2;
65 const centerGridX = gridX + cardW / 2;
66
67 let swapWithId: string | null = null;
68 let placement: 'above' | 'below' | null = null;
69
70 for (const other of items) {
71 if (other === dragState.item) continue;
72
73 // Use original positions for hit testing
74 const origPos = dragState.originalPositions.get(other.id);
75 if (!origPos) continue;
76
77 const otherX = isMobile ? origPos.mobileX : origPos.x;
78 const otherY = isMobile ? origPos.mobileY : origPos.y;
79 const otherW = isMobile ? other.mobileW : other.w;
80 const otherH = isMobile ? other.mobileH : other.h;
81
82 // Check if dragged card's center point is within this card's original bounds
83 if (
84 centerGridX >= otherX &&
85 centerGridX < otherX + otherW &&
86 centerGridY >= otherY &&
87 centerGridY < otherY + otherH
88 ) {
89 // Check if this is a swap situation:
90 // Cards have the same dimensions and are on the same row
91 const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY;
92
93 if (canSwap) {
94 // Swap positions
95 swapWithId = other.id;
96 gridX = otherX;
97 gridY = otherY;
98 placement = null;
99
100 dragState.lastTargetId = other.id;
101 dragState.lastPlacement = null;
102 } else {
103 // Vertical placement (above/below)
104 // Detect drag direction: if dragging up, always place above
105 const isDraggingUp = gridY < draggedOrigY;
106
107 if (isDraggingUp) {
108 // When dragging up, always place above
109 placement = 'above';
110 } else {
111 // When dragging down, use top/bottom half logic
112 const midpointY = otherY + otherH / 2;
113 const hysteresis = 0.3;
114
115 if (dragState.lastTargetId === other.id && dragState.lastPlacement) {
116 if (dragState.lastPlacement === 'above') {
117 placement = centerGridY > midpointY + hysteresis ? 'below' : 'above';
118 } else {
119 placement = centerGridY < midpointY - hysteresis ? 'above' : 'below';
120 }
121 } else {
122 placement = centerGridY < midpointY ? 'above' : 'below';
123 }
124 }
125
126 dragState.lastTargetId = other.id;
127 dragState.lastPlacement = placement;
128
129 if (placement === 'above') {
130 gridY = otherY;
131 } else {
132 gridY = otherY + otherH;
133 }
134 }
135 break;
136 }
137 }
138
139 // If we're not over any card, clear the tracking
140 if (!swapWithId && !placement) {
141 dragState.lastTargetId = null;
142 dragState.lastPlacement = null;
143 }
144
145 return { x: gridX, y: gridY, swapWithId, placement };
146}
147
148/**
149 * Get the grid Y coordinate at the viewport center.
150 */
151export function getViewportCenterGridY(
152 container: HTMLElement,
153 isMobile: boolean
154): { gridY: number; isMobile: boolean } {
155 const rect = container.getBoundingClientRect();
156 const currentMargin = isMobile ? mobileMargin : margin;
157 const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
158 const viewportCenterY = window.innerHeight / 2;
159 const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize;
160 return { gridY, isMobile };
161}
162
163/**
164 * Convert pixel drop coordinates to grid position. Used for file drops.
165 */
166export function pixelToGrid(
167 clientX: number,
168 clientY: number,
169 container: HTMLElement,
170 isMobile: boolean,
171 cardW: number
172): { gridX: number; gridY: number } {
173 const rect = container.getBoundingClientRect();
174 const currentMargin = isMobile ? mobileMargin : margin;
175 const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
176
177 let gridX = clamp(
178 Math.round((clientX - rect.left - currentMargin) / cellSize),
179 0,
180 COLUMNS - cardW
181 );
182 gridX = Math.floor(gridX / 2) * 2;
183
184 let gridY = Math.max(Math.round((clientY - rect.top - currentMargin) / cellSize), 0);
185 if (isMobile) {
186 gridY = Math.floor(gridY / 2) * 2;
187 }
188
189 return { gridX, gridY };
190}