your personal website on atproto - mirror blento.app

improve dragging

Florian 63bfeba4 da7f8e54

+180 -22
+7
.claude/settings.local.json
···
··· 1 + { 2 + "permissions": { 3 + "allow": [ 4 + "Bash(pnpm check:*)" 5 + ] 6 + } 7 + }
+4 -2
src/lib/helper.ts
··· 38 return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; 39 }; 40 41 - export function fixCollisions(items: Item[], movedItem: Item, mobile: boolean = false) { 42 const clampX = (item: Item) => { 43 if (mobile) item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); 44 else item.x = clamp(item.x, 0, COLUMNS - item.w); ··· 93 else it.x = clamp(it.x, 0, COLUMNS - it.w); 94 } 95 96 - compactItems(items, mobile); 97 } 98 99 // Fix all collisions between items (not just one moved item)
··· 38 return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; 39 }; 40 41 + export function fixCollisions(items: Item[], movedItem: Item, mobile: boolean = false, skipCompact: boolean = false) { 42 const clampX = (item: Item) => { 43 if (mobile) item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); 44 else item.x = clamp(item.x, 0, COLUMNS - item.w); ··· 93 else it.x = clamp(it.x, 0, COLUMNS - it.w); 94 } 95 96 + if (!skipCompact) { 97 + compactItems(items, mobile); 98 + } 99 } 100 101 // Fix all collisions between items (not just one moved item)
+169 -20
src/lib/website/EditableWebsite.svelte
··· 66 y: number; 67 mouseDeltaX: number; 68 mouseDeltaY: number; 69 } = $state({ 70 element: null, 71 item: null, ··· 74 x: -1, 75 y: -1, 76 mouseDeltaX: 0, 77 - mouseDeltaY: 0 78 }); 79 80 let showingMobileView = $state(false); ··· 249 e: DragEvent & { 250 currentTarget: EventTarget & HTMLDivElement; 251 } 252 - ) { 253 - if (!container) return; 254 255 const x = e.clientX + activeDragElement.mouseDeltaX; 256 const y = e.clientY + activeDragElement.mouseDeltaY; 257 258 const rect = container.getBoundingClientRect(); 259 260 - debugPoint.x = x - rect.left; 261 - debugPoint.y = y - rect.top + margin; 262 - console.log(rect.top); 263 264 let gridX = clamp( 265 - Math.floor(((x - rect.left) / rect.width) * 8), 266 0, 267 - COLUMNS - (activeDragElement.w ?? 0) 268 ); 269 gridX = Math.floor(gridX / 2) * 2; 270 let gridY = Math.max( 271 - Math.round(((y - rect.top + margin) / (rect.width - margin)) * COLUMNS), 272 0 273 ); 274 if (isMobile) { 275 gridX = Math.floor(gridX / 2) * 2; 276 gridY = Math.floor(gridY / 2) * 2; 277 } 278 - return { x: gridX, y: gridY }; 279 } 280 281 let linkValue = $state(''); ··· 402 ondragover={(e) => { 403 e.preventDefault(); 404 405 - const cell = getDragXY(e); 406 - if (!cell) return; 407 408 - activeDragElement.x = cell.x; 409 - activeDragElement.y = cell.y; 410 411 if (activeDragElement.item) { 412 if (isMobile) { 413 - activeDragElement.item.mobileX = cell.x; 414 - activeDragElement.item.mobileY = cell.y; 415 } else { 416 - activeDragElement.item.x = cell.x; 417 - activeDragElement.item.y = cell.y; 418 } 419 420 fixCollisions(items, activeDragElement.item, isMobile); 421 } 422 ··· 449 activeDragElement.item.y = cell.y; 450 } 451 452 fixCollisions(items, activeDragElement.item, isMobile); 453 } 454 activeDragElement.x = -1; 455 activeDragElement.y = -1; 456 activeDragElement.element = null; 457 return true; 458 }} 459 class="@container/grid relative col-span-3 px-2 py-8 @5xl/wrapper:px-8" ··· 484 activeDragElement.h = item.h; 485 activeDragElement.item = item; 486 487 const rect = target.getBoundingClientRect(); 488 activeDragElement.mouseDeltaX = rect.left - e.clientX; 489 activeDragElement.mouseDeltaY = rect.top - e.clientY; 490 - console.log(activeDragElement.mouseDeltaY); 491 - console.log(rect.width); 492 }} 493 > 494 <EditingCard bind:item={items[i]} />
··· 66 y: number; 67 mouseDeltaX: number; 68 mouseDeltaY: number; 69 + // For hysteresis - track last decision to prevent flickering 70 + lastTargetId: string | null; 71 + lastPlacement: 'above' | 'below' | null; 72 + // Store original positions to reset from during drag 73 + originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>; 74 } = $state({ 75 element: null, 76 item: null, ··· 79 x: -1, 80 y: -1, 81 mouseDeltaX: 0, 82 + mouseDeltaY: 0, 83 + lastTargetId: null, 84 + lastPlacement: null, 85 + originalPositions: new Map() 86 }); 87 88 let showingMobileView = $state(false); ··· 257 e: DragEvent & { 258 currentTarget: EventTarget & HTMLDivElement; 259 } 260 + ): { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } | undefined { 261 + if (!container || !activeDragElement.item) return; 262 263 + // x, y represent the top-left corner of the dragged card 264 const x = e.clientX + activeDragElement.mouseDeltaX; 265 const y = e.clientY + activeDragElement.mouseDeltaY; 266 267 const rect = container.getBoundingClientRect(); 268 + const currentMargin = isMobile ? mobileMargin : margin; 269 + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 270 271 + // Get card dimensions based on current view mode 272 + const cardW = isMobile ? (activeDragElement.item?.mobileW ?? activeDragElement.w) : activeDragElement.w; 273 + const cardH = isMobile ? (activeDragElement.item?.mobileH ?? activeDragElement.h) : activeDragElement.h; 274 275 + // Get dragged card's original position 276 + const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 277 + const draggedOrigX = draggedOrigPos ? (isMobile ? draggedOrigPos.mobileX : draggedOrigPos.x) : 0; 278 + const draggedOrigY = draggedOrigPos ? (isMobile ? draggedOrigPos.mobileY : draggedOrigPos.y) : 0; 279 + 280 + // Calculate raw grid position based on top-left of dragged card 281 let gridX = clamp( 282 + Math.round((x - rect.left - currentMargin) / cellSize), 283 0, 284 + COLUMNS - cardW 285 ); 286 gridX = Math.floor(gridX / 2) * 2; 287 + 288 let gridY = Math.max( 289 + Math.round((y - rect.top - currentMargin) / cellSize), 290 0 291 ); 292 + 293 if (isMobile) { 294 gridX = Math.floor(gridX / 2) * 2; 295 gridY = Math.floor(gridY / 2) * 2; 296 } 297 + 298 + // Find if we're hovering over another card (using ORIGINAL positions) 299 + const centerGridY = gridY + cardH / 2; 300 + const centerGridX = gridX + cardW / 2; 301 + 302 + let swapWithId: string | null = null; 303 + let placement: 'above' | 'below' | null = null; 304 + 305 + for (const other of items) { 306 + if (other === activeDragElement.item) continue; 307 + 308 + // Use original positions for hit testing 309 + const origPos = activeDragElement.originalPositions.get(other.id); 310 + if (!origPos) continue; 311 + 312 + const otherX = isMobile ? origPos.mobileX : origPos.x; 313 + const otherY = isMobile ? origPos.mobileY : origPos.y; 314 + const otherW = isMobile ? other.mobileW : other.w; 315 + const otherH = isMobile ? other.mobileH : other.h; 316 + 317 + // Check if dragged card's center point is within this card's original bounds 318 + if (centerGridX >= otherX && centerGridX < otherX + otherW && 319 + centerGridY >= otherY && centerGridY < otherY + otherH) { 320 + 321 + // Check if this is a swap situation: 322 + // Cards have the same dimensions and are on the same row 323 + const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; 324 + 325 + if (canSwap) { 326 + // Swap positions 327 + swapWithId = other.id; 328 + gridX = otherX; 329 + gridY = otherY; 330 + placement = null; 331 + 332 + activeDragElement.lastTargetId = other.id; 333 + activeDragElement.lastPlacement = null; 334 + } else { 335 + // Vertical placement (above/below) 336 + // Detect drag direction: if dragging up, always place above 337 + const isDraggingUp = gridY < draggedOrigY; 338 + 339 + if (isDraggingUp) { 340 + // When dragging up, always place above 341 + placement = 'above'; 342 + } else { 343 + // When dragging down, use top/bottom half logic 344 + const midpointY = otherY + otherH / 2; 345 + const hysteresis = 0.3; 346 + 347 + if (activeDragElement.lastTargetId === other.id && activeDragElement.lastPlacement) { 348 + if (activeDragElement.lastPlacement === 'above') { 349 + placement = centerGridY > midpointY + hysteresis ? 'below' : 'above'; 350 + } else { 351 + placement = centerGridY < midpointY - hysteresis ? 'above' : 'below'; 352 + } 353 + } else { 354 + placement = centerGridY < midpointY ? 'above' : 'below'; 355 + } 356 + } 357 + 358 + activeDragElement.lastTargetId = other.id; 359 + activeDragElement.lastPlacement = placement; 360 + 361 + if (placement === 'above') { 362 + gridY = otherY; 363 + } else { 364 + gridY = otherY + otherH; 365 + } 366 + } 367 + break; 368 + } 369 + } 370 + 371 + // If we're not over any card, clear the tracking 372 + if (!swapWithId && !placement) { 373 + activeDragElement.lastTargetId = null; 374 + activeDragElement.lastPlacement = null; 375 + } 376 + 377 + debugPoint.x = x - rect.left; 378 + debugPoint.y = y - rect.top + currentMargin; 379 + 380 + return { x: gridX, y: gridY, swapWithId, placement }; 381 } 382 383 let linkValue = $state(''); ··· 504 ondragover={(e) => { 505 e.preventDefault(); 506 507 + const result = getDragXY(e); 508 + if (!result) return; 509 510 + activeDragElement.x = result.x; 511 + activeDragElement.y = result.y; 512 513 if (activeDragElement.item) { 514 + // Get dragged card's original position for swapping 515 + const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 516 + 517 + // Reset all items to original positions first 518 + for (const it of items) { 519 + const origPos = activeDragElement.originalPositions.get(it.id); 520 + if (origPos && it !== activeDragElement.item) { 521 + if (isMobile) { 522 + it.mobileX = origPos.mobileX; 523 + it.mobileY = origPos.mobileY; 524 + } else { 525 + it.x = origPos.x; 526 + it.y = origPos.y; 527 + } 528 + } 529 + } 530 + 531 + // Update dragged item position 532 if (isMobile) { 533 + activeDragElement.item.mobileX = result.x; 534 + activeDragElement.item.mobileY = result.y; 535 } else { 536 + activeDragElement.item.x = result.x; 537 + activeDragElement.item.y = result.y; 538 + } 539 + 540 + // Handle horizontal swap 541 + if (result.swapWithId && draggedOrigPos) { 542 + const swapTarget = items.find(it => it.id === result.swapWithId); 543 + if (swapTarget) { 544 + // Move swap target to dragged card's original position 545 + if (isMobile) { 546 + swapTarget.mobileX = draggedOrigPos.mobileX; 547 + swapTarget.mobileY = draggedOrigPos.mobileY; 548 + } else { 549 + swapTarget.x = draggedOrigPos.x; 550 + swapTarget.y = draggedOrigPos.y; 551 + } 552 + } 553 } 554 555 + // Now fix collisions (with compacting) 556 fixCollisions(items, activeDragElement.item, isMobile); 557 } 558 ··· 585 activeDragElement.item.y = cell.y; 586 } 587 588 + // Fix collisions and compact items after drag ends 589 fixCollisions(items, activeDragElement.item, isMobile); 590 } 591 activeDragElement.x = -1; 592 activeDragElement.y = -1; 593 activeDragElement.element = null; 594 + activeDragElement.item = null; 595 + activeDragElement.lastTargetId = null; 596 + activeDragElement.lastPlacement = null; 597 return true; 598 }} 599 class="@container/grid relative col-span-3 px-2 py-8 @5xl/wrapper:px-8" ··· 624 activeDragElement.h = item.h; 625 activeDragElement.item = item; 626 627 + // Store original positions of all items 628 + activeDragElement.originalPositions = new Map(); 629 + for (const it of items) { 630 + activeDragElement.originalPositions.set(it.id, { 631 + x: it.x, 632 + y: it.y, 633 + mobileX: it.mobileX, 634 + mobileY: it.mobileY 635 + }); 636 + } 637 + 638 const rect = target.getBoundingClientRect(); 639 activeDragElement.mouseDeltaX = rect.left - e.clientX; 640 activeDragElement.mouseDeltaY = rect.top - e.clientY; 641 }} 642 > 643 <EditingCard bind:item={items[i]} />