your personal website on atproto - mirror blento.app
at updated-blentos 919 lines 26 kB view raw
1<script lang="ts"> 2 import { Button, toast, Toaster, Sidebar } from '@foxui/core'; 3 import { COLUMNS, margin, mobileMargin } from '$lib'; 4 import { 5 checkAndUploadImage, 6 clamp, 7 compactItems, 8 createEmptyCard, 9 findValidPosition, 10 fixCollisions, 11 getHideProfileSection, 12 getProfilePosition, 13 getName, 14 isTyping, 15 savePage, 16 scrollToItem, 17 setPositionOfNewItem, 18 validateLink, 19 getImage 20 } from '../helper'; 21 import EditableProfile from './EditableProfile.svelte'; 22 import type { Item, WebsiteData } from '../types'; 23 import { innerWidth } from 'svelte/reactivity/window'; 24 import EditingCard from '../cards/Card/EditingCard.svelte'; 25 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 26 import { tick, type Component } from 'svelte'; 27 import type { CreationModalComponentProps } from '../cards/types'; 28 import { dev } from '$app/environment'; 29 import { setIsMobile } from './context'; 30 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; 31 import Context from './Context.svelte'; 32 import Head from './Head.svelte'; 33 import Account from './Account.svelte'; 34 import { SelectThemePopover } from '$lib/components/select-theme'; 35 import EditBar from './EditBar.svelte'; 36 import SaveModal from './SaveModal.svelte'; 37 import FloatingEditButton from './FloatingEditButton.svelte'; 38 import { user } from '$lib/atproto'; 39 import { launchConfetti } from '@foxui/visual'; 40 import Controls from './Controls.svelte'; 41 42 let { 43 data 44 }: { 45 data: WebsiteData; 46 } = $props(); 47 48 // Check if floating login button will be visible (to hide MadeWithBlento) 49 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn); 50 51 let accentColor = $derived(data.publication?.preferences?.accentColor ?? 'pink'); 52 let baseColor = $derived(data.publication?.preferences?.baseColor ?? 'stone'); 53 54 function updateTheme(newAccent: string, newBase: string) { 55 data.publication.preferences ??= {}; 56 data.publication.preferences.accentColor = newAccent; 57 data.publication.preferences.baseColor = newBase; 58 data = { ...data }; 59 } 60 61 let imageDragOver = $state(false); 62 63 // svelte-ignore state_referenced_locally 64 let items: Item[] = $state(data.cards); 65 66 // svelte-ignore state_referenced_locally 67 let publication = $state(JSON.stringify(data.publication)); 68 69 // Track saved state for comparison 70 // svelte-ignore state_referenced_locally 71 let savedItems = $state(JSON.stringify(data.cards)); 72 // svelte-ignore state_referenced_locally 73 let savedPublication = $state(JSON.stringify(data.publication)); 74 75 let hasUnsavedChanges = $derived( 76 JSON.stringify(items) !== savedItems || JSON.stringify(data.publication) !== savedPublication 77 ); 78 79 // Warn user before closing tab if there are unsaved changes 80 $effect(() => { 81 function handleBeforeUnload(e: BeforeUnloadEvent) { 82 if (hasUnsavedChanges) { 83 e.preventDefault(); 84 return ''; 85 } 86 } 87 88 window.addEventListener('beforeunload', handleBeforeUnload); 89 return () => window.removeEventListener('beforeunload', handleBeforeUnload); 90 }); 91 92 let container: HTMLDivElement | undefined = $state(); 93 94 let activeDragElement: { 95 element: HTMLDivElement | null; 96 item: Item | null; 97 w: number; 98 h: number; 99 x: number; 100 y: number; 101 mouseDeltaX: number; 102 mouseDeltaY: number; 103 // For hysteresis - track last decision to prevent flickering 104 lastTargetId: string | null; 105 lastPlacement: 'above' | 'below' | null; 106 // Store original positions to reset from during drag 107 originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>; 108 } = $state({ 109 element: null, 110 item: null, 111 w: 0, 112 h: 0, 113 x: -1, 114 y: -1, 115 mouseDeltaX: 0, 116 mouseDeltaY: 0, 117 lastTargetId: null, 118 lastPlacement: null, 119 originalPositions: new Map() 120 }); 121 122 let showingMobileView = $state(false); 123 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); 124 125 setIsMobile(() => isMobile); 126 127 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 128 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 129 130 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 131 132 function getViewportCenterGridY(): { gridY: number; isMobile: boolean } | undefined { 133 if (!container) return undefined; 134 const rect = container.getBoundingClientRect(); 135 const currentMargin = isMobile ? mobileMargin : margin; 136 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 137 const viewportCenterY = window.innerHeight / 2; 138 const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize; 139 return { gridY, isMobile }; 140 } 141 142 function newCard(type: string = 'link', cardData?: any) { 143 // close sidebar if open 144 const popover = document.getElementById('mobile-menu'); 145 if (popover) { 146 popover.hidePopover(); 147 } 148 149 let item = createEmptyCard(data.page); 150 item.cardType = type; 151 152 item.cardData = cardData ?? {}; 153 154 const cardDef = CardDefinitionsByType[type]; 155 cardDef?.createNew?.(item); 156 157 newItem.item = item; 158 159 if (cardDef?.creationModalComponent) { 160 newItem.modal = cardDef.creationModalComponent; 161 } else { 162 saveNewItem(); 163 } 164 } 165 166 async function saveNewItem() { 167 if (!newItem.item) return; 168 const item = newItem.item; 169 170 const viewportCenter = getViewportCenterGridY(); 171 setPositionOfNewItem(item, items, viewportCenter); 172 173 items = [...items, item]; 174 175 // Push overlapping items down, then compact to fill gaps 176 fixCollisions(items, item, false, true); 177 fixCollisions(items, item, true, true); 178 compactItems(items, false); 179 compactItems(items, true); 180 181 newItem = {}; 182 183 await tick(); 184 185 scrollToItem(item, isMobile, container); 186 } 187 188 let isSaving = $state(false); 189 let showSaveModal = $state(false); 190 let saveSuccess = $state(false); 191 192 let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({}); 193 194 async function save() { 195 isSaving = true; 196 saveSuccess = false; 197 showSaveModal = true; 198 199 try { 200 // Upload profile icon if changed 201 if (data.publication?.icon) { 202 await checkAndUploadImage(data.publication, 'icon'); 203 } 204 205 await savePage(data, items, publication); 206 207 publication = JSON.stringify(data.publication); 208 209 // Update saved state 210 savedItems = JSON.stringify(items); 211 savedPublication = JSON.stringify(data.publication); 212 213 saveSuccess = true; 214 215 launchConfetti(); 216 217 // Refresh cached data 218 await fetch('/' + data.handle + '/api/refresh'); 219 } catch (error) { 220 console.log(error); 221 showSaveModal = false; 222 toast.error('Error saving page!'); 223 } finally { 224 isSaving = false; 225 } 226 } 227 228 const sidebarItems = AllCardDefinitions.filter((cardDef) => cardDef.sidebarButtonText); 229 230 let debugPoint = $state({ x: 0, y: 0 }); 231 232 function getDragXY( 233 e: DragEvent & { 234 currentTarget: EventTarget & HTMLDivElement; 235 } 236 ): 237 | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 238 | undefined { 239 if (!container || !activeDragElement.item) return; 240 241 // x, y represent the top-left corner of the dragged card 242 const x = e.clientX + activeDragElement.mouseDeltaX; 243 const y = e.clientY + activeDragElement.mouseDeltaY; 244 245 const rect = container.getBoundingClientRect(); 246 const currentMargin = isMobile ? mobileMargin : margin; 247 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 248 249 // Get card dimensions based on current view mode 250 const cardW = isMobile 251 ? (activeDragElement.item?.mobileW ?? activeDragElement.w) 252 : activeDragElement.w; 253 const cardH = isMobile 254 ? (activeDragElement.item?.mobileH ?? activeDragElement.h) 255 : activeDragElement.h; 256 257 // Get dragged card's original position 258 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 259 260 const draggedOrigY = draggedOrigPos 261 ? isMobile 262 ? draggedOrigPos.mobileY 263 : draggedOrigPos.y 264 : 0; 265 266 // Calculate raw grid position based on top-left of dragged card 267 let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 268 gridX = Math.floor(gridX / 2) * 2; 269 270 let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0); 271 272 if (isMobile) { 273 gridX = Math.floor(gridX / 2) * 2; 274 gridY = Math.floor(gridY / 2) * 2; 275 } 276 277 // Find if we're hovering over another card (using ORIGINAL positions) 278 const centerGridY = gridY + cardH / 2; 279 const centerGridX = gridX + cardW / 2; 280 281 let swapWithId: string | null = null; 282 let placement: 'above' | 'below' | null = null; 283 284 for (const other of items) { 285 if (other === activeDragElement.item) continue; 286 287 // Use original positions for hit testing 288 const origPos = activeDragElement.originalPositions.get(other.id); 289 if (!origPos) continue; 290 291 const otherX = isMobile ? origPos.mobileX : origPos.x; 292 const otherY = isMobile ? origPos.mobileY : origPos.y; 293 const otherW = isMobile ? other.mobileW : other.w; 294 const otherH = isMobile ? other.mobileH : other.h; 295 296 // Check if dragged card's center point is within this card's original bounds 297 if ( 298 centerGridX >= otherX && 299 centerGridX < otherX + otherW && 300 centerGridY >= otherY && 301 centerGridY < otherY + otherH 302 ) { 303 // Check if this is a swap situation: 304 // Cards have the same dimensions and are on the same row 305 const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; 306 307 if (canSwap) { 308 // Swap positions 309 swapWithId = other.id; 310 gridX = otherX; 311 gridY = otherY; 312 placement = null; 313 314 activeDragElement.lastTargetId = other.id; 315 activeDragElement.lastPlacement = null; 316 } else { 317 // Vertical placement (above/below) 318 // Detect drag direction: if dragging up, always place above 319 const isDraggingUp = gridY < draggedOrigY; 320 321 if (isDraggingUp) { 322 // When dragging up, always place above 323 placement = 'above'; 324 } else { 325 // When dragging down, use top/bottom half logic 326 const midpointY = otherY + otherH / 2; 327 const hysteresis = 0.3; 328 329 if (activeDragElement.lastTargetId === other.id && activeDragElement.lastPlacement) { 330 if (activeDragElement.lastPlacement === 'above') { 331 placement = centerGridY > midpointY + hysteresis ? 'below' : 'above'; 332 } else { 333 placement = centerGridY < midpointY - hysteresis ? 'above' : 'below'; 334 } 335 } else { 336 placement = centerGridY < midpointY ? 'above' : 'below'; 337 } 338 } 339 340 activeDragElement.lastTargetId = other.id; 341 activeDragElement.lastPlacement = placement; 342 343 if (placement === 'above') { 344 gridY = otherY; 345 } else { 346 gridY = otherY + otherH; 347 } 348 } 349 break; 350 } 351 } 352 353 // If we're not over any card, clear the tracking 354 if (!swapWithId && !placement) { 355 activeDragElement.lastTargetId = null; 356 activeDragElement.lastPlacement = null; 357 } 358 359 debugPoint.x = x - rect.left; 360 debugPoint.y = y - rect.top + currentMargin; 361 362 return { x: gridX, y: gridY, swapWithId, placement }; 363 } 364 365 let linkValue = $state(''); 366 367 function addLink(url: string) { 368 let link = validateLink(url); 369 if (!link) { 370 toast.error('invalid link'); 371 return; 372 } 373 let item = createEmptyCard(data.page); 374 375 for (const cardDef of AllCardDefinitions.toSorted( 376 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 377 )) { 378 if (cardDef.onUrlHandler?.(link, item)) { 379 item.cardType = cardDef.type; 380 381 newItem.item = item; 382 saveNewItem(); 383 toast(cardDef.name + ' added!'); 384 break; 385 } 386 } 387 388 if (linkValue === url) { 389 linkValue = ''; 390 } 391 } 392 393 function getImageDimensions(src: string): Promise<{ width: number; height: number }> { 394 return new Promise((resolve) => { 395 const img = new Image(); 396 img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); 397 img.onerror = () => resolve({ width: 1, height: 1 }); 398 img.src = src; 399 }); 400 } 401 402 function getBestGridSize( 403 imageWidth: number, 404 imageHeight: number, 405 candidates: [number, number][] 406 ): [number, number] { 407 const imageRatio = imageWidth / imageHeight; 408 let best: [number, number] = candidates[0]; 409 let bestDiff = Infinity; 410 411 for (const candidate of candidates) { 412 const gridRatio = candidate[0] / candidate[1]; 413 const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio)); 414 if (diff < bestDiff) { 415 bestDiff = diff; 416 best = candidate; 417 } 418 } 419 420 return best; 421 } 422 423 const desktopSizeCandidates: [number, number][] = [ 424 [2, 2], 425 [2, 4], 426 [4, 2], 427 [4, 4], 428 [4, 6], 429 [6, 4] 430 ]; 431 const mobileSizeCandidates: [number, number][] = [ 432 [4, 4], 433 [4, 6], 434 [4, 8], 435 [6, 4], 436 [8, 4], 437 [8, 6] 438 ]; 439 440 async function processImageFile(file: File, gridX?: number, gridY?: number) { 441 const isGif = file.type === 'image/gif'; 442 443 // Don't compress GIFs to preserve animation 444 const objectUrl = URL.createObjectURL(file); 445 446 let item = createEmptyCard(data.page); 447 448 item.cardType = isGif ? 'gif' : 'image'; 449 item.cardData = { 450 image: { blob: file, objectUrl } 451 }; 452 453 // Size card based on image aspect ratio 454 const { width, height } = await getImageDimensions(objectUrl); 455 const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates); 456 const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates); 457 item.w = dw; 458 item.h = dh; 459 item.mobileW = mw; 460 item.mobileH = mh; 461 462 // If grid position is provided (image dropped on grid) 463 if (gridX !== undefined && gridY !== undefined) { 464 if (isMobile) { 465 item.mobileX = gridX; 466 item.mobileY = gridY; 467 // Derive desktop Y from mobile 468 item.x = Math.floor((COLUMNS - item.w) / 2); 469 item.x = Math.floor(item.x / 2) * 2; 470 item.y = Math.max(0, Math.round(gridY / 2)); 471 } else { 472 item.x = gridX; 473 item.y = gridY; 474 // Derive mobile Y from desktop 475 item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2); 476 item.mobileX = Math.floor(item.mobileX / 2) * 2; 477 item.mobileY = Math.max(0, Math.round(gridY * 2)); 478 } 479 480 items = [...items, item]; 481 fixCollisions(items, item, isMobile); 482 fixCollisions(items, item, !isMobile); 483 } else { 484 const viewportCenter = getViewportCenterGridY(); 485 setPositionOfNewItem(item, items, viewportCenter); 486 items = [...items, item]; 487 fixCollisions(items, item, false, true); 488 fixCollisions(items, item, true, true); 489 compactItems(items, false); 490 compactItems(items, true); 491 } 492 493 await tick(); 494 495 scrollToItem(item, isMobile, container); 496 } 497 498 function handleImageDragOver(event: DragEvent) { 499 const dt = event.dataTransfer; 500 if (!dt) return; 501 502 let hasImage = false; 503 if (dt.items) { 504 for (let i = 0; i < dt.items.length; i++) { 505 const item = dt.items[i]; 506 if (item && item.kind === 'file' && item.type.startsWith('image/')) { 507 hasImage = true; 508 break; 509 } 510 } 511 } else if (dt.files) { 512 for (let i = 0; i < dt.files.length; i++) { 513 const file = dt.files[i]; 514 if (file?.type.startsWith('image/')) { 515 hasImage = true; 516 break; 517 } 518 } 519 } 520 521 if (hasImage) { 522 event.preventDefault(); 523 event.stopPropagation(); 524 525 imageDragOver = true; 526 } 527 } 528 529 function handleImageDragLeave(event: DragEvent) { 530 event.preventDefault(); 531 event.stopPropagation(); 532 imageDragOver = false; 533 } 534 535 async function handleImageDrop(event: DragEvent) { 536 event.preventDefault(); 537 event.stopPropagation(); 538 const dropX = event.clientX; 539 const dropY = event.clientY; 540 imageDragOver = false; 541 542 if (!event.dataTransfer?.files?.length) return; 543 544 const imageFiles = Array.from(event.dataTransfer.files).filter((f) => 545 f?.type.startsWith('image/') 546 ); 547 if (imageFiles.length === 0) return; 548 549 // Calculate starting grid position from drop coordinates 550 let gridX = 0; 551 let gridY = 0; 552 if (container) { 553 const rect = container.getBoundingClientRect(); 554 const currentMargin = isMobile ? mobileMargin : margin; 555 const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 556 const cardW = isMobile ? 4 : 2; 557 558 gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 559 gridX = Math.floor(gridX / 2) * 2; 560 561 gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0); 562 if (isMobile) { 563 gridY = Math.floor(gridY / 2) * 2; 564 } 565 } 566 567 for (let i = 0; i < imageFiles.length; i++) { 568 // First image gets the drop position, rest use normal placement 569 if (i === 0) { 570 await processImageFile(imageFiles[i], gridX, gridY); 571 } else { 572 await processImageFile(imageFiles[i]); 573 } 574 } 575 } 576 577 async function handleImageInputChange(event: Event) { 578 const target = event.target as HTMLInputElement; 579 if (!target.files || target.files.length < 1) return; 580 581 const files = Array.from(target.files); 582 583 if (files.length === 1) { 584 // Single file: use default positioning 585 await processImageFile(files[0]); 586 } else { 587 // Multiple files: place in grid pattern starting from first available position 588 let gridX = 0; 589 let gridY = maxHeight; 590 const cardW = isMobile ? 4 : 2; 591 const cardH = isMobile ? 4 : 2; 592 593 for (const file of files) { 594 await processImageFile(file, gridX, gridY); 595 596 // Move to next cell position 597 gridX += cardW; 598 if (gridX + cardW > COLUMNS) { 599 gridX = 0; 600 gridY += cardH; 601 } 602 } 603 } 604 605 // Reset the input so the same file can be selected again 606 target.value = ''; 607 } 608 609 async function processVideoFile(file: File) { 610 const objectUrl = URL.createObjectURL(file); 611 612 let item = createEmptyCard(data.page); 613 614 item.cardType = 'video'; 615 item.cardData = { 616 blob: file, 617 objectUrl 618 }; 619 620 const viewportCenter = getViewportCenterGridY(); 621 setPositionOfNewItem(item, items, viewportCenter); 622 items = [...items, item]; 623 fixCollisions(items, item, false, true); 624 fixCollisions(items, item, true, true); 625 compactItems(items, false); 626 compactItems(items, true); 627 628 await tick(); 629 630 scrollToItem(item, isMobile, container); 631 } 632 633 async function handleVideoInputChange(event: Event) { 634 const target = event.target as HTMLInputElement; 635 if (!target.files || target.files.length < 1) return; 636 637 const files = Array.from(target.files); 638 639 for (const file of files) { 640 await processVideoFile(file); 641 } 642 643 // Reset the input so the same file can be selected again 644 target.value = ''; 645 } 646 647 // $inspect(items); 648</script> 649 650<svelte:body 651 onpaste={(event) => { 652 if (isTyping()) return; 653 654 const text = event.clipboardData?.getData('text/plain'); 655 const link = validateLink(text, false); 656 if (!link) return; 657 658 addLink(link); 659 }} 660/> 661 662<svelte:window 663 ondragover={handleImageDragOver} 664 ondragleave={handleImageDragLeave} 665 ondrop={handleImageDrop} 666/> 667 668<Head 669 favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar} 670 title={getName(data)} 671 image={'/' + data.handle + '/og.png'} 672 accentColor={data.publication?.preferences?.accentColor} 673 baseColor={data.publication?.preferences?.baseColor} 674/> 675 676<Account {data} /> 677 678<Context {data}> 679 {#if !dev} 680 <div 681 class="bg-base-200 dark:bg-base-800 fixed inset-0 z-50 inline-flex h-full w-full items-center justify-center p-4 text-center lg:hidden" 682 > 683 Editing on mobile is not supported yet. Please use a desktop browser. 684 </div> 685 {/if} 686 687 <Controls bind:data /> 688 689 {#if showingMobileView} 690 <div 691 class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full" 692 ></div> 693 {/if} 694 695 {#if newItem.modal && newItem.item} 696 <newItem.modal 697 oncreate={() => { 698 saveNewItem(); 699 }} 700 bind:item={newItem.item} 701 oncancel={() => { 702 newItem = {}; 703 }} 704 /> 705 {/if} 706 707 <SaveModal 708 bind:open={showSaveModal} 709 success={saveSuccess} 710 handle={data.handle} 711 page={data.page} 712 /> 713 714 <div 715 class={[ 716 '@container/wrapper relative w-full', 717 showingMobileView 718 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90' 719 : '' 720 ]} 721 > 722 {#if !getHideProfileSection(data)} 723 <EditableProfile bind:data hideBlento={showLoginOnEditPage} /> 724 {/if} 725 726 <div 727 class={[ 728 'pointer-events-none relative mx-auto max-w-lg', 729 !getHideProfileSection(data) && getProfilePosition(data) === 'side' 730 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4' 731 : '@5xl/wrapper:max-w-4xl' 732 ]} 733 > 734 <div class="pointer-events-none"></div> 735 <!-- svelte-ignore a11y_no_static_element_interactions --> 736 <div 737 bind:this={container} 738 ondragover={(e) => { 739 e.preventDefault(); 740 741 const result = getDragXY(e); 742 if (!result) return; 743 744 activeDragElement.x = result.x; 745 activeDragElement.y = result.y; 746 747 if (activeDragElement.item) { 748 // Get dragged card's original position for swapping 749 const draggedOrigPos = activeDragElement.originalPositions.get( 750 activeDragElement.item.id 751 ); 752 753 // Reset all items to original positions first 754 for (const it of items) { 755 const origPos = activeDragElement.originalPositions.get(it.id); 756 if (origPos && it !== activeDragElement.item) { 757 if (isMobile) { 758 it.mobileX = origPos.mobileX; 759 it.mobileY = origPos.mobileY; 760 } else { 761 it.x = origPos.x; 762 it.y = origPos.y; 763 } 764 } 765 } 766 767 // Update dragged item position 768 if (isMobile) { 769 activeDragElement.item.mobileX = result.x; 770 activeDragElement.item.mobileY = result.y; 771 } else { 772 activeDragElement.item.x = result.x; 773 activeDragElement.item.y = result.y; 774 } 775 776 // Handle horizontal swap 777 if (result.swapWithId && draggedOrigPos) { 778 const swapTarget = items.find((it) => it.id === result.swapWithId); 779 if (swapTarget) { 780 // Move swap target to dragged card's original position 781 if (isMobile) { 782 swapTarget.mobileX = draggedOrigPos.mobileX; 783 swapTarget.mobileY = draggedOrigPos.mobileY; 784 } else { 785 swapTarget.x = draggedOrigPos.x; 786 swapTarget.y = draggedOrigPos.y; 787 } 788 } 789 } 790 791 // Now fix collisions (with compacting) 792 fixCollisions(items, activeDragElement.item, isMobile); 793 } 794 795 // Auto-scroll when dragging near top or bottom of viewport 796 const scrollZone = 100; 797 const scrollSpeed = 10; 798 const viewportHeight = window.innerHeight; 799 800 if (e.clientY < scrollZone) { 801 // Near top - scroll up 802 const intensity = 1 - e.clientY / scrollZone; 803 window.scrollBy(0, -scrollSpeed * intensity); 804 } else if (e.clientY > viewportHeight - scrollZone) { 805 // Near bottom - scroll down 806 const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; 807 window.scrollBy(0, scrollSpeed * intensity); 808 } 809 }} 810 ondragend={async (e) => { 811 e.preventDefault(); 812 const cell = getDragXY(e); 813 if (!cell) return; 814 815 if (activeDragElement.item) { 816 if (isMobile) { 817 activeDragElement.item.mobileX = cell.x; 818 activeDragElement.item.mobileY = cell.y; 819 } else { 820 activeDragElement.item.x = cell.x; 821 activeDragElement.item.y = cell.y; 822 } 823 824 // Fix collisions and compact items after drag ends 825 fixCollisions(items, activeDragElement.item, isMobile); 826 } 827 activeDragElement.x = -1; 828 activeDragElement.y = -1; 829 activeDragElement.element = null; 830 activeDragElement.item = null; 831 activeDragElement.lastTargetId = null; 832 activeDragElement.lastPlacement = null; 833 return true; 834 }} 835 class={[ 836 '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 837 imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' 838 ]} 839 > 840 {#each items as item, i (item.id)} 841 <!-- {#if item !== activeDragElement.item} --> 842 <BaseEditingCard 843 bind:item={items[i]} 844 ondelete={() => { 845 items = items.filter((it) => it !== item); 846 compactItems(items, false); 847 compactItems(items, true); 848 }} 849 onsetsize={(newW: number, newH: number) => { 850 if (isMobile) { 851 item.mobileW = newW; 852 item.mobileH = newH; 853 } else { 854 item.w = newW; 855 item.h = newH; 856 } 857 858 fixCollisions(items, item, isMobile); 859 }} 860 ondragstart={(e: DragEvent) => { 861 const target = e.currentTarget as HTMLDivElement; 862 activeDragElement.element = target; 863 activeDragElement.w = item.w; 864 activeDragElement.h = item.h; 865 activeDragElement.item = item; 866 867 // Store original positions of all items 868 activeDragElement.originalPositions = new Map(); 869 for (const it of items) { 870 activeDragElement.originalPositions.set(it.id, { 871 x: it.x, 872 y: it.y, 873 mobileX: it.mobileX, 874 mobileY: it.mobileY 875 }); 876 } 877 878 const rect = target.getBoundingClientRect(); 879 activeDragElement.mouseDeltaX = rect.left - e.clientX; 880 activeDragElement.mouseDeltaY = rect.top - e.clientY; 881 }} 882 > 883 <EditingCard bind:item={items[i]} /> 884 </BaseEditingCard> 885 <!-- {/if} --> 886 {/each} 887 888 <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> 889 </div> 890 </div> 891 </div> 892 893 <Sidebar mobileOnly mobileClasses="lg:block p-4 gap-4"> 894 <div class="flex flex-col gap-2"> 895 {#each sidebarItems as cardDef (cardDef.type)} 896 <Button onclick={() => newCard(cardDef.type)} variant="ghost" class="w-full justify-start" 897 >{cardDef.sidebarButtonText}</Button 898 > 899 {/each} 900 </div> 901 </Sidebar> 902 903 <EditBar 904 {data} 905 bind:linkValue 906 bind:isSaving 907 bind:showingMobileView 908 {hasUnsavedChanges} 909 {newCard} 910 {addLink} 911 {save} 912 {handleImageInputChange} 913 {handleVideoInputChange} 914 /> 915 916 <Toaster /> 917 918 <FloatingEditButton {data} /> 919</Context>