fix(web): stabilize multi-column memo layout

pull/6080/head
boojack 2 days ago
parent 5329e60407
commit 730c24592f

@ -5,6 +5,8 @@ interface ColumnGridProps<T> {
/** Stable identity for each item; also used as the React key. */
getKey: (item: T) => string;
renderItem: (item: T) => ReactNode;
/** Estimated rendered height in px, used only to choose a column deterministically. */
estimateHeight?: (item: T, context: { columnWidth: number }) => number;
/** Optional node packed as the very first tile (e.g. the note composer). */
leading?: ReactNode;
/** Key that must land at the top of column one (e.g. a just-created memo), not the shortest column. */
@ -20,12 +22,6 @@ 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 =>
@ -41,46 +37,59 @@ 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);
export const assignColumnsByEstimatedHeight = ({
keys,
columnCount,
getEstimatedHeight,
pinnedKeys,
}: {
keys: string[];
columnCount: number;
getEstimatedHeight: (key: string) => number;
pinnedKeys?: ReadonlySet<string>;
}): Map<string, number> => {
const totals = new Array<number>(columnCount).fill(0);
const columns = new Map<string, number>();
for (const key of keys) {
const column = pinnedKeys?.has(key) ? 0 : shortestColumn(totals);
columns.set(key, column);
totals[column] += Math.max(0, getEstimatedHeight(key));
}
return columns;
};
/**
* 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, 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.
* Columns are assigned from deterministic estimated heights. Real measured heights only decide
* each card's final y offset inside its assigned column, so late image/comment growth moves cards
* down within that column without reshuffling cards across columns.
*/
function ColumnGrid<T>({ items, getKey, renderItem, leading, priorityKey, maxColumns, maxColumnWidth }: ColumnGridProps<T>) {
function ColumnGrid<T>({
items,
getKey,
renderItem,
estimateHeight,
leading,
priorityKey,
maxColumns,
maxColumnWidth,
}: ColumnGridProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const refCallbacks = useRef<Map<string, (el: HTMLDivElement | null) => void>>(new Map());
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const rafRef = useRef<number | null>(null);
const lastWidthRef = useRef(0);
// Sticky column per card key: once assigned, a card never changes column, so adding
// 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; 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.
// browser reflows once), assign cards by estimated height, then translate every card to its
// column's measured running offset.
const relayout = useCallback(() => {
const container = containerRef.current;
if (!container) return;
@ -100,8 +109,10 @@ function ColumnGrid<T>({ items, getKey, renderItem, leading, priorityKey, maxCol
const ordered: { key: string; el: HTMLDivElement }[] = [];
const leadingEl = itemRefs.current.get(LEADING_KEY);
if (leadingEl) ordered.push({ key: LEADING_KEY, el: leadingEl });
const itemByKey = new Map<string, T>();
for (const item of items) {
const key = getKey(item);
itemByKey.set(key, item);
const el = itemRefs.current.get(key);
if (el) ordered.push({ key, el });
}
@ -127,88 +138,35 @@ function ColumnGrid<T>({ items, getKey, renderItem, leading, priorityKey, maxCol
}
const heightOf = (key: string) => heightByKey.get(key) ?? 0;
const assignments = assignmentsRef.current;
// 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;
}
// Forget cards that no longer exist so their column frees up.
const liveKeys = new Set(ordered.map((entry) => entry.key));
for (const key of assignments.keys()) {
if (!liveKeys.has(key)) assignments.delete(key);
const pinnedKeys = new Set<string>([LEADING_KEY]);
if (priorityKey) {
pinnedKeys.add(priorityKey);
}
const columnOf = assignColumnsByEstimatedHeight({
keys: ordered.map((entry) => entry.key),
columnCount: count,
pinnedKeys,
getEstimatedHeight: (key) => {
const item = itemByKey.get(key);
return item && estimateHeight ? estimateHeight(item, { columnWidth }) : 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;
}
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;
}
// 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.
// Apply the chosen positions without transitions. Relayouts may be caused by late media or
// comment preview height changes, and those should never visibly animate cards across the wall.
for (const { key, el } of ordered) {
const target = layout.pos.get(key);
const target = pos.get(key);
if (!target) continue;
const firstPlacement = !positionedKeysRef.current.has(key);
if (firstPlacement) positionedKeysRef.current.add(key);
// The leading tile (the note composer) is positioned with left/top rather than a
// transform so it never becomes the containing block for its own position:fixed
// descendants. A transform (or will-change:transform) here would trap the editor's
@ -222,12 +180,12 @@ function ColumnGrid<T>({ items, getKey, renderItem, leading, priorityKey, maxCol
el.style.top = `${target.y}px`;
continue;
}
el.style.transition = animateRepack && !firstPlacement ? "" : "none";
el.style.transition = "none";
el.style.transform = `translate3d(${target.x}px, ${target.y}px, 0)`;
}
setContainerHeight(Math.max(0, ...layout.columnY.map((h) => h - GRID_GAP)));
}, [items, getKey, priorityKey, maxColumns, maxColumnWidth]);
setContainerHeight(Math.max(0, ...columnY.map((h) => h - GRID_GAP)));
}, [items, getKey, estimateHeight, priorityKey, maxColumns, maxColumnWidth]);
// Keep a stable reference so observer callbacks always run the latest layout.
const relayoutRef = useRef(relayout);
@ -296,8 +254,6 @@ 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);

@ -1,4 +1,4 @@
import ColumnGrid from "./ColumnGrid";
export { columnCountForWidth, GRID_GAP } from "./ColumnGrid";
export { assignColumnsByEstimatedHeight, columnCountForWidth, GRID_GAP } from "./ColumnGrid";
export default ColumnGrid;

@ -21,9 +21,10 @@ import ColumnGrid, { columnCountForWidth, GRID_GAP } from "../ColumnGrid";
import MemoEditor from "../MemoEditor";
import MemoFilters from "../MemoFilters";
import Placeholder from "../Placeholder";
import { estimateMemoCardHeight } from "./memoCardHeight";
// Memo identity for React keys and the grid's sticky column assignments. The pages use it
// for their renderer keys too, so flow-list and grid identity can never drift apart.
// Memo identity for React keys and grid planning. The pages use it for their renderer keys too,
// so flow-list and grid identity can never drift apart.
export const getMemoKey = (memo: Memo) => `${memo.name}-${memo.updateTime}`;
// Columns never stretch past this, so 2 columns on a wide monitor stay readable and the
@ -262,6 +263,7 @@ const PagedMemoList = (props: Props) => {
items={sortedMemoList}
getKey={getMemoKey}
renderItem={(memo) => props.renderer(memo, { compact: effectiveCompact })}
estimateHeight={estimateMemoCardHeight}
leading={gridLeading}
priorityKey={priorityKey}
maxColumns={maxColumns}

@ -0,0 +1,110 @@
import { CLAMP_PREVIEW_HEIGHT_PX, CLAMP_TRIGGER_HEIGHT_PX } from "@/components/ClampedSection";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { getAttachmentType, isMotionAttachment } from "@/utils/attachment";
import { buildAttachmentVisualItems } from "@/utils/media-item";
interface EstimateMemoCardHeightOptions {
columnWidth: number;
}
const CARD_VERTICAL_PADDING = 24;
const CARD_HEADER_HEIGHT = 32;
const CARD_SECTION_GAP = 8;
const SHOW_MORE_BUTTON_HEIGHT = 28;
const CONTENT_HORIZONTAL_PADDING = 32;
const CONTENT_AVERAGE_CHAR_WIDTH = 7;
const CONTENT_LINE_HEIGHT = 22;
const CONTENT_MIN_CHARS_PER_LINE = 18;
const MARKDOWN_IMAGE_HEIGHT = 220;
const SINGLE_IMAGE_HEIGHT = 260;
const SINGLE_VIDEO_ASPECT_RATIO = 9 / 16;
const TWO_VISUAL_ITEMS_HEIGHT = 240;
const MOSAIC_VISUAL_ITEMS_HEIGHT = 288;
const SIX_VISUAL_ITEMS_HEIGHT = 320;
const ATTACHMENT_SECTION_HEADER_HEIGHT = 36;
const ATTACHMENT_SECTION_PADDING = 16;
const ATTACHMENT_SECTION_GAP = 8;
const AUDIO_ATTACHMENT_ROW_HEIGHT = 92;
const DOCUMENT_ATTACHMENT_ROW_HEIGHT = 64;
const REACTION_ROW_HEIGHT = 28;
const COMMENT_PREVIEW_HEADER_HEIGHT = 20;
const COMMENT_PREVIEW_ROW_HEIGHT = 44;
const COMMENT_PREVIEW_VERTICAL_PADDING = 20;
const MAX_VISIBLE_COMMENT_PREVIEWS = 3;
const estimateWrappedTextHeight = (content: string, columnWidth: number): number => {
const textWidth = Math.max(1, columnWidth - CONTENT_HORIZONTAL_PADDING);
const charsPerLine = Math.max(CONTENT_MIN_CHARS_PER_LINE, Math.floor(textWidth / CONTENT_AVERAGE_CHAR_WIDTH));
const lineCount = content.split("\n").reduce((total, line) => total + Math.max(1, Math.ceil(line.length / charsPerLine)), 0);
return lineCount * CONTENT_LINE_HEIGHT;
};
const countMarkdownImages = (content: string): number => {
const markdownImages = content.match(/!\[[^\]]*]\([^)]+\)/g)?.length ?? 0;
const htmlImages = content.match(/<img\s/gi)?.length ?? 0;
return markdownImages + htmlImages;
};
const isVisualAttachment = (attachment: Attachment): boolean => {
const type = getAttachmentType(attachment);
return type === "image/*" || type === "video/*" || isMotionAttachment(attachment);
};
const isAudioAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "audio/*";
const estimateVisualGalleryHeight = (visualAttachments: Attachment[], columnWidth: number): number => {
const count = buildAttachmentVisualItems(visualAttachments).length;
if (count === 0) return 0;
if (count === 1) {
const type = getAttachmentType(visualAttachments[0]!);
return type === "video/*" ? Math.round(columnWidth * SINGLE_VIDEO_ASPECT_RATIO) : SINGLE_IMAGE_HEIGHT;
}
if (count === 2) return TWO_VISUAL_ITEMS_HEIGHT;
if (count <= 4) return MOSAIC_VISUAL_ITEMS_HEIGHT;
return SIX_VISUAL_ITEMS_HEIGHT;
};
const estimateAttachmentSectionHeight = (attachments: Attachment[], columnWidth: number): number => {
if (attachments.length === 0) return 0;
const visual = attachments.filter(isVisualAttachment);
const audio = attachments.filter(isAudioAttachment);
const docs = attachments.filter((attachment) => !isVisualAttachment(attachment) && !isAudioAttachment(attachment));
const contentParts = [
estimateVisualGalleryHeight(visual, columnWidth),
audio.length > 0 ? audio.length * AUDIO_ATTACHMENT_ROW_HEIGHT + Math.max(0, audio.length - 1) * ATTACHMENT_SECTION_GAP : 0,
docs.length > 0 ? docs.length * DOCUMENT_ATTACHMENT_ROW_HEIGHT + Math.max(0, docs.length - 1) * ATTACHMENT_SECTION_GAP : 0,
].filter((height) => height > 0);
const contentHeight =
contentParts.reduce((total, height) => total + height, 0) + Math.max(0, contentParts.length - 1) * ATTACHMENT_SECTION_GAP;
return ATTACHMENT_SECTION_HEADER_HEIGHT + ATTACHMENT_SECTION_PADDING + contentHeight;
};
const countCommentRelations = (memo: Memo): number =>
(memo.relations ?? []).filter((relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memo.name)
.length;
const estimateCommentPreviewHeight = (memo: Memo): number => {
const visibleCommentCount = Math.min(countCommentRelations(memo), MAX_VISIBLE_COMMENT_PREVIEWS);
if (visibleCommentCount === 0) return 0;
return COMMENT_PREVIEW_VERTICAL_PADDING + COMMENT_PREVIEW_HEADER_HEIGHT + visibleCommentCount * COMMENT_PREVIEW_ROW_HEIGHT;
};
export const estimateMemoCardHeight = (memo: Memo, { columnWidth }: EstimateMemoCardHeightOptions): number => {
const content = memo.content ?? "";
const contentHeight = estimateWrappedTextHeight(content, columnWidth) + countMarkdownImages(content) * MARKDOWN_IMAGE_HEIGHT;
const attachmentHeight = estimateAttachmentSectionHeight(memo.attachments ?? [], columnWidth);
const bodyHeight = contentHeight + attachmentHeight + (contentHeight > 0 && attachmentHeight > 0 ? CARD_SECTION_GAP : 0);
const compactBodyHeight = bodyHeight > CLAMP_TRIGGER_HEIGHT_PX ? CLAMP_PREVIEW_HEIGHT_PX + SHOW_MORE_BUTTON_HEIGHT : bodyHeight;
const reactionHeight = (memo.reactions ?? []).length > 0 ? CARD_SECTION_GAP + REACTION_ROW_HEIGHT : 0;
return (
CARD_VERTICAL_PADDING + CARD_HEADER_HEIGHT + CARD_SECTION_GAP + compactBodyHeight + reactionHeight + estimateCommentPreviewHeight(memo)
);
};

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { assignColumnsByEstimatedHeight } from "@/components/ColumnGrid";
describe("assignColumnsByEstimatedHeight", () => {
it("assigns each card to the shortest estimated column with deterministic ties", () => {
const columns = assignColumnsByEstimatedHeight({
keys: ["a", "b", "c", "d"],
columnCount: 2,
getEstimatedHeight: (key) => ({ a: 100, b: 80, c: 70, d: 60 })[key] ?? 0,
});
expect(Object.fromEntries(columns)).toEqual({
a: 0,
b: 1,
c: 1,
d: 0,
});
});
it("keeps pinned cards in the first column while balancing later cards", () => {
const columns = assignColumnsByEstimatedHeight({
keys: ["leading", "a", "priority", "b", "c"],
columnCount: 3,
getEstimatedHeight: (key) => ({ leading: 160, a: 90, priority: 80, b: 120, c: 70 })[key] ?? 0,
pinnedKeys: new Set(["leading", "priority"]),
});
expect(Object.fromEntries(columns)).toEqual({
leading: 0,
a: 1,
priority: 0,
b: 2,
c: 1,
});
});
});

@ -0,0 +1,63 @@
import { create } from "@bufbuild/protobuf";
import { describe, expect, it } from "vitest";
import { estimateMemoCardHeight } from "@/components/PagedMemoList/memoCardHeight";
import { AttachmentSchema, type Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { MemoRelation_MemoSchema, MemoRelation_Type, MemoRelationSchema, MemoSchema, type Memo } from "@/types/proto/api/v1/memo_service_pb";
const buildAttachment = (overrides: Partial<Attachment>) =>
create(AttachmentSchema, {
name: "attachments/test",
filename: "test.bin",
type: "application/octet-stream",
...overrides,
});
const buildCommentRelation = (memoName: string, index: number) =>
create(MemoRelationSchema, {
type: MemoRelation_Type.COMMENT,
memo: create(MemoRelation_MemoSchema, { name: `memos/comment-${index}` }),
relatedMemo: create(MemoRelation_MemoSchema, { name: memoName }),
});
const buildMemo = (overrides: Partial<Memo> = {}) =>
create(MemoSchema, {
name: "memos/main",
content: "hello",
attachments: [],
relations: [],
...overrides,
});
describe("estimateMemoCardHeight", () => {
it("accounts for visual attachments before images have loaded", () => {
const plain = buildMemo();
const withImage = buildMemo({
attachments: [
buildAttachment({
name: "attachments/image",
filename: "image.png",
type: "image/png",
}),
],
});
expect(estimateMemoCardHeight(withImage, { columnWidth: 320 })).toBeGreaterThan(
estimateMemoCardHeight(plain, { columnWidth: 320 }) + 100,
);
});
it("estimates the visible comment preview height and caps it at three comments", () => {
const oneComment = buildMemo({ relations: [buildCommentRelation("memos/main", 1)] });
const threeComments = buildMemo({
relations: [1, 2, 3].map((index) => buildCommentRelation("memos/main", index)),
});
const fiveComments = buildMemo({
relations: [1, 2, 3, 4, 5].map((index) => buildCommentRelation("memos/main", index)),
});
expect(estimateMemoCardHeight(threeComments, { columnWidth: 320 })).toBeGreaterThan(
estimateMemoCardHeight(oneComment, { columnWidth: 320 }),
);
expect(estimateMemoCardHeight(fiveComments, { columnWidth: 320 })).toBe(estimateMemoCardHeight(threeComments, { columnWidth: 320 }));
});
});
Loading…
Cancel
Save