tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
commit
Florian
4 days ago
f9466881
7ac0af09
+633
-634
9 changed files
expand all
collapse all
unified
split
.gitignore
src
lib
cards
_base
BaseCard
BaseCard.svelte
BaseEditingCard.svelte
layout
EditableGrid.svelte
algorithms.ts
grid.ts
index.ts
mirror.ts
website
EditableWebsite.svelte
+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
-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
-6
src/lib/cards/_base/BaseCard/BaseEditingCard.svelte
···
193
193
]}
194
194
{...rest}
195
195
>
196
196
-
{#if isCoarse?.() ? !isSelected : !item.cardData?.locked}
196
196
+
{#if isCoarse?.() && !isSelected}
197
197
<!-- svelte-ignore a11y_click_events_have_key_events -->
198
198
<div
199
199
role="button"
200
200
tabindex="-1"
201
201
-
class={['absolute inset-0', isCoarse?.() ? 'z-20 cursor-pointer' : 'cursor-grab']}
201
201
+
class="absolute inset-0 z-20 cursor-pointer"
202
202
onclick={(e) => {
203
203
-
if (isCoarse?.()) {
204
204
-
e.stopPropagation();
205
205
-
selectCard?.(item.id);
206
206
-
}
203
203
+
e.stopPropagation();
204
204
+
selectCard?.(item.id);
207
205
}}
208
206
></div>
209
207
{/if}
+2
-2
src/lib/layout.ts
src/lib/layout/algorithms.ts
···
6
6
getFirstCollision,
7
7
verticalCompactor
8
8
} from 'react-grid-layout/core';
9
9
-
import type { Item } from './types';
9
9
+
import type { Item } from '../types';
10
10
import { COLUMNS } from '$lib';
11
11
-
import { clamp } from './helper';
11
11
+
import { clamp } from '../helper';
12
12
13
13
function toLayoutItem(item: Item, mobile: boolean): LayoutItem {
14
14
if (mobile) {
+378
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
+
// Do NOT preventDefault — allow scroll on touch
104
104
+
document.addEventListener('pointermove', handlePointerMove);
105
105
+
document.addEventListener('pointerup', handlePointerUp);
106
106
+
document.addEventListener('pointercancel', handlePointerCancel);
107
107
+
}
108
108
+
109
109
+
function activateDrag(e: PointerEvent) {
110
110
+
phase = 'active';
111
111
+
112
112
+
try {
113
113
+
(e.target as HTMLElement)?.setPointerCapture?.(pointerId);
114
114
+
} catch {
115
115
+
// setPointerCapture can throw if pointer is already released
116
116
+
}
117
117
+
118
118
+
// Visual feedback: lift the dragged card
119
119
+
draggedCardEl?.classList.add('dragging');
120
120
+
121
121
+
// Store original positions of all items
122
122
+
dragState.originalPositions = new Map();
123
123
+
for (const it of items) {
124
124
+
dragState.originalPositions.set(it.id, {
125
125
+
x: it.x,
126
126
+
y: it.y,
127
127
+
mobileX: it.mobileX,
128
128
+
mobileY: it.mobileY
129
129
+
});
130
130
+
}
131
131
+
dragState.lastTargetId = null;
132
132
+
dragState.lastPlacement = null;
133
133
+
134
134
+
document.body.style.userSelect = 'none';
135
135
+
}
136
136
+
137
137
+
function handlePointerMove(e: PointerEvent) {
138
138
+
if (!container) return;
139
139
+
140
140
+
if (phase === 'pending') {
141
141
+
// Check 3px threshold
142
142
+
const dx = e.clientX - startClientX;
143
143
+
const dy = e.clientY - startClientY;
144
144
+
if (dx * dx + dy * dy < 9) return;
145
145
+
activateDrag(e);
146
146
+
}
147
147
+
148
148
+
if (phase !== 'active') return;
149
149
+
150
150
+
// Auto-scroll near edges
151
151
+
const scrollZone = 100;
152
152
+
const scrollSpeed = 10;
153
153
+
const viewportHeight = window.innerHeight;
154
154
+
155
155
+
if (e.clientY < scrollZone) {
156
156
+
const intensity = 1 - e.clientY / scrollZone;
157
157
+
window.scrollBy(0, -scrollSpeed * intensity);
158
158
+
} else if (e.clientY > viewportHeight - scrollZone) {
159
159
+
const intensity = 1 - (viewportHeight - e.clientY) / scrollZone;
160
160
+
window.scrollBy(0, scrollSpeed * intensity);
161
161
+
}
162
162
+
163
163
+
const result = getGridPosition(e.clientX, e.clientY, container, dragState, items, isMobile);
164
164
+
if (!result || !dragState.item) return;
165
165
+
166
166
+
// Skip redundant work if grid position hasn't changed
167
167
+
if (
168
168
+
lastGridPos &&
169
169
+
lastGridPos.x === result.x &&
170
170
+
lastGridPos.y === result.y &&
171
171
+
lastGridPos.swapWithId === result.swapWithId &&
172
172
+
lastGridPos.placement === result.placement
173
173
+
) {
174
174
+
return;
175
175
+
}
176
176
+
lastGridPos = result;
177
177
+
178
178
+
const draggedOrigPos = dragState.originalPositions.get(dragState.item.id);
179
179
+
180
180
+
// Reset all items to original positions first
181
181
+
for (const it of items) {
182
182
+
const origPos = dragState.originalPositions.get(it.id);
183
183
+
if (origPos && it !== dragState.item) {
184
184
+
if (isMobile) {
185
185
+
it.mobileX = origPos.mobileX;
186
186
+
it.mobileY = origPos.mobileY;
187
187
+
} else {
188
188
+
it.x = origPos.x;
189
189
+
it.y = origPos.y;
190
190
+
}
191
191
+
}
192
192
+
}
193
193
+
194
194
+
// Update dragged item position
195
195
+
if (isMobile) {
196
196
+
dragState.item.mobileX = result.x;
197
197
+
dragState.item.mobileY = result.y;
198
198
+
} else {
199
199
+
dragState.item.x = result.x;
200
200
+
dragState.item.y = result.y;
201
201
+
}
202
202
+
203
203
+
// Handle horizontal swap
204
204
+
if (result.swapWithId && draggedOrigPos) {
205
205
+
const swapTarget = items.find((it) => it.id === result.swapWithId);
206
206
+
if (swapTarget) {
207
207
+
if (isMobile) {
208
208
+
swapTarget.mobileX = draggedOrigPos.mobileX;
209
209
+
swapTarget.mobileY = draggedOrigPos.mobileY;
210
210
+
} else {
211
211
+
swapTarget.x = draggedOrigPos.x;
212
212
+
swapTarget.y = draggedOrigPos.y;
213
213
+
}
214
214
+
}
215
215
+
}
216
216
+
217
217
+
fixCollisions(
218
218
+
items,
219
219
+
dragState.item,
220
220
+
isMobile,
221
221
+
false,
222
222
+
draggedOrigPos
223
223
+
? {
224
224
+
x: isMobile ? draggedOrigPos.mobileX : draggedOrigPos.x,
225
225
+
y: isMobile ? draggedOrigPos.mobileY : draggedOrigPos.y
226
226
+
}
227
227
+
: undefined
228
228
+
);
229
229
+
}
230
230
+
231
231
+
function handlePointerUp() {
232
232
+
if (phase === 'active' && dragState.item) {
233
233
+
fixCollisions(items, dragState.item, isMobile);
234
234
+
onlayoutchange();
235
235
+
}
236
236
+
cleanup();
237
237
+
}
238
238
+
239
239
+
function handlePointerCancel() {
240
240
+
if (phase === 'active') {
241
241
+
// Restore all items to original positions
242
242
+
for (const it of items) {
243
243
+
const origPos = dragState.originalPositions.get(it.id);
244
244
+
if (origPos) {
245
245
+
it.x = origPos.x;
246
246
+
it.y = origPos.y;
247
247
+
it.mobileX = origPos.mobileX;
248
248
+
it.mobileY = origPos.mobileY;
249
249
+
}
250
250
+
}
251
251
+
}
252
252
+
cleanup();
253
253
+
}
254
254
+
255
255
+
function cleanup() {
256
256
+
draggedCardEl?.classList.remove('dragging');
257
257
+
draggedCardEl = null;
258
258
+
phase = 'idle';
259
259
+
lastGridPos = null;
260
260
+
document.body.style.userSelect = '';
261
261
+
262
262
+
document.removeEventListener('pointermove', handlePointerMove);
263
263
+
document.removeEventListener('pointerup', handlePointerUp);
264
264
+
document.removeEventListener('pointercancel', handlePointerCancel);
265
265
+
}
266
266
+
267
267
+
// Ensure cleanup on unmount
268
268
+
$effect(() => {
269
269
+
return () => {
270
270
+
if (phase !== 'idle') cleanup();
271
271
+
};
272
272
+
});
273
273
+
274
274
+
// For touch: register non-passive touchmove to prevent scroll during active drag
275
275
+
$effect(() => {
276
276
+
if (phase !== 'active' || !container) return;
277
277
+
function preventTouch(e: TouchEvent) {
278
278
+
e.preventDefault();
279
279
+
}
280
280
+
container.addEventListener('touchmove', preventTouch, { passive: false });
281
281
+
return () => {
282
282
+
container?.removeEventListener('touchmove', preventTouch);
283
283
+
};
284
284
+
});
285
285
+
286
286
+
function handleClick(e: MouseEvent) {
287
287
+
// Deselect when tapping empty grid space
288
288
+
if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) {
289
289
+
ondeselect();
290
290
+
}
291
291
+
}
292
292
+
293
293
+
// --- File drop handlers ---
294
294
+
295
295
+
function hasImageFile(dt: DataTransfer): boolean {
296
296
+
if (dt.items) {
297
297
+
for (let i = 0; i < dt.items.length; i++) {
298
298
+
const item = dt.items[i];
299
299
+
if (item && item.kind === 'file' && item.type.startsWith('image/')) {
300
300
+
return true;
301
301
+
}
302
302
+
}
303
303
+
} else if (dt.files) {
304
304
+
for (let i = 0; i < dt.files.length; i++) {
305
305
+
const file = dt.files[i];
306
306
+
if (file?.type.startsWith('image/')) {
307
307
+
return true;
308
308
+
}
309
309
+
}
310
310
+
}
311
311
+
return false;
312
312
+
}
313
313
+
314
314
+
function handleFileDragOver(event: DragEvent) {
315
315
+
const dt = event.dataTransfer;
316
316
+
if (!dt) return;
317
317
+
318
318
+
if (hasImageFile(dt)) {
319
319
+
event.preventDefault();
320
320
+
event.stopPropagation();
321
321
+
fileDragOver = true;
322
322
+
}
323
323
+
}
324
324
+
325
325
+
function handleFileDragLeave(event: DragEvent) {
326
326
+
event.preventDefault();
327
327
+
event.stopPropagation();
328
328
+
fileDragOver = false;
329
329
+
}
330
330
+
331
331
+
function handleFileDrop(event: DragEvent) {
332
332
+
event.preventDefault();
333
333
+
event.stopPropagation();
334
334
+
fileDragOver = false;
335
335
+
336
336
+
if (!event.dataTransfer?.files?.length || !onfiledrop || !container) return;
337
337
+
338
338
+
const imageFiles = Array.from(event.dataTransfer.files).filter((f) =>
339
339
+
f?.type.startsWith('image/')
340
340
+
);
341
341
+
if (imageFiles.length === 0) return;
342
342
+
343
343
+
const cardW = isMobile ? 4 : 2;
344
344
+
const { gridX, gridY } = pixelToGrid(event.clientX, event.clientY, container, isMobile, cardW);
345
345
+
346
346
+
onfiledrop(imageFiles, gridX, gridY);
347
347
+
}
348
348
+
</script>
349
349
+
350
350
+
<svelte:window
351
351
+
ondragover={handleFileDragOver}
352
352
+
ondragleave={handleFileDragLeave}
353
353
+
ondrop={handleFileDrop}
354
354
+
/>
355
355
+
356
356
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
357
357
+
<div
358
358
+
bind:this={container}
359
359
+
onpointerdown={handlePointerDown}
360
360
+
onclick={handleClick}
361
361
+
ondragstart={(e) => e.preventDefault()}
362
362
+
class={[
363
363
+
'@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8',
364
364
+
fileDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed'
365
365
+
]}
366
366
+
>
367
367
+
{@render children()}
368
368
+
369
369
+
<div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div>
370
370
+
</div>
371
371
+
372
372
+
<style>
373
373
+
:global(.card.dragging) {
374
374
+
z-index: 50 !important;
375
375
+
scale: 1.03;
376
376
+
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
377
377
+
}
378
378
+
</style>
+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';
+40
-614
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
6
createEmptyCard,
8
7
getHideProfileSection,
9
8
getProfilePosition,
···
27
26
import Context from './Context.svelte';
28
27
import Head from './Head.svelte';
29
28
import Account from './Account.svelte';
30
30
-
import { SelectThemePopover } from '$lib/components/select-theme';
31
29
import EditBar from './EditBar.svelte';
32
30
import SaveModal from './SaveModal.svelte';
33
31
import FloatingEditButton from './FloatingEditButton.svelte';
···
36
34
import { launchConfetti } from '@foxui/visual';
37
35
import Controls from './Controls.svelte';
38
36
import CardCommand from '$lib/components/card-command/CardCommand.svelte';
39
39
-
import { shouldMirror, mirrorLayout } from './layout-mirror';
40
37
import { SvelteMap } from 'svelte/reactivity';
41
41
-
import { fixCollisions, compactItems, fixAllCollisions, setPositionOfNewItem } from '$lib/layout';
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';
42
48
43
49
let {
44
50
data
···
49
55
// Check if floating login button will be visible (to hide MadeWithBlento)
50
56
const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn);
51
57
52
52
-
function updateTheme(newAccent: string, newBase: string) {
53
53
-
data.publication.preferences ??= {};
54
54
-
data.publication.preferences.accentColor = newAccent;
55
55
-
data.publication.preferences.baseColor = newBase;
56
56
-
hasUnsavedChanges = true;
57
57
-
data = { ...data };
58
58
-
}
59
59
-
60
60
-
let imageDragOver = $state(false);
61
61
-
62
58
// svelte-ignore state_referenced_locally
63
59
let items: Item[] = $state(data.cards);
64
60
···
97
93
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
98
94
});
99
95
100
100
-
let container: HTMLDivElement | undefined = $state();
101
101
-
102
102
-
let activeDragElement: {
103
103
-
element: HTMLDivElement | null;
104
104
-
item: Item | null;
105
105
-
w: number;
106
106
-
h: number;
107
107
-
x: number;
108
108
-
y: number;
109
109
-
mouseDeltaX: number;
110
110
-
mouseDeltaY: number;
111
111
-
// For hysteresis - track last decision to prevent flickering
112
112
-
lastTargetId: string | null;
113
113
-
lastPlacement: 'above' | 'below' | null;
114
114
-
// Store original positions to reset from during drag
115
115
-
originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>;
116
116
-
} = $state({
117
117
-
element: null,
118
118
-
item: null,
119
119
-
w: 0,
120
120
-
h: 0,
121
121
-
x: -1,
122
122
-
y: -1,
123
123
-
mouseDeltaX: 0,
124
124
-
mouseDeltaY: 0,
125
125
-
lastTargetId: null,
126
126
-
lastPlacement: null,
127
127
-
originalPositions: new Map()
128
128
-
});
96
96
+
let gridContainer: HTMLDivElement | undefined = $state();
129
97
130
98
let showingMobileView = $state(false);
131
99
let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024);
···
160
128
161
129
const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y);
162
130
const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h);
163
163
-
164
131
let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0));
165
132
166
166
-
function getViewportCenterGridY(): { gridY: number; isMobile: boolean } | undefined {
167
167
-
if (!container) return undefined;
168
168
-
const rect = container.getBoundingClientRect();
169
169
-
const currentMargin = isMobile ? mobileMargin : margin;
170
170
-
const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
171
171
-
const viewportCenterY = window.innerHeight / 2;
172
172
-
const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize;
173
173
-
return { gridY, isMobile };
174
174
-
}
175
175
-
176
133
function newCard(type: string = 'link', cardData?: any) {
177
134
selectedCardId = null;
178
135
···
221
178
if (!newItem.item) return;
222
179
const item = newItem.item;
223
180
224
224
-
const viewportCenter = getViewportCenterGridY();
181
181
+
const viewportCenter = gridContainer
182
182
+
? getViewportCenterGridY(gridContainer, isMobile)
183
183
+
: undefined;
225
184
setPositionOfNewItem(item, items, viewportCenter);
226
185
227
186
items = [...items, item];
···
239
198
await tick();
240
199
cleanupDialogArtifacts();
241
200
242
242
-
scrollToItem(item, isMobile, container);
201
201
+
scrollToItem(item, isMobile, gridContainer);
243
202
}
244
203
245
204
let isSaving = $state(false);
···
558
517
}
559
518
}
560
519
561
561
-
let lastGridPos: {
562
562
-
x: number;
563
563
-
y: number;
564
564
-
swapWithId: string | null;
565
565
-
placement: string | null;
566
566
-
} | null = $state(null);
567
567
-
568
568
-
let debugPoint = $state({ x: 0, y: 0 });
569
569
-
570
570
-
function getGridPosition(
571
571
-
clientX: number,
572
572
-
clientY: number
573
573
-
):
574
574
-
| { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null }
575
575
-
| undefined {
576
576
-
if (!container || !activeDragElement.item) return;
577
577
-
578
578
-
// x, y represent the top-left corner of the dragged card
579
579
-
const x = clientX + activeDragElement.mouseDeltaX;
580
580
-
const y = clientY + activeDragElement.mouseDeltaY;
581
581
-
582
582
-
const rect = container.getBoundingClientRect();
583
583
-
const currentMargin = isMobile ? mobileMargin : margin;
584
584
-
const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
585
585
-
586
586
-
// Get card dimensions based on current view mode
587
587
-
const cardW = isMobile
588
588
-
? (activeDragElement.item?.mobileW ?? activeDragElement.w)
589
589
-
: activeDragElement.w;
590
590
-
const cardH = isMobile
591
591
-
? (activeDragElement.item?.mobileH ?? activeDragElement.h)
592
592
-
: activeDragElement.h;
593
593
-
594
594
-
// Get dragged card's original position
595
595
-
const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id);
596
596
-
597
597
-
const draggedOrigY = draggedOrigPos
598
598
-
? isMobile
599
599
-
? draggedOrigPos.mobileY
600
600
-
: draggedOrigPos.y
601
601
-
: 0;
602
602
-
603
603
-
// Calculate raw grid position based on top-left of dragged card
604
604
-
let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW);
605
605
-
gridX = Math.floor(gridX / 2) * 2;
606
606
-
607
607
-
let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0);
608
608
-
609
609
-
if (isMobile) {
610
610
-
gridX = Math.floor(gridX / 2) * 2;
611
611
-
gridY = Math.floor(gridY / 2) * 2;
612
612
-
}
613
613
-
614
614
-
// Find if we're hovering over another card (using ORIGINAL positions)
615
615
-
const centerGridY = gridY + cardH / 2;
616
616
-
const centerGridX = gridX + cardW / 2;
617
617
-
618
618
-
let swapWithId: string | null = null;
619
619
-
let placement: 'above' | 'below' | null = null;
620
620
-
621
621
-
for (const other of items) {
622
622
-
if (other === activeDragElement.item) continue;
623
623
-
624
624
-
// Use original positions for hit testing
625
625
-
const origPos = activeDragElement.originalPositions.get(other.id);
626
626
-
if (!origPos) continue;
627
627
-
628
628
-
const otherX = isMobile ? origPos.mobileX : origPos.x;
629
629
-
const otherY = isMobile ? origPos.mobileY : origPos.y;
630
630
-
const otherW = isMobile ? other.mobileW : other.w;
631
631
-
const otherH = isMobile ? other.mobileH : other.h;
632
632
-
633
633
-
// Check if dragged card's center point is within this card's original bounds
634
634
-
if (
635
635
-
centerGridX >= otherX &&
636
636
-
centerGridX < otherX + otherW &&
637
637
-
centerGridY >= otherY &&
638
638
-
centerGridY < otherY + otherH
639
639
-
) {
640
640
-
// Check if this is a swap situation:
641
641
-
// Cards have the same dimensions and are on the same row
642
642
-
const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY;
643
643
-
644
644
-
if (canSwap) {
645
645
-
// Swap positions
646
646
-
swapWithId = other.id;
647
647
-
gridX = otherX;
648
648
-
gridY = otherY;
649
649
-
placement = null;
650
650
-
651
651
-
activeDragElement.lastTargetId = other.id;
652
652
-
activeDragElement.lastPlacement = null;
653
653
-
} else {
654
654
-
// Vertical placement (above/below)
655
655
-
// Detect drag direction: if dragging up, always place above
656
656
-
const isDraggingUp = gridY < draggedOrigY;
657
657
-
658
658
-
if (isDraggingUp) {
659
659
-
// When dragging up, always place above
660
660
-
placement = 'above';
661
661
-
} else {
662
662
-
// When dragging down, use top/bottom half logic
663
663
-
const midpointY = otherY + otherH / 2;
664
664
-
const hysteresis = 0.3;
665
665
-
666
666
-
if (activeDragElement.lastTargetId === other.id && activeDragElement.lastPlacement) {
667
667
-
if (activeDragElement.lastPlacement === 'above') {
668
668
-
placement = centerGridY > midpointY + hysteresis ? 'below' : 'above';
669
669
-
} else {
670
670
-
placement = centerGridY < midpointY - hysteresis ? 'above' : 'below';
671
671
-
}
672
672
-
} else {
673
673
-
placement = centerGridY < midpointY ? 'above' : 'below';
674
674
-
}
675
675
-
}
676
676
-
677
677
-
activeDragElement.lastTargetId = other.id;
678
678
-
activeDragElement.lastPlacement = placement;
679
679
-
680
680
-
if (placement === 'above') {
681
681
-
gridY = otherY;
682
682
-
} else {
683
683
-
gridY = otherY + otherH;
684
684
-
}
685
685
-
}
686
686
-
break;
687
687
-
}
688
688
-
}
689
689
-
690
690
-
// If we're not over any card, clear the tracking
691
691
-
if (!swapWithId && !placement) {
692
692
-
activeDragElement.lastTargetId = null;
693
693
-
activeDragElement.lastPlacement = null;
694
694
-
}
695
695
-
696
696
-
debugPoint.x = x - rect.left;
697
697
-
debugPoint.y = y - rect.top + currentMargin;
698
698
-
699
699
-
return { x: gridX, y: gridY, swapWithId, placement };
700
700
-
}
701
701
-
702
702
-
function getDragXY(
703
703
-
e: DragEvent & {
704
704
-
currentTarget: EventTarget & HTMLDivElement;
705
705
-
}
706
706
-
) {
707
707
-
return getGridPosition(e.clientX, e.clientY);
708
708
-
}
709
709
-
710
710
-
// Touch drag system (instant drag on selected card)
711
711
-
let touchDragActive = $state(false);
712
712
-
713
713
-
function touchStart(e: TouchEvent) {
714
714
-
if (!selectedCardId || !container) return;
715
715
-
const touch = e.touches[0];
716
716
-
if (!touch) return;
717
717
-
718
718
-
// Check if the touch is on the selected card element
719
719
-
const target = (e.target as HTMLElement)?.closest?.('.card');
720
720
-
if (!target || target.id !== selectedCardId) return;
721
721
-
722
722
-
const item = items.find((i) => i.id === selectedCardId);
723
723
-
if (!item || item.cardData?.locked) return;
724
724
-
725
725
-
// Start dragging immediately
726
726
-
touchDragActive = true;
727
727
-
728
728
-
const cardEl = container.querySelector(`#${CSS.escape(selectedCardId)}`) as HTMLDivElement;
729
729
-
if (!cardEl) return;
730
730
-
731
731
-
activeDragElement.element = cardEl;
732
732
-
activeDragElement.w = item.w;
733
733
-
activeDragElement.h = item.h;
734
734
-
activeDragElement.item = item;
735
735
-
736
736
-
// Store original positions of all items
737
737
-
activeDragElement.originalPositions = new Map();
738
738
-
for (const it of items) {
739
739
-
activeDragElement.originalPositions.set(it.id, {
740
740
-
x: it.x,
741
741
-
y: it.y,
742
742
-
mobileX: it.mobileX,
743
743
-
mobileY: it.mobileY
744
744
-
});
745
745
-
}
746
746
-
747
747
-
const rect = cardEl.getBoundingClientRect();
748
748
-
activeDragElement.mouseDeltaX = rect.left - touch.clientX;
749
749
-
activeDragElement.mouseDeltaY = rect.top - touch.clientY;
750
750
-
}
751
751
-
752
752
-
function touchMove(e: TouchEvent) {
753
753
-
if (!touchDragActive) return;
754
754
-
755
755
-
const touch = e.touches[0];
756
756
-
if (!touch) return;
757
757
-
758
758
-
e.preventDefault();
759
759
-
760
760
-
// Auto-scroll near edges (always process, even if grid pos unchanged)
761
761
-
const scrollZone = 100;
762
762
-
const scrollSpeed = 10;
763
763
-
const viewportHeight = window.innerHeight;
764
764
-
765
765
-
if (touch.clientY < scrollZone) {
766
766
-
const intensity = 1 - touch.clientY / scrollZone;
767
767
-
window.scrollBy(0, -scrollSpeed * intensity);
768
768
-
} else if (touch.clientY > viewportHeight - scrollZone) {
769
769
-
const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone;
770
770
-
window.scrollBy(0, scrollSpeed * intensity);
771
771
-
}
772
772
-
773
773
-
const result = getGridPosition(touch.clientX, touch.clientY);
774
774
-
if (!result || !activeDragElement.item) return;
775
775
-
776
776
-
// Skip redundant work if grid position hasn't changed
777
777
-
if (
778
778
-
lastGridPos &&
779
779
-
lastGridPos.x === result.x &&
780
780
-
lastGridPos.y === result.y &&
781
781
-
lastGridPos.swapWithId === result.swapWithId &&
782
782
-
lastGridPos.placement === result.placement
783
783
-
) {
784
784
-
return;
785
785
-
}
786
786
-
lastGridPos = {
787
787
-
x: result.x,
788
788
-
y: result.y,
789
789
-
swapWithId: result.swapWithId,
790
790
-
placement: result.placement
791
791
-
};
792
792
-
793
793
-
const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id);
794
794
-
795
795
-
// Reset all items to original positions first
796
796
-
for (const it of items) {
797
797
-
const origPos = activeDragElement.originalPositions.get(it.id);
798
798
-
if (origPos && it !== activeDragElement.item) {
799
799
-
if (isMobile) {
800
800
-
it.mobileX = origPos.mobileX;
801
801
-
it.mobileY = origPos.mobileY;
802
802
-
} else {
803
803
-
it.x = origPos.x;
804
804
-
it.y = origPos.y;
805
805
-
}
806
806
-
}
807
807
-
}
808
808
-
809
809
-
// Update dragged item position
810
810
-
if (isMobile) {
811
811
-
activeDragElement.item.mobileX = result.x;
812
812
-
activeDragElement.item.mobileY = result.y;
813
813
-
} else {
814
814
-
activeDragElement.item.x = result.x;
815
815
-
activeDragElement.item.y = result.y;
816
816
-
}
817
817
-
818
818
-
// Handle horizontal swap
819
819
-
if (result.swapWithId && draggedOrigPos) {
820
820
-
const swapTarget = items.find((it) => it.id === result.swapWithId);
821
821
-
if (swapTarget) {
822
822
-
if (isMobile) {
823
823
-
swapTarget.mobileX = draggedOrigPos.mobileX;
824
824
-
swapTarget.mobileY = draggedOrigPos.mobileY;
825
825
-
} else {
826
826
-
swapTarget.x = draggedOrigPos.x;
827
827
-
swapTarget.y = draggedOrigPos.y;
828
828
-
}
829
829
-
}
830
830
-
}
831
831
-
832
832
-
fixCollisions(
833
833
-
items,
834
834
-
activeDragElement.item,
835
835
-
isMobile,
836
836
-
false,
837
837
-
draggedOrigPos
838
838
-
? {
839
839
-
x: isMobile ? draggedOrigPos.mobileX : draggedOrigPos.x,
840
840
-
y: isMobile ? draggedOrigPos.mobileY : draggedOrigPos.y
841
841
-
}
842
842
-
: undefined
843
843
-
);
844
844
-
}
845
845
-
846
846
-
function touchEnd() {
847
847
-
if (touchDragActive && activeDragElement.item) {
848
848
-
// Finalize position
849
849
-
fixCollisions(items, activeDragElement.item, isMobile);
850
850
-
onLayoutChanged();
851
851
-
852
852
-
activeDragElement.x = -1;
853
853
-
activeDragElement.y = -1;
854
854
-
activeDragElement.element = null;
855
855
-
activeDragElement.item = null;
856
856
-
activeDragElement.lastTargetId = null;
857
857
-
activeDragElement.lastPlacement = null;
858
858
-
}
859
859
-
860
860
-
lastGridPos = null;
861
861
-
touchDragActive = false;
862
862
-
}
863
863
-
864
864
-
// Only register non-passive touchmove when actively dragging
865
865
-
$effect(() => {
866
866
-
const el = container;
867
867
-
if (!touchDragActive || !el) return;
868
868
-
869
869
-
el.addEventListener('touchmove', touchMove, { passive: false });
870
870
-
return () => {
871
871
-
el.removeEventListener('touchmove', touchMove);
872
872
-
};
873
873
-
});
874
874
-
875
520
let linkValue = $state('');
876
521
877
522
function addLink(url: string, specificCardDef?: CardDefinition) {
···
995
640
fixCollisions(items, item, isMobile);
996
641
fixCollisions(items, item, !isMobile);
997
642
} else {
998
998
-
const viewportCenter = getViewportCenterGridY();
643
643
+
const viewportCenter = gridContainer
644
644
+
? getViewportCenterGridY(gridContainer, isMobile)
645
645
+
: undefined;
999
646
setPositionOfNewItem(item, items, viewportCenter);
1000
647
items = [...items, item];
1001
648
fixCollisions(items, item, false, true);
···
1008
655
1009
656
await tick();
1010
657
1011
1011
-
scrollToItem(item, isMobile, container);
1012
1012
-
}
1013
1013
-
1014
1014
-
function handleImageDragOver(event: DragEvent) {
1015
1015
-
const dt = event.dataTransfer;
1016
1016
-
if (!dt) return;
1017
1017
-
1018
1018
-
let hasImage = false;
1019
1019
-
if (dt.items) {
1020
1020
-
for (let i = 0; i < dt.items.length; i++) {
1021
1021
-
const item = dt.items[i];
1022
1022
-
if (item && item.kind === 'file' && item.type.startsWith('image/')) {
1023
1023
-
hasImage = true;
1024
1024
-
break;
1025
1025
-
}
1026
1026
-
}
1027
1027
-
} else if (dt.files) {
1028
1028
-
for (let i = 0; i < dt.files.length; i++) {
1029
1029
-
const file = dt.files[i];
1030
1030
-
if (file?.type.startsWith('image/')) {
1031
1031
-
hasImage = true;
1032
1032
-
break;
1033
1033
-
}
1034
1034
-
}
1035
1035
-
}
1036
1036
-
1037
1037
-
if (hasImage) {
1038
1038
-
event.preventDefault();
1039
1039
-
event.stopPropagation();
1040
1040
-
1041
1041
-
imageDragOver = true;
1042
1042
-
}
658
658
+
scrollToItem(item, isMobile, gridContainer);
1043
659
}
1044
660
1045
1045
-
function handleImageDragLeave(event: DragEvent) {
1046
1046
-
event.preventDefault();
1047
1047
-
event.stopPropagation();
1048
1048
-
imageDragOver = false;
1049
1049
-
}
1050
1050
-
1051
1051
-
async function handleImageDrop(event: DragEvent) {
1052
1052
-
event.preventDefault();
1053
1053
-
event.stopPropagation();
1054
1054
-
const dropX = event.clientX;
1055
1055
-
const dropY = event.clientY;
1056
1056
-
imageDragOver = false;
1057
1057
-
1058
1058
-
if (!event.dataTransfer?.files?.length) return;
1059
1059
-
1060
1060
-
const imageFiles = Array.from(event.dataTransfer.files).filter((f) =>
1061
1061
-
f?.type.startsWith('image/')
1062
1062
-
);
1063
1063
-
if (imageFiles.length === 0) return;
1064
1064
-
1065
1065
-
// Calculate starting grid position from drop coordinates
1066
1066
-
let gridX = 0;
1067
1067
-
let gridY = 0;
1068
1068
-
if (container) {
1069
1069
-
const rect = container.getBoundingClientRect();
1070
1070
-
const currentMargin = isMobile ? mobileMargin : margin;
1071
1071
-
const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
1072
1072
-
const cardW = isMobile ? 4 : 2;
1073
1073
-
1074
1074
-
gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW);
1075
1075
-
gridX = Math.floor(gridX / 2) * 2;
1076
1076
-
1077
1077
-
gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0);
1078
1078
-
if (isMobile) {
1079
1079
-
gridY = Math.floor(gridY / 2) * 2;
1080
1080
-
}
1081
1081
-
}
1082
1082
-
1083
1083
-
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++) {
1084
663
// First image gets the drop position, rest use normal placement
1085
664
if (i === 0) {
1086
1086
-
await processImageFile(imageFiles[i], gridX, gridY);
665
665
+
await processImageFile(files[i], gridX, gridY);
1087
666
} else {
1088
1088
-
await processImageFile(imageFiles[i]);
667
667
+
await processImageFile(files[i]);
1089
668
}
1090
669
}
1091
670
}
···
1133
712
objectUrl
1134
713
};
1135
714
1136
1136
-
const viewportCenter = getViewportCenterGridY();
715
715
+
const viewportCenter = gridContainer
716
716
+
? getViewportCenterGridY(gridContainer, isMobile)
717
717
+
: undefined;
1137
718
setPositionOfNewItem(item, items, viewportCenter);
1138
719
items = [...items, item];
1139
720
fixCollisions(items, item, false, true);
···
1145
726
1146
727
await tick();
1147
728
1148
1148
-
scrollToItem(item, isMobile, container);
729
729
+
scrollToItem(item, isMobile, gridContainer);
1149
730
}
1150
731
1151
732
async function handleVideoInputChange(event: Event) {
···
1175
756
1176
757
addLink(link);
1177
758
}}
1178
1178
-
/>
1179
1179
-
1180
1180
-
<svelte:window
1181
1181
-
ondragover={handleImageDragOver}
1182
1182
-
ondragleave={handleImageDragLeave}
1183
1183
-
ondrop={handleImageDrop}
1184
759
/>
1185
760
1186
761
<Head
···
1292
867
]}
1293
868
>
1294
869
<div class="pointer-events-none"></div>
1295
1295
-
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
1296
1296
-
<div
1297
1297
-
bind:this={container}
1298
1298
-
onclick={(e) => {
1299
1299
-
// Deselect when tapping empty grid space
1300
1300
-
if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) {
1301
1301
-
selectedCardId = null;
1302
1302
-
}
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;
1303
879
}}
1304
1304
-
ontouchstart={touchStart}
1305
1305
-
ontouchend={touchEnd}
1306
1306
-
ondragover={(e) => {
1307
1307
-
e.preventDefault();
1308
1308
-
1309
1309
-
// Auto-scroll when dragging near top or bottom of viewport (always process)
1310
1310
-
const scrollZone = 100;
1311
1311
-
const scrollSpeed = 10;
1312
1312
-
const viewportHeight = window.innerHeight;
1313
1313
-
1314
1314
-
if (e.clientY < scrollZone) {
1315
1315
-
const intensity = 1 - e.clientY / scrollZone;
1316
1316
-
window.scrollBy(0, -scrollSpeed * intensity);
1317
1317
-
} else if (e.clientY > viewportHeight - scrollZone) {
1318
1318
-
const intensity = 1 - (viewportHeight - e.clientY) / scrollZone;
1319
1319
-
window.scrollBy(0, scrollSpeed * intensity);
1320
1320
-
}
1321
1321
-
1322
1322
-
const result = getDragXY(e);
1323
1323
-
if (!result) return;
1324
1324
-
1325
1325
-
// Skip redundant work if grid position hasn't changed
1326
1326
-
if (
1327
1327
-
lastGridPos &&
1328
1328
-
lastGridPos.x === result.x &&
1329
1329
-
lastGridPos.y === result.y &&
1330
1330
-
lastGridPos.swapWithId === result.swapWithId &&
1331
1331
-
lastGridPos.placement === result.placement
1332
1332
-
) {
1333
1333
-
return;
1334
1334
-
}
1335
1335
-
lastGridPos = {
1336
1336
-
x: result.x,
1337
1337
-
y: result.y,
1338
1338
-
swapWithId: result.swapWithId,
1339
1339
-
placement: result.placement
1340
1340
-
};
1341
1341
-
1342
1342
-
activeDragElement.x = result.x;
1343
1343
-
activeDragElement.y = result.y;
1344
1344
-
1345
1345
-
if (activeDragElement.item) {
1346
1346
-
// Get dragged card's original position for swapping
1347
1347
-
const draggedOrigPos = activeDragElement.originalPositions.get(
1348
1348
-
activeDragElement.item.id
1349
1349
-
);
1350
1350
-
1351
1351
-
// Reset all items to original positions first
1352
1352
-
for (const it of items) {
1353
1353
-
const origPos = activeDragElement.originalPositions.get(it.id);
1354
1354
-
if (origPos && it !== activeDragElement.item) {
1355
1355
-
if (isMobile) {
1356
1356
-
it.mobileX = origPos.mobileX;
1357
1357
-
it.mobileY = origPos.mobileY;
1358
1358
-
} else {
1359
1359
-
it.x = origPos.x;
1360
1360
-
it.y = origPos.y;
1361
1361
-
}
1362
1362
-
}
1363
1363
-
}
1364
1364
-
1365
1365
-
// Update dragged item position
1366
1366
-
if (isMobile) {
1367
1367
-
activeDragElement.item.mobileX = result.x;
1368
1368
-
activeDragElement.item.mobileY = result.y;
1369
1369
-
} else {
1370
1370
-
activeDragElement.item.x = result.x;
1371
1371
-
activeDragElement.item.y = result.y;
1372
1372
-
}
1373
1373
-
1374
1374
-
// Handle horizontal swap
1375
1375
-
if (result.swapWithId && draggedOrigPos) {
1376
1376
-
const swapTarget = items.find((it) => it.id === result.swapWithId);
1377
1377
-
if (swapTarget) {
1378
1378
-
// Move swap target to dragged card's original position
1379
1379
-
if (isMobile) {
1380
1380
-
swapTarget.mobileX = draggedOrigPos.mobileX;
1381
1381
-
swapTarget.mobileY = draggedOrigPos.mobileY;
1382
1382
-
} else {
1383
1383
-
swapTarget.x = draggedOrigPos.x;
1384
1384
-
swapTarget.y = draggedOrigPos.y;
1385
1385
-
}
1386
1386
-
}
1387
1387
-
}
1388
1388
-
1389
1389
-
// Now fix collisions (with compacting)
1390
1390
-
fixCollisions(
1391
1391
-
items,
1392
1392
-
activeDragElement.item,
1393
1393
-
isMobile,
1394
1394
-
false,
1395
1395
-
draggedOrigPos
1396
1396
-
? {
1397
1397
-
x: isMobile ? draggedOrigPos.mobileX : draggedOrigPos.x,
1398
1398
-
y: isMobile ? draggedOrigPos.mobileY : draggedOrigPos.y
1399
1399
-
}
1400
1400
-
: undefined
1401
1401
-
);
1402
1402
-
}
1403
1403
-
}}
1404
1404
-
ondragend={async (e) => {
1405
1405
-
e.preventDefault();
1406
1406
-
// safari fix
1407
1407
-
activeDragElement.x = -1;
1408
1408
-
activeDragElement.y = -1;
1409
1409
-
activeDragElement.element = null;
1410
1410
-
activeDragElement.item = null;
1411
1411
-
activeDragElement.lastTargetId = null;
1412
1412
-
activeDragElement.lastPlacement = null;
1413
1413
-
lastGridPos = null;
1414
1414
-
return true;
1415
1415
-
}}
1416
1416
-
class={[
1417
1417
-
'@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8',
1418
1418
-
imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed'
1419
1419
-
]}
880
880
+
onfiledrop={handleFileDrop}
1420
881
>
1421
882
{#each items as item, i (item.id)}
1422
1422
-
<!-- {#if item !== activeDragElement.item} -->
1423
883
<BaseEditingCard
1424
884
bind:item={items[i]}
1425
885
ondelete={() => {
···
1440
900
fixCollisions(items, item, isMobile);
1441
901
onLayoutChanged();
1442
902
}}
1443
1443
-
ondragstart={(e: DragEvent) => {
1444
1444
-
const target = e.currentTarget as HTMLDivElement;
1445
1445
-
activeDragElement.element = target;
1446
1446
-
activeDragElement.w = item.w;
1447
1447
-
activeDragElement.h = item.h;
1448
1448
-
activeDragElement.item = item;
1449
1449
-
// fix for div shadow during drag and drop
1450
1450
-
const transparent = document.createElement('div');
1451
1451
-
transparent.style.position = 'fixed';
1452
1452
-
transparent.style.top = '-1000px';
1453
1453
-
transparent.style.width = '1px';
1454
1454
-
transparent.style.height = '1px';
1455
1455
-
document.body.appendChild(transparent);
1456
1456
-
e.dataTransfer?.setDragImage(transparent, 0, 0);
1457
1457
-
requestAnimationFrame(() => transparent.remove());
1458
1458
-
1459
1459
-
// Store original positions of all items
1460
1460
-
activeDragElement.originalPositions = new Map();
1461
1461
-
for (const it of items) {
1462
1462
-
activeDragElement.originalPositions.set(it.id, {
1463
1463
-
x: it.x,
1464
1464
-
y: it.y,
1465
1465
-
mobileX: it.mobileX,
1466
1466
-
mobileY: it.mobileY
1467
1467
-
});
1468
1468
-
}
1469
1469
-
1470
1470
-
const rect = target.getBoundingClientRect();
1471
1471
-
activeDragElement.mouseDeltaX = rect.left - e.clientX;
1472
1472
-
activeDragElement.mouseDeltaY = rect.top - e.clientY;
1473
1473
-
}}
1474
903
>
1475
904
<EditingCard bind:item={items[i]} />
1476
905
</BaseEditingCard>
1477
1477
-
<!-- {/if} -->
1478
906
{/each}
1479
1479
-
1480
1480
-
<div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div>
1481
1481
-
</div>
907
907
+
</EditableGrid>
1482
908
</div>
1483
909
</div>
1484
910
+1
-1
src/lib/website/layout-mirror.ts
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 '$lib/layout';
4
4
+
import { fixAllCollisions, findValidPosition } from './algorithms';
5
5
import type { Item } from '$lib/types';
6
6
7
7
/**