Files
readest/apps/readest-app/src/store/reviewModeStore.ts
T

209 lines
6.3 KiB
TypeScript

import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import type {
ReviewGptConfig,
ReviewRow,
ReviewSessionPayload,
} from '@/services/reviewEditorService';
export type ReviewBookState = {
enabled: boolean;
loading: boolean;
error: string;
baseUrl: string;
sessionId: string | null;
reviewRoot?: string;
version?: string;
rows: ReviewRow[];
session: ReviewSessionPayload | null;
gptConfig: ReviewGptConfig | null;
selectedRowId: string;
};
const DEFAULT_PANEL_WIDTH = '32%';
const DEFAULT_PANEL_HEIGHT = '68vh';
const MIN_PANEL_WIDTH_PERCENT = 24;
const MAX_PANEL_WIDTH_PERCENT = 48;
const MIN_PANEL_HEIGHT_VH = 35;
const MAX_PANEL_HEIGHT_VH = 92;
type ReviewModeStore = {
activeBookKey: string | null;
isPanelVisible: boolean;
isPanelPinned: boolean;
panelWidth: string;
panelHeight: string;
books: Record<string, ReviewBookState>;
getBookState: (bookKey: string | null) => ReviewBookState | null;
setActiveBookKey: (bookKey: string | null) => void;
setPanelVisible: (visible: boolean) => void;
togglePanelPin: () => void;
setPanelWidth: (width: string) => void;
setPanelHeight: (height: string) => void;
setBookLoading: (bookKey: string, loading: boolean) => void;
setBookError: (bookKey: string, error: string) => void;
setBookEnabled: (bookKey: string, enabled: boolean) => void;
setBookData: (
bookKey: string,
data: Partial<Omit<ReviewBookState, 'enabled' | 'loading' | 'error'>>,
) => void;
selectRow: (bookKey: string, rowId: string) => void;
updateRow: (bookKey: string, row: ReviewRow) => void;
clearBook: (bookKey: string) => void;
};
const emptyBookState = (): ReviewBookState => ({
enabled: false,
loading: false,
error: '',
baseUrl: '',
sessionId: null,
rows: [],
session: null,
gptConfig: null,
selectedRowId: '',
});
const formatSizedValue = (value: number, unit: '%' | 'vh') =>
`${Math.round(value * 100) / 100}${unit}`;
const normalizeSizedValue = (value: unknown, unit: '%' | 'vh', min: number, max: number) => {
if (typeof value !== 'string') return null;
const match = value.trim().match(new RegExp(`^(-?\\d+(?:\\.\\d+)?)${unit}$`));
if (!match) return null;
const numericValue = Number(match[1]);
if (!Number.isFinite(numericValue)) return null;
return formatSizedValue(Math.min(max, Math.max(min, numericValue)), unit);
};
const normalizePersistedLayout = (state: unknown) => {
if (!state || typeof state !== 'object') return {};
const persisted = state as Partial<ReviewModeStore>;
const panelWidth = normalizeSizedValue(
persisted.panelWidth,
'%',
MIN_PANEL_WIDTH_PERCENT,
MAX_PANEL_WIDTH_PERCENT,
);
const panelHeight = normalizeSizedValue(
persisted.panelHeight,
'vh',
MIN_PANEL_HEIGHT_VH,
MAX_PANEL_HEIGHT_VH,
);
return {
...(typeof persisted.isPanelPinned === 'boolean'
? { isPanelPinned: persisted.isPanelPinned }
: {}),
...(panelWidth ? { panelWidth } : {}),
...(panelHeight ? { panelHeight } : {}),
};
};
const withBookState = (
books: Record<string, ReviewBookState>,
bookKey: string,
patch: Partial<ReviewBookState>,
) => ({
...books,
[bookKey]: {
...(books[bookKey] || emptyBookState()),
...patch,
},
});
export const useReviewModeStore = create<ReviewModeStore>()(
persist(
(set, get) => ({
activeBookKey: null,
isPanelVisible: false,
isPanelPinned: false,
panelWidth: DEFAULT_PANEL_WIDTH,
panelHeight: DEFAULT_PANEL_HEIGHT,
books: {},
getBookState: (bookKey) => (bookKey ? get().books[bookKey] || null : null),
setActiveBookKey: (bookKey) => set({ activeBookKey: bookKey }),
setPanelVisible: (visible) => set({ isPanelVisible: visible }),
togglePanelPin: () => set((state) => ({ isPanelPinned: !state.isPanelPinned })),
setPanelWidth: (width) => set({ panelWidth: width }),
setPanelHeight: (height) => set({ panelHeight: height }),
setBookLoading: (bookKey, loading) =>
set((state) => ({
books: withBookState(
state.books,
bookKey,
loading ? { loading, error: '' } : { loading },
),
})),
setBookError: (bookKey, error) =>
set((state) => ({
books: withBookState(state.books, bookKey, { error, loading: false }),
})),
setBookEnabled: (bookKey, enabled) =>
set((state) => ({
activeBookKey: enabled
? bookKey
: state.activeBookKey === bookKey
? null
: state.activeBookKey,
isPanelVisible: enabled
? true
: state.activeBookKey === bookKey
? false
: state.isPanelVisible,
books: withBookState(state.books, bookKey, { enabled }),
})),
setBookData: (bookKey, data) =>
set((state) => ({
books: withBookState(state.books, bookKey, {
...data,
loading: false,
error: '',
}),
})),
selectRow: (bookKey, rowId) =>
set((state) => ({
activeBookKey: bookKey,
isPanelVisible: true,
books: withBookState(state.books, bookKey, {
selectedRowId: rowId,
}),
})),
updateRow: (bookKey, row) =>
set((state) => {
const current = state.books[bookKey] || emptyBookState();
return {
books: withBookState(state.books, bookKey, {
rows: current.rows.map((item) => (item.id === row.id ? row : item)),
}),
};
}),
clearBook: (bookKey) =>
set((state) => {
const books = { ...state.books };
delete books[bookKey];
return {
books,
activeBookKey: state.activeBookKey === bookKey ? null : state.activeBookKey,
isPanelVisible: state.activeBookKey === bookKey ? false : state.isPanelVisible,
};
}),
}),
{
name: 'readest-review-panel-layout',
storage: createJSONStorage(() => window.localStorage),
partialize: (state) => ({
isPanelPinned: state.isPanelPinned,
panelWidth: state.panelWidth,
panelHeight: state.panelHeight,
}),
merge: (persistedState, currentState) => ({
...currentState,
...normalizePersistedLayout(persistedState),
}),
},
),
);