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 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 39 }; 40 40 41 - export function fixCollisions(items: Item[], movedItem: Item, mobile: boolean = false) { 41 + export function fixCollisions(items: Item[], movedItem: Item, mobile: boolean = false, skipCompact: boolean = false) { 42 42 const clampX = (item: Item) => { 43 43 if (mobile) item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); 44 44 else item.x = clamp(item.x, 0, COLUMNS - item.w); ··· 93 93 else it.x = clamp(it.x, 0, COLUMNS - it.w); 94 94 } 95 95 96 - compactItems(items, mobile); 96 + if (!skipCompact) { 97 + compactItems(items, mobile); 98 + } 97 99 } 98 100 99 101 // Fix all collisions between items (not just one moved item)
+169 -20
src/lib/website/EditableWebsite.svelte
··· 66 66 y: number; 67 67 mouseDeltaX: number; 68 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 }>; 69 74 } = $state({ 70 75 element: null, 71 76 item: null, ··· 74 79 x: -1, 75 80 y: -1, 76 81 mouseDeltaX: 0, 77 - mouseDeltaY: 0 82 + mouseDeltaY: 0, 83 + lastTargetId: null, 84 + lastPlacement: null, 85 + originalPositions: new Map() 78 86 }); 79 87 80 88 let showingMobileView = $state(false); ··· 249 257 e: DragEvent & { 250 258 currentTarget: EventTarget & HTMLDivElement; 251 259 } 252 - ) { 253 - if (!container) return; 260 + ): { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } | undefined { 261 + if (!container || !activeDragElement.item) return; 254 262 263 + // x, y represent the top-left corner of the dragged card 255 264 const x = e.clientX + activeDragElement.mouseDeltaX; 256 265 const y = e.clientY + activeDragElement.mouseDeltaY; 257 266 258 267 const rect = container.getBoundingClientRect(); 268 + const currentMargin = isMobile ? mobileMargin : margin; 269 + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 259 270 260 - debugPoint.x = x - rect.left; 261 - debugPoint.y = y - rect.top + margin; 262 - console.log(rect.top); 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; 263 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 264 281 let gridX = clamp( 265 - Math.floor(((x - rect.left) / rect.width) * 8), 282 + Math.round((x - rect.left - currentMargin) / cellSize), 266 283 0, 267 - COLUMNS - (activeDragElement.w ?? 0) 284 + COLUMNS - cardW 268 285 ); 269 286 gridX = Math.floor(gridX / 2) * 2; 287 + 270 288 let gridY = Math.max( 271 - Math.round(((y - rect.top + margin) / (rect.width - margin)) * COLUMNS), 289 + Math.round((y - rect.top - currentMargin) / cellSize), 272 290 0 273 291 ); 292 + 274 293 if (isMobile) { 275 294 gridX = Math.floor(gridX / 2) * 2; 276 295 gridY = Math.floor(gridY / 2) * 2; 277 296 } 278 - return { x: gridX, y: gridY }; 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 }; 279 381 } 280 382 281 383 let linkValue = $state(''); ··· 402 504 ondragover={(e) => { 403 505 e.preventDefault(); 404 506 405 - const cell = getDragXY(e); 406 - if (!cell) return; 507 + const result = getDragXY(e); 508 + if (!result) return; 407 509 408 - activeDragElement.x = cell.x; 409 - activeDragElement.y = cell.y; 510 + activeDragElement.x = result.x; 511 + activeDragElement.y = result.y; 410 512 411 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 412 532 if (isMobile) { 413 - activeDragElement.item.mobileX = cell.x; 414 - activeDragElement.item.mobileY = cell.y; 533 + activeDragElement.item.mobileX = result.x; 534 + activeDragElement.item.mobileY = result.y; 415 535 } else { 416 - activeDragElement.item.x = cell.x; 417 - activeDragElement.item.y = cell.y; 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 + } 418 553 } 419 554 555 + // Now fix collisions (with compacting) 420 556 fixCollisions(items, activeDragElement.item, isMobile); 421 557 } 422 558 ··· 449 585 activeDragElement.item.y = cell.y; 450 586 } 451 587 588 + // Fix collisions and compact items after drag ends 452 589 fixCollisions(items, activeDragElement.item, isMobile); 453 590 } 454 591 activeDragElement.x = -1; 455 592 activeDragElement.y = -1; 456 593 activeDragElement.element = null; 594 + activeDragElement.item = null; 595 + activeDragElement.lastTargetId = null; 596 + activeDragElement.lastPlacement = null; 457 597 return true; 458 598 }} 459 599 class="@container/grid relative col-span-3 px-2 py-8 @5xl/wrapper:px-8" ··· 484 624 activeDragElement.h = item.h; 485 625 activeDragElement.item = item; 486 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 + 487 638 const rect = target.getBoundingClientRect(); 488 639 activeDragElement.mouseDeltaX = rect.left - e.clientX; 489 640 activeDragElement.mouseDeltaY = rect.top - e.clientY; 490 - console.log(activeDragElement.mouseDeltaY); 491 - console.log(rect.width); 492 641 }} 493 642 > 494 643 <EditingCard bind:item={items[i]} />