From 177d65a90e321fa975a7cf19bd3c075143af5c10 Mon Sep 17 00:00:00 2001 From: boojack Date: Mon, 6 Jul 2026 23:55:36 +0800 Subject: [PATCH] feat(web): add max-columns memo feed layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/src/components/ColumnGrid/ColumnGrid.tsx | 274 ++++++++++++++++++ web/src/components/ColumnGrid/index.ts | 4 + web/src/components/MemoDisplaySettingMenu.tsx | 47 ++- .../MemoEditor/Editor/extensions.ts | 18 +- .../components/MemoEditor/Editor/index.tsx | 7 +- .../MemoEditor/Toolbar/VisibilitySelector.tsx | 15 +- .../MemoEditor/components/EditorContent.tsx | 3 +- web/src/components/MemoEditor/hooks/index.ts | 1 - .../MemoEditor/hooks/useKeyboard.ts | 25 -- web/src/components/MemoEditor/index.tsx | 6 +- .../components/MemoEditor/types/components.ts | 2 + web/src/components/MemoFilters.tsx | 5 +- .../PagedMemoList/PagedMemoList.tsx | 168 ++++++++--- web/src/components/PagedMemoList/index.ts | 1 + web/src/contexts/ViewContext.tsx | 16 +- web/src/layouts/MainLayout.tsx | 2 +- web/src/locales/en.json | 6 +- web/src/pages/Archived.tsx | 6 +- web/src/pages/Explore.tsx | 8 +- web/src/pages/Home.tsx | 8 +- web/src/pages/UserProfile.tsx | 8 +- web/tests/column-count.test.ts | 27 ++ web/tests/column-grid.test.tsx | 54 ++++ web/tests/editor-keys.test.ts | 36 ++- web/tests/paged-memo-list.test.tsx | 74 ++++- web/tests/view-context-columns.test.tsx | 46 +++ 26 files changed, 745 insertions(+), 122 deletions(-) create mode 100644 web/src/components/ColumnGrid/ColumnGrid.tsx create mode 100644 web/src/components/ColumnGrid/index.ts delete mode 100644 web/src/components/MemoEditor/hooks/useKeyboard.ts create mode 100644 web/tests/column-count.test.ts create mode 100644 web/tests/column-grid.test.tsx create mode 100644 web/tests/view-context-columns.test.tsx diff --git a/web/src/components/ColumnGrid/ColumnGrid.tsx b/web/src/components/ColumnGrid/ColumnGrid.tsx new file mode 100644 index 000000000..7c4fa683b --- /dev/null +++ b/web/src/components/ColumnGrid/ColumnGrid.tsx @@ -0,0 +1,274 @@ +import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; + +interface ColumnGridProps { + 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({ items, getKey, renderItem, leading, maxItemHeight, 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); + 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 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(); + 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(); + 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(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(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 ( +
+ {leading != null && ( +
+ {leading} +
+ )} + {items.map((item) => { + const key = getKey(item); + return ( +
+ {renderItem(item)} + {maxItemHeight != null && ( +
+ )} +
+ ); + })} +
+ ); +} + +export default ColumnGrid; diff --git a/web/src/components/ColumnGrid/index.ts b/web/src/components/ColumnGrid/index.ts new file mode 100644 index 000000000..880572a26 --- /dev/null +++ b/web/src/components/ColumnGrid/index.ts @@ -0,0 +1,4 @@ +import ColumnGrid from "./ColumnGrid"; + +export { columnCountForWidth, GRID_GAP } from "./ColumnGrid"; +export default ColumnGrid; diff --git a/web/src/components/MemoDisplaySettingMenu.tsx b/web/src/components/MemoDisplaySettingMenu.tsx index a4599c6cf..7def673a3 100644 --- a/web/src/components/MemoDisplaySettingMenu.tsx +++ b/web/src/components/MemoDisplaySettingMenu.tsx @@ -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 ( @@ -22,6 +36,22 @@ function MemoDisplaySettingMenu({ className }: Props) {
+
+ {t("memo.columns")} + {/* Radix only reports rendered item values, so no re-validation is needed here. */} + +
{t("memo.shown-time")}
-
- {t("memo.compact-mode")} - -
+ {/* Multi-column grids always render compact tiles, so the toggle only applies to a single column. */} + {maxColumns === 1 && ( +
+ {t("memo.compact-mode")} + +
+ )}
{t("memo.link-preview")} diff --git a/web/src/components/MemoEditor/Editor/extensions.ts b/web/src/components/MemoEditor/Editor/extensions.ts index 395b76630..5d86cb1dd 100644 --- a/web/src/components/MemoEditor/Editor/extensions.ts +++ b/web/src/components/MemoEditor/Editor/extensions.ts @@ -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 diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index 4b13562a3..a3f9b5018 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -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) { - const { className, initialContent, placeholder, onContentChange, onPaste, isFocusMode } = props; + const { className, initialContent, placeholder, onContentChange, onPaste, onSubmit, isFocusMode } = props; const hostRef = useRef(null); const viewRef = useRef(null); const controllerRef = useRef(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, }), }), diff --git a/web/src/components/MemoEditor/Toolbar/VisibilitySelector.tsx b/web/src/components/MemoEditor/Toolbar/VisibilitySelector.tsx index b24c38315..64ccae2da 100644 --- a/web/src/components/MemoEditor/Toolbar/VisibilitySelector.tsx +++ b/web/src/components/MemoEditor/Toolbar/VisibilitySelector.tsx @@ -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) => { {visibilityOptions.map((option) => ( - onChange(option.value)}> + onChange(option.value)}> - {option.label} - {value === option.value && } +
+ {option.label} + {option.description} +
+ {value === option.value && }
))}
diff --git a/web/src/components/MemoEditor/components/EditorContent.tsx b/web/src/components/MemoEditor/components/EditorContent.tsx index 4ea7d9755..edf40545a 100644 --- a/web/src/components/MemoEditor/components/EditorContent.tsx +++ b/web/src/components/MemoEditor/components/EditorContent.tsx @@ -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(({ placeholder }, ref) => { +export const EditorContent = forwardRef(({ placeholder, onSubmit }, ref) => { const { actions, dispatch } = useEditorContext(); const { createBlobUrl } = useBlobUrls(); const content = useEditorSelector((s) => s.content); @@ -71,6 +71,7 @@ export const EditorContent = forwardRef(({ isFocusMode={isFocusMode} onContentChange={handleContentChange} onPaste={handlePaste} + onSubmit={onSubmit} />
); diff --git a/web/src/components/MemoEditor/hooks/index.ts b/web/src/components/MemoEditor/hooks/index.ts index 18013b645..0f89c2758 100644 --- a/web/src/components/MemoEditor/hooks/index.ts +++ b/web/src/components/MemoEditor/hooks/index.ts @@ -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"; diff --git a/web/src/components/MemoEditor/hooks/useKeyboard.ts b/web/src/components/MemoEditor/hooks/useKeyboard.ts deleted file mode 100644 index 291201be8..000000000 --- a/web/src/components/MemoEditor/hooks/useKeyboard.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { EditorController } from "../types/editorController"; - -export const useKeyboard = (editorRef: React.RefObject, 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]); -}; diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index ab2ae5115..09f4b3136 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -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 = ({ } }; - 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 = ({ )} {/* Editor content grows to fill available space in focus mode */} - + {isAudioRecorderOpen && (audioRecorder.isBusy || isTranscribingAudio) && ( void; } export interface EditorToolbarProps { diff --git a/web/src/components/MemoFilters.tsx b/web/src/components/MemoFilters.tsx index a7284fb7a..492fcf339 100644 --- a/web/src/components/MemoFilters.tsx +++ b/web/src/components/MemoFilters.tsx @@ -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 = { }, }; -const MemoFilters = () => { +const MemoFilters = ({ className }: { className?: string }) => { const t = useTranslate(); const { filters, removeFilter } = useMemoFilterContext(); @@ -76,7 +77,7 @@ const MemoFilters = () => { } return ( -
+
{filters.map((filter) => { const config = FILTER_CONFIGS[filter.factor]; const Icon = config?.icon; diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index 68786ce45..df5d43d99 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -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 = () => ( +
+ +
+); + 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(null); + const [fitsGridWidth, setFitsGridWidth] = useState(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 ? ( + + ) : 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 ? ( +
+ {memoEditor} + +
+ ) : undefined; + + // Pagination skeleton, empty state, and back-to-top are identical across both layouts. + const footer = ( + <> + {isFetchingNextPage && (useGrid ? : )} + {!isFetchingNextPage && !hasNextPage && sortedMemoList.length === 0 && !memoEditor && ( + + )} + {!isFetchingNextPage && (hasNextPage || sortedMemoList.length > 0) && ( +
+ +
+ )} + + ); + const children = ( - memo.content)}> -
- {/* During initial load, show the skeleton only after the delay; render nothing before then to avoid a flash. */} - {isLoading ? ( - showSkeleton ? ( - - ) : null - ) : ( - <> - {showMemoEditor ? ( - +
+
+ {/* During initial load, show the skeleton only after the delay; render nothing before then to avoid a flash. */} + {isLoading ? ( + showSkeleton ? ( + useGrid ? ( + + ) : ( + + ) + ) : null + ) : useGrid ? ( + <> + props.renderer(memo, { compact: effectiveCompact })} + leading={gridLeading} + maxItemHeight={MAX_ITEM_HEIGHT} + priorityKey={priorityKey} + maxColumns={maxColumns} + maxColumnWidth={MAX_COLUMN_WIDTH} /> - ) : null} - - {sortedMemoList.map((memo) => props.renderer(memo))} - - {/* Loading indicator for pagination */} - {isFetchingNextPage && } - - {/* Empty state or back-to-top button */} - {!isFetchingNextPage && ( - <> - {!hasNextPage && sortedMemoList.length === 0 ? ( - - ) : ( -
- -
- )} - - )} - - )} + {footer} + + ) : ( + <> + {memoEditor} + + {sortedMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))} + {footer} + + )} +
); diff --git a/web/src/components/PagedMemoList/index.ts b/web/src/components/PagedMemoList/index.ts index fa75beec3..3117112fe 100644 --- a/web/src/components/PagedMemoList/index.ts +++ b/web/src/components/PagedMemoList/index.ts @@ -1,3 +1,4 @@ import PagedMemoList from "./PagedMemoList"; +export { getMemoKey } from "./PagedMemoList"; export default PagedMemoList; diff --git a/web/src/contexts/ViewContext.tsx b/web/src/contexts/ViewContext.tsx index 90857c391..99f811e09 100644 --- a/web/src/contexts/ViewContext.tsx +++ b/web/src/contexts/ViewContext.tsx @@ -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(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; 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 ( {children} diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx index faabab17b..ad8c77498 100644 --- a/web/src/layouts/MainLayout.tsx +++ b/web/src/layouts/MainLayout.tsx @@ -77,7 +77,7 @@ const MainLayout = () => {
)}
-
+
diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 2ee2d893e..31c1b1f2f 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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": { diff --git a/web/src/pages/Archived.tsx b/web/src/pages/Archived.tsx index 95ae4af3a..28046135f 100644 --- a/web/src/pages/Archived.tsx +++ b/web/src/pages/Archived.tsx @@ -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 ( } + renderer={(memo: Memo, { compact }) => } listSort={listSort} state={State.ARCHIVED} orderBy={orderBy} diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 5eb4601d3..0c927a6ca 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -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 ( ( - - )} + renderer={(memo: Memo, { compact }) => } listSort={listSort} orderBy={orderBy} filter={memoFilter} diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 8db8676c9..7a6aedae1 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -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 = () => {
( - + renderer={(memo: Memo, { compact }) => ( + )} listSort={listSort} orderBy={orderBy} diff --git a/web/src/pages/UserProfile.tsx b/web/src/pages/UserProfile.tsx index 9b2faebe4..3d4aa74ec 100644 --- a/web/src/pages/UserProfile.tsx +++ b/web/src/pages/UserProfile.tsx @@ -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 = () => {
{activeTab === "memos" ? ( ( - + renderer={(memo: Memo, { compact }) => ( + )} listSort={listSort} orderBy={orderBy} diff --git a/web/tests/column-count.test.ts b/web/tests/column-count.test.ts new file mode 100644 index 000000000..40f02c4e0 --- /dev/null +++ b/web/tests/column-count.test.ts @@ -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); + }); +}); diff --git a/web/tests/column-grid.test.tsx b/web/tests/column-grid.test.tsx new file mode 100644 index 000000000..b8d46ab61 --- /dev/null +++ b/web/tests/column-grid.test.tsx @@ -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("", () => { + it("renders one card per item", () => { + const { container } = render( +
{i.id}
} />, + ); + + expect(container.querySelectorAll('[data-testid="card"]')).toHaveLength(3); + }); + + it("renders the leading node as the first tile, before the items", () => { + const { container, getByTestId } = render( +
{i.id}
} + leading={
} + />, + ); + + 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( +
} maxItemHeight={360} />, + ); + expect(withCap.container.querySelectorAll("[data-grid-fade]")).toHaveLength(2); + + const noCap = render(
} />); + expect(noCap.container.querySelectorAll("[data-grid-fade]")).toHaveLength(0); + }); + + it("renders nothing for an empty list", () => { + const { container } = render(
} />); + + expect(container.querySelectorAll('[data-testid="card"]')).toHaveLength(0); + }); +}); diff --git a/web/tests/editor-keys.test.ts b/web/tests/editor-keys.test.ts index 73c817eb5..e9d325ff8 100644 --- a/web/tests/editor-keys.test.ts +++ b/web/tests/editor-keys.test.ts @@ -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 } }); diff --git a/web/tests/paged-memo-list.test.tsx b/web/tests/paged-memo-list.test.tsx index d1e604e09..83c9d4577 100644 --- a/web/tests/paged-memo-list.test.tsx +++ b/web/tests/paged-memo-list.test.tsx @@ -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: () =>
, })); +const memo = { name: "memos/1", content: "hello", updateTime: undefined } as unknown as Memo; + +const renderList = (renderer: (memo: Memo, options: { compact: boolean }) => React.ReactElement = () =>
) => + render( + + + , + ); + describe("", () => { - it("uses the tile sprite Placeholder for the empty state", () => { - const queryClient = new QueryClient(); + beforeEach(() => { + view.maxColumns = 1; + view.compactMode = false; + feed.memos = []; + }); - render( - -
} /> - , - ); + 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) =>
); + 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) =>
); + 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) =>
); + 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) =>
); + renderList(renderer); + expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: true }); + } finally { + widthSpy.mockRestore(); + } + }); + }); }); diff --git a/web/tests/view-context-columns.test.tsx b/web/tests/view-context-columns.test.tsx new file mode 100644 index 000000000..f8f74a9f6 --- /dev/null +++ b/web/tests/view-context-columns.test.tsx @@ -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 }) => {children}; + +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); + }); +});