diff --git a/src/lib/helper.ts b/src/lib/helper.ts index 0a6b596..0253e6e 100644 --- a/src/lib/helper.ts +++ b/src/lib/helper.ts @@ -180,27 +180,36 @@ export function compactItems(items: Item[], mobile: boolean = false) { mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x ); + // For each item, find the lowest Y it can occupy by checking the bottom edges + // of all horizontally-overlapping items already placed above it. + const settled: Item[] = []; + for (const item of sortedItems) { - // Try moving item up row by row until we hit y=0 or a collision - while (true) { - const currentY = mobile ? item.mobileY : item.y; - if (currentY <= 0) break; + const itemX = mobile ? item.mobileX : item.x; + const itemW = mobile ? item.mobileW : item.w; - // Temporarily move up by 1 - if (mobile) item.mobileY -= 1; - else item.y -= 1; + let minY = 0; - // Check for collision with any other item - const hasCollision = items.some((other) => other !== item && overlaps(item, other, mobile)); + for (const other of settled) { + const otherX = mobile ? other.mobileX : other.x; + const otherW = mobile ? other.mobileW : other.w; - if (hasCollision) { - // Revert the move - if (mobile) item.mobileY += 1; - else item.y += 1; - break; + // Check horizontal overlap + if (itemX < otherX + otherW && itemX + itemW > otherX) { + const otherBottom = mobile ? other.mobileY + other.mobileH : other.y + other.h; + if (otherBottom > minY) { + minY = otherBottom; + } } - // No collision, keep the new position and try moving up again } + + if (mobile) { + item.mobileY = minY; + } else { + item.y = minY; + } + + settled.push(item); } } @@ -553,9 +562,17 @@ export async function savePage( originalPublication: string ) { const promises = []; + + // Build a lookup of original cards by ID for O(1) access + const originalCardsById = new Map(); + for (const card of data.cards) { + originalCardsById.set(card.id, card); + } + // find all cards that have been updated (where items differ from originalItems) for (let item of currentItems) { - const originalItem = data.cards.find((i) => cardsEqual(i, item)); + const orig = originalCardsById.get(item.id); + const originalItem = orig && cardsEqual(orig, item) ? orig : undefined; if (!originalItem) { console.log('updated or new item', item); diff --git a/src/lib/website/EditableWebsite.svelte b/src/lib/website/EditableWebsite.svelte index 5386476..b0fc5b1 100644 --- a/src/lib/website/EditableWebsite.svelte +++ b/src/lib/website/EditableWebsite.svelte @@ -57,6 +57,7 @@ data.publication.preferences ??= {}; data.publication.preferences.accentColor = newAccent; data.publication.preferences.baseColor = newBase; + hasUnsavedChanges = true; data = { ...data }; } @@ -68,19 +69,22 @@ // svelte-ignore state_referenced_locally let publication = $state(JSON.stringify(data.publication)); - // Track saved state for comparison // svelte-ignore state_referenced_locally - let savedItems = $state(JSON.stringify(data.cards)); - // svelte-ignore state_referenced_locally - let savedPublication = $state(JSON.stringify(data.publication)); + let savedItemsSnapshot = JSON.stringify(data.cards); let hasUnsavedChanges = $state(false); + // Detect card content and publication changes (e.g. sidebar edits) + // The guard ensures JSON.stringify only runs while no changes are detected yet. + // Once hasUnsavedChanges is true, Svelte still fires this effect on item mutations + // but the early return makes it effectively free. $effect(() => { - if (!hasUnsavedChanges) { - hasUnsavedChanges = - JSON.stringify(items) !== savedItems || - JSON.stringify(data.publication) !== savedPublication; + if (hasUnsavedChanges) return; + if ( + JSON.stringify(items) !== savedItemsSnapshot || + JSON.stringify(data.publication) !== publication + ) { + hasUnsavedChanges = true; } }); @@ -137,6 +141,7 @@ let editedOn = $state(data.publication.preferences?.editedOn ?? 0); function onLayoutChanged() { + hasUnsavedChanges = true; // Set the bit for the current layout: desktop=1, mobile=2 editedOn = editedOn | (isMobile ? 2 : 1); if (shouldMirror(editedOn)) { @@ -266,9 +271,8 @@ publication = JSON.stringify(data.publication); - // Update saved state - savedItems = JSON.stringify(items); - savedPublication = JSON.stringify(data.publication); + savedItemsSnapshot = JSON.stringify(items); + hasUnsavedChanges = false; saveSuccess = true; @@ -558,6 +562,13 @@ } } + let lastGridPos: { + x: number; + y: number; + swapWithId: string | null; + placement: string | null; + } | null = $state(null); + let debugPoint = $state({ x: 0, y: 0 }); function getGridPosition( @@ -750,9 +761,39 @@ e.preventDefault(); + // Auto-scroll near edges (always process, even if grid pos unchanged) + const scrollZone = 100; + const scrollSpeed = 10; + const viewportHeight = window.innerHeight; + + if (touch.clientY < scrollZone) { + const intensity = 1 - touch.clientY / scrollZone; + window.scrollBy(0, -scrollSpeed * intensity); + } else if (touch.clientY > viewportHeight - scrollZone) { + const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone; + window.scrollBy(0, scrollSpeed * intensity); + } + const result = getGridPosition(touch.clientX, touch.clientY); if (!result || !activeDragElement.item) return; + // Skip redundant work if grid position hasn't changed + if ( + lastGridPos && + lastGridPos.x === result.x && + lastGridPos.y === result.y && + lastGridPos.swapWithId === result.swapWithId && + lastGridPos.placement === result.placement + ) { + return; + } + lastGridPos = { + x: result.x, + y: result.y, + swapWithId: result.swapWithId, + placement: result.placement + }; + const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); // Reset all items to original positions first @@ -793,19 +834,6 @@ } fixCollisions(items, activeDragElement.item, isMobile); - - // Auto-scroll near edges - const scrollZone = 100; - const scrollSpeed = 10; - const viewportHeight = window.innerHeight; - - if (touch.clientY < scrollZone) { - const intensity = 1 - touch.clientY / scrollZone; - window.scrollBy(0, -scrollSpeed * intensity); - } else if (touch.clientY > viewportHeight - scrollZone) { - const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone; - window.scrollBy(0, scrollSpeed * intensity); - } } function touchEnd() { @@ -822,6 +850,7 @@ activeDragElement.lastPlacement = null; } + lastGridPos = null; touchDragActive = false; } @@ -1270,9 +1299,39 @@ ondragover={(e) => { e.preventDefault(); + // Auto-scroll when dragging near top or bottom of viewport (always process) + const scrollZone = 100; + const scrollSpeed = 10; + const viewportHeight = window.innerHeight; + + if (e.clientY < scrollZone) { + const intensity = 1 - e.clientY / scrollZone; + window.scrollBy(0, -scrollSpeed * intensity); + } else if (e.clientY > viewportHeight - scrollZone) { + const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; + window.scrollBy(0, scrollSpeed * intensity); + } + const result = getDragXY(e); if (!result) return; + // Skip redundant work if grid position hasn't changed + if ( + lastGridPos && + lastGridPos.x === result.x && + lastGridPos.y === result.y && + lastGridPos.swapWithId === result.swapWithId && + lastGridPos.placement === result.placement + ) { + return; + } + lastGridPos = { + x: result.x, + y: result.y, + swapWithId: result.swapWithId, + placement: result.placement + }; + activeDragElement.x = result.x; activeDragElement.y = result.y; @@ -1323,21 +1382,6 @@ // Now fix collisions (with compacting) fixCollisions(items, activeDragElement.item, isMobile); } - - // Auto-scroll when dragging near top or bottom of viewport - const scrollZone = 100; - const scrollSpeed = 10; - const viewportHeight = window.innerHeight; - - if (e.clientY < scrollZone) { - // Near top - scroll up - const intensity = 1 - e.clientY / scrollZone; - window.scrollBy(0, -scrollSpeed * intensity); - } else if (e.clientY > viewportHeight - scrollZone) { - // Near bottom - scroll down - const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; - window.scrollBy(0, scrollSpeed * intensity); - } }} ondragend={async (e) => { e.preventDefault(); @@ -1348,6 +1392,7 @@ activeDragElement.item = null; activeDragElement.lastTargetId = null; activeDragElement.lastPlacement = null; + lastGridPos = null; return true; }} class={[ diff --git a/src/lib/website/load.ts b/src/lib/website/load.ts index d11db55..e6ef259 100644 --- a/src/lib/website/load.ts +++ b/src/lib/website/load.ts @@ -73,26 +73,25 @@ export async function loadData( throw error(404); } - const cards = await listRecords({ did, collection: 'app.blento.card' }).catch(() => { - console.error('error getting records for collection app.blento.card'); - return [] as Awaited>; - }); - - const mainPublication = await getRecord({ - did, - collection: 'site.standard.publication', - rkey: 'blento.self' - }).catch(() => { - console.error('error getting record for collection site.standard.publication'); - return undefined; - }); - - const pages = await listRecords({ did, collection: 'app.blento.page' }).catch(() => { - console.error('error getting records for collection app.blento.page'); - return [] as Awaited>; - }); - - const profile = await getDetailedProfile({ did }); + const [cards, mainPublication, pages, profile] = await Promise.all([ + listRecords({ did, collection: 'app.blento.card' }).catch(() => { + console.error('error getting records for collection app.blento.card'); + return [] as Awaited>; + }), + getRecord({ + did, + collection: 'site.standard.publication', + rkey: 'blento.self' + }).catch(() => { + console.error('error getting record for collection site.standard.publication'); + return undefined; + }), + listRecords({ did, collection: 'app.blento.page' }).catch(() => { + console.error('error getting records for collection app.blento.page'); + return [] as Awaited>; + }), + getDetailedProfile({ did }) + ]); const cardTypes = new Set(cards.map((v) => v.value.cardType ?? '') as string[]); const cardTypesArray = Array.from(cardTypes); @@ -144,7 +143,7 @@ export async function loadData( const stringifiedResult = JSON.stringify(result); await cache?.put?.(handle, stringifiedResult); - const parsedResult = JSON.parse(stringifiedResult); + const parsedResult = structuredClone(result) as any; parsedResult.publication = ( parsedResult.publications as Awaited>