diff --git a/web/src/components/ColumnGrid/ColumnGrid.tsx b/web/src/components/ColumnGrid/ColumnGrid.tsx index 18c4221f..eb638a0b 100644 --- a/web/src/components/ColumnGrid/ColumnGrid.tsx +++ b/web/src/components/ColumnGrid/ColumnGrid.tsx @@ -20,6 +20,12 @@ const LEADING_KEY = "__grid_leading__"; const GRID_MIN_COLUMN_WIDTH = 260; export const GRID_GAP = 12; +// A from-scratch re-pack is adopted only when it shrinks the column-height spread by at least +// this much, so a genuine late-growth imbalance heals but a trivial few-pixel gain never +// reshuffles the wall. There is no absolute-drift trigger, so even a small late growth rebalances +// as long as re-packing actually helps. +const REPACK_MIN_IMPROVEMENT = 32; + // The single source of truth for how many columns fit a given width. Callers use it to detect a // one-column layout and fall back to a plain flow list instead of a degenerate one-column grid. export const columnCountForWidth = (width: number): number => @@ -35,15 +41,21 @@ const shortestColumn = (heights: number[]): number => { return index; }; +// Spread between the tallest and shortest column. Operates on the per-column heights (a small +// array sized to the column count), so the spread is cheap. +const driftOf = (columnHeights: number[]): number => Math.max(...columnHeights) - Math.min(...columnHeights); + /** * Absolute-positioned column grid (Google-Keep-style packing). Cards keep their document * order and are only translated into place, so appending pages or reordering the list * never remounts a card — preserving its state and avoiding flashes. * - * Columns are assigned incrementally and stick: a new card goes into the currently - * shortest column and stays there. Existing cards never switch columns, so creating a - * memo (or a card growing when its image loads) only shifts the one affected column — - * never the whole wall. Balance holds because every new card fills the shortest column. + * Columns are assigned incrementally and stick: a new card goes into the currently shortest + * column and stays there, so appending a page or a card growing only shifts that one column, + * not the whole wall. The exception is the self-heal below: when a late height change (an image + * or comment loading after a card's column was fixed) leaves the columns lopsided, a full re-pack + * is adopted and those cards animate to their new columns — the only time cards move between + * columns, and the only relayout that animates. */ function ColumnGrid({ items, getKey, renderItem, leading, priorityKey, maxColumns, maxColumnWidth }: ColumnGridProps) { const containerRef = useRef(null); @@ -56,11 +68,19 @@ function ColumnGrid({ items, getKey, renderItem, leading, priorityKey, maxCol // or editing memos never reshuffles the whole wall. Reset only when the column count changes. const assignmentsRef = useRef>(new Map()); const assignedColumnCountRef = useRef(0); + // Cards positioned at least once. A card's first placement skips the CSS transition so it + // doesn't visibly slide in from the top-left corner. + const positionedKeysRef = useRef>(new Set()); + // Last column width, so a resize (width change) can be detected and applied instantly: an eased + // x-offset would lag behind the width, which is always written to `el.style.width` immediately. + const lastColumnWidthRef = useRef(0); const [containerHeight, setContainerHeight] = useState(undefined); // Measure each card once (widths written in one pass, heights read in the next so the // browser reflows once), assign only new cards to the shortest column, then translate - // every card to its column's running offset. Existing assignments are reused verbatim. + // every card to its column's running offset. Existing assignments are reused; a from-scratch + // re-pack happens only on a column-count change or to heal drift left by a card that grew + // after its column was fixed. const relayout = useCallback(() => { const container = containerRef.current; if (!container) return; @@ -108,8 +128,13 @@ function ColumnGrid({ items, getKey, renderItem, leading, priorityKey, maxCol const heightOf = (key: string) => heightByKey.get(key) ?? 0; const assignments = assignmentsRef.current; - // A column-count change (resize/breakpoint) is the only time we re-pack from scratch. - if (assignedColumnCountRef.current !== count) { + // Structural changes (column count or width) re-lay-out crisply, with no animation: the width + // is written to `el.style.width` instantly, so an eased x-offset would lag behind it. + const countChanged = assignedColumnCountRef.current !== count; + const widthChanged = columnWidth !== lastColumnWidthRef.current; + lastColumnWidthRef.current = columnWidth; + // A column-count change (resize/breakpoint) forces a re-pack from scratch. + if (countChanged) { assignments.clear(); assignedColumnCountRef.current = count; } @@ -119,34 +144,76 @@ function ColumnGrid({ items, getKey, renderItem, leading, priorityKey, maxCol if (!liveKeys.has(key)) assignments.delete(key); } - // Assign only unassigned cards, into the column that is shortest right now. Existing - // cards keep their column, so nothing else moves between columns. - const totals = new Array(count).fill(0); - for (const { key } of ordered) { - const col = assignments.get(key); - if (col != null) totals[col] += heightOf(key); - } - for (const { key } of ordered) { - if (assignments.has(key)) continue; - // The leading tile and a just-created memo belong at the top of column one (the action - // column), regardless of mount timing; every other new card balances into the shortest - // column. `ordered` places leading first, so it stays above the priority memo. - const col = key === priorityKey || key === LEADING_KEY ? 0 : shortestColumn(totals); - assignments.set(key, col); - totals[col] += heightOf(key); + // Plan a layout from a starting set of sticky column assignments, without touching the DOM. + // Unassigned cards drop into the column that is shortest right now; the leading tile and the + // just-created memo pin to column one. Non-priority placements are persisted back into + // `sticky` so they stay put on later passes, but the priority pin is left transient — a + // superseded memo rebalances on its next pass instead of piling up in column one forever. + const plan = (sticky: Map) => { + const totals = new Array(count).fill(0); + const columnOf = new Map(); + for (const { key } of ordered) { + const col = sticky.get(key); + if (col != null) { + totals[col] += heightOf(key); + columnOf.set(key, col); + } + } + for (const { key } of ordered) { + if (columnOf.has(key)) continue; + // `ordered` places leading first, so it stays above the priority memo in column one. + const col = key === priorityKey || key === LEADING_KEY ? 0 : shortestColumn(totals); + totals[col] += heightOf(key); + columnOf.set(key, col); + if (key !== priorityKey) sticky.set(key, col); + } + // Stack each column's cards in feed order, recording each card's target position. + const columnY = new Array(count).fill(0); + const pos = new Map(); + for (const { key } of ordered) { + const col = columnOf.get(key) ?? 0; + const x = offsetX + col * (columnWidth + GRID_GAP); + const y = columnY[col]; + pos.set(key, { x, y }); + columnY[col] = y + heightOf(key) + GRID_GAP; + } + return { columnY, pos }; + }; + + // Incremental pass: keep existing columns, place only the new cards. + let layout = plan(assignments); + // Self-heal: a card that grew after its column was fixed (a late image/comment) leaves the + // columns lopsided, and the sticky rule can't undo it. Re-pack from a clean slate and adopt it + // only when it shrinks the spread by a worthwhile margin — so an unavoidable imbalance (forced + // column-one content, or fewer memos than columns) can't reshuffle the wall. The second plan() + // is cheap in-memory work; the once-per-pass DOM measurement above dominates. + let animateRepack = false; + const currentDrift = driftOf(layout.columnY); + if (count > 1 && currentDrift > REPACK_MIN_IMPROVEMENT) { + const rebalanced = new Map(); + const fresh = plan(rebalanced); + if (driftOf(fresh.columnY) + REPACK_MIN_IMPROVEMENT < currentDrift) { + assignmentsRef.current = rebalanced; + layout = fresh; + // Animate the rebalance only when the layout is otherwise stable. During a resize the + // positions must snap so they stay in lockstep with the instantly-applied widths. + animateRepack = !countChanged && !widthChanged; + } } - // Stack each column's cards in feed order. - const columnY = new Array(count).fill(0); + // Apply the chosen positions. Only an adopted re-pack on an otherwise-stable layout animates; + // first placement, resizes, growth reflows and column-count changes all snap instantly. The + // transition is suppressed on a card's first placement so it never slides in from 0,0. for (const { key, el } of ordered) { - const col = assignments.get(key) ?? 0; - const x = offsetX + col * (columnWidth + GRID_GAP); - const y = columnY[col]; - el.style.transform = `translate3d(${x}px, ${y}px, 0)`; - columnY[col] = y + heightOf(key) + GRID_GAP; + const target = layout.pos.get(key); + if (!target) continue; + const firstPlacement = !positionedKeysRef.current.has(key); + if (firstPlacement) positionedKeysRef.current.add(key); + el.style.transition = animateRepack && !firstPlacement ? "" : "none"; + el.style.transform = `translate3d(${target.x}px, ${target.y}px, 0)`; } - setContainerHeight(Math.max(0, ...columnY.map((h) => h - GRID_GAP))); + setContainerHeight(Math.max(0, ...layout.columnY.map((h) => h - GRID_GAP))); }, [items, getKey, priorityKey, maxColumns, maxColumnWidth]); // Keep a stable reference so observer callbacks always run the latest layout. @@ -216,6 +283,8 @@ function ColumnGrid({ items, getKey, renderItem, leading, priorityKey, maxCol } else { map.delete(key); refCallbacks.current.delete(key); + // A re-added card should animate in fresh, not slide from its old transform. + positionedKeysRef.current.delete(key); } }; refCallbacks.current.set(key, callback); @@ -225,14 +294,24 @@ function ColumnGrid({ items, getKey, renderItem, leading, priorityKey, maxCol return (
{leading != null && ( -
+
{leading}
)} {items.map((item) => { const key = getKey(item); return ( -
+
{renderItem(item)}
);