diff --git a/web/src/components/ColumnGrid/ColumnGrid.tsx b/web/src/components/ColumnGrid/ColumnGrid.tsx index 0a50e1d47..0ed295d14 100644 --- a/web/src/components/ColumnGrid/ColumnGrid.tsx +++ b/web/src/components/ColumnGrid/ColumnGrid.tsx @@ -5,6 +5,8 @@ interface ColumnGridProps { /** 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; +}): Map => { + const totals = new Array(columnCount).fill(0); + const columns = new Map(); + + 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({ items, getKey, renderItem, leading, priorityKey, maxColumns, maxColumnWidth }: ColumnGridProps) { +function ColumnGrid({ + items, + getKey, + renderItem, + estimateHeight, + leading, + priorityKey, + maxColumns, + maxColumnWidth, +}: ColumnGridProps) { const containerRef = useRef(null); const itemRefs = useRef>(new Map()); const refCallbacks = useRef void>>(new Map()); const resizeObserverRef = useRef(null); const rafRef = useRef(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>(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; 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({ 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(); 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({ 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([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) => { - 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; - } + 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; } - // 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({ 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({ 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); diff --git a/web/src/components/ColumnGrid/index.ts b/web/src/components/ColumnGrid/index.ts index 880572a26..9cb4bdcad 100644 --- a/web/src/components/ColumnGrid/index.ts +++ b/web/src/components/ColumnGrid/index.ts @@ -1,4 +1,4 @@ import ColumnGrid from "./ColumnGrid"; -export { columnCountForWidth, GRID_GAP } from "./ColumnGrid"; +export { assignColumnsByEstimatedHeight, columnCountForWidth, GRID_GAP } from "./ColumnGrid"; export default ColumnGrid; diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index 58a9e72a8..7974bcfe8 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -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} diff --git a/web/src/components/PagedMemoList/memoCardHeight.ts b/web/src/components/PagedMemoList/memoCardHeight.ts new file mode 100644 index 000000000..3e95e9629 --- /dev/null +++ b/web/src/components/PagedMemoList/memoCardHeight.ts @@ -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(/ { + 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) + ); +}; diff --git a/web/tests/column-grid-planner.test.ts b/web/tests/column-grid-planner.test.ts new file mode 100644 index 000000000..a0a78e6b5 --- /dev/null +++ b/web/tests/column-grid-planner.test.ts @@ -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, + }); + }); +}); diff --git a/web/tests/memo-card-height.test.ts b/web/tests/memo-card-height.test.ts new file mode 100644 index 000000000..8fea2fe93 --- /dev/null +++ b/web/tests/memo-card-height.test.ts @@ -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) => + 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 = {}) => + 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 })); + }); +});