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