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