diff --git a/apps/readest-app/src/__tests__/store/review-mode-store.test.ts b/apps/readest-app/src/__tests__/store/review-mode-store.test.ts
index 2af098f3..8ade035d 100644
--- a/apps/readest-app/src/__tests__/store/review-mode-store.test.ts
+++ b/apps/readest-app/src/__tests__/store/review-mode-store.test.ts
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test } from 'vitest';
import { useReviewModeStore } from '@/store/reviewModeStore';
beforeEach(() => {
+ localStorage.clear();
useReviewModeStore.setState({
activeBookKey: null,
isPanelVisible: false,
@@ -99,4 +100,69 @@ describe('reviewModeStore', () => {
expect(state.activeBookKey).toBeNull();
expect(state.isPanelVisible).toBe(false);
});
+
+ test('persists only panel layout preferences', () => {
+ useReviewModeStore.getState().setPanelWidth('41%');
+ useReviewModeStore.getState().setPanelHeight('77vh');
+ useReviewModeStore.getState().togglePanelPin();
+ useReviewModeStore.getState().setBookData('book-a', {
+ baseUrl: 'http://127.0.0.1:5177',
+ sessionId: 'session-a',
+ rows: [
+ {
+ id: 'R00001',
+ file: 'chapter.xhtml',
+ jp_html: '原文',
+ jp_text: '原文',
+ cn_html: '译文',
+ current_html: '译文',
+ },
+ ],
+ });
+
+ const stored = JSON.parse(localStorage.getItem('readest-review-panel-layout') || '{}');
+
+ expect(stored.state).toEqual({
+ isPanelPinned: true,
+ panelWidth: '41%',
+ panelHeight: '77vh',
+ });
+ expect(stored.state.books).toBeUndefined();
+ expect(stored.state.activeBookKey).toBeUndefined();
+ });
+
+ test('ignores invalid persisted layout values during hydration', async () => {
+ useReviewModeStore.setState({
+ activeBookKey: null,
+ isPanelVisible: false,
+ isPanelPinned: false,
+ panelWidth: '32%',
+ panelHeight: '68vh',
+ books: {},
+ });
+
+ useReviewModeStore.persist.clearStorage();
+ localStorage.setItem(
+ 'readest-review-panel-layout',
+ JSON.stringify({
+ state: {
+ isPanelPinned: true,
+ panelWidth: '999%',
+ panelHeight: 'not-a-height',
+ books: {
+ stale: { enabled: true },
+ },
+ },
+ version: 0,
+ }),
+ );
+ await useReviewModeStore.persist.rehydrate();
+
+ const state = useReviewModeStore.getState();
+ expect(state.isPanelPinned).toBe(true);
+ expect(state.panelWidth).toBe('48%');
+ expect(state.panelHeight).toBe('68vh');
+ expect(state.books).toEqual({});
+ expect(state.activeBookKey).toBeNull();
+ });
});
diff --git a/apps/readest-app/src/app/reader/components/ReviewPanel.tsx b/apps/readest-app/src/app/reader/components/ReviewPanel.tsx
index 0d1cb404..107ce81f 100644
--- a/apps/readest-app/src/app/reader/components/ReviewPanel.tsx
+++ b/apps/readest-app/src/app/reader/components/ReviewPanel.tsx
@@ -13,17 +13,11 @@ import {
Sparkles,
X,
} from 'lucide-react';
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
- type ReactNode,
-} from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { Overlay } from '@/components/Overlay';
import { useEnv } from '@/context/EnvContext';
+import { useIsMobileViewport } from '@/hooks/useIsMobileViewport';
import { useTranslation } from '@/hooks/useTranslation';
import { usePanelResize } from '@/hooks/usePanelResize';
import { useBookDataStore } from '@/store/bookDataStore';
@@ -32,6 +26,8 @@ import { useReviewModeStore } from '@/store/reviewModeStore';
import { useThemeStore } from '@/store/themeStore';
import { getPanelTopInset } from '@/utils/insets';
import { openExternalUrl } from '@/utils/open';
+import { useReviewPanelDrag } from '../hooks/useReviewPanelDrag';
+import { useReviewPanelFloatingResize } from '../hooks/useReviewPanelFloatingResize';
import {
exportReviewedEpub,
generateReviewFeedback,
@@ -54,6 +50,7 @@ const MAX_REVIEW_PANEL_WIDTH = 0.48;
const MIN_FLOATING_PANEL_HEIGHT = 0.35;
const MAX_FLOATING_PANEL_HEIGHT = 0.92;
const FLOATING_PANEL_MARGIN = 12;
+const DEFAULT_REVIEW_PANEL_WIDTH = 0.32;
const cnHtmlPurifyOptions = {
ALLOWED_TAGS: [
@@ -110,10 +107,7 @@ const emptyGptForm: GptForm = {
const sanitizeInlineHtml = (html: string) => DOMPurify.sanitize(html || '', cnHtmlPurifyOptions);
const escapeInlineHtml = (value: string) =>
- value
- .replace(/&/g, '&')
- .replace(//g, '>');
+ value.replace(/&/g, '&').replace(//g, '>');
const rowTitle = (row: { document_title?: string; file_label?: string; file: string }) =>
row.document_title || row.file_label || row.file;
@@ -209,7 +203,7 @@ function GptConfigPanel({
{open ? '收起' : '展开'}
{open ? (
-
+