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:
boojack
2026-07-08 21:51:02 +08:00
parent dc41480ece
commit a72ae804c0
+111 -32
View File
@@ -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>
);