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