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