From da5fd6fba2f1516fa1b78be6113db1778c3d23c1 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Thu, 05 Feb 2026 11:02:53 +0000 Subject: [PATCH] Merge pull request #160 from flo-bit/switch-grid-layout Switch grid layout editing to react-grid-layout --- .gitignore | 2 ++ package.json | 1 + pnpm-lock.yaml | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib/helper.ts | 361 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- src/lib/layout/EditableGrid.svelte | 398 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib/layout/algorithms.ts | 254 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib/layout/grid.ts | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib/layout/index.ts | 15 +++++++++++++++ src/lib/layout/mirror.ts | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib/website/EditableWebsite.svelte | 640 ++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- src/lib/website/layout-mirror.ts | 72 ------------------------------------------------------------------------ src/lib/website/load.ts | 6 +++--- src/lib/cards/_base/BaseCard/BaseCard.svelte | 12 +----------- src/lib/cards/_base/BaseCard/BaseEditingCard.svelte | 11 ++++------- 14 file(s) changed, 1093 insertion(s)(+), 1052 deletion(s)(-) diff --git a/.gitignore b/.gitignore --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* + +react-grid-layout \ No newline at end of file diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "perfect-freehand": "^1.2.2", "plyr": "^3.8.4", "qr-code-styling": "^1.8.6", + "react-grid-layout": "^2.2.2", "simple-icons": "^16.6.0", "svelte-sonner": "^1.0.7", "tailwind-merge": "^3.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,9 @@ qr-code-styling: specifier: ^1.8.6 version: 1.9.2 + react-grid-layout: + specifier: ^2.2.2 + version: 2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) simple-icons: specifier: ^16.6.0 version: 16.6.0 @@ -1967,6 +1970,9 @@ fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@4.0.3: + resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==, tarball: https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -2102,6 +2108,9 @@ jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz} js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} @@ -2239,6 +2248,10 @@ lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==, tarball: https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz} + hasBin: true + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -2339,6 +2352,10 @@ number-flow@0.5.9: resolution: {integrity: sha512-o3102c/4qRd6eV4n+rw6B/UP8+FosbhIxj4uA6GsjhryrGZRVtCtKIKEeBiOwUV52cUGJneeu0treELcV7U/lw==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, tarball: https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz} + engines: {node: '>=0.10.0'} obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -2526,6 +2543,9 @@ engines: {node: '>=14'} hasBin: true + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, tarball: https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz} + prosemirror-changeset@2.3.1: resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==} @@ -2608,6 +2628,36 @@ rangetouch@2.0.1: resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==} + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==, tarball: https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz} + peerDependencies: + react: ^19.2.4 + + react-draggable@4.5.0: + resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==, tarball: https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + + react-grid-layout@2.2.2: + resolution: {integrity: sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==, tarball: https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.2.tgz} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==, tarball: https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz} + + react-resizable@3.1.3: + resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==, tarball: https://registry.npmjs.org/react-resizable/-/react-resizable-3.1.3.tgz} + peerDependencies: + react: '>= 16.3' + react-dom: '>= 16.3' + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==, tarball: https://registry.npmjs.org/react/-/react-19.2.4.tgz} + engines: {node: '>=0.10.0'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -2619,6 +2669,9 @@ require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==, tarball: https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz} resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} @@ -2675,6 +2728,9 @@ satori@0.10.14: resolution: {integrity: sha512-abovcqmwl97WKioxpkfuMeZmndB1TuDFY/R+FymrZyiGP+pMYomvgSzVPnbNMWHHESOPosVHGL352oFbdAnJcA==} engines: {node: '>=16'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==, tarball: https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz} semver@7.7.3: resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} @@ -4731,6 +4787,8 @@ fast-deep-equal@3.1.3: {} + fast-equals@4.0.3: {} + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -4837,6 +4895,8 @@ jiti@2.6.1: {} + js-tokens@4.0.0: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -4941,6 +5001,10 @@ p-locate: 5.0.0 lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 lz-string@1.5.0: {} @@ -5066,6 +5130,8 @@ number-flow@0.5.9: dependencies: esm-env: 1.2.2 + + object-assign@4.1.1: {} obug@2.1.1: {} @@ -5200,6 +5266,12 @@ prettier@3.8.1: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + prosemirror-changeset@2.3.1: dependencies: prosemirror-transform: 1.11.0 @@ -5319,11 +5391,47 @@ rangetouch@2.0.1: {} + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-draggable@4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + clsx: 2.1.1 + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + react-grid-layout@2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + clsx: 2.1.1 + fast-equals: 4.0.3 + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-draggable: 4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-resizable: 3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + resize-observer-polyfill: 1.5.1 + + react-is@16.13.1: {} + + react-resizable@3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-draggable: 4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + + react@19.2.4: {} + readdirp@4.1.2: {} regexparam@3.0.0: {} require-from-string@2.0.2: {} + + resize-observer-polyfill@1.5.1: {} resolve-from@4.0.0: {} @@ -5412,6 +5520,8 @@ parse-css-color: 0.2.1 postcss-value-parser: 4.2.0 yoga-wasm-web: 0.3.3 + + scheduler@0.27.0: {} semver@7.7.3: {} diff --git a/src/lib/helper.ts b/src/lib/helper.ts --- a/src/lib/helper.ts +++ b/src/lib/helper.ts @@ -3,7 +3,6 @@ import { CardDefinitionsByType } from './cards'; import { deleteRecord, getCDNImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto'; import * as TID from '@atcute/tid'; - export function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } @@ -28,229 +27,6 @@ 'bg-rose-500' ]; -export const overlaps = (a: Item, b: Item, mobile: boolean = false) => { - if (a === b) return false; - if (mobile) { - return ( - a.mobileX < b.mobileX + b.mobileW && - a.mobileX + a.mobileW > b.mobileX && - a.mobileY < b.mobileY + b.mobileH && - a.mobileY + a.mobileH > b.mobileY - ); - } - 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; -}; - -export function fixCollisions( - items: Item[], - movedItem: Item, - mobile: boolean = false, - skipCompact: boolean = false -) { - const clampX = (item: Item) => { - if (mobile) item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); - else item.x = clamp(item.x, 0, COLUMNS - item.w); - }; - - // Push `target` down until it no longer overlaps with any item (including movedItem), - // while keeping target.x fixed. Any item we collide with gets pushed down first (cascade). - const pushDownCascade = (target: Item, blocker: Item) => { - // Keep x fixed always when pushing down - const fixedX = mobile ? target.mobileX : target.x; - const prevY = mobile ? target.mobileY : target.y; - - // We need target to move just below `blocker` - const desiredY = mobile ? blocker.mobileY + blocker.mobileH : blocker.y + blocker.h; - if (!mobile && target.y < desiredY) target.y = desiredY; - if (mobile && target.mobileY < desiredY) target.mobileY = desiredY; - - const newY = mobile ? target.mobileY : target.y; - const targetH = mobile ? target.mobileH : target.h; - - // fall trough fix - if (newY > prevY) { - const prevBottom = prevY + targetH; - const newBottom = newY + targetH; - for (const it of items) { - if (it === target || it === movedItem || it === blocker) continue; - const itY = mobile ? it.mobileY : it.y; - const itH = mobile ? it.mobileH : it.h; - const itBottom = itY + itH; - if (itBottom <= prevBottom || itY >= newBottom) continue; - // horizontal overlap check - const hOverlap = mobile - ? target.mobileX < it.mobileX + it.mobileW && target.mobileX + target.mobileW > it.mobileX - : target.x < it.x + it.w && target.x + target.w > it.x; - if (hOverlap) { - pushDownCascade(it, target); - } - } - } - - // Now resolve any collisions that creates by pushing those items down first - // Repeat until target is clean. - while (true) { - const hit = items.find((it) => it !== target && overlaps(target, it, mobile)); - if (!hit) break; - - // push the hit item down first (cascade), keeping its x fixed - pushDownCascade(hit, target); - - // after moving the hit item, target.x must remain fixed - if (mobile) target.mobileX = fixedX; - else target.x = fixedX; - } - }; - - // Ensure moved item is in bounds - clampX(movedItem); - - // Find all items colliding with movedItem, and push them down in a stable order: - // top-to-bottom so you get the nice chain reaction (0,0 -> 0,1 -> 0,2). - const colliders = items - .filter((it) => it !== movedItem && overlaps(movedItem, it, mobile)) - .toSorted((a, b) => - mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x - ); - - for (const it of colliders) { - // keep x clamped, but do NOT change x during push (we rely on fixed x) - clampX(it); - - // push it down just below movedItem; cascade handles the rest - pushDownCascade(it, movedItem); - - // enforce "x stays the same" during pushing (clamp already applied) - if (mobile) it.mobileX = clamp(it.mobileX, 0, COLUMNS - it.mobileW); - else it.x = clamp(it.x, 0, COLUMNS - it.w); - } - - if (!skipCompact) { - compactItems(items, mobile); - } -} - -// Fix all collisions between items (not just one moved item) -// Items higher on the page have priority and stay in place -export function fixAllCollisions(items: Item[], mobile: boolean = false) { - // Sort by Y position (top-to-bottom, then left-to-right) - // Items at the top have priority and won't be moved - const sortedItems = items.toSorted((a, b) => - mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x - ); - - // Process each item and push it down if it overlaps with any item above it - for (let i = 0; i < sortedItems.length; i++) { - const item = sortedItems[i]; - - // Clamp X to valid range - if (mobile) { - item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); - } else { - item.x = clamp(item.x, 0, COLUMNS - item.w); - } - - // Check for collisions with all items that come before (higher priority) - let hasCollision = true; - while (hasCollision) { - hasCollision = false; - for (let j = 0; j < i; j++) { - const other = sortedItems[j]; - if (overlaps(item, other, mobile)) { - // Push item down below the colliding item - if (mobile) { - item.mobileY = other.mobileY + other.mobileH; - } else { - item.y = other.y + other.h; - } - hasCollision = true; - break; // Restart collision check from the beginning - } - } - } - } - - compactItems(items, mobile); -} - -// Move all items up as far as possible without collisions -export function compactItems(items: Item[], mobile: boolean = false) { - // Sort by Y position (top-to-bottom) so upper items settle first. - const sortedItems = items.toSorted((a, b) => - mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x - ); - - // For each item, find the lowest Y it can occupy by checking the bottom edges - // of all horizontally-overlapping items already placed above it. - const settled: Item[] = []; - - for (const item of sortedItems) { - const itemX = mobile ? item.mobileX : item.x; - const itemW = mobile ? item.mobileW : item.w; - - let minY = 0; - - for (const other of settled) { - const otherX = mobile ? other.mobileX : other.x; - const otherW = mobile ? other.mobileW : other.w; - - // Check horizontal overlap - if (itemX < otherX + otherW && itemX + itemW > otherX) { - const otherBottom = mobile ? other.mobileY + other.mobileH : other.y + other.h; - if (otherBottom > minY) { - minY = otherBottom; - } - } - } - - if (mobile) { - item.mobileY = minY; - } else { - item.y = minY; - } - - settled.push(item); - } -} - -// Simulate where an item would end up after fixCollisions + compaction -export function simulateFinalPosition( - items: Item[], - movedItem: Item, - newX: number, - newY: number, - mobile: boolean = false -): { x: number; y: number } { - // Deep clone positions for simulation - const clonedItems: Item[] = items.map((item) => ({ - ...item, - x: item.x, - y: item.y, - mobileX: item.mobileX, - mobileY: item.mobileY - })); - - const clonedMovedItem = clonedItems.find((item) => item.id === movedItem.id); - if (!clonedMovedItem) return { x: newX, y: newY }; - - // Set the new position - if (mobile) { - clonedMovedItem.mobileX = newX; - clonedMovedItem.mobileY = newY; - } else { - clonedMovedItem.x = newX; - clonedMovedItem.y = newY; - } - - // Run fixCollisions on the cloned data - fixCollisions(clonedItems, clonedMovedItem, mobile); - - // Return the final position of the moved item - return mobile - ? { x: clonedMovedItem.mobileX, y: clonedMovedItem.mobileY } - : { x: clonedMovedItem.x, y: clonedMovedItem.y }; -} - export function sortItems(a: Item, b: Item) { return a.y * COLUMNS + a.x - b.y * COLUMNS - b.x; } @@ -271,143 +47,6 @@ a.color === b.color && a.page === b.page ); -} - -export function setPositionOfNewItem( - newItem: Item, - items: Item[], - viewportCenter?: { gridY: number; isMobile: boolean } -) { - if (viewportCenter) { - const { gridY, isMobile } = viewportCenter; - - if (isMobile) { - // Place at viewport center Y - newItem.mobileY = Math.max(0, Math.round(gridY - newItem.mobileH / 2)); - newItem.mobileY = Math.floor(newItem.mobileY / 2) * 2; - - // Try to find a free X at this Y - let found = false; - for ( - newItem.mobileX = 0; - newItem.mobileX <= COLUMNS - newItem.mobileW; - newItem.mobileX += 2 - ) { - if (!items.some((item) => overlaps(newItem, item, true))) { - found = true; - break; - } - } - if (!found) { - newItem.mobileX = 0; - } - - // Desktop: derive from mobile - newItem.y = Math.max(0, Math.round(newItem.mobileY / 2)); - found = false; - for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { - if (!items.some((item) => overlaps(newItem, item, false))) { - found = true; - break; - } - } - if (!found) { - newItem.x = 0; - } - } else { - // Place at viewport center Y - newItem.y = Math.max(0, Math.round(gridY - newItem.h / 2)); - - // Try to find a free X at this Y - let found = false; - for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { - if (!items.some((item) => overlaps(newItem, item, false))) { - found = true; - break; - } - } - if (!found) { - newItem.x = 0; - } - - // Mobile: derive from desktop - newItem.mobileY = Math.max(0, Math.round(newItem.y * 2)); - found = false; - for ( - newItem.mobileX = 0; - newItem.mobileX <= COLUMNS - newItem.mobileW; - newItem.mobileX += 2 - ) { - if (!items.some((item) => overlaps(newItem, item, true))) { - found = true; - break; - } - } - if (!found) { - newItem.mobileX = 0; - } - } - return; - } - - let foundPosition = false; - while (!foundPosition) { - for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { - const collision = items.find((item) => overlaps(newItem, item)); - if (!collision) { - foundPosition = true; - break; - } - } - if (!foundPosition) newItem.y += 1; - } - - let foundMobilePosition = false; - while (!foundMobilePosition) { - for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX += 1) { - const collision = items.find((item) => overlaps(newItem, item, true)); - - if (!collision) { - foundMobilePosition = true; - break; - } - } - if (!foundMobilePosition) newItem.mobileY! += 1; - } -} - -/** - * Find a valid position for a new item in a single mode (desktop or mobile). - * This modifies the item's position properties in-place. - */ -export function findValidPosition(newItem: Item, items: Item[], mobile: boolean) { - if (mobile) { - let foundPosition = false; - newItem.mobileY = 0; - while (!foundPosition) { - for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX++) { - const collision = items.find((item) => overlaps(newItem, item, true)); - if (!collision) { - foundPosition = true; - break; - } - } - if (!foundPosition) newItem.mobileY! += 1; - } - } else { - let foundPosition = false; - newItem.y = 0; - while (!foundPosition) { - for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { - const collision = items.find((item) => overlaps(newItem, item, false)); - if (!collision) { - foundPosition = true; - break; - } - } - if (!foundPosition) newItem.y += 1; - } - } } export async function refreshData(data: { updatedAt?: number; handle: string }) { diff --git a/src/lib/layout/EditableGrid.svelte b/src/lib/layout/EditableGrid.svelte new file mode 100644 --- /dev/null +++ b/src/lib/layout/EditableGrid.svelte @@ -0,0 +1,398 @@ + + + + + +
e.preventDefault()} + class={[ + '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', + fileDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' + ]} +> + {@render children()} + +
+
+ + diff --git a/src/lib/layout/algorithms.ts b/src/lib/layout/algorithms.ts new file mode 100644 --- /dev/null +++ b/src/lib/layout/algorithms.ts @@ -0,0 +1,254 @@ +import { type LayoutItem, type Layout } from 'react-grid-layout/core'; +import { + collides, + moveElement, + correctBounds, + getFirstCollision, + verticalCompactor +} from 'react-grid-layout/core'; +import type { Item } from '../types'; +import { COLUMNS } from '$lib'; +import { clamp } from '../helper'; + +function toLayoutItem(item: Item, mobile: boolean): LayoutItem { + if (mobile) { + return { + x: item.mobileX, + y: item.mobileY, + w: item.mobileW, + h: item.mobileH, + i: item.id + }; + } + return { + x: item.x, + y: item.y, + w: item.w, + h: item.h, + i: item.id + }; +} + +function toLayout(items: Item[], mobile: boolean): LayoutItem[] { + return items.map((i) => toLayoutItem(i, mobile)); +} + +function applyLayout(items: Item[], layout: LayoutItem[], mobile: boolean): void { + const itemsMap: Map = new Map(); + + for (const item of items) { + itemsMap.set(item.id, item); + } + for (const l of layout) { + const item = itemsMap.get(l.i); + + if (!item) { + console.error('item not found in layout!! this should never happen!'); + continue; + } + + if (mobile) { + item.mobileX = l.x; + item.mobileY = l.y; + } else { + item.x = l.x; + item.y = l.y; + } + } +} + +export function overlaps(a: Item, b: Item, mobile: boolean) { + if (a === b) return false; + return collides(toLayoutItem(a, mobile), toLayoutItem(b, mobile)); +} + +export function fixCollisions( + items: Item[], + item: Item, + mobile: boolean = false, + skipCompact: boolean = false, + originalPos?: { x: number; y: number } +) { + if (mobile) item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); + else item.x = clamp(item.x, 0, COLUMNS - item.w); + + const targetX = mobile ? item.mobileX : item.x; + const targetY = mobile ? item.mobileY : item.y; + + let layout = toLayout(items, mobile); + + const movedLayoutItem = layout.find((i) => i.i === item.id); + + if (!movedLayoutItem) { + console.error('item not found in layout! this should never happen!'); + return; + } + + // If we know the original position, set it on the layout item so + // moveElement can detect direction and push items properly. + if (originalPos) { + movedLayoutItem.x = originalPos.x; + movedLayoutItem.y = originalPos.y; + } + + layout = moveElement(layout, movedLayoutItem, targetX, targetY, true, false, 'vertical', COLUMNS); + + if (!skipCompact) layout = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; + + applyLayout(items, layout, mobile); +} + +export function fixAllCollisions(items: Item[], mobile: boolean) { + let layout = toLayout(items, mobile); + correctBounds(layout as any, { cols: COLUMNS }); + layout = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; + applyLayout(items, layout, mobile); +} + +export function compactItems(items: Item[], mobile: boolean) { + const layout = toLayout(items, mobile); + const compacted = verticalCompactor.compact(layout, COLUMNS) as LayoutItem[]; + applyLayout(items, compacted, mobile); +} + +export function setPositionOfNewItem( + newItem: Item, + items: Item[], + viewportCenter?: { gridY: number; isMobile: boolean } +) { + const desktopLayout = toLayout(items, false); + const mobileLayout = toLayout(items, true); + + function hasCollision(mobile: boolean): boolean { + const layout = mobile ? mobileLayout : desktopLayout; + return getFirstCollision(layout, toLayoutItem(newItem, mobile)) !== undefined; + } + + if (viewportCenter) { + const { gridY, isMobile } = viewportCenter; + + if (isMobile) { + // Place at viewport center Y + newItem.mobileY = Math.max(0, Math.round(gridY - newItem.mobileH / 2)); + newItem.mobileY = Math.floor(newItem.mobileY / 2) * 2; + + // Try to find a free X at this Y + let found = false; + for ( + newItem.mobileX = 0; + newItem.mobileX <= COLUMNS - newItem.mobileW; + newItem.mobileX += 2 + ) { + if (!hasCollision(true)) { + found = true; + break; + } + } + if (!found) { + newItem.mobileX = 0; + } + + // Desktop: derive from mobile + newItem.y = Math.max(0, Math.round(newItem.mobileY / 2)); + found = false; + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { + if (!hasCollision(false)) { + found = true; + break; + } + } + if (!found) { + newItem.x = 0; + } + } else { + // Place at viewport center Y + newItem.y = Math.max(0, Math.round(gridY - newItem.h / 2)); + + // Try to find a free X at this Y + let found = false; + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) { + if (!hasCollision(false)) { + found = true; + break; + } + } + if (!found) { + newItem.x = 0; + } + + // Mobile: derive from desktop + newItem.mobileY = Math.max(0, Math.round(newItem.y * 2)); + found = false; + for ( + newItem.mobileX = 0; + newItem.mobileX <= COLUMNS - newItem.mobileW; + newItem.mobileX += 2 + ) { + if (!hasCollision(true)) { + found = true; + break; + } + } + if (!found) { + newItem.mobileX = 0; + } + } + return; + } + + let foundPosition = false; + while (!foundPosition) { + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { + if (!hasCollision(false)) { + foundPosition = true; + break; + } + } + if (!foundPosition) newItem.y += 1; + } + + let foundMobilePosition = false; + while (!foundMobilePosition) { + for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX += 1) { + if (!hasCollision(true)) { + foundMobilePosition = true; + break; + } + } + if (!foundMobilePosition) newItem.mobileY! += 1; + } +} + +/** + * Find a valid position for a new item in a single mode (desktop or mobile). + * This modifies the item's position properties in-place. + */ +export function findValidPosition(newItem: Item, items: Item[], mobile: boolean) { + const layout = toLayout(items, mobile); + + if (mobile) { + let foundPosition = false; + newItem.mobileY = 0; + while (!foundPosition) { + for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX++) { + if (!getFirstCollision(layout, toLayoutItem(newItem, true))) { + foundPosition = true; + break; + } + } + if (!foundPosition) newItem.mobileY! += 1; + } + } else { + let foundPosition = false; + newItem.y = 0; + while (!foundPosition) { + for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) { + if (!getFirstCollision(layout, toLayoutItem(newItem, false))) { + foundPosition = true; + break; + } + } + if (!foundPosition) newItem.y += 1; + } + } +} diff --git a/src/lib/layout/grid.ts b/src/lib/layout/grid.ts new file mode 100644 --- /dev/null +++ b/src/lib/layout/grid.ts @@ -0,0 +1,190 @@ +import { COLUMNS, margin, mobileMargin } from '$lib'; +import { clamp } from '$lib/helper'; +import type { Item } from '$lib/types'; + +export type GridPosition = { + x: number; + y: number; + swapWithId: string | null; + placement: 'above' | 'below' | null; +}; + +export type DragState = { + item: Item; + mouseDeltaX: number; + mouseDeltaY: number; + originalPositions: Map; + lastTargetId: string | null; + lastPlacement: 'above' | 'below' | null; +}; + +/** + * Convert client coordinates to a grid position with swap detection and hysteresis. + * Returns undefined if container or dragState.item is missing. + * Mutates dragState.lastTargetId and dragState.lastPlacement for hysteresis tracking. + */ +export function getGridPosition( + clientX: number, + clientY: number, + container: HTMLElement, + dragState: DragState, + items: Item[], + isMobile: boolean +): GridPosition | undefined { + if (!dragState.item) return; + + // x, y represent the top-left corner of the dragged card + const x = clientX + dragState.mouseDeltaX; + const y = clientY + dragState.mouseDeltaY; + + const rect = container.getBoundingClientRect(); + const currentMargin = isMobile ? mobileMargin : margin; + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; + + // Get card dimensions based on current view mode + const cardW = isMobile ? dragState.item.mobileW : dragState.item.w; + const cardH = isMobile ? dragState.item.mobileH : dragState.item.h; + + // Get dragged card's original position + const draggedOrigPos = dragState.originalPositions.get(dragState.item.id); + const draggedOrigY = draggedOrigPos ? (isMobile ? draggedOrigPos.mobileY : draggedOrigPos.y) : 0; + + // Calculate raw grid position based on top-left of dragged card + let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); + gridX = Math.floor(gridX / 2) * 2; + + let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0); + + if (isMobile) { + gridX = Math.floor(gridX / 2) * 2; + gridY = Math.floor(gridY / 2) * 2; + } + + // Find if we're hovering over another card (using ORIGINAL positions) + const centerGridY = gridY + cardH / 2; + const centerGridX = gridX + cardW / 2; + + let swapWithId: string | null = null; + let placement: 'above' | 'below' | null = null; + + for (const other of items) { + if (other === dragState.item) continue; + + // Use original positions for hit testing + const origPos = dragState.originalPositions.get(other.id); + if (!origPos) continue; + + const otherX = isMobile ? origPos.mobileX : origPos.x; + const otherY = isMobile ? origPos.mobileY : origPos.y; + const otherW = isMobile ? other.mobileW : other.w; + const otherH = isMobile ? other.mobileH : other.h; + + // Check if dragged card's center point is within this card's original bounds + if ( + centerGridX >= otherX && + centerGridX < otherX + otherW && + centerGridY >= otherY && + centerGridY < otherY + otherH + ) { + // Check if this is a swap situation: + // Cards have the same dimensions and are on the same row + const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; + + if (canSwap) { + // Swap positions + swapWithId = other.id; + gridX = otherX; + gridY = otherY; + placement = null; + + dragState.lastTargetId = other.id; + dragState.lastPlacement = null; + } else { + // Vertical placement (above/below) + // Detect drag direction: if dragging up, always place above + const isDraggingUp = gridY < draggedOrigY; + + if (isDraggingUp) { + // When dragging up, always place above + placement = 'above'; + } else { + // When dragging down, use top/bottom half logic + const midpointY = otherY + otherH / 2; + const hysteresis = 0.3; + + if (dragState.lastTargetId === other.id && dragState.lastPlacement) { + if (dragState.lastPlacement === 'above') { + placement = centerGridY > midpointY + hysteresis ? 'below' : 'above'; + } else { + placement = centerGridY < midpointY - hysteresis ? 'above' : 'below'; + } + } else { + placement = centerGridY < midpointY ? 'above' : 'below'; + } + } + + dragState.lastTargetId = other.id; + dragState.lastPlacement = placement; + + if (placement === 'above') { + gridY = otherY; + } else { + gridY = otherY + otherH; + } + } + break; + } + } + + // If we're not over any card, clear the tracking + if (!swapWithId && !placement) { + dragState.lastTargetId = null; + dragState.lastPlacement = null; + } + + return { x: gridX, y: gridY, swapWithId, placement }; +} + +/** + * Get the grid Y coordinate at the viewport center. + */ +export function getViewportCenterGridY( + container: HTMLElement, + isMobile: boolean +): { gridY: number; isMobile: boolean } { + const rect = container.getBoundingClientRect(); + const currentMargin = isMobile ? mobileMargin : margin; + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; + const viewportCenterY = window.innerHeight / 2; + const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize; + return { gridY, isMobile }; +} + +/** + * Convert pixel drop coordinates to grid position. Used for file drops. + */ +export function pixelToGrid( + clientX: number, + clientY: number, + container: HTMLElement, + isMobile: boolean, + cardW: number +): { gridX: number; gridY: number } { + const rect = container.getBoundingClientRect(); + const currentMargin = isMobile ? mobileMargin : margin; + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; + + let gridX = clamp( + Math.round((clientX - rect.left - currentMargin) / cellSize), + 0, + COLUMNS - cardW + ); + gridX = Math.floor(gridX / 2) * 2; + + let gridY = Math.max(Math.round((clientY - rect.top - currentMargin) / cellSize), 0); + if (isMobile) { + gridY = Math.floor(gridY / 2) * 2; + } + + return { gridX, gridY }; +} diff --git a/src/lib/layout/index.ts b/src/lib/layout/index.ts new file mode 100644 --- /dev/null +++ b/src/lib/layout/index.ts @@ -0,0 +1,15 @@ +export { + overlaps, + fixCollisions, + fixAllCollisions, + compactItems, + setPositionOfNewItem, + findValidPosition +} from './algorithms'; + +export { shouldMirror, mirrorItemSize, mirrorLayout } from './mirror'; + +export { getGridPosition, getViewportCenterGridY, pixelToGrid } from './grid'; +export type { GridPosition, DragState } from './grid'; + +export { default as EditableGrid } from './EditableGrid.svelte'; diff --git a/src/lib/layout/mirror.ts b/src/lib/layout/mirror.ts new file mode 100644 --- /dev/null +++ b/src/lib/layout/mirror.ts @@ -0,0 +1,73 @@ +import { COLUMNS } from '$lib'; +import { CardDefinitionsByType } from '$lib/cards'; +import { clamp } from '$lib/helper'; +import { fixAllCollisions, findValidPosition } from './algorithms'; +import type { Item } from '$lib/types'; + +/** + * Returns true when mirroring should still happen (i.e. user hasn't edited both layouts). + * editedOn: 0/undefined = never, 1 = desktop only, 2 = mobile only, 3 = both + */ +export function shouldMirror(editedOn: number | undefined): boolean { + return (editedOn ?? 0) !== 3; +} + +/** Snap a value to the nearest even integer (min 2). */ +function snapEven(v: number): number { + return Math.max(2, Math.round(v / 2) * 2); +} + +/** + * Compute the other layout's size for a single item, preserving aspect ratio. + * Clamps to the card definition's minW/maxW/minH/maxH if defined. + * Mutates the item in-place. + */ +export function mirrorItemSize(item: Item, fromMobile: boolean): void { + const def = CardDefinitionsByType[item.cardType]; + + if (fromMobile) { + // Mobile → Desktop: halve both dimensions, then clamp to card def constraints + // (constraints are in desktop units) + item.w = clamp(snapEven(item.mobileW / 2), def?.minW ?? 2, def?.maxW ?? COLUMNS); + item.h = clamp(Math.round(item.mobileH / 2), def?.minH ?? 1, def?.maxH ?? Infinity); + } else { + // Desktop → Mobile: double both dimensions + // (don't apply card def constraints — they're in desktop units) + item.mobileW = Math.min(item.w * 2, COLUMNS); + item.mobileH = Math.max(item.h * 2, 2); + } +} + +/** + * Mirror the full layout from one view to the other. + * Copies sizes proportionally and maps positions, then resolves collisions. + * Mutates items in-place. + */ +export function mirrorLayout(items: Item[], fromMobile: boolean): void { + // Mirror sizes first + for (const item of items) { + mirrorItemSize(item, fromMobile); + } + + if (fromMobile) { + // Mobile → Desktop: reflow items to use the full grid width. + // Sort by mobile position so items are placed in reading order. + const sorted = items.toSorted((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); + + // Place each item into the first available spot on the desktop grid + const placed: Item[] = []; + for (const item of sorted) { + item.x = 0; + item.y = 0; + findValidPosition(item, placed, false); + placed.push(item); + } + } else { + // Desktop → Mobile: proportional positions + for (const item of items) { + item.mobileX = clamp(Math.floor((item.x * 2) / 2) * 2, 0, COLUMNS - item.mobileW); + item.mobileY = Math.max(0, Math.round(item.y * 2)); + } + fixAllCollisions(items, true); + } +} diff --git a/src/lib/website/EditableWebsite.svelte b/src/lib/website/EditableWebsite.svelte --- a/src/lib/website/EditableWebsite.svelte +++ b/src/lib/website/EditableWebsite.svelte @@ -1,21 +1,15 @@