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