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
boojack 5 days ago
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,7 +1,7 @@
import { Settings2Icon } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useView } from "@/contexts/ViewContext";
import { MAX_COLUMNS_VALUES, type MemoMaxColumns, useView } from "@/contexts/ViewContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
@ -10,10 +10,24 @@ interface Props {
className?: string;
}
// Derived from the context's canonical value list so the two can never drift; 0 renders as ∞.
const MAX_COLUMNS_OPTIONS = MAX_COLUMNS_VALUES.map((value) => ({ value, label: value === 0 ? "∞" : String(value) }));
function MemoDisplaySettingMenu({ className }: Props) {
const t = useTranslate();
const { orderByTimeAsc, timeBasis, compactMode, linkPreview, setTimeBasis, toggleSortOrder, setCompactMode, setLinkPreview } = useView();
const isApplying = orderByTimeAsc !== false || timeBasis !== "create_time" || compactMode || !linkPreview;
const {
orderByTimeAsc,
timeBasis,
compactMode,
linkPreview,
maxColumns,
setTimeBasis,
toggleSortOrder,
setCompactMode,
setLinkPreview,
setMaxColumns,
} = useView();
const isApplying = orderByTimeAsc !== false || timeBasis !== "create_time" || compactMode || !linkPreview || maxColumns !== 1;
return (
<Popover>
@ -22,6 +36,22 @@ function MemoDisplaySettingMenu({ className }: Props) {
</PopoverTrigger>
<PopoverContent align="end" alignOffset={-12} sideOffset={14}>
<div className="flex flex-col gap-2 p-1">
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.columns")}</span>
{/* Radix only reports rendered item values, so no re-validation is needed here. */}
<Select value={String(maxColumns)} onValueChange={(value) => setMaxColumns(Number(value) as MemoMaxColumns)}>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MAX_COLUMNS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.shown-time")}</span>
<Select value={timeBasis} onValueChange={(value) => setTimeBasis(value === "update_time" ? "update_time" : "create_time")}>
@ -53,10 +83,13 @@ function MemoDisplaySettingMenu({ className }: Props) {
</SelectContent>
</Select>
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.compact-mode")}</span>
<Switch checked={compactMode} onCheckedChange={setCompactMode} />
</div>
{/* Multi-column grids always render compact tiles, so the toggle only applies to a single column. */}
{maxColumns === 1 && (
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.compact-mode")}</span>
<Switch checked={compactMode} onCheckedChange={setCompactMode} />
</div>
)}
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.link-preview")}</span>
<Switch checked={linkPreview} onCheckedChange={setLinkPreview} />

@ -31,10 +31,24 @@ export interface EditorExtensionsOptions {
placeholder: string;
onChange: (markdown: string) => void;
onUpdate: () => void;
onSubmit: () => void;
getTags: () => string[];
}
export function buildEditorExtensions({ placeholder, onChange, onUpdate, getTags }: EditorExtensionsOptions): Extension[] {
export function buildEditorExtensions({ placeholder, onChange, onUpdate, onSubmit, getTags }: EditorExtensionsOptions): Extension[] {
// Submitting must outrank defaultKeymap's own Mod-Enter (insertBlankLine): the save
// shortcut ends the memo, it must not also edit the document. Meta and Ctrl are bound
// explicitly (not via the platform-dependent Mod-) so Cmd+Enter and Ctrl+Enter both
// submit everywhere, matching the historical window-level shortcut.
const submit = () => {
onSubmit();
return true;
};
const submitKeys: KeyBinding[] = [
{ key: "Meta-Enter", run: submit },
{ key: "Ctrl-Enter", run: submit },
];
return [
// Core editing behavior. Without these the editor relies on raw
// contenteditable: typing works but there is no visible caret on focus
@ -56,7 +70,7 @@ export function buildEditorExtensions({ placeholder, onChange, onUpdate, getTags
// tagAutocomplete must precede the editing keymap so the completion popup's
// Enter/Tab/arrow bindings win while it is open.
tagAutocomplete(getTags),
keymap.of([...editorKeys, indentWithTab, ...defaultKeymap, ...historyKeymap]),
keymap.of([...submitKeys, ...editorKeys, indentWithTab, ...defaultKeymap, ...historyKeymap]),
EditorView.updateListener.of((u) => {
if (u.docChanged) onChange(u.state.doc.toString());
// Toolbar active-state depends only on the doc and selection; skip the

@ -16,16 +16,20 @@ interface EditorProps {
placeholder: string;
onContentChange: (content: string) => void;
onPaste: (event: React.ClipboardEvent) => void;
/** Invoked by the in-editor save shortcut (Cmd/Ctrl+Enter). */
onSubmit: () => void;
isFocusMode?: boolean;
}
const Editor = forwardRef(function Editor(props: EditorProps, ref: React.ForwardedRef<EditorController>) {
const { className, initialContent, placeholder, onContentChange, onPaste, isFocusMode } = props;
const { className, initialContent, placeholder, onContentChange, onPaste, onSubmit, isFocusMode } = props;
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const controllerRef = useRef<EditorController | null>(null);
const onChangeRef = useRef(onContentChange);
onChangeRef.current = onContentChange;
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
const listenersRef = useRef(new Set<() => void>());
const { data: tagData } = useTagCounts();
const tags = useMemo(() => Object.keys(tagData ?? {}), [tagData]);
@ -44,6 +48,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
placeholder,
onChange: (md) => onChangeRef.current(md),
onUpdate: () => listenersRef.current.forEach((l) => l()),
onSubmit: () => onSubmitRef.current(),
getTags: () => tagsRef.current,
}),
}),

@ -10,9 +10,9 @@ const VisibilitySelector = (props: VisibilitySelectorProps) => {
const t = useTranslate();
const visibilityOptions = [
{ value: Visibility.PRIVATE, label: t("memo.visibility.private") },
{ value: Visibility.PROTECTED, label: t("memo.visibility.protected") },
{ value: Visibility.PUBLIC, label: t("memo.visibility.public") },
{ value: Visibility.PRIVATE, label: t("memo.visibility.private"), description: t("memo.visibility.private-description") },
{ value: Visibility.PROTECTED, label: t("memo.visibility.protected"), description: t("memo.visibility.protected-description") },
{ value: Visibility.PUBLIC, label: t("memo.visibility.public"), description: t("memo.visibility.public-description") },
] as const;
const currentLabel = visibilityOptions.find((option) => option.value === value)?.label || "";
@ -28,10 +28,13 @@ const VisibilitySelector = (props: VisibilitySelectorProps) => {
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{visibilityOptions.map((option) => (
<DropdownMenuItem key={option.value} className="cursor-pointer gap-2" onClick={() => onChange(option.value)}>
<DropdownMenuItem key={option.value} onClick={() => onChange(option.value)}>
<VisibilityIcon visibility={option.value} />
<span className="flex-1">{option.label}</span>
{value === option.value && <CheckIcon className="w-4 h-4 text-primary" />}
<div className="flex flex-col">
<span>{option.label}</span>
<span className="text-xs text-muted-foreground">{option.description}</span>
</div>
{value === option.value && <CheckIcon className="ml-auto w-4 h-4 text-primary" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>

@ -16,7 +16,7 @@ import type { EditorController } from "../types/editorController";
* editor serializes into state.content on every change and exposes its
* formatting capability for the focus-mode toolbar.
*/
export const EditorContent = forwardRef<EditorController, EditorContentProps>(({ placeholder }, ref) => {
export const EditorContent = forwardRef<EditorController, EditorContentProps>(({ placeholder, onSubmit }, ref) => {
const { actions, dispatch } = useEditorContext();
const { createBlobUrl } = useBlobUrls();
const content = useEditorSelector((s) => s.content);
@ -71,6 +71,7 @@ export const EditorContent = forwardRef<EditorController, EditorContentProps>(({
isFocusMode={isFocusMode}
onContentChange={handleContentChange}
onPaste={handlePaste}
onSubmit={onSubmit}
/>
</div>
);

@ -9,7 +9,6 @@ export { useEditorActiveState } from "./useEditorActiveState";
export { COMPACT_TOOLBAR_WIDTH, isCompactWidth, useElementWidth } from "./useElementWidth";
export { useFileUpload } from "./useFileUpload";
export { useFocusMode } from "./useFocusMode";
export { useKeyboard } from "./useKeyboard";
export { useLinkMemo } from "./useLinkMemo";
export { useLocation } from "./useLocation";
export { useMemoInit } from "./useMemoInit";

@ -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]);
};

@ -15,7 +15,7 @@ import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString } from "@/utils/memo";
import { AudioRecorderPanel, EditorContent, EditorMetadata, FocusModeOverlay, TimestampPopover } from "./components";
import { FOCUS_MODE_STYLES, FORMATTING_TOOLBAR_STORAGE_KEY } from "./constants";
import { useAudioRecorder, useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks";
import { useAudioRecorder, useAutoSave, useFocusMode, useMemoInit } from "./hooks";
import { errorService, memoService, transcriptionService, validationService } from "./services";
import { EditorProvider, useEditorContext, useEditorSelector } from "./state";
import { EditorToolbar, FormattingToolbar } from "./Toolbar";
@ -227,8 +227,6 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
}
};
useKeyboard(editorRef, handleSave);
async function handleSave() {
// Read the latest state imperatively — this component no longer subscribes
// to content, so the closure can't rely on a per-render `state` snapshot.
@ -336,7 +334,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
)}
{/* Editor content grows to fill available space in focus mode */}
<EditorContent ref={editorRef} placeholder={placeholder} />
<EditorContent ref={editorRef} placeholder={placeholder} onSubmit={handleSave} />
{isAudioRecorderOpen && (audioRecorder.isBusy || isTranscribingAudio) && (
<AudioRecorderPanel

@ -23,6 +23,8 @@ export interface MemoEditorProps {
export interface EditorContentProps {
placeholder?: string;
/** Invoked by the in-editor save shortcut (Cmd/Ctrl+Enter). */
onSubmit: () => void;
}
export interface EditorToolbarProps {

@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { FilterFactor, getMemoFilterKey, MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
interface FilterConfig {
@ -55,7 +56,7 @@ const FILTER_CONFIGS: Record<FilterFactor, FilterConfig> = {
},
};
const MemoFilters = () => {
const MemoFilters = ({ className }: { className?: string }) => {
const t = useTranslate();
const { filters, removeFilter } = useMemoFilterContext();
@ -76,7 +77,7 @@ const MemoFilters = () => {
}
return (
<div className="w-full mb-2 flex flex-row justify-start items-center flex-wrap gap-2">
<div className={cn("w-full flex flex-row justify-start items-center flex-wrap gap-2", className)}>
{filters.map((filter) => {
const config = FILTER_CONFIGS[filter.factor];
const Icon = config?.icon;

@ -1,27 +1,47 @@
import { useQueryClient } from "@tanstack/react-query";
import { ArrowUpIcon } from "lucide-react";
import { type ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowUpIcon, LoaderCircleIcon } from "lucide-react";
import { type ReactElement, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useNewMemo } from "@/contexts/NewMemoContext";
import { useView } from "@/contexts/ViewContext";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE, SKELETON_LOADING_DELAY_MS } from "@/helpers/consts";
import { useDelayedFlag } from "@/hooks/useDelayedFlag";
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import { hoistMemoToFront } from "@/hooks/useMemoSorting";
import { userKeys } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import ColumnGrid, { columnCountForWidth, GRID_GAP } from "../ColumnGrid";
import MemoEditor from "../MemoEditor";
import MemoFilters from "../MemoFilters";
import Placeholder from "../Placeholder";
import Skeleton from "../Skeleton";
// 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.
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
// grid centers in the leftover space instead of filling it.
const MAX_COLUMN_WIDTH = 420;
// Grid tiles are clipped to this height (with a fade), so one long memo can't dominate a column.
const MAX_ITEM_HEIGHT = 360;
// The grid packs cards into columns, so a card-shaped skeleton doesn't fit; use a spinner.
const GridLoader = () => (
<div className="w-full flex flex-row justify-center items-center py-8">
<LoaderCircleIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
interface Props {
renderer: (memo: Memo) => ReactElement;
renderer: (memo: Memo, options: { compact: boolean }) => ReactElement;
listSort?: (list: Memo[]) => Memo[];
state?: State;
orderBy?: string;
@ -88,6 +108,32 @@ const PagedMemoList = (props: Props) => {
const t = useTranslate();
const queryClient = useQueryClient();
const { filters } = useMemoFilterContext();
const { maxColumns, compactMode } = useView();
// maxColumns is a ceiling: 1 = single reading column, 0 = as many as fit. The single
// column renders in normal document flow; anything wider becomes the packed grid.
const multiColumn = maxColumns !== 1;
// Measure the available width: when it only fits one column anyway, render the flow
// layout rather than a degenerate one-column grid (capped tiles, composer-as-tile).
// Only the boolean is stored, so continuous resizes re-render nothing until the
// one-column threshold is actually crossed.
const layoutMeasureRef = useRef<HTMLDivElement>(null);
const [fitsGridWidth, setFitsGridWidth] = useState<boolean | undefined>(undefined);
useLayoutEffect(() => {
const el = layoutMeasureRef.current;
if (!el) return;
const apply = (nextWidth: number) => setFitsGridWidth(columnCountForWidth(nextWidth) >= 2);
apply(el.clientWidth);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => apply(entries[0]?.contentRect.width ?? el.clientWidth));
observer.observe(el);
return () => observer.disconnect();
}, []);
const useGrid = multiColumn && (fitsGridWidth ?? true);
// Grid tiles are always bounded/compact; the narrow-width fallback behaves exactly like
// maxColumns = 1, so it respects the user's own compact setting. Centralized here so the
// pages don't each repeat the policy.
const effectiveCompact = compactMode || useGrid;
const showMemoEditor = props.showMemoEditor ?? false;
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromFilters(filters), [filters]);
@ -159,44 +205,88 @@ const PagedMemoList = (props: Props) => {
return () => window.removeEventListener("scroll", handleScroll);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// In the grid the leading stack owns all spacing (GRID_GAP), so the composer carries
// its own bottom margin only in the flow layout.
const memoEditor = showMemoEditor ? (
<MemoEditor
className={useGrid ? undefined : "mb-2"}
cacheKey="home-memo-editor"
placeholder={t("editor.any-thoughts")}
defaultCreateTime={defaultCreateTime}
/>
) : null;
// A freshly created memo is hoisted to the front; pin it to the top of column one so it
// appears right under the composer instead of dropping into a random (shortest) column.
const firstMemo = sortedMemoList[0];
const priorityKey = newMemoName && firstMemo?.name === newMemoName ? getMemoKey(firstMemo) : undefined;
// Stable reference so MentionResolutionProvider's memo (keyed on the array) actually holds.
const contents = useMemo(() => sortedMemoList.map((memo) => memo.content), [sortedMemoList]);
// Column one is the action column: the composer and any active filters head it, and the
// newest memo lands directly beneath them (priorityKey above). Every vertical seam inside
// the stack uses GRID_GAP so y-spacing matches the grid's x-spacing exactly.
const hasFilters = filters.length > 0;
const gridLeading =
memoEditor || hasFilters ? (
<div className="flex w-full flex-col" style={{ gap: GRID_GAP }}>
{memoEditor}
<MemoFilters />
</div>
) : undefined;
// Pagination skeleton, empty state, and back-to-top are identical across both layouts.
const footer = (
<>
{isFetchingNextPage && (useGrid ? <GridLoader /> : <Skeleton showCreator={props.showCreator} count={2} />)}
{!isFetchingNextPage && !hasNextPage && sortedMemoList.length === 0 && !memoEditor && (
<Placeholder variant="empty" message={t("message.no-data")} />
)}
{!isFetchingNextPage && (hasNextPage || sortedMemoList.length > 0) && (
<div className="w-full opacity-70 flex flex-row justify-center items-center my-4">
<BackToTop />
</div>
)}
</>
);
const children = (
<MentionResolutionProvider contents={sortedMemoList.map((memo) => memo.content)}>
<div className="flex flex-col justify-start w-full max-w-2xl mx-auto">
{/* During initial load, show the skeleton only after the delay; render nothing before then to avoid a flash. */}
{isLoading ? (
showSkeleton ? (
<Skeleton showCreator={props.showCreator} count={4} />
) : null
) : (
<>
{showMemoEditor ? (
<MemoEditor
className="mb-2"
cacheKey="home-memo-editor"
placeholder={t("editor.any-thoughts")}
defaultCreateTime={defaultCreateTime}
<MentionResolutionProvider contents={contents}>
<div ref={layoutMeasureRef} className="w-full">
<div className={cn("flex flex-col justify-start w-full mx-auto", useGrid ? "max-w-none" : "max-w-2xl")}>
{/* During initial load, show the skeleton only after the delay; render nothing before then to avoid a flash. */}
{isLoading ? (
showSkeleton ? (
useGrid ? (
<GridLoader />
) : (
<Skeleton showCreator={props.showCreator} count={4} />
)
) : null
) : useGrid ? (
<>
<ColumnGrid
items={sortedMemoList}
getKey={getMemoKey}
renderItem={(memo) => props.renderer(memo, { compact: effectiveCompact })}
leading={gridLeading}
maxItemHeight={MAX_ITEM_HEIGHT}
priorityKey={priorityKey}
maxColumns={maxColumns}
maxColumnWidth={MAX_COLUMN_WIDTH}
/>
) : null}
<MemoFilters />
{sortedMemoList.map((memo) => props.renderer(memo))}
{/* Loading indicator for pagination */}
{isFetchingNextPage && <Skeleton showCreator={props.showCreator} count={2} />}
{/* Empty state or back-to-top button */}
{!isFetchingNextPage && (
<>
{!hasNextPage && sortedMemoList.length === 0 ? (
<Placeholder variant="empty" message={t("message.no-data")} />
) : (
<div className="w-full opacity-70 flex flex-row justify-center items-center my-4">
<BackToTop />
</div>
)}
</>
)}
</>
)}
{footer}
</>
) : (
<>
{memoEditor}
<MemoFilters className="mb-2" />
{sortedMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))}
{footer}
</>
)}
</div>
</div>
</MentionResolutionProvider>
);

@ -1,3 +1,4 @@
import PagedMemoList from "./PagedMemoList";
export { getMemoKey } from "./PagedMemoList";
export default PagedMemoList;

@ -2,12 +2,17 @@ import { createContext, type ReactNode, useContext, useState } from "react";
export type MemoTimeBasis = "create_time" | "update_time";
/** Upper bound on feed columns, in display order. 1 = single reading column; 0 = as many as fit. */
export const MAX_COLUMNS_VALUES = [1, 2, 3, 0] as const;
export type MemoMaxColumns = (typeof MAX_COLUMNS_VALUES)[number];
interface ViewState {
orderByTimeAsc: boolean;
timeBasis?: MemoTimeBasis;
sortTimeField?: MemoTimeBasis;
compactMode: boolean;
linkPreview: boolean;
maxColumns: MemoMaxColumns;
}
interface ViewContextValue {
@ -15,17 +20,19 @@ interface ViewContextValue {
timeBasis: MemoTimeBasis;
compactMode: boolean;
linkPreview: boolean;
maxColumns: MemoMaxColumns;
toggleSortOrder: () => void;
setTimeBasis: (field: MemoTimeBasis) => void;
setCompactMode: (value: boolean) => void;
setLinkPreview: (value: boolean) => void;
setMaxColumns: (value: MemoMaxColumns) => void;
}
const ViewContext = createContext<ViewContextValue | null>(null);
const LOCAL_STORAGE_KEY = "memos-view-setting";
const DEFAULT_VIEW_STATE: ViewState = { orderByTimeAsc: false, compactMode: false, linkPreview: true };
const DEFAULT_VIEW_STATE: ViewState = { orderByTimeAsc: false, compactMode: false, linkPreview: true, maxColumns: 1 };
export function ViewProvider({ children }: { children: ReactNode }) {
const getInitialState = (): ViewState => {
@ -35,11 +42,15 @@ export function ViewProvider({ children }: { children: ReactNode }) {
const data = JSON.parse(cached) as Partial<ViewState>;
const cachedTimeBasis = data.timeBasis ?? data.sortTimeField;
const timeBasis = cachedTimeBasis === "create_time" || cachedTimeBasis === "update_time" ? cachedTimeBasis : undefined;
const maxColumns = MAX_COLUMNS_VALUES.includes(data.maxColumns as MemoMaxColumns)
? (data.maxColumns as MemoMaxColumns)
: DEFAULT_VIEW_STATE.maxColumns;
return {
orderByTimeAsc: Boolean(data.orderByTimeAsc ?? DEFAULT_VIEW_STATE.orderByTimeAsc),
timeBasis,
compactMode: Boolean(data.compactMode ?? DEFAULT_VIEW_STATE.compactMode),
linkPreview: Boolean(data.linkPreview ?? DEFAULT_VIEW_STATE.linkPreview),
maxColumns,
};
}
} catch (error) {
@ -71,6 +82,7 @@ export function ViewProvider({ children }: { children: ReactNode }) {
const setTimeBasis = (field: MemoTimeBasis) => updateState({ timeBasis: field });
const setCompactMode = (value: boolean) => updateState({ compactMode: value });
const setLinkPreview = (value: boolean) => updateState({ linkPreview: value });
const setMaxColumns = (value: MemoMaxColumns) => updateState({ maxColumns: value });
return (
<ViewContext.Provider
@ -79,10 +91,12 @@ export function ViewProvider({ children }: { children: ReactNode }) {
timeBasis,
compactMode: viewState.compactMode,
linkPreview: viewState.linkPreview,
maxColumns: viewState.maxColumns,
toggleSortOrder,
setTimeBasis,
setCompactMode,
setLinkPreview,
setMaxColumns,
}}
>
{children}

@ -77,7 +77,7 @@ const MainLayout = () => {
</div>
)}
<div className={MAIN_CONTENT_CLASS_NAME}>
<div className={cn("w-full mx-auto px-4 sm:px-6 md:pt-6 pb-8")}>
<div className={cn("w-full mx-auto px-4 sm:px-6 pt-2 md:pt-6 pb-8")}>
<Outlet />
</div>
</div>

@ -265,6 +265,7 @@
"has-task-list": "hasTaskList",
"label": "Filters"
},
"columns": "Columns",
"compact-mode": "Compact mode",
"link-preview": "Link preview",
"links": "Links",
@ -326,8 +327,11 @@
"visibility": {
"disabled": "Public memos are disabled",
"private": "Private",
"private-description": "Only visible to you",
"protected": "Protected",
"public": "Public"
"protected-description": "Visible to signed-in users",
"public": "Public",
"public-description": "Visible to everyone"
}
},
"message": {

@ -1,6 +1,5 @@
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import { useView } from "@/contexts/ViewContext";
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb";
@ -8,7 +7,6 @@ import { Memo } from "@/types/proto/api/v1/memo_service_pb";
const Archived = () => {
const user = useCurrentUser();
const { compactMode } = useView();
// Build filter using unified hook (no shortcuts or pinned filter)
const memoFilter = useMemoFilters({
@ -25,7 +23,7 @@ const Archived = () => {
return (
<PagedMemoList
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact={compactMode} />}
renderer={(memo: Memo, { compact }) => <MemoView key={getMemoKey(memo)} memo={memo} showVisibility compact={compact} />}
listSort={listSort}
state={State.ARCHIVED}
orderBy={orderBy}

@ -1,6 +1,5 @@
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import { useView } from "@/contexts/ViewContext";
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb";
@ -8,7 +7,6 @@ import { Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb";
const Explore = () => {
const currentUser = useCurrentUser();
const { compactMode } = useView();
// Determine visibility filter based on authentication status
// - Logged-in users: Can see PUBLIC and PROTECTED memos
@ -31,9 +29,7 @@ const Explore = () => {
return (
<PagedMemoList
renderer={(memo: Memo) => (
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showCreator showVisibility compact={compactMode} />
)}
renderer={(memo: Memo, { compact }) => <MemoView key={getMemoKey(memo)} memo={memo} showCreator showVisibility compact={compact} />}
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}

@ -1,8 +1,7 @@
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
import { useInstance } from "@/contexts/InstanceContext";
import { NewMemoProvider } from "@/contexts/NewMemoContext";
import { useView } from "@/contexts/ViewContext";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb";
@ -11,7 +10,6 @@ import { Memo } from "@/types/proto/api/v1/memo_service_pb";
const Home = () => {
const user = useCurrentUser();
const { isInitialized } = useInstance();
const { compactMode } = useView();
const memoFilter = useMemoFilters({
creatorName: user?.name,
@ -28,8 +26,8 @@ const Home = () => {
<div className="w-full min-h-full bg-background text-foreground">
<NewMemoProvider>
<PagedMemoList
renderer={(memo: Memo) => (
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility showPinned compact={compactMode} />
renderer={(memo: Memo, { compact }) => (
<MemoView key={getMemoKey(memo)} memo={memo} showVisibility showPinned compact={compact} />
)}
listSort={listSort}
orderBy={orderBy}

@ -4,11 +4,10 @@ import { lazy, Suspense } from "react";
import { toast } from "react-hot-toast";
import { useParams, useSearchParams } from "react-router-dom";
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
import UserAvatar from "@/components/UserAvatar";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useView } from "@/contexts/ViewContext";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import { useUser } from "@/hooks/useUserQueries";
import { State } from "@/types/proto/api/v1/common_pb";
@ -51,7 +50,6 @@ const UserProfile = () => {
const username = useParams().username;
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = (searchParams.get("view") === "map" ? "map" : "memos") as TabView;
const { compactMode } = useView();
const { data: user, isLoading, error } = useUser(`users/${username}`, { enabled: !!username });
@ -112,8 +110,8 @@ const UserProfile = () => {
<div className="mx-auto w-full max-w-2xl">
{activeTab === "memos" ? (
<PagedMemoList
renderer={(memo: Memo) => (
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility showPinned compact={compactMode} />
renderer={(memo: Memo, { compact }) => (
<MemoView key={getMemoKey(memo)} memo={memo} showVisibility showPinned compact={compact} />
)}
listSort={listSort}
orderBy={orderBy}

@ -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);
});
});

@ -6,11 +6,11 @@ import { describe, expect, it } from "vitest";
import { MemoMarkdownRenderer } from "@/components/MemoContent/MemoMarkdownRenderer";
import { buildEditorExtensions } from "@/components/MemoEditor/Editor/extensions";
function makeView(doc: string) {
function makeView(doc: string, onSubmit: () => void = () => {}) {
return new EditorView({
state: EditorState.create({
doc,
extensions: buildEditorExtensions({ placeholder: "", onChange: () => {}, onUpdate: () => {}, getTags: () => [] }),
extensions: buildEditorExtensions({ placeholder: "", onChange: () => {}, onUpdate: () => {}, onSubmit, getTags: () => [] }),
}),
parent: document.body,
});
@ -28,6 +28,38 @@ function tabOnLine(view: EditorView, lineNumber: number, shiftKey = false) {
}
describe("editor key bindings", () => {
it("Cmd+Enter submits without inserting a blank line", () => {
let submitted = 0;
const view = makeView("hello", () => {
submitted += 1;
});
view.dispatch({ selection: { anchor: 5 } });
press(view, "Enter", { metaKey: true });
// The save shortcut must outrank defaultKeymap's Mod-Enter (insertBlankLine).
expect(submitted).toBe(1);
expect(view.state.doc.toString()).toBe("hello");
view.destroy();
});
it("Ctrl+Enter also submits without editing the document", () => {
let submitted = 0;
const view = makeView("hello", () => {
submitted += 1;
});
press(view, "Enter", { ctrlKey: true });
expect(submitted).toBe(1);
expect(view.state.doc.toString()).toBe("hello");
view.destroy();
});
it("plain Enter still inserts a newline", () => {
const view = makeView("hello");
view.dispatch({ selection: { anchor: 5 } });
press(view, "Enter");
expect(view.state.doc.toString()).toBe("hello\n");
view.destroy();
});
it("Tab indents a non-list line by two spaces", () => {
const view = makeView("hello");
view.dispatch({ selection: { anchor: 0 } });

@ -1,11 +1,15 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import PagedMemoList from "@/components/PagedMemoList";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
const view = vi.hoisted(() => ({ maxColumns: 1 as 0 | 1 | 2 | 3, compactMode: false }));
const feed = vi.hoisted(() => ({ memos: [] as unknown[] }));
vi.mock("@/hooks/useMemoQueries", () => ({
useInfiniteMemos: () => ({
data: { pages: [{ memos: [], nextPageToken: "" }] },
data: { pages: [{ memos: feed.memos, nextPageToken: "" }] },
fetchNextPage: vi.fn(async () => undefined),
hasNextPage: false,
isFetchingNextPage: false,
@ -17,6 +21,10 @@ vi.mock("@/contexts/MemoFilterContext", () => ({
useMemoFilterContext: () => ({ filters: [] }),
}));
vi.mock("@/contexts/ViewContext", () => ({
useView: () => view,
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => (key === "message.no-data" ? "No data found." : key),
}));
@ -33,17 +41,65 @@ vi.mock("@/components/MemoEditor", () => ({
default: () => <div data-testid="memo-editor" />,
}));
const memo = { name: "memos/1", content: "hello", updateTime: undefined } as unknown as Memo;
const renderList = (renderer: (memo: Memo, options: { compact: boolean }) => React.ReactElement = () => <div />) =>
render(
<QueryClientProvider client={new QueryClient()}>
<PagedMemoList renderer={renderer} />
</QueryClientProvider>,
);
describe("<PagedMemoList>", () => {
it("uses the tile sprite Placeholder for the empty state", () => {
const queryClient = new QueryClient();
beforeEach(() => {
view.maxColumns = 1;
view.compactMode = false;
feed.memos = [];
});
render(
<QueryClientProvider client={queryClient}>
<PagedMemoList renderer={() => <div />} />
</QueryClientProvider>,
);
it("uses the tile sprite Placeholder for the empty state", () => {
renderList();
expect(screen.getByText("No data found.")).toBeInTheDocument();
expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument();
});
describe("compact policy", () => {
beforeEach(() => {
feed.memos = [memo];
});
it("threads compact=false at one column with compact mode off", () => {
const renderer = vi.fn((m: Memo) => <div key={m.name} />);
renderList(renderer);
expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: false });
});
it("threads compact=true at one column with compact mode on", () => {
view.compactMode = true;
const renderer = vi.fn((m: Memo) => <div key={m.name} />);
renderList(renderer);
expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: true });
});
it("respects the compact setting in the narrow-width fallback even when columns are allowed", () => {
// jsdom measures 0px, so the flow fallback renders and behaves exactly like maxColumns = 1.
view.maxColumns = 0;
const renderer = vi.fn((m: Memo) => <div key={m.name} />);
renderList(renderer);
expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: false });
});
it("forces compact once the width fits the grid", () => {
view.maxColumns = 0;
const widthSpy = vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1200);
try {
const renderer = vi.fn((m: Memo) => <div key={m.name} />);
renderList(renderer);
expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: true });
} finally {
widthSpy.mockRestore();
}
});
});
});

@ -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…
Cancel
Save