fix(web): keep the multi-column feed balanced
Newly-created memos were pinned to column one and never released, and cards that grew after placement (late images/comments) never rebalanced, so the columns drifted badly out of balance. - Leave the priority (just-created) memo's column assignment transient, so a superseded memo rebalances instead of piling up in column one forever. - Self-heal: re-pack from a clean slate when a late height change leaves the columns lopsided, adopting it only when it meaningfully shrinks the spread. - Animate only the adopted rebalance; resizes, column-count changes, growth reflows and first paint stay instant so widths and positions stay in lockstep.
This commit is contained in:
@@ -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<T>({ items, getKey, renderItem, leading, priorityKey, maxColumns, maxColumnWidth }: ColumnGridProps<T>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -56,11 +68,19 @@ function ColumnGrid<T>({ items, getKey, renderItem, leading, priorityKey, maxCol
|
||||
// or editing memos never reshuffles the whole wall. Reset only when the column count changes.
|
||||
const assignmentsRef = useRef<Map<string, number>>(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<Set<string>>(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<number | undefined>(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<T>({ 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<T>({ 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<number>(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<string, number>) => {
|
||||
const totals = new Array<number>(count).fill(0);
|
||||
const columnOf = new Map<string, number>();
|
||||
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<number>(count).fill(0);
|
||||
const pos = new Map<string, { x: number; y: number }>();
|
||||
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<string, number>();
|
||||
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<number>(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<T>({ 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<T>({ items, getKey, renderItem, leading, priorityKey, maxCol
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full" style={{ height: containerHeight }}>
|
||||
{leading != null && (
|
||||
<div key={LEADING_KEY} ref={getItemRef(LEADING_KEY)} className="absolute top-0 left-0" style={{ willChange: "transform" }}>
|
||||
<div
|
||||
key={LEADING_KEY}
|
||||
ref={getItemRef(LEADING_KEY)}
|
||||
className="absolute top-0 left-0 transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
{leading}
|
||||
</div>
|
||||
)}
|
||||
{items.map((item) => {
|
||||
const key = getKey(item);
|
||||
return (
|
||||
<div key={key} ref={getItemRef(key)} className="absolute top-0 left-0" style={{ willChange: "transform" }}>
|
||||
<div
|
||||
key={key}
|
||||
ref={getItemRef(key)}
|
||||
className="absolute top-0 left-0 transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user