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.
120 lines
4.3 KiB
TypeScript
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;
|
|
}
|