your personal website on atproto - mirror blento.app

Compare changes

Choose any two refs to compare.

+1093 -1052
+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}
-361
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 each item, find the lowest Y it can occupy by checking the bottom edges 184 - // of all horizontally-overlapping items already placed above it. 185 - const settled: Item[] = []; 186 - 187 - for (const item of sortedItems) { 188 - const itemX = mobile ? item.mobileX : item.x; 189 - const itemW = mobile ? item.mobileW : item.w; 190 - 191 - let minY = 0; 192 - 193 - for (const other of settled) { 194 - const otherX = mobile ? other.mobileX : other.x; 195 - const otherW = mobile ? other.mobileW : other.w; 196 - 197 - // Check horizontal overlap 198 - if (itemX < otherX + otherW && itemX + itemW > otherX) { 199 - const otherBottom = mobile ? other.mobileY + other.mobileH : other.y + other.h; 200 - if (otherBottom > minY) { 201 - minY = otherBottom; 202 - } 203 - } 204 - } 205 - 206 - if (mobile) { 207 - item.mobileY = minY; 208 - } else { 209 - item.y = minY; 210 - } 211 - 212 - settled.push(item); 213 - } 214 - } 215 - 216 - // Simulate where an item would end up after fixCollisions + compaction 217 - export function simulateFinalPosition( 218 - items: Item[], 219 - movedItem: Item, 220 - newX: number, 221 - newY: number, 222 - mobile: boolean = false 223 - ): { x: number; y: number } { 224 - // Deep clone positions for simulation 225 - const clonedItems: Item[] = items.map((item) => ({ 226 - ...item, 227 - x: item.x, 228 - y: item.y, 229 - mobileX: item.mobileX, 230 - mobileY: item.mobileY 231 - })); 232 - 233 - const clonedMovedItem = clonedItems.find((item) => item.id === movedItem.id); 234 - if (!clonedMovedItem) return { x: newX, y: newY }; 235 - 236 - // Set the new position 237 - if (mobile) { 238 - clonedMovedItem.mobileX = newX; 239 - clonedMovedItem.mobileY = newY; 240 - } else { 241 - clonedMovedItem.x = newX; 242 - clonedMovedItem.y = newY; 243 - } 244 - 245 - // Run fixCollisions on the cloned data 246 - fixCollisions(clonedItems, clonedMovedItem, mobile); 247 - 248 - // Return the final position of the moved item 249 - return mobile 250 - ? { x: clonedMovedItem.mobileX, y: clonedMovedItem.mobileY } 251 - : { x: clonedMovedItem.x, y: clonedMovedItem.y }; 252 - } 253 - 254 30 export function sortItems(a: Item, b: Item) { 255 31 return a.y * COLUMNS + a.x - b.y * COLUMNS - b.x; 256 32 } ··· 271 47 a.color === b.color && 272 48 a.page === b.page 273 49 ); 274 - } 275 - 276 - export function setPositionOfNewItem( 277 - newItem: Item, 278 - items: Item[], 279 - viewportCenter?: { gridY: number; isMobile: boolean } 280 - ) { 281 - if (viewportCenter) { 282 - const { gridY, isMobile } = viewportCenter; 283 - 284 - if (isMobile) { 285 - // Place at viewport center Y 286 - newItem.mobileY = Math.max(0, Math.round(gridY - newItem.mobileH / 2)); 287 - newItem.mobileY = Math.floor(newItem.mobileY / 2) * 2; 288 - 289 - // Try to find a free X at this Y 290 - let found = false; 291 - for ( 292 - newItem.mobileX = 0; 293 - newItem.mobileX <= COLUMNS - newItem.mobileW; 294 - newItem.mobileX += 2 295 - ) { 296 - if (!items.some((item) => overlaps(newItem, item, true))) { 297 - found = true; 298 - break; 299 - } 300 - } 301 - if (!found) { 302 - newItem.mobileX = 0; 303 - } 304 - 305 - // Desktop: derive from mobile 306 - newItem.y = Math.max(0, Math.round(newItem.mobileY / 2)); 307 - found = false; 308 - for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 309 - if (!items.some((item) => overlaps(newItem, item, false))) { 310 - found = true; 311 - break; 312 - } 313 - } 314 - if (!found) { 315 - newItem.x = 0; 316 - } 317 - } else { 318 - // Place at viewport center Y 319 - newItem.y = Math.max(0, Math.round(gridY - newItem.h / 2)); 320 - 321 - // Try to find a free X at this Y 322 - let found = false; 323 - for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { 324 - if (!items.some((item) => overlaps(newItem, item, false))) { 325 - found = true; 326 - break; 327 - } 328 - } 329 - if (!found) { 330 - newItem.x = 0; 331 - } 332 - 333 - // Mobile: derive from desktop 334 - newItem.mobileY = Math.max(0, Math.round(newItem.y * 2)); 335 - found = false; 336 - for ( 337 - newItem.mobileX = 0; 338 - newItem.mobileX <= COLUMNS - newItem.mobileW; 339 - newItem.mobileX += 2 340 - ) { 341 - if (!items.some((item) => overlaps(newItem, item, true))) { 342 - found = true; 343 - break; 344 - } 345 - } 346 - if (!found) { 347 - newItem.mobileX = 0; 348 - } 349 - } 350 - return; 351 - } 352 - 353 - let foundPosition = false; 354 - while (!foundPosition) { 355 - for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { 356 - const collision = items.find((item) => overlaps(newItem, item)); 357 - if (!collision) { 358 - foundPosition = true; 359 - break; 360 - } 361 - } 362 - if (!foundPosition) newItem.y += 1; 363 - } 364 - 365 - let foundMobilePosition = false; 366 - while (!foundMobilePosition) { 367 - for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX += 1) { 368 - const collision = items.find((item) => overlaps(newItem, item, true)); 369 - 370 - if (!collision) { 371 - foundMobilePosition = true; 372 - break; 373 - } 374 - } 375 - if (!foundMobilePosition) newItem.mobileY! += 1; 376 - } 377 - } 378 - 379 - /** 380 - * Find a valid position for a new item in a single mode (desktop or mobile). 381 - * This modifies the item's position properties in-place. 382 - */ 383 - export function findValidPosition(newItem: Item, items: Item[], mobile: boolean) { 384 - if (mobile) { 385 - let foundPosition = false; 386 - newItem.mobileY = 0; 387 - while (!foundPosition) { 388 - for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX++) { 389 - const collision = items.find((item) => overlaps(newItem, item, true)); 390 - if (!collision) { 391 - foundPosition = true; 392 - break; 393 - } 394 - } 395 - if (!foundPosition) newItem.mobileY! += 1; 396 - } 397 - } else { 398 - let foundPosition = false; 399 - newItem.y = 0; 400 - while (!foundPosition) { 401 - for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { 402 - const collision = items.find((item) => overlaps(newItem, item, false)); 403 - if (!collision) { 404 - foundPosition = true; 405 - break; 406 - } 407 - } 408 - if (!foundPosition) newItem.y += 1; 409 - } 410 - } 411 50 } 412 51 413 52 export async function refreshData(data: { updatedAt?: number; handle: string }) {
+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 + }
+42 -598
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 ··· 52 54 53 55 // Check if floating login button will be visible (to hide MadeWithBlento) 54 56 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn); 55 - 56 - function updateTheme(newAccent: string, newBase: string) { 57 - data.publication.preferences ??= {}; 58 - data.publication.preferences.accentColor = newAccent; 59 - data.publication.preferences.baseColor = newBase; 60 - hasUnsavedChanges = true; 61 - data = { ...data }; 62 - } 63 - 64 - let imageDragOver = $state(false); 65 57 66 58 // svelte-ignore state_referenced_locally 67 59 let items: Item[] = $state(data.cards); ··· 101 93 return () => window.removeEventListener('beforeunload', handleBeforeUnload); 102 94 }); 103 95 104 - let container: HTMLDivElement | undefined = $state(); 105 - 106 - let activeDragElement: { 107 - element: HTMLDivElement | null; 108 - item: Item | null; 109 - w: number; 110 - h: number; 111 - x: number; 112 - y: number; 113 - mouseDeltaX: number; 114 - mouseDeltaY: number; 115 - // For hysteresis - track last decision to prevent flickering 116 - lastTargetId: string | null; 117 - lastPlacement: 'above' | 'below' | null; 118 - // Store original positions to reset from during drag 119 - originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>; 120 - } = $state({ 121 - element: null, 122 - item: null, 123 - w: 0, 124 - h: 0, 125 - x: -1, 126 - y: -1, 127 - mouseDeltaX: 0, 128 - mouseDeltaY: 0, 129 - lastTargetId: null, 130 - lastPlacement: null, 131 - originalPositions: new Map() 132 - }); 96 + let gridContainer: HTMLDivElement | undefined = $state(); 133 97 134 98 let showingMobileView = $state(false); 135 99 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); ··· 164 128 165 129 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 166 130 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 167 - 168 131 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 169 132 170 - function getViewportCenterGridY(): { gridY: number; isMobile: boolean } | undefined { 171 - if (!container) return undefined; 172 - const rect = container.getBoundingClientRect(); 173 - const currentMargin = isMobile ? mobileMargin : margin; 174 - const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 175 - const viewportCenterY = window.innerHeight / 2; 176 - const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize; 177 - return { gridY, isMobile }; 178 - } 179 - 180 133 function newCard(type: string = 'link', cardData?: any) { 181 134 selectedCardId = null; 182 135 ··· 225 178 if (!newItem.item) return; 226 179 const item = newItem.item; 227 180 228 - const viewportCenter = getViewportCenterGridY(); 181 + const viewportCenter = gridContainer 182 + ? getViewportCenterGridY(gridContainer, isMobile) 183 + : undefined; 229 184 setPositionOfNewItem(item, items, viewportCenter); 230 185 231 186 items = [...items, item]; ··· 243 198 await tick(); 244 199 cleanupDialogArtifacts(); 245 200 246 - scrollToItem(item, isMobile, container); 201 + scrollToItem(item, isMobile, gridContainer); 247 202 } 248 203 249 204 let isSaving = $state(false); ··· 546 501 return; 547 502 } 548 503 549 - fixAllCollisions(copiedCards); 504 + fixAllCollisions(copiedCards, false); 550 505 fixAllCollisions(copiedCards, true); 551 - compactItems(copiedCards); 506 + compactItems(copiedCards, false); 552 507 compactItems(copiedCards, true); 553 508 554 509 items = copiedCards; ··· 562 517 } 563 518 } 564 519 565 - let lastGridPos: { 566 - x: number; 567 - y: number; 568 - swapWithId: string | null; 569 - placement: string | null; 570 - } | null = $state(null); 571 - 572 - let debugPoint = $state({ x: 0, y: 0 }); 573 - 574 - function getGridPosition( 575 - clientX: number, 576 - clientY: number 577 - ): 578 - | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 579 - | undefined { 580 - if (!container || !activeDragElement.item) return; 581 - 582 - // x, y represent the top-left corner of the dragged card 583 - const x = clientX + activeDragElement.mouseDeltaX; 584 - const y = clientY + activeDragElement.mouseDeltaY; 585 - 586 - const rect = container.getBoundingClientRect(); 587 - const currentMargin = isMobile ? mobileMargin : margin; 588 - const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 589 - 590 - // Get card dimensions based on current view mode 591 - const cardW = isMobile 592 - ? (activeDragElement.item?.mobileW ?? activeDragElement.w) 593 - : activeDragElement.w; 594 - const cardH = isMobile 595 - ? (activeDragElement.item?.mobileH ?? activeDragElement.h) 596 - : activeDragElement.h; 597 - 598 - // Get dragged card's original position 599 - const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 600 - 601 - const draggedOrigY = draggedOrigPos 602 - ? isMobile 603 - ? draggedOrigPos.mobileY 604 - : draggedOrigPos.y 605 - : 0; 606 - 607 - // Calculate raw grid position based on top-left of dragged card 608 - let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 609 - gridX = Math.floor(gridX / 2) * 2; 610 - 611 - let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0); 612 - 613 - if (isMobile) { 614 - gridX = Math.floor(gridX / 2) * 2; 615 - gridY = Math.floor(gridY / 2) * 2; 616 - } 617 - 618 - // Find if we're hovering over another card (using ORIGINAL positions) 619 - const centerGridY = gridY + cardH / 2; 620 - const centerGridX = gridX + cardW / 2; 621 - 622 - let swapWithId: string | null = null; 623 - let placement: 'above' | 'below' | null = null; 624 - 625 - for (const other of items) { 626 - if (other === activeDragElement.item) continue; 627 - 628 - // Use original positions for hit testing 629 - const origPos = activeDragElement.originalPositions.get(other.id); 630 - if (!origPos) continue; 631 - 632 - const otherX = isMobile ? origPos.mobileX : origPos.x; 633 - const otherY = isMobile ? origPos.mobileY : origPos.y; 634 - const otherW = isMobile ? other.mobileW : other.w; 635 - const otherH = isMobile ? other.mobileH : other.h; 636 - 637 - // Check if dragged card's center point is within this card's original bounds 638 - if ( 639 - centerGridX >= otherX && 640 - centerGridX < otherX + otherW && 641 - centerGridY >= otherY && 642 - centerGridY < otherY + otherH 643 - ) { 644 - // Check if this is a swap situation: 645 - // Cards have the same dimensions and are on the same row 646 - const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; 647 - 648 - if (canSwap) { 649 - // Swap positions 650 - swapWithId = other.id; 651 - gridX = otherX; 652 - gridY = otherY; 653 - placement = null; 654 - 655 - activeDragElement.lastTargetId = other.id; 656 - activeDragElement.lastPlacement = null; 657 - } else { 658 - // Vertical placement (above/below) 659 - // Detect drag direction: if dragging up, always place above 660 - const isDraggingUp = gridY < draggedOrigY; 661 - 662 - if (isDraggingUp) { 663 - // When dragging up, always place above 664 - placement = 'above'; 665 - } else { 666 - // When dragging down, use top/bottom half logic 667 - const midpointY = otherY + otherH / 2; 668 - const hysteresis = 0.3; 669 - 670 - if (activeDragElement.lastTargetId === other.id && activeDragElement.lastPlacement) { 671 - if (activeDragElement.lastPlacement === 'above') { 672 - placement = centerGridY > midpointY + hysteresis ? 'below' : 'above'; 673 - } else { 674 - placement = centerGridY < midpointY - hysteresis ? 'above' : 'below'; 675 - } 676 - } else { 677 - placement = centerGridY < midpointY ? 'above' : 'below'; 678 - } 679 - } 680 - 681 - activeDragElement.lastTargetId = other.id; 682 - activeDragElement.lastPlacement = placement; 683 - 684 - if (placement === 'above') { 685 - gridY = otherY; 686 - } else { 687 - gridY = otherY + otherH; 688 - } 689 - } 690 - break; 691 - } 692 - } 693 - 694 - // If we're not over any card, clear the tracking 695 - if (!swapWithId && !placement) { 696 - activeDragElement.lastTargetId = null; 697 - activeDragElement.lastPlacement = null; 698 - } 699 - 700 - debugPoint.x = x - rect.left; 701 - debugPoint.y = y - rect.top + currentMargin; 702 - 703 - return { x: gridX, y: gridY, swapWithId, placement }; 704 - } 705 - 706 - function getDragXY( 707 - e: DragEvent & { 708 - currentTarget: EventTarget & HTMLDivElement; 709 - } 710 - ) { 711 - return getGridPosition(e.clientX, e.clientY); 712 - } 713 - 714 - // Touch drag system (instant drag on selected card) 715 - let touchDragActive = $state(false); 716 - 717 - function touchStart(e: TouchEvent) { 718 - if (!selectedCardId || !container) return; 719 - const touch = e.touches[0]; 720 - if (!touch) return; 721 - 722 - // Check if the touch is on the selected card element 723 - const target = (e.target as HTMLElement)?.closest?.('.card'); 724 - if (!target || target.id !== selectedCardId) return; 725 - 726 - const item = items.find((i) => i.id === selectedCardId); 727 - if (!item || item.cardData?.locked) return; 728 - 729 - // Start dragging immediately 730 - touchDragActive = true; 731 - 732 - const cardEl = container.querySelector(`#${CSS.escape(selectedCardId)}`) as HTMLDivElement; 733 - if (!cardEl) return; 734 - 735 - activeDragElement.element = cardEl; 736 - activeDragElement.w = item.w; 737 - activeDragElement.h = item.h; 738 - activeDragElement.item = item; 739 - 740 - // Store original positions of all items 741 - activeDragElement.originalPositions = new Map(); 742 - for (const it of items) { 743 - activeDragElement.originalPositions.set(it.id, { 744 - x: it.x, 745 - y: it.y, 746 - mobileX: it.mobileX, 747 - mobileY: it.mobileY 748 - }); 749 - } 750 - 751 - const rect = cardEl.getBoundingClientRect(); 752 - activeDragElement.mouseDeltaX = rect.left - touch.clientX; 753 - activeDragElement.mouseDeltaY = rect.top - touch.clientY; 754 - } 755 - 756 - function touchMove(e: TouchEvent) { 757 - if (!touchDragActive) return; 758 - 759 - const touch = e.touches[0]; 760 - if (!touch) return; 761 - 762 - e.preventDefault(); 763 - 764 - // Auto-scroll near edges (always process, even if grid pos unchanged) 765 - const scrollZone = 100; 766 - const scrollSpeed = 10; 767 - const viewportHeight = window.innerHeight; 768 - 769 - if (touch.clientY < scrollZone) { 770 - const intensity = 1 - touch.clientY / scrollZone; 771 - window.scrollBy(0, -scrollSpeed * intensity); 772 - } else if (touch.clientY > viewportHeight - scrollZone) { 773 - const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone; 774 - window.scrollBy(0, scrollSpeed * intensity); 775 - } 776 - 777 - const result = getGridPosition(touch.clientX, touch.clientY); 778 - if (!result || !activeDragElement.item) return; 779 - 780 - // Skip redundant work if grid position hasn't changed 781 - if ( 782 - lastGridPos && 783 - lastGridPos.x === result.x && 784 - lastGridPos.y === result.y && 785 - lastGridPos.swapWithId === result.swapWithId && 786 - lastGridPos.placement === result.placement 787 - ) { 788 - return; 789 - } 790 - lastGridPos = { 791 - x: result.x, 792 - y: result.y, 793 - swapWithId: result.swapWithId, 794 - placement: result.placement 795 - }; 796 - 797 - const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 798 - 799 - // Reset all items to original positions first 800 - for (const it of items) { 801 - const origPos = activeDragElement.originalPositions.get(it.id); 802 - if (origPos && it !== activeDragElement.item) { 803 - if (isMobile) { 804 - it.mobileX = origPos.mobileX; 805 - it.mobileY = origPos.mobileY; 806 - } else { 807 - it.x = origPos.x; 808 - it.y = origPos.y; 809 - } 810 - } 811 - } 812 - 813 - // Update dragged item position 814 - if (isMobile) { 815 - activeDragElement.item.mobileX = result.x; 816 - activeDragElement.item.mobileY = result.y; 817 - } else { 818 - activeDragElement.item.x = result.x; 819 - activeDragElement.item.y = result.y; 820 - } 821 - 822 - // Handle horizontal swap 823 - if (result.swapWithId && draggedOrigPos) { 824 - const swapTarget = items.find((it) => it.id === result.swapWithId); 825 - if (swapTarget) { 826 - if (isMobile) { 827 - swapTarget.mobileX = draggedOrigPos.mobileX; 828 - swapTarget.mobileY = draggedOrigPos.mobileY; 829 - } else { 830 - swapTarget.x = draggedOrigPos.x; 831 - swapTarget.y = draggedOrigPos.y; 832 - } 833 - } 834 - } 835 - 836 - fixCollisions(items, activeDragElement.item, isMobile); 837 - } 838 - 839 - function touchEnd() { 840 - if (touchDragActive && activeDragElement.item) { 841 - // Finalize position 842 - fixCollisions(items, activeDragElement.item, isMobile); 843 - onLayoutChanged(); 844 - 845 - activeDragElement.x = -1; 846 - activeDragElement.y = -1; 847 - activeDragElement.element = null; 848 - activeDragElement.item = null; 849 - activeDragElement.lastTargetId = null; 850 - activeDragElement.lastPlacement = null; 851 - } 852 - 853 - lastGridPos = null; 854 - touchDragActive = false; 855 - } 856 - 857 - // Only register non-passive touchmove when actively dragging 858 - $effect(() => { 859 - const el = container; 860 - if (!touchDragActive || !el) return; 861 - 862 - el.addEventListener('touchmove', touchMove, { passive: false }); 863 - return () => { 864 - el.removeEventListener('touchmove', touchMove); 865 - }; 866 - }); 867 - 868 520 let linkValue = $state(''); 869 521 870 522 function addLink(url: string, specificCardDef?: CardDefinition) { ··· 988 640 fixCollisions(items, item, isMobile); 989 641 fixCollisions(items, item, !isMobile); 990 642 } else { 991 - const viewportCenter = getViewportCenterGridY(); 643 + const viewportCenter = gridContainer 644 + ? getViewportCenterGridY(gridContainer, isMobile) 645 + : undefined; 992 646 setPositionOfNewItem(item, items, viewportCenter); 993 647 items = [...items, item]; 994 648 fixCollisions(items, item, false, true); ··· 1001 655 1002 656 await tick(); 1003 657 1004 - scrollToItem(item, isMobile, container); 1005 - } 1006 - 1007 - function handleImageDragOver(event: DragEvent) { 1008 - const dt = event.dataTransfer; 1009 - if (!dt) return; 1010 - 1011 - let hasImage = false; 1012 - if (dt.items) { 1013 - for (let i = 0; i < dt.items.length; i++) { 1014 - const item = dt.items[i]; 1015 - if (item && item.kind === 'file' && item.type.startsWith('image/')) { 1016 - hasImage = true; 1017 - break; 1018 - } 1019 - } 1020 - } else if (dt.files) { 1021 - for (let i = 0; i < dt.files.length; i++) { 1022 - const file = dt.files[i]; 1023 - if (file?.type.startsWith('image/')) { 1024 - hasImage = true; 1025 - break; 1026 - } 1027 - } 1028 - } 1029 - 1030 - if (hasImage) { 1031 - event.preventDefault(); 1032 - event.stopPropagation(); 1033 - 1034 - imageDragOver = true; 1035 - } 1036 - } 1037 - 1038 - function handleImageDragLeave(event: DragEvent) { 1039 - event.preventDefault(); 1040 - event.stopPropagation(); 1041 - imageDragOver = false; 658 + scrollToItem(item, isMobile, gridContainer); 1042 659 } 1043 660 1044 - async function handleImageDrop(event: DragEvent) { 1045 - event.preventDefault(); 1046 - event.stopPropagation(); 1047 - const dropX = event.clientX; 1048 - const dropY = event.clientY; 1049 - imageDragOver = false; 1050 - 1051 - if (!event.dataTransfer?.files?.length) return; 1052 - 1053 - const imageFiles = Array.from(event.dataTransfer.files).filter((f) => 1054 - f?.type.startsWith('image/') 1055 - ); 1056 - if (imageFiles.length === 0) return; 1057 - 1058 - // Calculate starting grid position from drop coordinates 1059 - let gridX = 0; 1060 - let gridY = 0; 1061 - if (container) { 1062 - const rect = container.getBoundingClientRect(); 1063 - const currentMargin = isMobile ? mobileMargin : margin; 1064 - const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 1065 - const cardW = isMobile ? 4 : 2; 1066 - 1067 - gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 1068 - gridX = Math.floor(gridX / 2) * 2; 1069 - 1070 - gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0); 1071 - if (isMobile) { 1072 - gridY = Math.floor(gridY / 2) * 2; 1073 - } 1074 - } 1075 - 1076 - 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++) { 1077 663 // First image gets the drop position, rest use normal placement 1078 664 if (i === 0) { 1079 - await processImageFile(imageFiles[i], gridX, gridY); 665 + await processImageFile(files[i], gridX, gridY); 1080 666 } else { 1081 - await processImageFile(imageFiles[i]); 667 + await processImageFile(files[i]); 1082 668 } 1083 669 } 1084 670 } ··· 1126 712 objectUrl 1127 713 }; 1128 714 1129 - const viewportCenter = getViewportCenterGridY(); 715 + const viewportCenter = gridContainer 716 + ? getViewportCenterGridY(gridContainer, isMobile) 717 + : undefined; 1130 718 setPositionOfNewItem(item, items, viewportCenter); 1131 719 items = [...items, item]; 1132 720 fixCollisions(items, item, false, true); ··· 1138 726 1139 727 await tick(); 1140 728 1141 - scrollToItem(item, isMobile, container); 729 + scrollToItem(item, isMobile, gridContainer); 1142 730 } 1143 731 1144 732 async function handleVideoInputChange(event: Event) { ··· 1168 756 1169 757 addLink(link); 1170 758 }} 1171 - /> 1172 - 1173 - <svelte:window 1174 - ondragover={handleImageDragOver} 1175 - ondragleave={handleImageDragLeave} 1176 - ondrop={handleImageDrop} 1177 759 /> 1178 760 1179 761 <Head ··· 1285 867 ]} 1286 868 > 1287 869 <div class="pointer-events-none"></div> 1288 - <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 1289 - <div 1290 - bind:this={container} 1291 - onclick={(e) => { 1292 - // Deselect when tapping empty grid space 1293 - if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) { 1294 - selectedCardId = null; 1295 - } 1296 - }} 1297 - ontouchstart={touchStart} 1298 - ontouchend={touchEnd} 1299 - ondragover={(e) => { 1300 - e.preventDefault(); 1301 - 1302 - // Auto-scroll when dragging near top or bottom of viewport (always process) 1303 - const scrollZone = 100; 1304 - const scrollSpeed = 10; 1305 - const viewportHeight = window.innerHeight; 1306 - 1307 - if (e.clientY < scrollZone) { 1308 - const intensity = 1 - e.clientY / scrollZone; 1309 - window.scrollBy(0, -scrollSpeed * intensity); 1310 - } else if (e.clientY > viewportHeight - scrollZone) { 1311 - const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; 1312 - window.scrollBy(0, scrollSpeed * intensity); 1313 - } 1314 - 1315 - const result = getDragXY(e); 1316 - if (!result) return; 1317 - 1318 - // Skip redundant work if grid position hasn't changed 1319 - if ( 1320 - lastGridPos && 1321 - lastGridPos.x === result.x && 1322 - lastGridPos.y === result.y && 1323 - lastGridPos.swapWithId === result.swapWithId && 1324 - lastGridPos.placement === result.placement 1325 - ) { 1326 - return; 1327 - } 1328 - lastGridPos = { 1329 - x: result.x, 1330 - y: result.y, 1331 - swapWithId: result.swapWithId, 1332 - placement: result.placement 1333 - }; 1334 - 1335 - activeDragElement.x = result.x; 1336 - activeDragElement.y = result.y; 1337 - 1338 - if (activeDragElement.item) { 1339 - // Get dragged card's original position for swapping 1340 - const draggedOrigPos = activeDragElement.originalPositions.get( 1341 - activeDragElement.item.id 1342 - ); 1343 - 1344 - // Reset all items to original positions first 1345 - for (const it of items) { 1346 - const origPos = activeDragElement.originalPositions.get(it.id); 1347 - if (origPos && it !== activeDragElement.item) { 1348 - if (isMobile) { 1349 - it.mobileX = origPos.mobileX; 1350 - it.mobileY = origPos.mobileY; 1351 - } else { 1352 - it.x = origPos.x; 1353 - it.y = origPos.y; 1354 - } 1355 - } 1356 - } 1357 - 1358 - // Update dragged item position 1359 - if (isMobile) { 1360 - activeDragElement.item.mobileX = result.x; 1361 - activeDragElement.item.mobileY = result.y; 1362 - } else { 1363 - activeDragElement.item.x = result.x; 1364 - activeDragElement.item.y = result.y; 1365 - } 1366 - 1367 - // Handle horizontal swap 1368 - if (result.swapWithId && draggedOrigPos) { 1369 - const swapTarget = items.find((it) => it.id === result.swapWithId); 1370 - if (swapTarget) { 1371 - // Move swap target to dragged card's original position 1372 - if (isMobile) { 1373 - swapTarget.mobileX = draggedOrigPos.mobileX; 1374 - swapTarget.mobileY = draggedOrigPos.mobileY; 1375 - } else { 1376 - swapTarget.x = draggedOrigPos.x; 1377 - swapTarget.y = draggedOrigPos.y; 1378 - } 1379 - } 1380 - } 1381 - 1382 - // Now fix collisions (with compacting) 1383 - fixCollisions(items, activeDragElement.item, isMobile); 1384 - } 1385 - }} 1386 - ondragend={async (e) => { 1387 - e.preventDefault(); 1388 - // safari fix 1389 - activeDragElement.x = -1; 1390 - activeDragElement.y = -1; 1391 - activeDragElement.element = null; 1392 - activeDragElement.item = null; 1393 - activeDragElement.lastTargetId = null; 1394 - activeDragElement.lastPlacement = null; 1395 - lastGridPos = null; 1396 - return true; 870 + <EditableGrid 871 + bind:items 872 + bind:ref={gridContainer} 873 + {isMobile} 874 + {selectedCardId} 875 + {isCoarse} 876 + onlayoutchange={onLayoutChanged} 877 + ondeselect={() => { 878 + selectedCardId = null; 1397 879 }} 1398 - class={[ 1399 - '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 1400 - imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' 1401 - ]} 880 + onfiledrop={handleFileDrop} 1402 881 > 1403 882 {#each items as item, i (item.id)} 1404 - <!-- {#if item !== activeDragElement.item} --> 1405 883 <BaseEditingCard 1406 884 bind:item={items[i]} 1407 885 ondelete={() => { ··· 1422 900 fixCollisions(items, item, isMobile); 1423 901 onLayoutChanged(); 1424 902 }} 1425 - ondragstart={(e: DragEvent) => { 1426 - const target = e.currentTarget as HTMLDivElement; 1427 - activeDragElement.element = target; 1428 - activeDragElement.w = item.w; 1429 - activeDragElement.h = item.h; 1430 - activeDragElement.item = item; 1431 - // fix for div shadow during drag and drop 1432 - const transparent = document.createElement('div'); 1433 - transparent.style.position = 'fixed'; 1434 - transparent.style.top = '-1000px'; 1435 - transparent.style.width = '1px'; 1436 - transparent.style.height = '1px'; 1437 - document.body.appendChild(transparent); 1438 - e.dataTransfer?.setDragImage(transparent, 0, 0); 1439 - requestAnimationFrame(() => transparent.remove()); 1440 - 1441 - // Store original positions of all items 1442 - activeDragElement.originalPositions = new Map(); 1443 - for (const it of items) { 1444 - activeDragElement.originalPositions.set(it.id, { 1445 - x: it.x, 1446 - y: it.y, 1447 - mobileX: it.mobileX, 1448 - mobileY: it.mobileY 1449 - }); 1450 - } 1451 - 1452 - const rect = target.getBoundingClientRect(); 1453 - activeDragElement.mouseDeltaX = rect.left - e.clientX; 1454 - activeDragElement.mouseDeltaY = rect.top - e.clientY; 1455 - }} 1456 903 > 1457 904 <EditingCard bind:item={items[i]} /> 1458 905 </BaseEditingCard> 1459 - <!-- {/if} --> 1460 906 {/each} 1461 - 1462 - <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> 1463 - </div> 907 + </EditableGrid> 1464 908 </div> 1465 909 </div> 1466 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 - }
+3 -3
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 ··· 202 202 const cards = data.cards.filter((v) => v.page === data.page); 203 203 204 204 if (cards.length > 0) { 205 - fixAllCollisions(cards); 205 + fixAllCollisions(cards, false); 206 206 fixAllCollisions(cards, true); 207 207 208 - compactItems(cards); 208 + compactItems(cards, false); 209 209 compactItems(cards, true); 210 210 } 211 211