your personal website on atproto - mirror blento.app
at npmx 398 lines 11 kB view raw
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>