your personal website on atproto - mirror blento.app

Compare changes

Choose any two refs to compare.

+1136 -1034
+2
.gitignore
··· 21 21 # Vite 22 22 vite.config.js.timestamp-* 23 23 vite.config.ts.timestamp-* 24 + 25 + react-grid-layout
+1
package.json
··· 81 81 "perfect-freehand": "^1.2.2", 82 82 "plyr": "^3.8.4", 83 83 "qr-code-styling": "^1.8.6", 84 + "react-grid-layout": "^2.2.2", 84 85 "simple-icons": "^16.6.0", 85 86 "svelte-sonner": "^1.0.7", 86 87 "tailwind-merge": "^3.4.0",
+110
pnpm-lock.yaml
··· 134 134 qr-code-styling: 135 135 specifier: ^1.8.6 136 136 version: 1.9.2 137 + react-grid-layout: 138 + specifier: ^2.2.2 139 + version: 2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 137 140 simple-icons: 138 141 specifier: ^16.6.0 139 142 version: 16.6.0 ··· 1967 1970 fast-deep-equal@3.1.3: 1968 1971 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} 1969 1972 1973 + fast-equals@4.0.3: 1974 + resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==, tarball: https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz} 1975 + 1970 1976 fast-json-stable-stringify@2.1.0: 1971 1977 resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} 1972 1978 ··· 2103 2109 resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} 2104 2110 hasBin: true 2105 2111 2112 + js-tokens@4.0.0: 2113 + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz} 2114 + 2106 2115 js-yaml@4.1.1: 2107 2116 resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} 2108 2117 hasBin: true ··· 2239 2248 lodash.merge@4.6.2: 2240 2249 resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} 2241 2250 2251 + loose-envify@1.4.0: 2252 + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==, tarball: https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz} 2253 + hasBin: true 2254 + 2242 2255 lz-string@1.5.0: 2243 2256 resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} 2244 2257 hasBin: true ··· 2340 2353 number-flow@0.5.9: 2341 2354 resolution: {integrity: sha512-o3102c/4qRd6eV4n+rw6B/UP8+FosbhIxj4uA6GsjhryrGZRVtCtKIKEeBiOwUV52cUGJneeu0treELcV7U/lw==} 2342 2355 2356 + object-assign@4.1.1: 2357 + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, tarball: https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz} 2358 + engines: {node: '>=0.10.0'} 2359 + 2343 2360 obug@2.1.1: 2344 2361 resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} 2345 2362 ··· 2526 2543 engines: {node: '>=14'} 2527 2544 hasBin: true 2528 2545 2546 + prop-types@15.8.1: 2547 + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, tarball: https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz} 2548 + 2529 2549 prosemirror-changeset@2.3.1: 2530 2550 resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==} 2531 2551 ··· 2608 2628 rangetouch@2.0.1: 2609 2629 resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==} 2610 2630 2631 + react-dom@19.2.4: 2632 + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==, tarball: https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz} 2633 + peerDependencies: 2634 + react: ^19.2.4 2635 + 2636 + react-draggable@4.5.0: 2637 + resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==, tarball: https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz} 2638 + peerDependencies: 2639 + react: '>= 16.3.0' 2640 + react-dom: '>= 16.3.0' 2641 + 2642 + react-grid-layout@2.2.2: 2643 + resolution: {integrity: sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==, tarball: https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.2.tgz} 2644 + peerDependencies: 2645 + react: '>= 16.3.0' 2646 + react-dom: '>= 16.3.0' 2647 + 2648 + react-is@16.13.1: 2649 + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==, tarball: https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz} 2650 + 2651 + react-resizable@3.1.3: 2652 + resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==, tarball: https://registry.npmjs.org/react-resizable/-/react-resizable-3.1.3.tgz} 2653 + peerDependencies: 2654 + react: '>= 16.3' 2655 + react-dom: '>= 16.3' 2656 + 2657 + react@19.2.4: 2658 + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==, tarball: https://registry.npmjs.org/react/-/react-19.2.4.tgz} 2659 + engines: {node: '>=0.10.0'} 2660 + 2611 2661 readdirp@4.1.2: 2612 2662 resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} 2613 2663 engines: {node: '>= 14.18.0'} ··· 2619 2669 require-from-string@2.0.2: 2620 2670 resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} 2621 2671 engines: {node: '>=0.10.0'} 2672 + 2673 + resize-observer-polyfill@1.5.1: 2674 + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==, tarball: https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz} 2622 2675 2623 2676 resolve-from@4.0.0: 2624 2677 resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} ··· 2676 2729 resolution: {integrity: sha512-abovcqmwl97WKioxpkfuMeZmndB1TuDFY/R+FymrZyiGP+pMYomvgSzVPnbNMWHHESOPosVHGL352oFbdAnJcA==} 2677 2730 engines: {node: '>=16'} 2678 2731 2732 + scheduler@0.27.0: 2733 + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==, tarball: https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz} 2734 + 2679 2735 semver@7.7.3: 2680 2736 resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} 2681 2737 engines: {node: '>=10'} ··· 4731 4787 4732 4788 fast-deep-equal@3.1.3: {} 4733 4789 4790 + fast-equals@4.0.3: {} 4791 + 4734 4792 fast-json-stable-stringify@2.1.0: {} 4735 4793 4736 4794 fast-levenshtein@2.0.6: {} ··· 4836 4894 iso-datestring-validator@2.2.2: {} 4837 4895 4838 4896 jiti@2.6.1: {} 4897 + 4898 + js-tokens@4.0.0: {} 4839 4899 4840 4900 js-yaml@4.1.1: 4841 4901 dependencies: ··· 4942 5002 4943 5003 lodash.merge@4.6.2: {} 4944 5004 5005 + loose-envify@1.4.0: 5006 + dependencies: 5007 + js-tokens: 4.0.0 5008 + 4945 5009 lz-string@1.5.0: {} 4946 5010 4947 5011 maath@0.10.8(@types/three@0.176.0)(three@0.176.0): ··· 5066 5130 number-flow@0.5.9: 5067 5131 dependencies: 5068 5132 esm-env: 1.2.2 5133 + 5134 + object-assign@4.1.1: {} 5069 5135 5070 5136 obug@2.1.1: {} 5071 5137 ··· 5200 5266 5201 5267 prettier@3.8.1: {} 5202 5268 5269 + prop-types@15.8.1: 5270 + dependencies: 5271 + loose-envify: 1.4.0 5272 + object-assign: 4.1.1 5273 + react-is: 16.13.1 5274 + 5203 5275 prosemirror-changeset@2.3.1: 5204 5276 dependencies: 5205 5277 prosemirror-transform: 1.11.0 ··· 5319 5391 5320 5392 rangetouch@2.0.1: {} 5321 5393 5394 + react-dom@19.2.4(react@19.2.4): 5395 + dependencies: 5396 + react: 19.2.4 5397 + scheduler: 0.27.0 5398 + 5399 + react-draggable@4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): 5400 + dependencies: 5401 + clsx: 2.1.1 5402 + prop-types: 15.8.1 5403 + react: 19.2.4 5404 + react-dom: 19.2.4(react@19.2.4) 5405 + 5406 + react-grid-layout@2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): 5407 + dependencies: 5408 + clsx: 2.1.1 5409 + fast-equals: 4.0.3 5410 + prop-types: 15.8.1 5411 + react: 19.2.4 5412 + react-dom: 19.2.4(react@19.2.4) 5413 + react-draggable: 4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 5414 + react-resizable: 3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 5415 + resize-observer-polyfill: 1.5.1 5416 + 5417 + react-is@16.13.1: {} 5418 + 5419 + react-resizable@3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): 5420 + dependencies: 5421 + prop-types: 15.8.1 5422 + react: 19.2.4 5423 + react-dom: 19.2.4(react@19.2.4) 5424 + react-draggable: 4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 5425 + 5426 + react@19.2.4: {} 5427 + 5322 5428 readdirp@4.1.2: {} 5323 5429 5324 5430 regexparam@3.0.0: {} 5325 5431 5326 5432 require-from-string@2.0.2: {} 5433 + 5434 + resize-observer-polyfill@1.5.1: {} 5327 5435 5328 5436 resolve-from@4.0.0: {} 5329 5437 ··· 5412 5520 parse-css-color: 0.2.1 5413 5521 postcss-value-parser: 4.2.0 5414 5522 yoga-wasm-web: 0.3.3 5523 + 5524 + scheduler@0.27.0: {} 5415 5525 5416 5526 semver@7.7.3: {} 5417 5527
+1 -11
src/lib/cards/_base/BaseCard/BaseCard.svelte
··· 5 5 import type { Snippet } from 'svelte'; 6 6 import type { HTMLAttributes } from 'svelte/elements'; 7 7 import { getColor } from '../..'; 8 - import { getIsCoarse } from '$lib/website/context'; 9 - 10 - function tryGetIsCoarse(): (() => boolean) | undefined { 11 - try { 12 - return getIsCoarse(); 13 - } catch { 14 - return undefined; 15 - } 16 - } 17 - const isCoarse = tryGetIsCoarse(); 18 8 19 9 const colors = { 20 10 base: 'bg-base-200/50 dark:bg-base-950/50', ··· 49 39 id={item.id} 50 40 data-flip-id={item.id} 51 41 bind:this={ref} 52 - draggable={isEditing && !locked && !isCoarse?.()} 42 + draggable={false} 53 43 class={[ 54 44 'card group/card selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card absolute isolate z-0 rounded-3xl outline-offset-2 transition-all duration-200 focus-within:outline-2', 55 45 color ? (colors[color] ?? colors.accent) : colors.base,
+4 -7
src/lib/cards/_base/BaseCard/BaseEditingCard.svelte
··· 15 15 getSelectCard 16 16 } from '$lib/website/context'; 17 17 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 18 - import { fixAllCollisions, fixCollisions } from '$lib/helper'; 19 18 20 19 let colorsChoices = [ 21 20 { class: 'text-base-500', label: 'base' }, ··· 194 193 ]} 195 194 {...rest} 196 195 > 197 - {#if isCoarse?.() ? !isSelected : !item.cardData?.locked} 196 + {#if isCoarse?.() && !isSelected} 198 197 <!-- svelte-ignore a11y_click_events_have_key_events --> 199 198 <div 200 199 role="button" 201 200 tabindex="-1" 202 - class={['absolute inset-0', isCoarse?.() ? 'z-20 cursor-pointer' : 'cursor-grab']} 201 + class="absolute inset-0 z-20 cursor-pointer" 203 202 onclick={(e) => { 204 - if (isCoarse?.()) { 205 - e.stopPropagation(); 206 - selectCard?.(item.id); 207 - } 203 + e.stopPropagation(); 204 + selectCard?.(item.id); 208 205 }} 209 206 ></div> 210 207 {/if}
+9 -353
src/lib/helper.ts
··· 3 3 import { CardDefinitionsByType } from './cards'; 4 4 import { deleteRecord, getCDNImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; 5 5 import * as TID from '@atcute/tid'; 6 - 7 6 export function clamp(value: number, min: number, max: number): number { 8 7 return Math.min(Math.max(value, min), max); 9 8 } ··· 28 27 'bg-rose-500' 29 28 ]; 30 29 31 - export const overlaps = (a: Item, b: Item, mobile: boolean = false) => { 32 - if (a === b) return false; 33 - if (mobile) { 34 - return ( 35 - a.mobileX < b.mobileX + b.mobileW && 36 - a.mobileX + a.mobileW > b.mobileX && 37 - a.mobileY < b.mobileY + b.mobileH && 38 - a.mobileY + a.mobileH > b.mobileY 39 - ); 40 - } 41 - return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; 42 - }; 43 - 44 - export function fixCollisions( 45 - items: Item[], 46 - movedItem: Item, 47 - mobile: boolean = false, 48 - skipCompact: boolean = false 49 - ) { 50 - const clampX = (item: Item) => { 51 - if (mobile) item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); 52 - else item.x = clamp(item.x, 0, COLUMNS - item.w); 53 - }; 54 - 55 - // Push `target` down until it no longer overlaps with any item (including movedItem), 56 - // while keeping target.x fixed. Any item we collide with gets pushed down first (cascade). 57 - const pushDownCascade = (target: Item, blocker: Item) => { 58 - // Keep x fixed always when pushing down 59 - const fixedX = mobile ? target.mobileX : target.x; 60 - const prevY = mobile ? target.mobileY : target.y; 61 - 62 - // We need target to move just below `blocker` 63 - const desiredY = mobile ? blocker.mobileY + blocker.mobileH : blocker.y + blocker.h; 64 - if (!mobile && target.y < desiredY) target.y = desiredY; 65 - if (mobile && target.mobileY < desiredY) target.mobileY = desiredY; 66 - 67 - const newY = mobile ? target.mobileY : target.y; 68 - const targetH = mobile ? target.mobileH : target.h; 69 - 70 - // fall trough fix 71 - if (newY > prevY) { 72 - const prevBottom = prevY + targetH; 73 - const newBottom = newY + targetH; 74 - for (const it of items) { 75 - if (it === target || it === movedItem || it === blocker) continue; 76 - const itY = mobile ? it.mobileY : it.y; 77 - const itH = mobile ? it.mobileH : it.h; 78 - const itBottom = itY + itH; 79 - if (itBottom <= prevBottom || itY >= newBottom) continue; 80 - // horizontal overlap check 81 - const hOverlap = mobile 82 - ? target.mobileX < it.mobileX + it.mobileW && target.mobileX + target.mobileW > it.mobileX 83 - : target.x < it.x + it.w && target.x + target.w > it.x; 84 - if (hOverlap) { 85 - pushDownCascade(it, target); 86 - } 87 - } 88 - } 89 - 90 - // Now resolve any collisions that creates by pushing those items down first 91 - // Repeat until target is clean. 92 - while (true) { 93 - const hit = items.find((it) => it !== target && overlaps(target, it, mobile)); 94 - if (!hit) break; 95 - 96 - // push the hit item down first (cascade), keeping its x fixed 97 - pushDownCascade(hit, target); 98 - 99 - // after moving the hit item, target.x must remain fixed 100 - if (mobile) target.mobileX = fixedX; 101 - else target.x = fixedX; 102 - } 103 - }; 104 - 105 - // Ensure moved item is in bounds 106 - clampX(movedItem); 107 - 108 - // Find all items colliding with movedItem, and push them down in a stable order: 109 - // top-to-bottom so you get the nice chain reaction (0,0 -> 0,1 -> 0,2). 110 - const colliders = items 111 - .filter((it) => it !== movedItem && overlaps(movedItem, it, mobile)) 112 - .toSorted((a, b) => 113 - mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x 114 - ); 115 - 116 - for (const it of colliders) { 117 - // keep x clamped, but do NOT change x during push (we rely on fixed x) 118 - clampX(it); 119 - 120 - // push it down just below movedItem; cascade handles the rest 121 - pushDownCascade(it, movedItem); 122 - 123 - // enforce "x stays the same" during pushing (clamp already applied) 124 - if (mobile) it.mobileX = clamp(it.mobileX, 0, COLUMNS - it.mobileW); 125 - else it.x = clamp(it.x, 0, COLUMNS - it.w); 126 - } 127 - 128 - if (!skipCompact) { 129 - compactItems(items, mobile); 130 - } 131 - } 132 - 133 - // Fix all collisions between items (not just one moved item) 134 - // Items higher on the page have priority and stay in place 135 - export function fixAllCollisions(items: Item[], mobile: boolean = false) { 136 - // Sort by Y position (top-to-bottom, then left-to-right) 137 - // Items at the top have priority and won't be moved 138 - const sortedItems = items.toSorted((a, b) => 139 - mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x 140 - ); 141 - 142 - // Process each item and push it down if it overlaps with any item above it 143 - for (let i = 0; i < sortedItems.length; i++) { 144 - const item = sortedItems[i]; 145 - 146 - // Clamp X to valid range 147 - if (mobile) { 148 - item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); 149 - } else { 150 - item.x = clamp(item.x, 0, COLUMNS - item.w); 151 - } 152 - 153 - // Check for collisions with all items that come before (higher priority) 154 - let hasCollision = true; 155 - while (hasCollision) { 156 - hasCollision = false; 157 - for (let j = 0; j < i; j++) { 158 - const other = sortedItems[j]; 159 - if (overlaps(item, other, mobile)) { 160 - // Push item down below the colliding item 161 - if (mobile) { 162 - item.mobileY = other.mobileY + other.mobileH; 163 - } else { 164 - item.y = other.y + other.h; 165 - } 166 - hasCollision = true; 167 - break; // Restart collision check from the beginning 168 - } 169 - } 170 - } 171 - } 172 - 173 - compactItems(items, mobile); 174 - } 175 - 176 - // Move all items up as far as possible without collisions 177 - export function compactItems(items: Item[], mobile: boolean = false) { 178 - // Sort by Y position (top-to-bottom) so upper items settle first. 179 - const sortedItems = items.toSorted((a, b) => 180 - mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x 181 - ); 182 - 183 - for (const item of sortedItems) { 184 - // Try moving item up row by row until we hit y=0 or a collision 185 - while (true) { 186 - const currentY = mobile ? item.mobileY : item.y; 187 - if (currentY <= 0) break; 188 - 189 - // Temporarily move up by 1 190 - if (mobile) item.mobileY -= 1; 191 - else item.y -= 1; 192 - 193 - // Check for collision with any other item 194 - const hasCollision = items.some((other) => other !== item && overlaps(item, other, mobile)); 195 - 196 - if (hasCollision) { 197 - // Revert the move 198 - if (mobile) item.mobileY += 1; 199 - else item.y += 1; 200 - break; 201 - } 202 - // No collision, keep the new position and try moving up again 203 - } 204 - } 205 - } 206 - 207 - // Simulate where an item would end up after fixCollisions + compaction 208 - export function simulateFinalPosition( 209 - items: Item[], 210 - movedItem: Item, 211 - newX: number, 212 - newY: number, 213 - mobile: boolean = false 214 - ): { x: number; y: number } { 215 - // Deep clone positions for simulation 216 - const clonedItems: Item[] = items.map((item) => ({ 217 - ...item, 218 - x: item.x, 219 - y: item.y, 220 - mobileX: item.mobileX, 221 - mobileY: item.mobileY 222 - })); 223 - 224 - const clonedMovedItem = clonedItems.find((item) => item.id === movedItem.id); 225 - if (!clonedMovedItem) return { x: newX, y: newY }; 226 - 227 - // Set the new position 228 - if (mobile) { 229 - clonedMovedItem.mobileX = newX; 230 - clonedMovedItem.mobileY = newY; 231 - } else { 232 - clonedMovedItem.x = newX; 233 - clonedMovedItem.y = newY; 234 - } 235 - 236 - // Run fixCollisions on the cloned data 237 - fixCollisions(clonedItems, clonedMovedItem, mobile); 238 - 239 - // Return the final position of the moved item 240 - return mobile 241 - ? { x: clonedMovedItem.mobileX, y: clonedMovedItem.mobileY } 242 - : { x: clonedMovedItem.x, y: clonedMovedItem.y }; 243 - } 244 - 245 30 export function sortItems(a: Item, b: Item) { 246 31 return a.y * COLUMNS + a.x - b.y * COLUMNS - b.x; 247 32 } ··· 264 49 ); 265 50 } 266 51 267 - export function setPositionOfNewItem( 268 - newItem: Item, 269 - items: Item[], 270 - viewportCenter?: { gridY: number; isMobile: boolean } 271 - ) { 272 - if (viewportCenter) { 273 - const { gridY, isMobile } = viewportCenter; 274 - 275 - if (isMobile) { 276 - // Place at viewport center Y 277 - newItem.mobileY = Math.max(0, Math.round(gridY - newItem.mobileH / 2)); 278 - newItem.mobileY = Math.floor(newItem.mobileY / 2) * 2; 279 - 280 - // Try to find a free X at this Y 281 - let found = false; 282 - for ( 283 - newItem.mobileX = 0; 284 - newItem.mobileX <= COLUMNS - newItem.mobileW; 285 - newItem.mobileX += 2 286 - ) { 287 - if (!items.some((item) => overlaps(newItem, item, true))) { 288 - found = true; 289 - break; 290 - } 291 - } 292 - if (!found) { 293 - newItem.mobileX = 0; 294 - } 295 - 296 - // Desktop: derive from mobile 297 - newItem.y = Math.max(0, Math.round(newItem.mobileY / 2)); 298 - found = false; 299 - for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 300 - if (!items.some((item) => overlaps(newItem, item, false))) { 301 - found = true; 302 - break; 303 - } 304 - } 305 - if (!found) { 306 - newItem.x = 0; 307 - } 308 - } else { 309 - // Place at viewport center Y 310 - newItem.y = Math.max(0, Math.round(gridY - newItem.h / 2)); 311 - 312 - // Try to find a free X at this Y 313 - let found = false; 314 - for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 315 - if (!items.some((item) => overlaps(newItem, item, false))) { 316 - found = true; 317 - break; 318 - } 319 - } 320 - if (!found) { 321 - newItem.x = 0; 322 - } 323 - 324 - // Mobile: derive from desktop 325 - newItem.mobileY = Math.max(0, Math.round(newItem.y * 2)); 326 - found = false; 327 - for ( 328 - newItem.mobileX = 0; 329 - newItem.mobileX <= COLUMNS - newItem.mobileW; 330 - newItem.mobileX += 2 331 - ) { 332 - if (!items.some((item) => overlaps(newItem, item, true))) { 333 - found = true; 334 - break; 335 - } 336 - } 337 - if (!found) { 338 - newItem.mobileX = 0; 339 - } 340 - } 341 - return; 342 - } 343 - 344 - let foundPosition = false; 345 - while (!foundPosition) { 346 - for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { 347 - const collision = items.find((item) => overlaps(newItem, item)); 348 - if (!collision) { 349 - foundPosition = true; 350 - break; 351 - } 352 - } 353 - if (!foundPosition) newItem.y += 1; 354 - } 355 - 356 - let foundMobilePosition = false; 357 - while (!foundMobilePosition) { 358 - for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX += 1) { 359 - const collision = items.find((item) => overlaps(newItem, item, true)); 360 - 361 - if (!collision) { 362 - foundMobilePosition = true; 363 - break; 364 - } 365 - } 366 - if (!foundMobilePosition) newItem.mobileY! += 1; 367 - } 368 - } 369 - 370 - /** 371 - * Find a valid position for a new item in a single mode (desktop or mobile). 372 - * This modifies the item's position properties in-place. 373 - */ 374 - export function findValidPosition(newItem: Item, items: Item[], mobile: boolean) { 375 - if (mobile) { 376 - let foundPosition = false; 377 - newItem.mobileY = 0; 378 - while (!foundPosition) { 379 - for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX++) { 380 - const collision = items.find((item) => overlaps(newItem, item, true)); 381 - if (!collision) { 382 - foundPosition = true; 383 - break; 384 - } 385 - } 386 - if (!foundPosition) newItem.mobileY! += 1; 387 - } 388 - } else { 389 - let foundPosition = false; 390 - newItem.y = 0; 391 - while (!foundPosition) { 392 - for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { 393 - const collision = items.find((item) => overlaps(newItem, item, false)); 394 - if (!collision) { 395 - foundPosition = true; 396 - break; 397 - } 398 - } 399 - if (!foundPosition) newItem.y += 1; 400 - } 401 - } 402 - } 403 - 404 52 export async function refreshData(data: { updatedAt?: number; handle: string }) { 405 53 const TEN_MINUTES = 10 * 60 * 1000; 406 54 const now = Date.now(); ··· 553 201 originalPublication: string 554 202 ) { 555 203 const promises = []; 204 + 205 + // Build a lookup of original cards by ID for O(1) access 206 + const originalCardsById = new Map<string, Item>(); 207 + for (const card of data.cards) { 208 + originalCardsById.set(card.id, card); 209 + } 210 + 556 211 // find all cards that have been updated (where items differ from originalItems) 557 212 for (let item of currentItems) { 558 - const originalItem = data.cards.find((i) => cardsEqual(i, item)); 213 + const orig = originalCardsById.get(item.id); 214 + const originalItem = orig && cardsEqual(orig, item) ? orig : undefined; 559 215 560 216 if (!originalItem) { 561 217 console.log('updated or new item', item);
+398
src/lib/layout/EditableGrid.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte'; 3 + import type { Item } from '$lib/types'; 4 + import { getGridPosition, pixelToGrid, type DragState, type GridPosition } from './grid'; 5 + import { fixCollisions } from './algorithms'; 6 + 7 + let { 8 + items = $bindable(), 9 + isMobile, 10 + selectedCardId, 11 + isCoarse, 12 + children, 13 + ref = $bindable<HTMLDivElement | undefined>(undefined), 14 + onlayoutchange, 15 + ondeselect, 16 + onfiledrop 17 + }: { 18 + items: Item[]; 19 + isMobile: boolean; 20 + selectedCardId: string | null; 21 + isCoarse: boolean; 22 + children: Snippet; 23 + ref?: HTMLDivElement | undefined; 24 + onlayoutchange: () => void; 25 + ondeselect: () => void; 26 + onfiledrop?: (files: File[], gridX: number, gridY: number) => void; 27 + } = $props(); 28 + 29 + // Internal container ref (synced with bindable ref) 30 + let container: HTMLDivElement | undefined = $state(); 31 + $effect(() => { 32 + ref = container; 33 + }); 34 + 35 + const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 36 + const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 37 + let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 38 + 39 + // --- Drag state --- 40 + type Phase = 'idle' | 'pending' | 'active'; 41 + 42 + let phase: Phase = $state('idle'); 43 + let pointerId: number = $state(0); 44 + let startClientX = $state(0); 45 + let startClientY = $state(0); 46 + 47 + let dragState: DragState = $state({ 48 + item: null as unknown as Item, 49 + mouseDeltaX: 0, 50 + mouseDeltaY: 0, 51 + originalPositions: new Map(), 52 + lastTargetId: null, 53 + lastPlacement: null 54 + }); 55 + 56 + let lastGridPos: GridPosition | null = $state(null); 57 + 58 + // Ref to the dragged card DOM element (for visual feedback) 59 + let draggedCardEl: HTMLElement | null = null; 60 + 61 + // --- File drag state --- 62 + let fileDragOver = $state(false); 63 + 64 + // --- Pointer event handlers --- 65 + 66 + function handlePointerDown(e: PointerEvent) { 67 + if (phase !== 'idle') return; 68 + 69 + const cardEl = (e.target as HTMLElement)?.closest?.('.card') as HTMLDivElement | null; 70 + if (!cardEl) return; 71 + 72 + // On touch devices, only drag the selected card 73 + if (e.pointerType === 'touch' && cardEl.id !== selectedCardId) return; 74 + 75 + // On mouse, don't intercept interactive elements 76 + if (e.pointerType === 'mouse') { 77 + const tag = (e.target as HTMLElement)?.tagName; 78 + if ( 79 + tag === 'BUTTON' || 80 + tag === 'INPUT' || 81 + tag === 'TEXTAREA' || 82 + (e.target as HTMLElement)?.isContentEditable 83 + ) { 84 + return; 85 + } 86 + } 87 + 88 + const item = items.find((i) => i.id === cardEl.id); 89 + if (!item || item.cardData?.locked) return; 90 + 91 + phase = 'pending'; 92 + pointerId = e.pointerId; 93 + startClientX = e.clientX; 94 + startClientY = e.clientY; 95 + draggedCardEl = cardEl; 96 + 97 + // Pre-compute mouse delta from card rect 98 + const rect = cardEl.getBoundingClientRect(); 99 + dragState.item = item; 100 + dragState.mouseDeltaX = rect.left - e.clientX; 101 + dragState.mouseDeltaY = rect.top - e.clientY; 102 + 103 + document.addEventListener('pointermove', handlePointerMove); 104 + document.addEventListener('pointerup', handlePointerUp); 105 + document.addEventListener('pointercancel', handlePointerCancel); 106 + } 107 + 108 + function activateDrag(e: PointerEvent) { 109 + phase = 'active'; 110 + 111 + try { 112 + (e.target as HTMLElement)?.setPointerCapture?.(pointerId); 113 + } catch { 114 + // setPointerCapture can throw if pointer is already released 115 + } 116 + 117 + // Visual feedback: lift the dragged card 118 + draggedCardEl?.classList.add('dragging'); 119 + 120 + // Store original positions of all items 121 + dragState.originalPositions = new Map(); 122 + for (const it of items) { 123 + dragState.originalPositions.set(it.id, { 124 + x: it.x, 125 + y: it.y, 126 + mobileX: it.mobileX, 127 + mobileY: it.mobileY 128 + }); 129 + } 130 + dragState.lastTargetId = null; 131 + dragState.lastPlacement = null; 132 + 133 + document.body.style.userSelect = 'none'; 134 + } 135 + 136 + function handlePointerMove(e: PointerEvent) { 137 + if (!container) return; 138 + 139 + if (phase === 'pending') { 140 + // Check 3px threshold 141 + const dx = e.clientX - startClientX; 142 + const dy = e.clientY - startClientY; 143 + if (dx * dx + dy * dy < 9) return; 144 + activateDrag(e); 145 + } 146 + 147 + if (phase !== 'active') return; 148 + 149 + // Auto-scroll near edges 150 + const scrollZone = 100; 151 + const scrollSpeed = 10; 152 + const viewportHeight = window.innerHeight; 153 + 154 + if (e.clientY < scrollZone) { 155 + const intensity = 1 - e.clientY / scrollZone; 156 + window.scrollBy(0, -scrollSpeed * intensity); 157 + } else if (e.clientY > viewportHeight - scrollZone) { 158 + const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; 159 + window.scrollBy(0, scrollSpeed * intensity); 160 + } 161 + 162 + const result = getGridPosition(e.clientX, e.clientY, container, dragState, items, isMobile); 163 + if (!result || !dragState.item) return; 164 + 165 + // Skip redundant work if grid position hasn't changed 166 + if ( 167 + lastGridPos && 168 + lastGridPos.x === result.x && 169 + lastGridPos.y === result.y && 170 + lastGridPos.swapWithId === result.swapWithId && 171 + lastGridPos.placement === result.placement 172 + ) { 173 + return; 174 + } 175 + lastGridPos = result; 176 + 177 + const draggedOrigPos = dragState.originalPositions.get(dragState.item.id); 178 + 179 + // Reset all items to original positions first 180 + for (const it of items) { 181 + const origPos = dragState.originalPositions.get(it.id); 182 + if (origPos && it !== dragState.item) { 183 + if (isMobile) { 184 + it.mobileX = origPos.mobileX; 185 + it.mobileY = origPos.mobileY; 186 + } else { 187 + it.x = origPos.x; 188 + it.y = origPos.y; 189 + } 190 + } 191 + } 192 + 193 + // Update dragged item position 194 + if (isMobile) { 195 + dragState.item.mobileX = result.x; 196 + dragState.item.mobileY = result.y; 197 + } else { 198 + dragState.item.x = result.x; 199 + dragState.item.y = result.y; 200 + } 201 + 202 + // Handle horizontal swap 203 + if (result.swapWithId && draggedOrigPos) { 204 + const swapTarget = items.find((it) => it.id === result.swapWithId); 205 + if (swapTarget) { 206 + if (isMobile) { 207 + swapTarget.mobileX = draggedOrigPos.mobileX; 208 + swapTarget.mobileY = draggedOrigPos.mobileY; 209 + } else { 210 + swapTarget.x = draggedOrigPos.x; 211 + swapTarget.y = draggedOrigPos.y; 212 + } 213 + } 214 + } 215 + 216 + fixCollisions( 217 + items, 218 + dragState.item, 219 + isMobile, 220 + false, 221 + draggedOrigPos 222 + ? { 223 + x: isMobile ? draggedOrigPos.mobileX : draggedOrigPos.x, 224 + y: isMobile ? draggedOrigPos.mobileY : draggedOrigPos.y 225 + } 226 + : undefined 227 + ); 228 + } 229 + 230 + function handlePointerUp() { 231 + if (phase === 'active' && dragState.item) { 232 + fixCollisions(items, dragState.item, isMobile); 233 + onlayoutchange(); 234 + } 235 + cleanup(); 236 + } 237 + 238 + function handlePointerCancel() { 239 + if (phase === 'active') { 240 + // Restore all items to original positions 241 + for (const it of items) { 242 + const origPos = dragState.originalPositions.get(it.id); 243 + if (origPos) { 244 + it.x = origPos.x; 245 + it.y = origPos.y; 246 + it.mobileX = origPos.mobileX; 247 + it.mobileY = origPos.mobileY; 248 + } 249 + } 250 + } 251 + cleanup(); 252 + } 253 + 254 + function cleanup() { 255 + draggedCardEl?.classList.remove('dragging'); 256 + draggedCardEl = null; 257 + phase = 'idle'; 258 + lastGridPos = null; 259 + document.body.style.userSelect = ''; 260 + 261 + document.removeEventListener('pointermove', handlePointerMove); 262 + document.removeEventListener('pointerup', handlePointerUp); 263 + document.removeEventListener('pointercancel', handlePointerCancel); 264 + } 265 + 266 + // Ensure cleanup on unmount 267 + $effect(() => { 268 + return () => { 269 + if (phase !== 'idle') cleanup(); 270 + }; 271 + }); 272 + 273 + // For touch: register non-passive touchstart to prevent scroll when touching selected card 274 + $effect(() => { 275 + if (!container || !selectedCardId) return; 276 + container.addEventListener('touchstart', handleTouchStart, { passive: false }); 277 + return () => { 278 + container?.removeEventListener('touchstart', handleTouchStart); 279 + }; 280 + }); 281 + 282 + // For touch: register non-passive touchmove to prevent scroll during active drag 283 + $effect(() => { 284 + if (phase !== 'active' || !container) return; 285 + function preventTouch(e: TouchEvent) { 286 + e.preventDefault(); 287 + } 288 + container.addEventListener('touchmove', preventTouch, { passive: false }); 289 + return () => { 290 + container?.removeEventListener('touchmove', preventTouch); 291 + }; 292 + }); 293 + 294 + function handleClick(e: MouseEvent) { 295 + // Deselect when tapping empty grid space 296 + if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) { 297 + ondeselect(); 298 + } 299 + } 300 + 301 + function handleTouchStart(e: TouchEvent) { 302 + // On touch, prevent scrolling when touching the selected card 303 + // This must happen on touchstart (not pointerdown) to claim the gesture 304 + const cardEl = (e.target as HTMLElement)?.closest?.('.card') as HTMLElement | null; 305 + if (cardEl && cardEl.id === selectedCardId) { 306 + const item = items.find((i) => i.id === cardEl.id); 307 + if (item && !item.cardData?.locked) { 308 + e.preventDefault(); 309 + } 310 + } 311 + } 312 + 313 + // --- File drop handlers --- 314 + 315 + function hasImageFile(dt: DataTransfer): boolean { 316 + if (dt.items) { 317 + for (let i = 0; i < dt.items.length; i++) { 318 + const item = dt.items[i]; 319 + if (item && item.kind === 'file' && item.type.startsWith('image/')) { 320 + return true; 321 + } 322 + } 323 + } else if (dt.files) { 324 + for (let i = 0; i < dt.files.length; i++) { 325 + const file = dt.files[i]; 326 + if (file?.type.startsWith('image/')) { 327 + return true; 328 + } 329 + } 330 + } 331 + return false; 332 + } 333 + 334 + function handleFileDragOver(event: DragEvent) { 335 + const dt = event.dataTransfer; 336 + if (!dt) return; 337 + 338 + if (hasImageFile(dt)) { 339 + event.preventDefault(); 340 + event.stopPropagation(); 341 + fileDragOver = true; 342 + } 343 + } 344 + 345 + function handleFileDragLeave(event: DragEvent) { 346 + event.preventDefault(); 347 + event.stopPropagation(); 348 + fileDragOver = false; 349 + } 350 + 351 + function handleFileDrop(event: DragEvent) { 352 + event.preventDefault(); 353 + event.stopPropagation(); 354 + fileDragOver = false; 355 + 356 + if (!event.dataTransfer?.files?.length || !onfiledrop || !container) return; 357 + 358 + const imageFiles = Array.from(event.dataTransfer.files).filter((f) => 359 + f?.type.startsWith('image/') 360 + ); 361 + if (imageFiles.length === 0) return; 362 + 363 + const cardW = isMobile ? 4 : 2; 364 + const { gridX, gridY } = pixelToGrid(event.clientX, event.clientY, container, isMobile, cardW); 365 + 366 + onfiledrop(imageFiles, gridX, gridY); 367 + } 368 + </script> 369 + 370 + <svelte:window 371 + ondragover={handleFileDragOver} 372 + ondragleave={handleFileDragLeave} 373 + ondrop={handleFileDrop} 374 + /> 375 + 376 + <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 377 + <div 378 + bind:this={container} 379 + onpointerdown={handlePointerDown} 380 + onclick={handleClick} 381 + ondragstart={(e) => e.preventDefault()} 382 + class={[ 383 + '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 384 + fileDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' 385 + ]} 386 + > 387 + {@render children()} 388 + 389 + <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> 390 + </div> 391 + 392 + <style> 393 + :global(.card.dragging) { 394 + z-index: 50 !important; 395 + scale: 1.03; 396 + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); 397 + } 398 + </style>
+254
src/lib/layout/algorithms.ts
··· 1 + import { type LayoutItem, type Layout } from 'react-grid-layout/core'; 2 + import { 3 + collides, 4 + moveElement, 5 + correctBounds, 6 + getFirstCollision, 7 + verticalCompactor 8 + } from 'react-grid-layout/core'; 9 + import type { Item } from '../types'; 10 + import { COLUMNS } from '$lib'; 11 + import { clamp } from '../helper'; 12 + 13 + function toLayoutItem(item: Item, mobile: boolean): LayoutItem { 14 + if (mobile) { 15 + return { 16 + x: item.mobileX, 17 + y: item.mobileY, 18 + w: item.mobileW, 19 + h: item.mobileH, 20 + i: item.id 21 + }; 22 + } 23 + return { 24 + x: item.x, 25 + y: item.y, 26 + w: item.w, 27 + h: item.h, 28 + i: item.id 29 + }; 30 + } 31 + 32 + function toLayout(items: Item[], mobile: boolean): LayoutItem[] { 33 + return items.map((i) => toLayoutItem(i, mobile)); 34 + } 35 + 36 + function applyLayout(items: Item[], layout: LayoutItem[], mobile: boolean): void { 37 + const itemsMap: Map<string, Item> = new Map(); 38 + 39 + for (const item of items) { 40 + itemsMap.set(item.id, item); 41 + } 42 + for (const l of layout) { 43 + const item = itemsMap.get(l.i); 44 + 45 + if (!item) { 46 + console.error('item not found in layout!! this should never happen!'); 47 + continue; 48 + } 49 + 50 + if (mobile) { 51 + item.mobileX = l.x; 52 + item.mobileY = l.y; 53 + } else { 54 + item.x = l.x; 55 + item.y = l.y; 56 + } 57 + } 58 + } 59 + 60 + export function overlaps(a: Item, b: Item, mobile: boolean) { 61 + if (a === b) return false; 62 + return collides(toLayoutItem(a, mobile), toLayoutItem(b, mobile)); 63 + } 64 + 65 + export function fixCollisions( 66 + items: Item[], 67 + item: Item, 68 + mobile: boolean = false, 69 + skipCompact: boolean = false, 70 + originalPos?: { x: number; y: number } 71 + ) { 72 + if (mobile) item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); 73 + else item.x = clamp(item.x, 0, COLUMNS - item.w); 74 + 75 + const targetX = mobile ? item.mobileX : item.x; 76 + const targetY = mobile ? item.mobileY : item.y; 77 + 78 + let layout = toLayout(items, mobile); 79 + 80 + const movedLayoutItem = layout.find((i) => i.i === item.id); 81 + 82 + if (!movedLayoutItem) { 83 + console.error('item not found in layout! this should never happen!'); 84 + return; 85 + } 86 + 87 + // If we know the original position, set it on the layout item so 88 + // moveElement can detect direction and push items properly. 89 + if (originalPos) { 90 + movedLayoutItem.x = originalPos.x; 91 + movedLayoutItem.y = originalPos.y; 92 + } 93 + 94 + layout = moveElement(layout, movedLayoutItem, targetX, targetY, true, false, 'vertical', COLUMNS); 95 + 96 + if (!skipCompact) layout = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; 97 + 98 + applyLayout(items, layout, mobile); 99 + } 100 + 101 + export function fixAllCollisions(items: Item[], mobile: boolean) { 102 + let layout = toLayout(items, mobile); 103 + correctBounds(layout as any, { cols: COLUMNS }); 104 + layout = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; 105 + applyLayout(items, layout, mobile); 106 + } 107 + 108 + export function compactItems(items: Item[], mobile: boolean) { 109 + const layout = toLayout(items, mobile); 110 + const compacted = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; 111 + applyLayout(items, compacted, mobile); 112 + } 113 + 114 + export function setPositionOfNewItem( 115 + newItem: Item, 116 + items: Item[], 117 + viewportCenter?: { gridY: number; isMobile: boolean } 118 + ) { 119 + const desktopLayout = toLayout(items, false); 120 + const mobileLayout = toLayout(items, true); 121 + 122 + function hasCollision(mobile: boolean): boolean { 123 + const layout = mobile ? mobileLayout : desktopLayout; 124 + return getFirstCollision(layout, toLayoutItem(newItem, mobile)) !== undefined; 125 + } 126 + 127 + if (viewportCenter) { 128 + const { gridY, isMobile } = viewportCenter; 129 + 130 + if (isMobile) { 131 + // Place at viewport center Y 132 + newItem.mobileY = Math.max(0, Math.round(gridY - newItem.mobileH / 2)); 133 + newItem.mobileY = Math.floor(newItem.mobileY / 2) * 2; 134 + 135 + // Try to find a free X at this Y 136 + let found = false; 137 + for ( 138 + newItem.mobileX = 0; 139 + newItem.mobileX <= COLUMNS - newItem.mobileW; 140 + newItem.mobileX += 2 141 + ) { 142 + if (!hasCollision(true)) { 143 + found = true; 144 + break; 145 + } 146 + } 147 + if (!found) { 148 + newItem.mobileX = 0; 149 + } 150 + 151 + // Desktop: derive from mobile 152 + newItem.y = Math.max(0, Math.round(newItem.mobileY / 2)); 153 + found = false; 154 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 155 + if (!hasCollision(false)) { 156 + found = true; 157 + break; 158 + } 159 + } 160 + if (!found) { 161 + newItem.x = 0; 162 + } 163 + } else { 164 + // Place at viewport center Y 165 + newItem.y = Math.max(0, Math.round(gridY - newItem.h / 2)); 166 + 167 + // Try to find a free X at this Y 168 + let found = false; 169 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 170 + if (!hasCollision(false)) { 171 + found = true; 172 + break; 173 + } 174 + } 175 + if (!found) { 176 + newItem.x = 0; 177 + } 178 + 179 + // Mobile: derive from desktop 180 + newItem.mobileY = Math.max(0, Math.round(newItem.y * 2)); 181 + found = false; 182 + for ( 183 + newItem.mobileX = 0; 184 + newItem.mobileX <= COLUMNS - newItem.mobileW; 185 + newItem.mobileX += 2 186 + ) { 187 + if (!hasCollision(true)) { 188 + found = true; 189 + break; 190 + } 191 + } 192 + if (!found) { 193 + newItem.mobileX = 0; 194 + } 195 + } 196 + return; 197 + } 198 + 199 + let foundPosition = false; 200 + while (!foundPosition) { 201 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { 202 + if (!hasCollision(false)) { 203 + foundPosition = true; 204 + break; 205 + } 206 + } 207 + if (!foundPosition) newItem.y += 1; 208 + } 209 + 210 + let foundMobilePosition = false; 211 + while (!foundMobilePosition) { 212 + for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX += 1) { 213 + if (!hasCollision(true)) { 214 + foundMobilePosition = true; 215 + break; 216 + } 217 + } 218 + if (!foundMobilePosition) newItem.mobileY! += 1; 219 + } 220 + } 221 + 222 + /** 223 + * Find a valid position for a new item in a single mode (desktop or mobile). 224 + * This modifies the item's position properties in-place. 225 + */ 226 + export function findValidPosition(newItem: Item, items: Item[], mobile: boolean) { 227 + const layout = toLayout(items, mobile); 228 + 229 + if (mobile) { 230 + let foundPosition = false; 231 + newItem.mobileY = 0; 232 + while (!foundPosition) { 233 + for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX++) { 234 + if (!getFirstCollision(layout, toLayoutItem(newItem, true))) { 235 + foundPosition = true; 236 + break; 237 + } 238 + } 239 + if (!foundPosition) newItem.mobileY! += 1; 240 + } 241 + } else { 242 + let foundPosition = false; 243 + newItem.y = 0; 244 + while (!foundPosition) { 245 + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { 246 + if (!getFirstCollision(layout, toLayoutItem(newItem, false))) { 247 + foundPosition = true; 248 + break; 249 + } 250 + } 251 + if (!foundPosition) newItem.y += 1; 252 + } 253 + } 254 + }
+190
src/lib/layout/grid.ts
··· 1 + import { COLUMNS, margin, mobileMargin } from '$lib'; 2 + import { clamp } from '$lib/helper'; 3 + import type { Item } from '$lib/types'; 4 + 5 + export type GridPosition = { 6 + x: number; 7 + y: number; 8 + swapWithId: string | null; 9 + placement: 'above' | 'below' | null; 10 + }; 11 + 12 + export type DragState = { 13 + item: Item; 14 + mouseDeltaX: number; 15 + mouseDeltaY: number; 16 + originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>; 17 + lastTargetId: string | null; 18 + lastPlacement: 'above' | 'below' | null; 19 + }; 20 + 21 + /** 22 + * Convert client coordinates to a grid position with swap detection and hysteresis. 23 + * Returns undefined if container or dragState.item is missing. 24 + * Mutates dragState.lastTargetId and dragState.lastPlacement for hysteresis tracking. 25 + */ 26 + export function getGridPosition( 27 + clientX: number, 28 + clientY: number, 29 + container: HTMLElement, 30 + dragState: DragState, 31 + items: Item[], 32 + isMobile: boolean 33 + ): GridPosition | undefined { 34 + if (!dragState.item) return; 35 + 36 + // x, y represent the top-left corner of the dragged card 37 + const x = clientX + dragState.mouseDeltaX; 38 + const y = clientY + dragState.mouseDeltaY; 39 + 40 + const rect = container.getBoundingClientRect(); 41 + const currentMargin = isMobile ? mobileMargin : margin; 42 + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 43 + 44 + // Get card dimensions based on current view mode 45 + const cardW = isMobile ? dragState.item.mobileW : dragState.item.w; 46 + const cardH = isMobile ? dragState.item.mobileH : dragState.item.h; 47 + 48 + // Get dragged card's original position 49 + const draggedOrigPos = dragState.originalPositions.get(dragState.item.id); 50 + const draggedOrigY = draggedOrigPos ? (isMobile ? draggedOrigPos.mobileY : draggedOrigPos.y) : 0; 51 + 52 + // Calculate raw grid position based on top-left of dragged card 53 + let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 54 + gridX = Math.floor(gridX / 2) * 2; 55 + 56 + let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0); 57 + 58 + if (isMobile) { 59 + gridX = Math.floor(gridX / 2) * 2; 60 + gridY = Math.floor(gridY / 2) * 2; 61 + } 62 + 63 + // Find if we're hovering over another card (using ORIGINAL positions) 64 + const centerGridY = gridY + cardH / 2; 65 + const centerGridX = gridX + cardW / 2; 66 + 67 + let swapWithId: string | null = null; 68 + let placement: 'above' | 'below' | null = null; 69 + 70 + for (const other of items) { 71 + if (other === dragState.item) continue; 72 + 73 + // Use original positions for hit testing 74 + const origPos = dragState.originalPositions.get(other.id); 75 + if (!origPos) continue; 76 + 77 + const otherX = isMobile ? origPos.mobileX : origPos.x; 78 + const otherY = isMobile ? origPos.mobileY : origPos.y; 79 + const otherW = isMobile ? other.mobileW : other.w; 80 + const otherH = isMobile ? other.mobileH : other.h; 81 + 82 + // Check if dragged card's center point is within this card's original bounds 83 + if ( 84 + centerGridX >= otherX && 85 + centerGridX < otherX + otherW && 86 + centerGridY >= otherY && 87 + centerGridY < otherY + otherH 88 + ) { 89 + // Check if this is a swap situation: 90 + // Cards have the same dimensions and are on the same row 91 + const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; 92 + 93 + if (canSwap) { 94 + // Swap positions 95 + swapWithId = other.id; 96 + gridX = otherX; 97 + gridY = otherY; 98 + placement = null; 99 + 100 + dragState.lastTargetId = other.id; 101 + dragState.lastPlacement = null; 102 + } else { 103 + // Vertical placement (above/below) 104 + // Detect drag direction: if dragging up, always place above 105 + const isDraggingUp = gridY < draggedOrigY; 106 + 107 + if (isDraggingUp) { 108 + // When dragging up, always place above 109 + placement = 'above'; 110 + } else { 111 + // When dragging down, use top/bottom half logic 112 + const midpointY = otherY + otherH / 2; 113 + const hysteresis = 0.3; 114 + 115 + if (dragState.lastTargetId === other.id && dragState.lastPlacement) { 116 + if (dragState.lastPlacement === 'above') { 117 + placement = centerGridY > midpointY + hysteresis ? 'below' : 'above'; 118 + } else { 119 + placement = centerGridY < midpointY - hysteresis ? 'above' : 'below'; 120 + } 121 + } else { 122 + placement = centerGridY < midpointY ? 'above' : 'below'; 123 + } 124 + } 125 + 126 + dragState.lastTargetId = other.id; 127 + dragState.lastPlacement = placement; 128 + 129 + if (placement === 'above') { 130 + gridY = otherY; 131 + } else { 132 + gridY = otherY + otherH; 133 + } 134 + } 135 + break; 136 + } 137 + } 138 + 139 + // If we're not over any card, clear the tracking 140 + if (!swapWithId && !placement) { 141 + dragState.lastTargetId = null; 142 + dragState.lastPlacement = null; 143 + } 144 + 145 + return { x: gridX, y: gridY, swapWithId, placement }; 146 + } 147 + 148 + /** 149 + * Get the grid Y coordinate at the viewport center. 150 + */ 151 + export function getViewportCenterGridY( 152 + container: HTMLElement, 153 + isMobile: boolean 154 + ): { gridY: number; isMobile: boolean } { 155 + const rect = container.getBoundingClientRect(); 156 + const currentMargin = isMobile ? mobileMargin : margin; 157 + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 158 + const viewportCenterY = window.innerHeight / 2; 159 + const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize; 160 + return { gridY, isMobile }; 161 + } 162 + 163 + /** 164 + * Convert pixel drop coordinates to grid position. Used for file drops. 165 + */ 166 + export function pixelToGrid( 167 + clientX: number, 168 + clientY: number, 169 + container: HTMLElement, 170 + isMobile: boolean, 171 + cardW: number 172 + ): { gridX: number; gridY: number } { 173 + const rect = container.getBoundingClientRect(); 174 + const currentMargin = isMobile ? mobileMargin : margin; 175 + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 176 + 177 + let gridX = clamp( 178 + Math.round((clientX - rect.left - currentMargin) / cellSize), 179 + 0, 180 + COLUMNS - cardW 181 + ); 182 + gridX = Math.floor(gridX / 2) * 2; 183 + 184 + let gridY = Math.max(Math.round((clientY - rect.top - currentMargin) / cellSize), 0); 185 + if (isMobile) { 186 + gridY = Math.floor(gridY / 2) * 2; 187 + } 188 + 189 + return { gridX, gridY }; 190 + }
+15
src/lib/layout/index.ts
··· 1 + export { 2 + overlaps, 3 + fixCollisions, 4 + fixAllCollisions, 5 + compactItems, 6 + setPositionOfNewItem, 7 + findValidPosition 8 + } from './algorithms'; 9 + 10 + export { shouldMirror, mirrorItemSize, mirrorLayout } from './mirror'; 11 + 12 + export { getGridPosition, getViewportCenterGridY, pixelToGrid } from './grid'; 13 + export type { GridPosition, DragState } from './grid'; 14 + 15 + export { default as EditableGrid } from './EditableGrid.svelte';
+73
src/lib/layout/mirror.ts
··· 1 + import { COLUMNS } from '$lib'; 2 + import { CardDefinitionsByType } from '$lib/cards'; 3 + import { clamp } from '$lib/helper'; 4 + import { fixAllCollisions, findValidPosition } from './algorithms'; 5 + import type { Item } from '$lib/types'; 6 + 7 + /** 8 + * Returns true when mirroring should still happen (i.e. user hasn't edited both layouts). 9 + * editedOn: 0/undefined = never, 1 = desktop only, 2 = mobile only, 3 = both 10 + */ 11 + export function shouldMirror(editedOn: number | undefined): boolean { 12 + return (editedOn ?? 0) !== 3; 13 + } 14 + 15 + /** Snap a value to the nearest even integer (min 2). */ 16 + function snapEven(v: number): number { 17 + return Math.max(2, Math.round(v / 2) * 2); 18 + } 19 + 20 + /** 21 + * Compute the other layout's size for a single item, preserving aspect ratio. 22 + * Clamps to the card definition's minW/maxW/minH/maxH if defined. 23 + * Mutates the item in-place. 24 + */ 25 + export function mirrorItemSize(item: Item, fromMobile: boolean): void { 26 + const def = CardDefinitionsByType[item.cardType]; 27 + 28 + if (fromMobile) { 29 + // Mobile โ†’ Desktop: halve both dimensions, then clamp to card def constraints 30 + // (constraints are in desktop units) 31 + item.w = clamp(snapEven(item.mobileW / 2), def?.minW ?? 2, def?.maxW ?? COLUMNS); 32 + item.h = clamp(Math.round(item.mobileH / 2), def?.minH ?? 1, def?.maxH ?? Infinity); 33 + } else { 34 + // Desktop โ†’ Mobile: double both dimensions 35 + // (don't apply card def constraints โ€” they're in desktop units) 36 + item.mobileW = Math.min(item.w * 2, COLUMNS); 37 + item.mobileH = Math.max(item.h * 2, 2); 38 + } 39 + } 40 + 41 + /** 42 + * Mirror the full layout from one view to the other. 43 + * Copies sizes proportionally and maps positions, then resolves collisions. 44 + * Mutates items in-place. 45 + */ 46 + export function mirrorLayout(items: Item[], fromMobile: boolean): void { 47 + // Mirror sizes first 48 + for (const item of items) { 49 + mirrorItemSize(item, fromMobile); 50 + } 51 + 52 + if (fromMobile) { 53 + // Mobile โ†’ Desktop: reflow items to use the full grid width. 54 + // Sort by mobile position so items are placed in reading order. 55 + const sorted = items.toSorted((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); 56 + 57 + // Place each item into the first available spot on the desktop grid 58 + const placed: Item[] = []; 59 + for (const item of sorted) { 60 + item.x = 0; 61 + item.y = 0; 62 + findValidPosition(item, placed, false); 63 + placed.push(item); 64 + } 65 + } else { 66 + // Desktop โ†’ Mobile: proportional positions 67 + for (const item of items) { 68 + item.mobileX = clamp(Math.floor((item.x * 2) / 2) * 2, 0, COLUMNS - item.mobileW); 69 + item.mobileY = Math.max(0, Math.round(item.y * 2)); 70 + } 71 + fixAllCollisions(items, true); 72 + } 73 + }
+56 -567
src/lib/website/EditableWebsite.svelte
··· 1 1 <script lang="ts"> 2 - import { Button, Modal, toast, Toaster, Sidebar } from '@foxui/core'; 3 - import { COLUMNS, margin, mobileMargin } from '$lib'; 2 + import { Button, Modal, toast, Toaster } from '@foxui/core'; 3 + import { COLUMNS } from '$lib'; 4 4 import { 5 5 checkAndUploadImage, 6 - clamp, 7 - compactItems, 8 6 createEmptyCard, 9 - findValidPosition, 10 - fixAllCollisions, 11 - fixCollisions, 12 7 getHideProfileSection, 13 8 getProfilePosition, 14 9 getName, 15 10 isTyping, 16 11 savePage, 17 12 scrollToItem, 18 - setPositionOfNewItem, 19 13 validateLink, 20 14 getImage 21 15 } from '../helper'; ··· 32 26 import Context from './Context.svelte'; 33 27 import Head from './Head.svelte'; 34 28 import Account from './Account.svelte'; 35 - import { SelectThemePopover } from '$lib/components/select-theme'; 36 29 import EditBar from './EditBar.svelte'; 37 30 import SaveModal from './SaveModal.svelte'; 38 31 import FloatingEditButton from './FloatingEditButton.svelte'; ··· 41 34 import { launchConfetti } from '@foxui/visual'; 42 35 import Controls from './Controls.svelte'; 43 36 import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 44 - import { shouldMirror, mirrorLayout } from './layout-mirror'; 45 37 import { SvelteMap } from 'svelte/reactivity'; 38 + import { 39 + fixCollisions, 40 + compactItems, 41 + fixAllCollisions, 42 + setPositionOfNewItem, 43 + shouldMirror, 44 + mirrorLayout, 45 + getViewportCenterGridY, 46 + EditableGrid 47 + } from '$lib/layout'; 46 48 47 49 let { 48 50 data ··· 53 55 // Check if floating login button will be visible (to hide MadeWithBlento) 54 56 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn); 55 57 56 - function updateTheme(newAccent: string, newBase: string) { 57 - data.publication.preferences ??= {}; 58 - data.publication.preferences.accentColor = newAccent; 59 - data.publication.preferences.baseColor = newBase; 60 - data = { ...data }; 61 - } 62 - 63 - let imageDragOver = $state(false); 64 - 65 58 // svelte-ignore state_referenced_locally 66 59 let items: Item[] = $state(data.cards); 67 60 68 61 // svelte-ignore state_referenced_locally 69 62 let publication = $state(JSON.stringify(data.publication)); 70 63 71 - // Track saved state for comparison 72 64 // svelte-ignore state_referenced_locally 73 - let savedItems = $state(JSON.stringify(data.cards)); 74 - // svelte-ignore state_referenced_locally 75 - let savedPublication = $state(JSON.stringify(data.publication)); 65 + let savedItemsSnapshot = JSON.stringify(data.cards); 76 66 77 67 let hasUnsavedChanges = $state(false); 78 68 69 + // Detect card content and publication changes (e.g. sidebar edits) 70 + // The guard ensures JSON.stringify only runs while no changes are detected yet. 71 + // Once hasUnsavedChanges is true, Svelte still fires this effect on item mutations 72 + // but the early return makes it effectively free. 79 73 $effect(() => { 80 - if (!hasUnsavedChanges) { 81 - hasUnsavedChanges = 82 - JSON.stringify(items) !== savedItems || 83 - JSON.stringify(data.publication) !== savedPublication; 74 + if (hasUnsavedChanges) return; 75 + if ( 76 + JSON.stringify(items) !== savedItemsSnapshot || 77 + JSON.stringify(data.publication) !== publication 78 + ) { 79 + hasUnsavedChanges = true; 84 80 } 85 81 }); 86 82 ··· 97 93 return () => window.removeEventListener('beforeunload', handleBeforeUnload); 98 94 }); 99 95 100 - let container: HTMLDivElement | undefined = $state(); 101 - 102 - let activeDragElement: { 103 - element: HTMLDivElement | null; 104 - item: Item | null; 105 - w: number; 106 - h: number; 107 - x: number; 108 - y: number; 109 - mouseDeltaX: number; 110 - mouseDeltaY: number; 111 - // For hysteresis - track last decision to prevent flickering 112 - lastTargetId: string | null; 113 - lastPlacement: 'above' | 'below' | null; 114 - // Store original positions to reset from during drag 115 - originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>; 116 - } = $state({ 117 - element: null, 118 - item: null, 119 - w: 0, 120 - h: 0, 121 - x: -1, 122 - y: -1, 123 - mouseDeltaX: 0, 124 - mouseDeltaY: 0, 125 - lastTargetId: null, 126 - lastPlacement: null, 127 - originalPositions: new Map() 128 - }); 96 + let gridContainer: HTMLDivElement | undefined = $state(); 129 97 130 98 let showingMobileView = $state(false); 131 99 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); ··· 137 105 let editedOn = $state(data.publication.preferences?.editedOn ?? 0); 138 106 139 107 function onLayoutChanged() { 108 + hasUnsavedChanges = true; 140 109 // Set the bit for the current layout: desktop=1, mobile=2 141 110 editedOn = editedOn | (isMobile ? 2 : 1); 142 111 if (shouldMirror(editedOn)) { ··· 159 128 160 129 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 161 130 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 162 - 163 131 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 164 132 165 - function getViewportCenterGridY(): { gridY: number; isMobile: boolean } | undefined { 166 - if (!container) return undefined; 167 - const rect = container.getBoundingClientRect(); 168 - const currentMargin = isMobile ? mobileMargin : margin; 169 - const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 170 - const viewportCenterY = window.innerHeight / 2; 171 - const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize; 172 - return { gridY, isMobile }; 173 - } 174 - 175 133 function newCard(type: string = 'link', cardData?: any) { 176 134 selectedCardId = null; 177 135 ··· 220 178 if (!newItem.item) return; 221 179 const item = newItem.item; 222 180 223 - const viewportCenter = getViewportCenterGridY(); 181 + const viewportCenter = gridContainer 182 + ? getViewportCenterGridY(gridContainer, isMobile) 183 + : undefined; 224 184 setPositionOfNewItem(item, items, viewportCenter); 225 185 226 186 items = [...items, item]; ··· 238 198 await tick(); 239 199 cleanupDialogArtifacts(); 240 200 241 - scrollToItem(item, isMobile, container); 201 + scrollToItem(item, isMobile, gridContainer); 242 202 } 243 203 244 204 let isSaving = $state(false); ··· 266 226 267 227 publication = JSON.stringify(data.publication); 268 228 269 - // Update saved state 270 - savedItems = JSON.stringify(items); 271 - savedPublication = JSON.stringify(data.publication); 229 + savedItemsSnapshot = JSON.stringify(items); 230 + hasUnsavedChanges = false; 272 231 273 232 saveSuccess = true; 274 233 ··· 542 501 return; 543 502 } 544 503 545 - fixAllCollisions(copiedCards); 504 + fixAllCollisions(copiedCards, false); 546 505 fixAllCollisions(copiedCards, true); 547 - compactItems(copiedCards); 506 + compactItems(copiedCards, false); 548 507 compactItems(copiedCards, true); 549 508 550 509 items = copiedCards; ··· 558 517 } 559 518 } 560 519 561 - let debugPoint = $state({ x: 0, y: 0 }); 562 - 563 - function getGridPosition( 564 - clientX: number, 565 - clientY: number 566 - ): 567 - | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 568 - | undefined { 569 - if (!container || !activeDragElement.item) return; 570 - 571 - // x, y represent the top-left corner of the dragged card 572 - const x = clientX + activeDragElement.mouseDeltaX; 573 - const y = clientY + activeDragElement.mouseDeltaY; 574 - 575 - const rect = container.getBoundingClientRect(); 576 - const currentMargin = isMobile ? mobileMargin : margin; 577 - const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 578 - 579 - // Get card dimensions based on current view mode 580 - const cardW = isMobile 581 - ? (activeDragElement.item?.mobileW ?? activeDragElement.w) 582 - : activeDragElement.w; 583 - const cardH = isMobile 584 - ? (activeDragElement.item?.mobileH ?? activeDragElement.h) 585 - : activeDragElement.h; 586 - 587 - // Get dragged card's original position 588 - const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 589 - 590 - const draggedOrigY = draggedOrigPos 591 - ? isMobile 592 - ? draggedOrigPos.mobileY 593 - : draggedOrigPos.y 594 - : 0; 595 - 596 - // Calculate raw grid position based on top-left of dragged card 597 - let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 598 - gridX = Math.floor(gridX / 2) * 2; 599 - 600 - let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0); 601 - 602 - if (isMobile) { 603 - gridX = Math.floor(gridX / 2) * 2; 604 - gridY = Math.floor(gridY / 2) * 2; 605 - } 606 - 607 - // Find if we're hovering over another card (using ORIGINAL positions) 608 - const centerGridY = gridY + cardH / 2; 609 - const centerGridX = gridX + cardW / 2; 610 - 611 - let swapWithId: string | null = null; 612 - let placement: 'above' | 'below' | null = null; 613 - 614 - for (const other of items) { 615 - if (other === activeDragElement.item) continue; 616 - 617 - // Use original positions for hit testing 618 - const origPos = activeDragElement.originalPositions.get(other.id); 619 - if (!origPos) continue; 620 - 621 - const otherX = isMobile ? origPos.mobileX : origPos.x; 622 - const otherY = isMobile ? origPos.mobileY : origPos.y; 623 - const otherW = isMobile ? other.mobileW : other.w; 624 - const otherH = isMobile ? other.mobileH : other.h; 625 - 626 - // Check if dragged card's center point is within this card's original bounds 627 - if ( 628 - centerGridX >= otherX && 629 - centerGridX < otherX + otherW && 630 - centerGridY >= otherY && 631 - centerGridY < otherY + otherH 632 - ) { 633 - // Check if this is a swap situation: 634 - // Cards have the same dimensions and are on the same row 635 - const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; 636 - 637 - if (canSwap) { 638 - // Swap positions 639 - swapWithId = other.id; 640 - gridX = otherX; 641 - gridY = otherY; 642 - placement = null; 643 - 644 - activeDragElement.lastTargetId = other.id; 645 - activeDragElement.lastPlacement = null; 646 - } else { 647 - // Vertical placement (above/below) 648 - // Detect drag direction: if dragging up, always place above 649 - const isDraggingUp = gridY < draggedOrigY; 650 - 651 - if (isDraggingUp) { 652 - // When dragging up, always place above 653 - placement = 'above'; 654 - } else { 655 - // When dragging down, use top/bottom half logic 656 - const midpointY = otherY + otherH / 2; 657 - const hysteresis = 0.3; 658 - 659 - if (activeDragElement.lastTargetId === other.id && activeDragElement.lastPlacement) { 660 - if (activeDragElement.lastPlacement === 'above') { 661 - placement = centerGridY > midpointY + hysteresis ? 'below' : 'above'; 662 - } else { 663 - placement = centerGridY < midpointY - hysteresis ? 'above' : 'below'; 664 - } 665 - } else { 666 - placement = centerGridY < midpointY ? 'above' : 'below'; 667 - } 668 - } 669 - 670 - activeDragElement.lastTargetId = other.id; 671 - activeDragElement.lastPlacement = placement; 672 - 673 - if (placement === 'above') { 674 - gridY = otherY; 675 - } else { 676 - gridY = otherY + otherH; 677 - } 678 - } 679 - break; 680 - } 681 - } 682 - 683 - // If we're not over any card, clear the tracking 684 - if (!swapWithId && !placement) { 685 - activeDragElement.lastTargetId = null; 686 - activeDragElement.lastPlacement = null; 687 - } 688 - 689 - debugPoint.x = x - rect.left; 690 - debugPoint.y = y - rect.top + currentMargin; 691 - 692 - return { x: gridX, y: gridY, swapWithId, placement }; 693 - } 694 - 695 - function getDragXY( 696 - e: DragEvent & { 697 - currentTarget: EventTarget & HTMLDivElement; 698 - } 699 - ) { 700 - return getGridPosition(e.clientX, e.clientY); 701 - } 702 - 703 - // Touch drag system (instant drag on selected card) 704 - let touchDragActive = $state(false); 705 - 706 - function touchStart(e: TouchEvent) { 707 - if (!selectedCardId || !container) return; 708 - const touch = e.touches[0]; 709 - if (!touch) return; 710 - 711 - // Check if the touch is on the selected card element 712 - const target = (e.target as HTMLElement)?.closest?.('.card'); 713 - if (!target || target.id !== selectedCardId) return; 714 - 715 - const item = items.find((i) => i.id === selectedCardId); 716 - if (!item || item.cardData?.locked) return; 717 - 718 - // Start dragging immediately 719 - touchDragActive = true; 720 - 721 - const cardEl = container.querySelector(`#${CSS.escape(selectedCardId)}`) as HTMLDivElement; 722 - if (!cardEl) return; 723 - 724 - activeDragElement.element = cardEl; 725 - activeDragElement.w = item.w; 726 - activeDragElement.h = item.h; 727 - activeDragElement.item = item; 728 - 729 - // Store original positions of all items 730 - activeDragElement.originalPositions = new Map(); 731 - for (const it of items) { 732 - activeDragElement.originalPositions.set(it.id, { 733 - x: it.x, 734 - y: it.y, 735 - mobileX: it.mobileX, 736 - mobileY: it.mobileY 737 - }); 738 - } 739 - 740 - const rect = cardEl.getBoundingClientRect(); 741 - activeDragElement.mouseDeltaX = rect.left - touch.clientX; 742 - activeDragElement.mouseDeltaY = rect.top - touch.clientY; 743 - } 744 - 745 - function touchMove(e: TouchEvent) { 746 - if (!touchDragActive) return; 747 - 748 - const touch = e.touches[0]; 749 - if (!touch) return; 750 - 751 - e.preventDefault(); 752 - 753 - const result = getGridPosition(touch.clientX, touch.clientY); 754 - if (!result || !activeDragElement.item) return; 755 - 756 - const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 757 - 758 - // Reset all items to original positions first 759 - for (const it of items) { 760 - const origPos = activeDragElement.originalPositions.get(it.id); 761 - if (origPos && it !== activeDragElement.item) { 762 - if (isMobile) { 763 - it.mobileX = origPos.mobileX; 764 - it.mobileY = origPos.mobileY; 765 - } else { 766 - it.x = origPos.x; 767 - it.y = origPos.y; 768 - } 769 - } 770 - } 771 - 772 - // Update dragged item position 773 - if (isMobile) { 774 - activeDragElement.item.mobileX = result.x; 775 - activeDragElement.item.mobileY = result.y; 776 - } else { 777 - activeDragElement.item.x = result.x; 778 - activeDragElement.item.y = result.y; 779 - } 780 - 781 - // Handle horizontal swap 782 - if (result.swapWithId && draggedOrigPos) { 783 - const swapTarget = items.find((it) => it.id === result.swapWithId); 784 - if (swapTarget) { 785 - if (isMobile) { 786 - swapTarget.mobileX = draggedOrigPos.mobileX; 787 - swapTarget.mobileY = draggedOrigPos.mobileY; 788 - } else { 789 - swapTarget.x = draggedOrigPos.x; 790 - swapTarget.y = draggedOrigPos.y; 791 - } 792 - } 793 - } 794 - 795 - fixCollisions(items, activeDragElement.item, isMobile); 796 - 797 - // Auto-scroll near edges 798 - const scrollZone = 100; 799 - const scrollSpeed = 10; 800 - const viewportHeight = window.innerHeight; 801 - 802 - if (touch.clientY < scrollZone) { 803 - const intensity = 1 - touch.clientY / scrollZone; 804 - window.scrollBy(0, -scrollSpeed * intensity); 805 - } else if (touch.clientY > viewportHeight - scrollZone) { 806 - const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone; 807 - window.scrollBy(0, scrollSpeed * intensity); 808 - } 809 - } 810 - 811 - function touchEnd() { 812 - if (touchDragActive && activeDragElement.item) { 813 - // Finalize position 814 - fixCollisions(items, activeDragElement.item, isMobile); 815 - onLayoutChanged(); 816 - 817 - activeDragElement.x = -1; 818 - activeDragElement.y = -1; 819 - activeDragElement.element = null; 820 - activeDragElement.item = null; 821 - activeDragElement.lastTargetId = null; 822 - activeDragElement.lastPlacement = null; 823 - } 824 - 825 - touchDragActive = false; 826 - } 827 - 828 - // Only register non-passive touchmove when actively dragging 829 - $effect(() => { 830 - const el = container; 831 - if (!touchDragActive || !el) return; 832 - 833 - el.addEventListener('touchmove', touchMove, { passive: false }); 834 - return () => { 835 - el.removeEventListener('touchmove', touchMove); 836 - }; 837 - }); 838 - 839 520 let linkValue = $state(''); 840 521 841 522 function addLink(url: string, specificCardDef?: CardDefinition) { ··· 959 640 fixCollisions(items, item, isMobile); 960 641 fixCollisions(items, item, !isMobile); 961 642 } else { 962 - const viewportCenter = getViewportCenterGridY(); 643 + const viewportCenter = gridContainer 644 + ? getViewportCenterGridY(gridContainer, isMobile) 645 + : undefined; 963 646 setPositionOfNewItem(item, items, viewportCenter); 964 647 items = [...items, item]; 965 648 fixCollisions(items, item, false, true); ··· 972 655 973 656 await tick(); 974 657 975 - scrollToItem(item, isMobile, container); 658 + scrollToItem(item, isMobile, gridContainer); 976 659 } 977 660 978 - function handleImageDragOver(event: DragEvent) { 979 - const dt = event.dataTransfer; 980 - if (!dt) return; 981 - 982 - let hasImage = false; 983 - if (dt.items) { 984 - for (let i = 0; i < dt.items.length; i++) { 985 - const item = dt.items[i]; 986 - if (item && item.kind === 'file' && item.type.startsWith('image/')) { 987 - hasImage = true; 988 - break; 989 - } 990 - } 991 - } else if (dt.files) { 992 - for (let i = 0; i < dt.files.length; i++) { 993 - const file = dt.files[i]; 994 - if (file?.type.startsWith('image/')) { 995 - hasImage = true; 996 - break; 997 - } 998 - } 999 - } 1000 - 1001 - if (hasImage) { 1002 - event.preventDefault(); 1003 - event.stopPropagation(); 1004 - 1005 - imageDragOver = true; 1006 - } 1007 - } 1008 - 1009 - function handleImageDragLeave(event: DragEvent) { 1010 - event.preventDefault(); 1011 - event.stopPropagation(); 1012 - imageDragOver = false; 1013 - } 1014 - 1015 - async function handleImageDrop(event: DragEvent) { 1016 - event.preventDefault(); 1017 - event.stopPropagation(); 1018 - const dropX = event.clientX; 1019 - const dropY = event.clientY; 1020 - imageDragOver = false; 1021 - 1022 - if (!event.dataTransfer?.files?.length) return; 1023 - 1024 - const imageFiles = Array.from(event.dataTransfer.files).filter((f) => 1025 - f?.type.startsWith('image/') 1026 - ); 1027 - if (imageFiles.length === 0) return; 1028 - 1029 - // Calculate starting grid position from drop coordinates 1030 - let gridX = 0; 1031 - let gridY = 0; 1032 - if (container) { 1033 - const rect = container.getBoundingClientRect(); 1034 - const currentMargin = isMobile ? mobileMargin : margin; 1035 - const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 1036 - const cardW = isMobile ? 4 : 2; 1037 - 1038 - gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 1039 - gridX = Math.floor(gridX / 2) * 2; 1040 - 1041 - gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0); 1042 - if (isMobile) { 1043 - gridY = Math.floor(gridY / 2) * 2; 1044 - } 1045 - } 1046 - 1047 - for (let i = 0; i < imageFiles.length; i++) { 661 + async function handleFileDrop(files: File[], gridX: number, gridY: number) { 662 + for (let i = 0; i < files.length; i++) { 1048 663 // First image gets the drop position, rest use normal placement 1049 664 if (i === 0) { 1050 - await processImageFile(imageFiles[i], gridX, gridY); 665 + await processImageFile(files[i], gridX, gridY); 1051 666 } else { 1052 - await processImageFile(imageFiles[i]); 667 + await processImageFile(files[i]); 1053 668 } 1054 669 } 1055 670 } ··· 1097 712 objectUrl 1098 713 }; 1099 714 1100 - const viewportCenter = getViewportCenterGridY(); 715 + const viewportCenter = gridContainer 716 + ? getViewportCenterGridY(gridContainer, isMobile) 717 + : undefined; 1101 718 setPositionOfNewItem(item, items, viewportCenter); 1102 719 items = [...items, item]; 1103 720 fixCollisions(items, item, false, true); ··· 1109 726 1110 727 await tick(); 1111 728 1112 - scrollToItem(item, isMobile, container); 729 + scrollToItem(item, isMobile, gridContainer); 1113 730 } 1114 731 1115 732 async function handleVideoInputChange(event: Event) { ··· 1139 756 1140 757 addLink(link); 1141 758 }} 1142 - /> 1143 - 1144 - <svelte:window 1145 - ondragover={handleImageDragOver} 1146 - ondragleave={handleImageDragLeave} 1147 - ondrop={handleImageDrop} 1148 759 /> 1149 760 1150 761 <Head ··· 1256 867 ]} 1257 868 > 1258 869 <div class="pointer-events-none"></div> 1259 - <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 1260 - <div 1261 - bind:this={container} 1262 - onclick={(e) => { 1263 - // Deselect when tapping empty grid space 1264 - if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) { 1265 - selectedCardId = null; 1266 - } 870 + <EditableGrid 871 + bind:items 872 + bind:ref={gridContainer} 873 + {isMobile} 874 + {selectedCardId} 875 + {isCoarse} 876 + onlayoutchange={onLayoutChanged} 877 + ondeselect={() => { 878 + selectedCardId = null; 1267 879 }} 1268 - ontouchstart={touchStart} 1269 - ontouchend={touchEnd} 1270 - ondragover={(e) => { 1271 - e.preventDefault(); 1272 - 1273 - const result = getDragXY(e); 1274 - if (!result) return; 1275 - 1276 - activeDragElement.x = result.x; 1277 - activeDragElement.y = result.y; 1278 - 1279 - if (activeDragElement.item) { 1280 - // Get dragged card's original position for swapping 1281 - const draggedOrigPos = activeDragElement.originalPositions.get( 1282 - activeDragElement.item.id 1283 - ); 1284 - 1285 - // Reset all items to original positions first 1286 - for (const it of items) { 1287 - const origPos = activeDragElement.originalPositions.get(it.id); 1288 - if (origPos && it !== activeDragElement.item) { 1289 - if (isMobile) { 1290 - it.mobileX = origPos.mobileX; 1291 - it.mobileY = origPos.mobileY; 1292 - } else { 1293 - it.x = origPos.x; 1294 - it.y = origPos.y; 1295 - } 1296 - } 1297 - } 1298 - 1299 - // Update dragged item position 1300 - if (isMobile) { 1301 - activeDragElement.item.mobileX = result.x; 1302 - activeDragElement.item.mobileY = result.y; 1303 - } else { 1304 - activeDragElement.item.x = result.x; 1305 - activeDragElement.item.y = result.y; 1306 - } 1307 - 1308 - // Handle horizontal swap 1309 - if (result.swapWithId && draggedOrigPos) { 1310 - const swapTarget = items.find((it) => it.id === result.swapWithId); 1311 - if (swapTarget) { 1312 - // Move swap target to dragged card's original position 1313 - if (isMobile) { 1314 - swapTarget.mobileX = draggedOrigPos.mobileX; 1315 - swapTarget.mobileY = draggedOrigPos.mobileY; 1316 - } else { 1317 - swapTarget.x = draggedOrigPos.x; 1318 - swapTarget.y = draggedOrigPos.y; 1319 - } 1320 - } 1321 - } 1322 - 1323 - // Now fix collisions (with compacting) 1324 - fixCollisions(items, activeDragElement.item, isMobile); 1325 - } 1326 - 1327 - // Auto-scroll when dragging near top or bottom of viewport 1328 - const scrollZone = 100; 1329 - const scrollSpeed = 10; 1330 - const viewportHeight = window.innerHeight; 1331 - 1332 - if (e.clientY < scrollZone) { 1333 - // Near top - scroll up 1334 - const intensity = 1 - e.clientY / scrollZone; 1335 - window.scrollBy(0, -scrollSpeed * intensity); 1336 - } else if (e.clientY > viewportHeight - scrollZone) { 1337 - // Near bottom - scroll down 1338 - const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; 1339 - window.scrollBy(0, scrollSpeed * intensity); 1340 - } 1341 - }} 1342 - ondragend={async (e) => { 1343 - e.preventDefault(); 1344 - // safari fix 1345 - activeDragElement.x = -1; 1346 - activeDragElement.y = -1; 1347 - activeDragElement.element = null; 1348 - activeDragElement.item = null; 1349 - activeDragElement.lastTargetId = null; 1350 - activeDragElement.lastPlacement = null; 1351 - return true; 1352 - }} 1353 - class={[ 1354 - '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 1355 - imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' 1356 - ]} 880 + onfiledrop={handleFileDrop} 1357 881 > 1358 882 {#each items as item, i (item.id)} 1359 - <!-- {#if item !== activeDragElement.item} --> 1360 883 <BaseEditingCard 1361 884 bind:item={items[i]} 1362 885 ondelete={() => { ··· 1377 900 fixCollisions(items, item, isMobile); 1378 901 onLayoutChanged(); 1379 902 }} 1380 - ondragstart={(e: DragEvent) => { 1381 - const target = e.currentTarget as HTMLDivElement; 1382 - activeDragElement.element = target; 1383 - activeDragElement.w = item.w; 1384 - activeDragElement.h = item.h; 1385 - activeDragElement.item = item; 1386 - // fix for div shadow during drag and drop 1387 - const transparent = document.createElement('div'); 1388 - transparent.style.position = 'fixed'; 1389 - transparent.style.top = '-1000px'; 1390 - transparent.style.width = '1px'; 1391 - transparent.style.height = '1px'; 1392 - document.body.appendChild(transparent); 1393 - e.dataTransfer?.setDragImage(transparent, 0, 0); 1394 - requestAnimationFrame(() => transparent.remove()); 1395 - 1396 - // Store original positions of all items 1397 - activeDragElement.originalPositions = new Map(); 1398 - for (const it of items) { 1399 - activeDragElement.originalPositions.set(it.id, { 1400 - x: it.x, 1401 - y: it.y, 1402 - mobileX: it.mobileX, 1403 - mobileY: it.mobileY 1404 - }); 1405 - } 1406 - 1407 - const rect = target.getBoundingClientRect(); 1408 - activeDragElement.mouseDeltaX = rect.left - e.clientX; 1409 - activeDragElement.mouseDeltaY = rect.top - e.clientY; 1410 - }} 1411 903 > 1412 904 <EditingCard bind:item={items[i]} /> 1413 905 </BaseEditingCard> 1414 - <!-- {/if} --> 1415 906 {/each} 1416 - 1417 - <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> 1418 - </div> 907 + </EditableGrid> 1419 908 </div> 1420 909 </div> 1421 910
-72
src/lib/website/layout-mirror.ts
··· 1 - import { COLUMNS } from '$lib'; 2 - import { CardDefinitionsByType } from '$lib/cards'; 3 - import { clamp, findValidPosition, fixAllCollisions } from '$lib/helper'; 4 - import type { Item } from '$lib/types'; 5 - 6 - /** 7 - * Returns true when mirroring should still happen (i.e. user hasn't edited both layouts). 8 - * editedOn: 0/undefined = never, 1 = desktop only, 2 = mobile only, 3 = both 9 - */ 10 - export function shouldMirror(editedOn: number | undefined): boolean { 11 - return (editedOn ?? 0) !== 3; 12 - } 13 - 14 - /** Snap a value to the nearest even integer (min 2). */ 15 - function snapEven(v: number): number { 16 - return Math.max(2, Math.round(v / 2) * 2); 17 - } 18 - 19 - /** 20 - * Compute the other layout's size for a single item, preserving aspect ratio. 21 - * Clamps to the card definition's minW/maxW/minH/maxH if defined. 22 - * Mutates the item in-place. 23 - */ 24 - export function mirrorItemSize(item: Item, fromMobile: boolean): void { 25 - const def = CardDefinitionsByType[item.cardType]; 26 - 27 - if (fromMobile) { 28 - // Mobile โ†’ Desktop: halve both dimensions, then clamp to card def constraints 29 - // (constraints are in desktop units) 30 - item.w = clamp(snapEven(item.mobileW / 2), def?.minW ?? 2, def?.maxW ?? COLUMNS); 31 - item.h = clamp(Math.round(item.mobileH / 2), def?.minH ?? 1, def?.maxH ?? Infinity); 32 - } else { 33 - // Desktop โ†’ Mobile: double both dimensions 34 - // (don't apply card def constraints โ€” they're in desktop units) 35 - item.mobileW = Math.min(item.w * 2, COLUMNS); 36 - item.mobileH = Math.max(item.h * 2, 2); 37 - } 38 - } 39 - 40 - /** 41 - * Mirror the full layout from one view to the other. 42 - * Copies sizes proportionally and maps positions, then resolves collisions. 43 - * Mutates items in-place. 44 - */ 45 - export function mirrorLayout(items: Item[], fromMobile: boolean): void { 46 - // Mirror sizes first 47 - for (const item of items) { 48 - mirrorItemSize(item, fromMobile); 49 - } 50 - 51 - if (fromMobile) { 52 - // Mobile โ†’ Desktop: reflow items to use the full grid width. 53 - // Sort by mobile position so items are placed in reading order. 54 - const sorted = items.toSorted((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); 55 - 56 - // Place each item into the first available spot on the desktop grid 57 - const placed: Item[] = []; 58 - for (const item of sorted) { 59 - item.x = 0; 60 - item.y = 0; 61 - findValidPosition(item, placed, false); 62 - placed.push(item); 63 - } 64 - } else { 65 - // Desktop โ†’ Mobile: proportional positions 66 - for (const item of items) { 67 - item.mobileX = clamp(Math.floor((item.x * 2) / 2) * 2, 0, COLUMNS - item.mobileW); 68 - item.mobileY = Math.max(0, Math.round(item.y * 2)); 69 - } 70 - fixAllCollisions(items, true); 71 - } 72 - }
+23 -24
src/lib/website/load.ts
··· 1 1 import { getDetailedProfile, listRecords, resolveHandle, parseUri, getRecord } from '$lib/atproto'; 2 2 import { CardDefinitionsByType } from '$lib/cards'; 3 3 import type { Item, UserCache, WebsiteData } from '$lib/types'; 4 - import { compactItems, fixAllCollisions } from '$lib/helper'; 5 4 import { error } from '@sveltejs/kit'; 6 5 import type { ActorIdentifier, Did } from '@atcute/lexicons'; 7 6 8 7 import { isDid, isHandle } from '@atcute/lexicons/syntax'; 8 + import { fixAllCollisions, compactItems } from '$lib/layout'; 9 9 10 10 const CURRENT_CACHE_VERSION = 1; 11 11 ··· 73 73 throw error(404); 74 74 } 75 75 76 - const cards = await listRecords({ did, collection: 'app.blento.card' }).catch(() => { 77 - console.error('error getting records for collection app.blento.card'); 78 - return [] as Awaited<ReturnType<typeof listRecords>>; 79 - }); 80 - 81 - const mainPublication = await getRecord({ 82 - did, 83 - collection: 'site.standard.publication', 84 - rkey: 'blento.self' 85 - }).catch(() => { 86 - console.error('error getting record for collection site.standard.publication'); 87 - return undefined; 88 - }); 89 - 90 - const pages = await listRecords({ did, collection: 'app.blento.page' }).catch(() => { 91 - console.error('error getting records for collection app.blento.page'); 92 - return [] as Awaited<ReturnType<typeof listRecords>>; 93 - }); 94 - 95 - const profile = await getDetailedProfile({ did }); 76 + const [cards, mainPublication, pages, profile] = await Promise.all([ 77 + listRecords({ did, collection: 'app.blento.card' }).catch(() => { 78 + console.error('error getting records for collection app.blento.card'); 79 + return [] as Awaited<ReturnType<typeof listRecords>>; 80 + }), 81 + getRecord({ 82 + did, 83 + collection: 'site.standard.publication', 84 + rkey: 'blento.self' 85 + }).catch(() => { 86 + console.error('error getting record for collection site.standard.publication'); 87 + return undefined; 88 + }), 89 + listRecords({ did, collection: 'app.blento.page' }).catch(() => { 90 + console.error('error getting records for collection app.blento.page'); 91 + return [] as Awaited<ReturnType<typeof listRecords>>; 92 + }), 93 + getDetailedProfile({ did }) 94 + ]); 96 95 97 96 const cardTypes = new Set(cards.map((v) => v.value.cardType ?? '') as string[]); 98 97 const cardTypesArray = Array.from(cardTypes); ··· 144 143 const stringifiedResult = JSON.stringify(result); 145 144 await cache?.put?.(handle, stringifiedResult); 146 145 147 - const parsedResult = JSON.parse(stringifiedResult); 146 + const parsedResult = structuredClone(result) as any; 148 147 149 148 parsedResult.publication = ( 150 149 parsedResult.publications as Awaited<ReturnType<typeof listRecords>> ··· 203 202 const cards = data.cards.filter((v) => v.page === data.page); 204 203 205 204 if (cards.length > 0) { 206 - fixAllCollisions(cards); 205 + fixAllCollisions(cards, false); 207 206 fixAllCollisions(cards, true); 208 207 209 - compactItems(cards); 208 + compactItems(cards, false); 210 209 compactItems(cards, true); 211 210 } 212 211