···180 mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x
181 );
1820000183 for (const item of sortedItems) {
184- // Try moving item up row by row until we hit y=0 or a collision
185- while (true) {
186- const currentY = mobile ? item.mobileY : item.y;
187- if (currentY <= 0) break;
188189- // Temporarily move up by 1
190- if (mobile) item.mobileY -= 1;
191- else item.y -= 1;
192193- // Check for collision with any other item
194- const hasCollision = items.some((other) => other !== item && overlaps(item, other, mobile));
0195196- if (hasCollision) {
197- // Revert the move
198- if (mobile) item.mobileY += 1;
199- else item.y += 1;
200- break;
0201 }
202- // No collision, keep the new position and try moving up again
00000203 }
00204 }
205}
206···553 originalPublication: string
554) {
555 const promises = [];
0000000556 // find all cards that have been updated (where items differ from originalItems)
557 for (let item of currentItems) {
558- const originalItem = data.cards.find((i) => cardsEqual(i, item));
0559560 if (!originalItem) {
561 console.log('updated or new item', item);
···180 mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x
181 );
182183+ // For each item, find the lowest Y it can occupy by checking the bottom edges
184+ // of all horizontally-overlapping items already placed above it.
185+ const settled: Item[] = [];
186+187 for (const item of sortedItems) {
188+ const itemX = mobile ? item.mobileX : item.x;
189+ const itemW = mobile ? item.mobileW : item.w;
00190191+ let minY = 0;
00192193+ for (const other of settled) {
194+ const otherX = mobile ? other.mobileX : other.x;
195+ const otherW = mobile ? other.mobileW : other.w;
196197+ // Check horizontal overlap
198+ if (itemX < otherX + otherW && itemX + itemW > otherX) {
199+ const otherBottom = mobile ? other.mobileY + other.mobileH : other.y + other.h;
200+ if (otherBottom > minY) {
201+ minY = otherBottom;
202+ }
203 }
204+ }
205+206+ if (mobile) {
207+ item.mobileY = minY;
208+ } else {
209+ item.y = minY;
210 }
211+212+ settled.push(item);
213 }
214}
215···562 originalPublication: string
563) {
564 const promises = [];
565+566+ // Build a lookup of original cards by ID for O(1) access
567+ const originalCardsById = new Map<string, Item>();
568+ for (const card of data.cards) {
569+ originalCardsById.set(card.id, card);
570+ }
571+572 // find all cards that have been updated (where items differ from originalItems)
573 for (let item of currentItems) {
574+ const orig = originalCardsById.get(item.id);
575+ const originalItem = orig && cardsEqual(orig, item) ? orig : undefined;
576577 if (!originalItem) {
578 console.log('updated or new item', item);
+84-39
src/lib/website/EditableWebsite.svelte
···57 data.publication.preferences ??= {};
58 data.publication.preferences.accentColor = newAccent;
59 data.publication.preferences.baseColor = newBase;
060 data = { ...data };
61 }
62···68 // svelte-ignore state_referenced_locally
69 let publication = $state(JSON.stringify(data.publication));
7071- // Track saved state for comparison
72 // svelte-ignore state_referenced_locally
73- let savedItems = $state(JSON.stringify(data.cards));
74- // svelte-ignore state_referenced_locally
75- let savedPublication = $state(JSON.stringify(data.publication));
7677 let hasUnsavedChanges = $state(false);
78000079 $effect(() => {
80- if (!hasUnsavedChanges) {
81- hasUnsavedChanges =
82- JSON.stringify(items) !== savedItems ||
83- JSON.stringify(data.publication) !== savedPublication;
0084 }
85 });
86···137 let editedOn = $state(data.publication.preferences?.editedOn ?? 0);
138139 function onLayoutChanged() {
0140 // Set the bit for the current layout: desktop=1, mobile=2
141 editedOn = editedOn | (isMobile ? 2 : 1);
142 if (shouldMirror(editedOn)) {
···266267 publication = JSON.stringify(data.publication);
268269- // Update saved state
270- savedItems = JSON.stringify(items);
271- savedPublication = JSON.stringify(data.publication);
272273 saveSuccess = true;
274···558 }
559 }
5600000000561 let debugPoint = $state({ x: 0, y: 0 });
562563 function getGridPosition(
···750751 e.preventDefault();
7520000000000000753 const result = getGridPosition(touch.clientX, touch.clientY);
754 if (!result || !activeDragElement.item) return;
75500000000000000000756 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id);
757758 // Reset all items to original positions first
···793 }
794795 fixCollisions(items, activeDragElement.item, isMobile);
796-797- // Auto-scroll near edges
798- const scrollZone = 100;
799- const scrollSpeed = 10;
800- const viewportHeight = window.innerHeight;
801-802- if (touch.clientY < scrollZone) {
803- const intensity = 1 - touch.clientY / scrollZone;
804- window.scrollBy(0, -scrollSpeed * intensity);
805- } else if (touch.clientY > viewportHeight - scrollZone) {
806- const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone;
807- window.scrollBy(0, scrollSpeed * intensity);
808- }
809 }
810811 function touchEnd() {
···822 activeDragElement.lastPlacement = null;
823 }
8240825 touchDragActive = false;
826 }
827···1270 ondragover={(e) => {
1271 e.preventDefault();
127200000000000001273 const result = getDragXY(e);
1274 if (!result) return;
0000000000000000012751276 activeDragElement.x = result.x;
1277 activeDragElement.y = result.y;
···1323 // Now fix collisions (with compacting)
1324 fixCollisions(items, activeDragElement.item, isMobile);
1325 }
1326-1327- // Auto-scroll when dragging near top or bottom of viewport
1328- const scrollZone = 100;
1329- const scrollSpeed = 10;
1330- const viewportHeight = window.innerHeight;
1331-1332- if (e.clientY < scrollZone) {
1333- // Near top - scroll up
1334- const intensity = 1 - e.clientY / scrollZone;
1335- window.scrollBy(0, -scrollSpeed * intensity);
1336- } else if (e.clientY > viewportHeight - scrollZone) {
1337- // Near bottom - scroll down
1338- const intensity = 1 - (viewportHeight - e.clientY) / scrollZone;
1339- window.scrollBy(0, scrollSpeed * intensity);
1340- }
1341 }}
1342 ondragend={async (e) => {
1343 e.preventDefault();
···1348 activeDragElement.item = null;
1349 activeDragElement.lastTargetId = null;
1350 activeDragElement.lastPlacement = null;
01351 return true;
1352 }}
1353 class={[
···57 data.publication.preferences ??= {};
58 data.publication.preferences.accentColor = newAccent;
59 data.publication.preferences.baseColor = newBase;
60+ hasUnsavedChanges = true;
61 data = { ...data };
62 }
63···69 // svelte-ignore state_referenced_locally
70 let publication = $state(JSON.stringify(data.publication));
71072 // svelte-ignore state_referenced_locally
73+ let savedItemsSnapshot = JSON.stringify(data.cards);
007475 let hasUnsavedChanges = $state(false);
7677+ // Detect card content and publication changes (e.g. sidebar edits)
78+ // The guard ensures JSON.stringify only runs while no changes are detected yet.
79+ // Once hasUnsavedChanges is true, Svelte still fires this effect on item mutations
80+ // but the early return makes it effectively free.
81 $effect(() => {
82+ if (hasUnsavedChanges) return;
83+ if (
84+ JSON.stringify(items) !== savedItemsSnapshot ||
85+ JSON.stringify(data.publication) !== publication
86+ ) {
87+ hasUnsavedChanges = true;
88 }
89 });
90···141 let editedOn = $state(data.publication.preferences?.editedOn ?? 0);
142143 function onLayoutChanged() {
144+ hasUnsavedChanges = true;
145 // Set the bit for the current layout: desktop=1, mobile=2
146 editedOn = editedOn | (isMobile ? 2 : 1);
147 if (shouldMirror(editedOn)) {
···271272 publication = JSON.stringify(data.publication);
273274+ savedItemsSnapshot = JSON.stringify(items);
275+ hasUnsavedChanges = false;
0276277 saveSuccess = true;
278···562 }
563 }
564565+ let lastGridPos: {
566+ x: number;
567+ y: number;
568+ swapWithId: string | null;
569+ placement: string | null;
570+ } | null = $state(null);
571+572 let debugPoint = $state({ x: 0, y: 0 });
573574 function getGridPosition(
···761762 e.preventDefault();
763764+ // Auto-scroll near edges (always process, even if grid pos unchanged)
765+ const scrollZone = 100;
766+ const scrollSpeed = 10;
767+ const viewportHeight = window.innerHeight;
768+769+ if (touch.clientY < scrollZone) {
770+ const intensity = 1 - touch.clientY / scrollZone;
771+ window.scrollBy(0, -scrollSpeed * intensity);
772+ } else if (touch.clientY > viewportHeight - scrollZone) {
773+ const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone;
774+ window.scrollBy(0, scrollSpeed * intensity);
775+ }
776+777 const result = getGridPosition(touch.clientX, touch.clientY);
778 if (!result || !activeDragElement.item) return;
779780+ // Skip redundant work if grid position hasn't changed
781+ if (
782+ lastGridPos &&
783+ lastGridPos.x === result.x &&
784+ lastGridPos.y === result.y &&
785+ lastGridPos.swapWithId === result.swapWithId &&
786+ lastGridPos.placement === result.placement
787+ ) {
788+ return;
789+ }
790+ lastGridPos = {
791+ x: result.x,
792+ y: result.y,
793+ swapWithId: result.swapWithId,
794+ placement: result.placement
795+ };
796+797 const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id);
798799 // Reset all items to original positions first
···834 }
835836 fixCollisions(items, activeDragElement.item, isMobile);
0000000000000837 }
838839 function touchEnd() {
···850 activeDragElement.lastPlacement = null;
851 }
852853+ lastGridPos = null;
854 touchDragActive = false;
855 }
856···1299 ondragover={(e) => {
1300 e.preventDefault();
13011302+ // Auto-scroll when dragging near top or bottom of viewport (always process)
1303+ const scrollZone = 100;
1304+ const scrollSpeed = 10;
1305+ const viewportHeight = window.innerHeight;
1306+1307+ if (e.clientY < scrollZone) {
1308+ const intensity = 1 - e.clientY / scrollZone;
1309+ window.scrollBy(0, -scrollSpeed * intensity);
1310+ } else if (e.clientY > viewportHeight - scrollZone) {
1311+ const intensity = 1 - (viewportHeight - e.clientY) / scrollZone;
1312+ window.scrollBy(0, scrollSpeed * intensity);
1313+ }
1314+1315 const result = getDragXY(e);
1316 if (!result) return;
1317+1318+ // Skip redundant work if grid position hasn't changed
1319+ if (
1320+ lastGridPos &&
1321+ lastGridPos.x === result.x &&
1322+ lastGridPos.y === result.y &&
1323+ lastGridPos.swapWithId === result.swapWithId &&
1324+ lastGridPos.placement === result.placement
1325+ ) {
1326+ return;
1327+ }
1328+ lastGridPos = {
1329+ x: result.x,
1330+ y: result.y,
1331+ swapWithId: result.swapWithId,
1332+ placement: result.placement
1333+ };
13341335 activeDragElement.x = result.x;
1336 activeDragElement.y = result.y;
···1382 // Now fix collisions (with compacting)
1383 fixCollisions(items, activeDragElement.item, isMobile);
1384 }
0000000000000001385 }}
1386 ondragend={async (e) => {
1387 e.preventDefault();
···1392 activeDragElement.item = null;
1393 activeDragElement.lastTargetId = null;
1394 activeDragElement.lastPlacement = null;
1395+ lastGridPos = null;
1396 return true;
1397 }}
1398 class={[