memos/web/src/contexts/ViewContext.tsx
boojack 177d65a90e 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.
2026-07-06 23:55:36 +08:00

120 lines
4.3 KiB
TypeScript

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 {
orderByTimeAsc: boolean;
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, maxColumns: 1 };
export function ViewProvider({ children }: { children: ReactNode }) {
const getInitialState = (): ViewState => {
try {
const cached = localStorage.getItem(LOCAL_STORAGE_KEY);
if (cached) {
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) {
console.warn("Failed to load view settings from localStorage:", error);
}
return { ...DEFAULT_VIEW_STATE };
};
const [viewState, setViewState] = useState(getInitialState);
const timeBasis = viewState.timeBasis ?? "create_time";
const persistToStorage = (newState: ViewState) => {
try {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newState));
} catch (error) {
console.warn("Failed to persist view settings:", error);
}
};
const updateState = (patch: Partial<ViewState> | ((prev: ViewState) => Partial<ViewState>)) => {
setViewState((prev) => {
const newState = { ...prev, ...(typeof patch === "function" ? patch(prev) : patch) };
persistToStorage(newState);
return newState;
});
};
const toggleSortOrder = () => updateState((prev) => ({ orderByTimeAsc: !prev.orderByTimeAsc }));
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
value={{
orderByTimeAsc: viewState.orderByTimeAsc,
timeBasis,
compactMode: viewState.compactMode,
linkPreview: viewState.linkPreview,
maxColumns: viewState.maxColumns,
toggleSortOrder,
setTimeBasis,
setCompactMode,
setLinkPreview,
setMaxColumns,
}}
>
{children}
</ViewContext.Provider>
);
}
export function useView() {
const context = useContext(ViewContext);
if (!context) {
throw new Error("useView must be used within ViewProvider");
}
return context;
}
// Read the link-preview preference from deep inside the markdown renderer, which
// can render outside a ViewProvider (tests, isolated previews). Defaults to
// enabled when no provider is present, preserving the historical behavior.
export function useLinkPreviewEnabled() {
return useContext(ViewContext)?.linkPreview ?? DEFAULT_VIEW_STATE.linkPreview;
}