mirror of https://github.com/usememos/memos
feat(web): add max-columns memo feed layout
Replace the single-column-only feed with a max-columns model: one
Columns setting (1 / 2 / 3 / ∞) where 1 is the reading list and
anything wider packs into a Google-Keep-style column grid. There is
deliberately no separate list/grid mode — the setting is a ceiling,
and widths that only fit one column fall back to the flow list.
- ColumnGrid: absolute-positioned packing that only translates cards,
so appends and reorders never remount them; sticky column assignment
keeps existing cards in place when new memos arrive; tiles are capped
at 360px with a fade; columns clamp to 420px and center.
- Column one is the action column: the composer and active filters
stack as its first tile, and a just-created memo is pinned directly
beneath them.
- Multi-column always renders compact cards (policy centralized in
PagedMemoList and threaded through renderer(memo, { compact })).
- Setting persists in ViewContext localStorage; the settings menu
derives its options from the context's canonical value list.
pull/6071/head
parent
d1cef7a9ab
commit
177d65a90e
@ -0,0 +1,274 @@
|
||||
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
interface ColumnGridProps<T> {
|
||||
items: T[];
|
||||
/** Stable identity for each item; also used as the React key. */
|
||||
getKey: (item: T) => string;
|
||||
renderItem: (item: T) => ReactNode;
|
||||
/** Optional node packed as the very first tile (e.g. the note composer). */
|
||||
leading?: ReactNode;
|
||||
/** When set, cap each tile to this height (px) with a bottom fade, so no tile dominates a column. */
|
||||
maxItemHeight?: number;
|
||||
/** Key that must land at the top of column one (e.g. a just-created memo), not the shortest column. */
|
||||
priorityKey?: string;
|
||||
/** Upper bound on the column count; 0 or undefined means as many as fit. */
|
||||
maxColumns?: number;
|
||||
/** Cap on each column's width in px; leftover space centers the grid. */
|
||||
maxColumnWidth?: number;
|
||||
}
|
||||
|
||||
const LEADING_KEY = "__grid_leading__";
|
||||
|
||||
const GRID_MIN_COLUMN_WIDTH = 260;
|
||||
export const GRID_GAP = 12;
|
||||
|
||||
// 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 =>
|
||||
Math.max(1, Math.floor((width + GRID_GAP) / (GRID_MIN_COLUMN_WIDTH + GRID_GAP)));
|
||||
|
||||
const shortestColumn = (heights: number[]): number => {
|
||||
let index = 0;
|
||||
for (let i = 1; i < heights.length; i++) {
|
||||
if (heights[i] < heights[index]) {
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function ColumnGrid<T>({ items, getKey, renderItem, leading, maxItemHeight, 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);
|
||||
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.
|
||||
const relayout = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const width = container.clientWidth;
|
||||
// What fits (each column >= the minimum width), then the user's max-columns ceiling on top.
|
||||
const fit = columnCountForWidth(width);
|
||||
const count = maxColumns && maxColumns > 0 ? Math.min(fit, maxColumns) : fit;
|
||||
// Whole-pixel columns that fill the row, clamped to maxColumnWidth so few columns on a
|
||||
// wide screen stay readable instead of stretching.
|
||||
let columnWidth = count > 1 ? Math.floor((width - GRID_GAP * (count - 1)) / count) : width;
|
||||
if (maxColumnWidth != null) columnWidth = Math.min(columnWidth, maxColumnWidth);
|
||||
// Center the packed columns in whatever width the clamp leaves over.
|
||||
const offsetX = Math.floor((width - (columnWidth * count + GRID_GAP * (count - 1))) / 2);
|
||||
|
||||
// Ordered by feed position: the leading tile (composer) first, then items.
|
||||
const ordered: { key: string; el: HTMLDivElement }[] = [];
|
||||
const leadingEl = itemRefs.current.get(LEADING_KEY);
|
||||
if (leadingEl) ordered.push({ key: LEADING_KEY, el: leadingEl });
|
||||
for (const item of items) {
|
||||
const key = getKey(item);
|
||||
const el = itemRefs.current.get(key);
|
||||
if (el) ordered.push({ key, el });
|
||||
}
|
||||
|
||||
// Pass 1 (writes): fix each card's width, drop its own bottom margin so spacing is owned
|
||||
// entirely by `gap`, and clear any prior cap so the natural height can be measured.
|
||||
// `firstElementChild` is the item's rendered root (the memo card, or the comment wrapper).
|
||||
for (const { el } of ordered) {
|
||||
el.style.width = `${columnWidth}px`;
|
||||
const child = el.firstElementChild;
|
||||
if (child instanceof HTMLElement) {
|
||||
child.style.marginBottom = "0px";
|
||||
child.style.maxHeight = "";
|
||||
child.style.overflow = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 (reads): natural height of every card, measured once. We read the inner element,
|
||||
// not the absolutely-positioned wrapper (whose block-formatting context would fold in margins).
|
||||
const naturalByKey = new Map<string, number>();
|
||||
for (const { key, el } of ordered) {
|
||||
const child = el.firstElementChild;
|
||||
naturalByKey.set(key, child instanceof HTMLElement ? child.offsetHeight : el.offsetHeight);
|
||||
}
|
||||
|
||||
// Pass 3 (writes): clip tall cards to maxItemHeight and show their fade, so a single long
|
||||
// memo can't dominate a column and the wall stays tidy. The leading tile is never capped.
|
||||
const heightByKey = new Map<string, number>();
|
||||
for (const { key, el } of ordered) {
|
||||
const natural = naturalByKey.get(key) ?? 0;
|
||||
const capped = maxItemHeight != null && key !== LEADING_KEY && natural > maxItemHeight;
|
||||
const child = el.firstElementChild;
|
||||
if (capped && child instanceof HTMLElement) {
|
||||
child.style.maxHeight = `${maxItemHeight}px`;
|
||||
child.style.overflow = "hidden";
|
||||
}
|
||||
// The fade overlay, when present, is the wrapper's last child (rendered right after the item).
|
||||
const fade = el.lastElementChild;
|
||||
if (fade instanceof HTMLElement && fade.hasAttribute("data-grid-fade")) {
|
||||
fade.style.display = capped ? "block" : "";
|
||||
}
|
||||
heightByKey.set(key, capped ? (maxItemHeight as number) : natural);
|
||||
}
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Stack each column's cards in feed order.
|
||||
const columnY = new Array<number>(count).fill(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;
|
||||
}
|
||||
|
||||
setContainerHeight(Math.max(0, ...columnY.map((h) => h - GRID_GAP)));
|
||||
}, [items, getKey, maxItemHeight, priorityKey, maxColumns, maxColumnWidth]);
|
||||
|
||||
// Keep a stable reference so observer callbacks always run the latest layout.
|
||||
const relayoutRef = useRef(relayout);
|
||||
relayoutRef.current = relayout;
|
||||
|
||||
const scheduleRelayout = useCallback(() => {
|
||||
if (rafRef.current != null) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
relayoutRef.current();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Re-layout on container resize (sidebar toggles, window resize, breakpoints).
|
||||
// Only width matters — height changes are our own (we set the container height),
|
||||
// so ignoring them avoids a feedback loop of redundant re-packs.
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || typeof ResizeObserver === "undefined") return;
|
||||
lastWidthRef.current = container.clientWidth;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const nextWidth = Math.round(entries[0]?.contentRect.width ?? container.clientWidth);
|
||||
if (nextWidth !== lastWidthRef.current) {
|
||||
lastWidthRef.current = nextWidth;
|
||||
scheduleRelayout();
|
||||
}
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [scheduleRelayout]);
|
||||
|
||||
// A single observer watches every card so late-loading images (which change a
|
||||
// card's height) trigger a re-pack.
|
||||
useEffect(() => {
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => scheduleRelayout());
|
||||
resizeObserverRef.current = observer;
|
||||
for (const el of itemRefs.current.values()) observer.observe(el);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
};
|
||||
}, [scheduleRelayout]);
|
||||
|
||||
// Re-pack after DOM updates (items added/removed/reordered), before paint.
|
||||
useLayoutEffect(() => {
|
||||
relayout();
|
||||
}, [relayout]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getItemRef = (key: string) => {
|
||||
const cached = refCallbacks.current.get(key);
|
||||
if (cached) return cached;
|
||||
const callback = (el: HTMLDivElement | null) => {
|
||||
const map = itemRefs.current;
|
||||
const previous = map.get(key);
|
||||
if (previous && resizeObserverRef.current) resizeObserverRef.current.unobserve(previous);
|
||||
if (el) {
|
||||
map.set(key, el);
|
||||
resizeObserverRef.current?.observe(el);
|
||||
} else {
|
||||
map.delete(key);
|
||||
refCallbacks.current.delete(key);
|
||||
}
|
||||
};
|
||||
refCallbacks.current.set(key, callback);
|
||||
return callback;
|
||||
};
|
||||
|
||||
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" }}>
|
||||
{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" }}>
|
||||
{renderItem(item)}
|
||||
{maxItemHeight != null && (
|
||||
<div
|
||||
data-grid-fade
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 hidden h-12 rounded-b-lg"
|
||||
style={{ background: "linear-gradient(to top, var(--card), transparent)" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColumnGrid;
|
||||
@ -0,0 +1,4 @@
|
||||
import ColumnGrid from "./ColumnGrid";
|
||||
|
||||
export { columnCountForWidth, GRID_GAP } from "./ColumnGrid";
|
||||
export default ColumnGrid;
|
||||
@ -1,25 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { EditorController } from "../types/editorController";
|
||||
|
||||
export const useKeyboard = (editorRef: React.RefObject<EditorController | null>, onSave: () => void) => {
|
||||
const onSaveRef = useRef(onSave);
|
||||
onSaveRef.current = onSave;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editorRef.current?.hasFocus()) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
onSaveRef.current();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [editorRef]);
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
import PagedMemoList from "./PagedMemoList";
|
||||
|
||||
export { getMemoKey } from "./PagedMemoList";
|
||||
export default PagedMemoList;
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { columnCountForWidth } from "@/components/ColumnGrid";
|
||||
|
||||
// Min column width 260px, gap 12px. A width fits N columns when
|
||||
// floor((width + gap) / (minColumnWidth + gap)) === N, never below 1.
|
||||
describe("columnCountForWidth", () => {
|
||||
it("never returns fewer than one column, even at zero width", () => {
|
||||
expect(columnCountForWidth(0)).toBe(1);
|
||||
expect(columnCountForWidth(100)).toBe(1);
|
||||
expect(columnCountForWidth(259)).toBe(1);
|
||||
});
|
||||
|
||||
it("stays at one column just below the two-column threshold", () => {
|
||||
// 2*260 + 12 = 532 is the first width that fits two columns.
|
||||
expect(columnCountForWidth(531)).toBe(1);
|
||||
});
|
||||
|
||||
it("reaches two columns exactly at the threshold (the list-fallback boundary)", () => {
|
||||
expect(columnCountForWidth(532)).toBe(2);
|
||||
});
|
||||
|
||||
it("scales up with width", () => {
|
||||
expect(columnCountForWidth(804)).toBe(3);
|
||||
expect(columnCountForWidth(1152)).toBe(4);
|
||||
expect(columnCountForWidth(1600)).toBe(5);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,54 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ColumnGrid from "@/components/ColumnGrid";
|
||||
|
||||
// jsdom has no layout engine (offsetHeight/clientWidth are 0) and no ResizeObserver,
|
||||
// both of which ColumnGrid guards for — so these assert render structure, not positioning.
|
||||
interface Item {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const item = (id: string): Item => ({ id });
|
||||
const getKey = (i: Item) => i.id;
|
||||
|
||||
describe("<ColumnGrid>", () => {
|
||||
it("renders one card per item", () => {
|
||||
const { container } = render(
|
||||
<ColumnGrid items={[item("a"), item("b"), item("c")]} getKey={getKey} renderItem={(i) => <div data-testid="card">{i.id}</div>} />,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll('[data-testid="card"]')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders the leading node as the first tile, before the items", () => {
|
||||
const { container, getByTestId } = render(
|
||||
<ColumnGrid
|
||||
items={[item("a")]}
|
||||
getKey={getKey}
|
||||
renderItem={(i) => <div data-testid={`card-${i.id}`}>{i.id}</div>}
|
||||
leading={<div data-testid="composer" />}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId("composer")).toBeInTheDocument();
|
||||
// The grid is the container's only child; its first wrapper holds the leading node.
|
||||
const grid = container.firstElementChild as HTMLElement;
|
||||
expect(grid.children[0].querySelector('[data-testid="composer"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders a fade overlay per item only when a max item height is set", () => {
|
||||
const withCap = render(
|
||||
<ColumnGrid items={[item("a"), item("b")]} getKey={getKey} renderItem={(i) => <div key={i.id} />} maxItemHeight={360} />,
|
||||
);
|
||||
expect(withCap.container.querySelectorAll("[data-grid-fade]")).toHaveLength(2);
|
||||
|
||||
const noCap = render(<ColumnGrid items={[item("a"), item("b")]} getKey={getKey} renderItem={(i) => <div key={i.id} />} />);
|
||||
expect(noCap.container.querySelectorAll("[data-grid-fade]")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders nothing for an empty list", () => {
|
||||
const { container } = render(<ColumnGrid items={[]} getKey={getKey} renderItem={() => <div data-testid="card" />} />);
|
||||
|
||||
expect(container.querySelectorAll('[data-testid="card"]')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,46 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useView, ViewProvider } from "@/contexts/ViewContext";
|
||||
|
||||
const LOCAL_STORAGE_KEY = "memos-view-setting";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <ViewProvider>{children}</ViewProvider>;
|
||||
|
||||
const persisted = () => JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? "{}");
|
||||
|
||||
describe("ViewContext maxColumns setting", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("defaults to a single column", () => {
|
||||
const { result } = renderHook(() => useView(), { wrapper });
|
||||
expect(result.current.maxColumns).toBe(1);
|
||||
});
|
||||
|
||||
it("updates and persists the column ceiling", () => {
|
||||
const { result } = renderHook(() => useView(), { wrapper });
|
||||
|
||||
act(() => result.current.setMaxColumns(0));
|
||||
|
||||
expect(result.current.maxColumns).toBe(0);
|
||||
expect(persisted().maxColumns).toBe(0);
|
||||
});
|
||||
|
||||
it("restores a persisted column count on init", () => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify({ maxColumns: 2 }));
|
||||
|
||||
const { result } = renderHook(() => useView(), { wrapper });
|
||||
|
||||
expect(result.current.maxColumns).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back to a single column for an invalid persisted value", () => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify({ maxColumns: 7 }));
|
||||
|
||||
const { result } = renderHook(() => useView(), { wrapper });
|
||||
|
||||
expect(result.current.maxColumns).toBe(1);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue