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