your personal website on atproto - mirror
blento.app
1<script lang="ts">
2 import type { Snippet } from 'svelte';
3 import type { Item } from '$lib/types';
4 import { getGridPosition, pixelToGrid, type DragState, type GridPosition } from './grid';
5 import { fixCollisions } from './algorithms';
6
7 let {
8 items = $bindable(),
9 isMobile,
10 selectedCardId,
11 isCoarse,
12 children,
13 ref = $bindable<HTMLDivElement | undefined>(undefined),
14 onlayoutchange,
15 ondeselect,
16 onfiledrop
17 }: {
18 items: Item[];
19 isMobile: boolean;
20 selectedCardId: string | null;
21 isCoarse: boolean;
22 children: Snippet;
23 ref?: HTMLDivElement | undefined;
24 onlayoutchange: () => void;
25 ondeselect: () => void;
26 onfiledrop?: (files: File[], gridX: number, gridY: number) => void;
27 } = $props();
28
29 // Internal container ref (synced with bindable ref)
30 let container: HTMLDivElement | undefined = $state();
31 $effect(() => {
32 ref = container;
33 });
34
35 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y);
36 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h);
37 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0));
38
39 // --- Drag state ---
40 type Phase = 'idle' | 'pending' | 'active';
41
42 let phase: Phase = $state('idle');
43 let pointerId: number = $state(0);
44 let startClientX = $state(0);
45 let startClientY = $state(0);
46
47 let dragState: DragState = $state({
48 item: null as unknown as Item,
49 mouseDeltaX: 0,
50 mouseDeltaY: 0,
51 originalPositions: new Map(),
52 lastTargetId: null,
53 lastPlacement: null
54 });
55
56 let lastGridPos: GridPosition | null = $state(null);
57
58 // Ref to the dragged card DOM element (for visual feedback)
59 let draggedCardEl: HTMLElement | null = null;
60
61 // --- File drag state ---
62 let fileDragOver = $state(false);
63
64 // --- Pointer event handlers ---
65
66 function handlePointerDown(e: PointerEvent) {
67 if (phase !== 'idle') return;
68
69 const cardEl = (e.target as HTMLElement)?.closest?.('.card') as HTMLDivElement | null;
70 if (!cardEl) return;
71
72 // On touch devices, only drag the selected card
73 if (e.pointerType === 'touch' && cardEl.id !== selectedCardId) return;
74
75 // On mouse, don't intercept interactive elements
76 if (e.pointerType === 'mouse') {
77 const tag = (e.target as HTMLElement)?.tagName;
78 if (
79 tag === 'BUTTON' ||
80 tag === 'INPUT' ||
81 tag === 'TEXTAREA' ||
82 (e.target as HTMLElement)?.isContentEditable
83 ) {
84 return;
85 }
86 }
87
88 const item = items.find((i) => i.id === cardEl.id);
89 if (!item || item.cardData?.locked) return;
90
91 phase = 'pending';
92 pointerId = e.pointerId;
93 startClientX = e.clientX;
94 startClientY = e.clientY;
95 draggedCardEl = cardEl;
96
97 // Pre-compute mouse delta from card rect
98 const rect = cardEl.getBoundingClientRect();
99 dragState.item = item;
100 dragState.mouseDeltaX = rect.left - e.clientX;
101 dragState.mouseDeltaY = rect.top - e.clientY;
102
103 document.addEventListener('pointermove', handlePointerMove);
104 document.addEventListener('pointerup', handlePointerUp);
105 document.addEventListener('pointercancel', handlePointerCancel);
106 }
107
108 function activateDrag(e: PointerEvent) {
109 phase = 'active';
110
111 try {
112 (e.target as HTMLElement)?.setPointerCapture?.(pointerId);
113 } catch {
114 // setPointerCapture can throw if pointer is already released
115 }
116
117 // Visual feedback: lift the dragged card
118 draggedCardEl?.classList.add('dragging');
119
120 // Store original positions of all items
121 dragState.originalPositions = new Map();
122 for (const it of items) {
123 dragState.originalPositions.set(it.id, {
124 x: it.x,
125 y: it.y,
126 mobileX: it.mobileX,
127 mobileY: it.mobileY
128 });
129 }
130 dragState.lastTargetId = null;
131 dragState.lastPlacement = null;
132
133 document.body.style.userSelect = 'none';
134 }
135
136 function handlePointerMove(e: PointerEvent) {
137 if (!container) return;
138
139 if (phase === 'pending') {
140 // Check 3px threshold
141 const dx = e.clientX - startClientX;
142 const dy = e.clientY - startClientY;
143 if (dx * dx + dy * dy < 9) return;
144 activateDrag(e);
145 }
146
147 if (phase !== 'active') return;
148
149 // Auto-scroll near edges
150 const scrollZone = 100;
151 const scrollSpeed = 10;
152 const viewportHeight = window.innerHeight;
153
154 if (e.clientY < scrollZone) {
155 const intensity = 1 - e.clientY / scrollZone;
156 window.scrollBy(0, -scrollSpeed * intensity);
157 } else if (e.clientY > viewportHeight - scrollZone) {
158 const intensity = 1 - (viewportHeight - e.clientY) / scrollZone;
159 window.scrollBy(0, scrollSpeed * intensity);
160 }
161
162 const result = getGridPosition(e.clientX, e.clientY, container, dragState, items, isMobile);
163 if (!result || !dragState.item) return;
164
165 // Skip redundant work if grid position hasn't changed
166 if (
167 lastGridPos &&
168 lastGridPos.x === result.x &&
169 lastGridPos.y === result.y &&
170 lastGridPos.swapWithId === result.swapWithId &&
171 lastGridPos.placement === result.placement
172 ) {
173 return;
174 }
175 lastGridPos = result;
176
177 const draggedOrigPos = dragState.originalPositions.get(dragState.item.id);
178
179 // Reset all items to original positions first
180 for (const it of items) {
181 const origPos = dragState.originalPositions.get(it.id);
182 if (origPos && it !== dragState.item) {
183 if (isMobile) {
184 it.mobileX = origPos.mobileX;
185 it.mobileY = origPos.mobileY;
186 } else {
187 it.x = origPos.x;
188 it.y = origPos.y;
189 }
190 }
191 }
192
193 // Update dragged item position
194 if (isMobile) {
195 dragState.item.mobileX = result.x;
196 dragState.item.mobileY = result.y;
197 } else {
198 dragState.item.x = result.x;
199 dragState.item.y = result.y;
200 }
201
202 // Handle horizontal swap
203 if (result.swapWithId && draggedOrigPos) {
204 const swapTarget = items.find((it) => it.id === result.swapWithId);
205 if (swapTarget) {
206 if (isMobile) {
207 swapTarget.mobileX = draggedOrigPos.mobileX;
208 swapTarget.mobileY = draggedOrigPos.mobileY;
209 } else {
210 swapTarget.x = draggedOrigPos.x;
211 swapTarget.y = draggedOrigPos.y;
212 }
213 }
214 }
215
216 fixCollisions(
217 items,
218 dragState.item,
219 isMobile,
220 false,
221 draggedOrigPos
222 ? {
223 x: isMobile ? draggedOrigPos.mobileX : draggedOrigPos.x,
224 y: isMobile ? draggedOrigPos.mobileY : draggedOrigPos.y
225 }
226 : undefined
227 );
228 }
229
230 function handlePointerUp() {
231 if (phase === 'active' && dragState.item) {
232 fixCollisions(items, dragState.item, isMobile);
233 onlayoutchange();
234 }
235 cleanup();
236 }
237
238 function handlePointerCancel() {
239 if (phase === 'active') {
240 // Restore all items to original positions
241 for (const it of items) {
242 const origPos = dragState.originalPositions.get(it.id);
243 if (origPos) {
244 it.x = origPos.x;
245 it.y = origPos.y;
246 it.mobileX = origPos.mobileX;
247 it.mobileY = origPos.mobileY;
248 }
249 }
250 }
251 cleanup();
252 }
253
254 function cleanup() {
255 draggedCardEl?.classList.remove('dragging');
256 draggedCardEl = null;
257 phase = 'idle';
258 lastGridPos = null;
259 document.body.style.userSelect = '';
260
261 document.removeEventListener('pointermove', handlePointerMove);
262 document.removeEventListener('pointerup', handlePointerUp);
263 document.removeEventListener('pointercancel', handlePointerCancel);
264 }
265
266 // Ensure cleanup on unmount
267 $effect(() => {
268 return () => {
269 if (phase !== 'idle') cleanup();
270 };
271 });
272
273 // For touch: register non-passive touchstart to prevent scroll when touching selected card
274 $effect(() => {
275 if (!container || !selectedCardId) return;
276 container.addEventListener('touchstart', handleTouchStart, { passive: false });
277 return () => {
278 container?.removeEventListener('touchstart', handleTouchStart);
279 };
280 });
281
282 // For touch: register non-passive touchmove to prevent scroll during active drag
283 $effect(() => {
284 if (phase !== 'active' || !container) return;
285 function preventTouch(e: TouchEvent) {
286 e.preventDefault();
287 }
288 container.addEventListener('touchmove', preventTouch, { passive: false });
289 return () => {
290 container?.removeEventListener('touchmove', preventTouch);
291 };
292 });
293
294 function handleClick(e: MouseEvent) {
295 // Deselect when tapping empty grid space
296 if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) {
297 ondeselect();
298 }
299 }
300
301 function handleTouchStart(e: TouchEvent) {
302 // On touch, prevent scrolling when touching the selected card
303 // This must happen on touchstart (not pointerdown) to claim the gesture
304 const cardEl = (e.target as HTMLElement)?.closest?.('.card') as HTMLElement | null;
305 if (cardEl && cardEl.id === selectedCardId) {
306 const item = items.find((i) => i.id === cardEl.id);
307 if (item && !item.cardData?.locked) {
308 e.preventDefault();
309 }
310 }
311 }
312
313 // --- File drop handlers ---
314
315 function hasImageFile(dt: DataTransfer): boolean {
316 if (dt.items) {
317 for (let i = 0; i < dt.items.length; i++) {
318 const item = dt.items[i];
319 if (item && item.kind === 'file' && item.type.startsWith('image/')) {
320 return true;
321 }
322 }
323 } else if (dt.files) {
324 for (let i = 0; i < dt.files.length; i++) {
325 const file = dt.files[i];
326 if (file?.type.startsWith('image/')) {
327 return true;
328 }
329 }
330 }
331 return false;
332 }
333
334 function handleFileDragOver(event: DragEvent) {
335 const dt = event.dataTransfer;
336 if (!dt) return;
337
338 if (hasImageFile(dt)) {
339 event.preventDefault();
340 event.stopPropagation();
341 fileDragOver = true;
342 }
343 }
344
345 function handleFileDragLeave(event: DragEvent) {
346 event.preventDefault();
347 event.stopPropagation();
348 fileDragOver = false;
349 }
350
351 function handleFileDrop(event: DragEvent) {
352 event.preventDefault();
353 event.stopPropagation();
354 fileDragOver = false;
355
356 if (!event.dataTransfer?.files?.length || !onfiledrop || !container) return;
357
358 const imageFiles = Array.from(event.dataTransfer.files).filter((f) =>
359 f?.type.startsWith('image/')
360 );
361 if (imageFiles.length === 0) return;
362
363 const cardW = isMobile ? 4 : 2;
364 const { gridX, gridY } = pixelToGrid(event.clientX, event.clientY, container, isMobile, cardW);
365
366 onfiledrop(imageFiles, gridX, gridY);
367 }
368</script>
369
370<svelte:window
371 ondragover={handleFileDragOver}
372 ondragleave={handleFileDragLeave}
373 ondrop={handleFileDrop}
374/>
375
376<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
377<div
378 bind:this={container}
379 onpointerdown={handlePointerDown}
380 onclick={handleClick}
381 ondragstart={(e) => e.preventDefault()}
382 class={[
383 '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8',
384 fileDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed'
385 ]}
386>
387 {@render children()}
388
389 <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div>
390</div>
391
392<style>
393 :global(.card.dragging) {
394 z-index: 50 !important;
395 scale: 1.03;
396 box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
397 }
398</style>