refactor: harden inline review panel layout
CodeQL Advanced / Analyze (actions) (pull_request) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Waiting to run
CodeQL Advanced / Analyze (rust) (pull_request) Waiting to run
PR checks / rust_lint (pull_request) Waiting to run
PR checks / build_web_app (pull_request) Waiting to run
PR checks / test_web_app (1) (pull_request) Waiting to run
PR checks / test_web_app (2) (pull_request) Waiting to run
PR checks / test_extensions (pull_request) Waiting to run
PR checks / build_tauri_app (pull_request) Waiting to run
Android E2E (CDP) / android-e2e (pull_request) Has been cancelled

This commit is contained in:
akai
2026-07-09 19:16:52 +08:00
parent 3b6d49c1ee
commit 75293bd1a4
10 changed files with 605 additions and 308 deletions
@@ -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();
});
});
@@ -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, '&lt;')
.replace(/>/g, '&gt;');
value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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({
<span className='text-base-content/60 text-xs'>{open ? '收起' : '展开'}</span>
</button>
{open ? (
<div className='grid gap-3 px-3 pb-3'>
<div className='grid gap-3 px-3 pb-3'>
<div className='grid gap-2'>
<label className='grid gap-1 text-xs font-medium'>
Base URL
@@ -411,7 +405,7 @@ function GlossaryPanel({
<div className='grid gap-3 px-3 pb-3'>
<div className='grid gap-2'>
<input
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
value={path}
onChange={(event) => setPath(event.target.value)}
placeholder='mingcibiao.json'
@@ -515,7 +509,7 @@ const ReviewPanel: React.FC = () => {
const [saving, setSaving] = useState(false);
const [retranslating, setRetranslating] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const isMobile = window.innerWidth < 640;
const isMobile = useIsMobileViewport();
const panelTopInset = getPanelTopInset({
isMobile,
isFullHeightInMobile: true,
@@ -527,14 +521,6 @@ const ReviewPanel: React.FC = () => {
const panelRef = useRef<HTMLElement>(null);
const cnEditorRef = useRef<HTMLTextAreaElement>(null);
const restoreTimeoutRef = useRef<number | null>(null);
const panelPositionRef = useRef({
x: Math.max(
FLOATING_PANEL_MARGIN,
window.innerWidth - Math.round(window.innerWidth * 0.32) - FLOATING_PANEL_MARGIN,
),
y: FLOATING_PANEL_MARGIN,
});
const [panelPosition, setPanelPosition] = useState(panelPositionRef.current);
const selectedRow = useMemo(
() => bookState?.rows.find((row) => row.id === bookState.selectedRowId) || null,
@@ -588,58 +574,40 @@ const ReviewPanel: React.FC = () => {
togglePanelPin();
};
const clampPanelPosition = useCallback((next: { x: number; y: number }) => {
const panel = panelRef.current;
const width = panel?.offsetWidth || Math.round(window.innerWidth * 0.32);
const height = panel?.offsetHeight || Math.round(window.innerHeight * 0.82);
return {
x: Math.min(
Math.max(FLOATING_PANEL_MARGIN, next.x),
Math.max(FLOATING_PANEL_MARGIN, window.innerWidth - width - FLOATING_PANEL_MARGIN),
),
y: Math.min(
Math.max(FLOATING_PANEL_MARGIN, next.y),
Math.max(FLOATING_PANEL_MARGIN, window.innerHeight - height - FLOATING_PANEL_MARGIN),
),
};
}, []);
const floatingPanelEnabled = !isPanelPinned && !isMobile;
const {
panelPosition,
panelPositionRef,
updatePanelPosition,
moveToDefaultPosition: moveFloatingPanelToDefault,
handlePanelDragStart,
} = useReviewPanelDrag({
enabled: floatingPanelEnabled,
panelRef,
margin: FLOATING_PANEL_MARGIN,
defaultWidthRatio: DEFAULT_REVIEW_PANEL_WIDTH,
topInset: panelTopInset,
});
const updatePanelPosition = useCallback(
(next: { x: number; y: number }) => {
const clamped = clampPanelPosition(next);
panelPositionRef.current = clamped;
setPanelPosition(clamped);
},
[clampPanelPosition],
);
const toPanelPercent = (fraction: number) => `${Math.round(fraction * 10000) / 100}%`;
const toPanelVh = (fraction: number) => `${Math.round(fraction * 10000) / 100}vh`;
const resizeFloatingPanel = useCallback(
(clientX: number, rightEdge: number) => {
const fraction = Math.max(
MIN_REVIEW_PANEL_WIDTH,
Math.min(MAX_REVIEW_PANEL_WIDTH, (rightEdge - clientX) / window.innerWidth),
);
const width = fraction * window.innerWidth;
setPanelWidth(toPanelPercent(fraction));
updatePanelPosition({ x: rightEdge - width, y: panelPositionRef.current.y });
},
[setPanelWidth, updatePanelPosition],
);
const resizeFloatingPanelHeight = useCallback(
(clientY: number, topEdge: number) => {
const fraction = Math.max(
MIN_FLOATING_PANEL_HEIGHT,
Math.min(MAX_FLOATING_PANEL_HEIGHT, (clientY - topEdge) / window.innerHeight),
);
setPanelHeight(toPanelVh(fraction));
requestAnimationFrame(() => updatePanelPosition(panelPositionRef.current));
},
[setPanelHeight, updatePanelPosition],
);
const {
handleWidthPointerDown: handleFloatingWidthPointerDown,
handleWidthKeyDown: handleFloatingWidthKeyDown,
handleHeightPointerDown: handleFloatingHeightPointerDown,
handleHeightKeyDown: handleFloatingHeightKeyDown,
} = useReviewPanelFloatingResize({
enabled: floatingPanelEnabled,
panelRef,
panelPositionRef,
panelWidth,
panelHeight,
minWidth: MIN_REVIEW_PANEL_WIDTH,
maxWidth: MAX_REVIEW_PANEL_WIDTH,
minHeight: MIN_FLOATING_PANEL_HEIGHT,
maxHeight: MAX_FLOATING_PANEL_HEIGHT,
setPanelWidth,
setPanelHeight,
updatePanelPosition,
});
const {
handleResizeStart: handleDockedResizeStart,
@@ -652,148 +620,12 @@ const ReviewPanel: React.FC = () => {
onResize: setPanelWidth,
});
const handleResizeStart = (event: React.MouseEvent | React.TouchEvent) => {
if (isPanelPinned || isMobile) {
handleDockedResizeStart(event);
return;
}
const panel = panelRef.current;
if (!panel) return;
const rightEdge = panel.getBoundingClientRect().right;
document.body.style.pointerEvents = 'none';
document.body.style.userSelect = 'none';
document.documentElement.style.cursor = 'col-resize';
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
const clientX =
'touches' in moveEvent ? moveEvent.touches[0]?.clientX : (moveEvent as MouseEvent).clientX;
if (typeof clientX === 'number') resizeFloatingPanel(clientX, rightEdge);
};
const handleEnd = () => {
document.body.style.pointerEvents = '';
document.body.style.userSelect = '';
document.documentElement.style.cursor = '';
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleEnd);
window.removeEventListener('touchmove', handleMove);
window.removeEventListener('touchend', handleEnd);
};
window.addEventListener('mousemove', handleMove, { passive: true });
window.addEventListener('mouseup', handleEnd);
window.addEventListener('touchmove', handleMove, { passive: true });
window.addEventListener('touchend', handleEnd);
};
const handleResizeKeyDown = (event: React.KeyboardEvent) => {
if (isPanelPinned || isMobile) {
if (floatingPanelEnabled) {
handleFloatingWidthKeyDown(event);
} else {
handleDockedResizeKeyDown(event);
return;
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
const currentWidth = parseFloat(panelWidth) / 100;
const nextWidth =
event.key === 'ArrowLeft'
? Math.min(MAX_REVIEW_PANEL_WIDTH, currentWidth + 0.02)
: Math.max(MIN_REVIEW_PANEL_WIDTH, currentWidth - 0.02);
const panel = panelRef.current;
const rightEdge =
panel?.getBoundingClientRect().right ||
panelPositionRef.current.x + currentWidth * window.innerWidth;
setPanelWidth(toPanelPercent(nextWidth));
updatePanelPosition({
x: rightEdge - nextWidth * window.innerWidth,
y: panelPositionRef.current.y,
});
event.preventDefault();
event.stopPropagation();
};
const handleHeightResizeStart = (event: React.MouseEvent | React.TouchEvent) => {
if (isPanelPinned || isMobile) return;
const panel = panelRef.current;
if (!panel) return;
event.preventDefault();
event.stopPropagation();
const topEdge = panel.getBoundingClientRect().top;
document.body.style.pointerEvents = 'none';
document.body.style.userSelect = 'none';
document.documentElement.style.cursor = 'row-resize';
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
const clientY =
'touches' in moveEvent ? moveEvent.touches[0]?.clientY : (moveEvent as MouseEvent).clientY;
if (typeof clientY === 'number') resizeFloatingPanelHeight(clientY, topEdge);
};
const handleEnd = () => {
document.body.style.pointerEvents = '';
document.body.style.userSelect = '';
document.documentElement.style.cursor = '';
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleEnd);
window.removeEventListener('touchmove', handleMove);
window.removeEventListener('touchend', handleEnd);
};
window.addEventListener('mousemove', handleMove, { passive: true });
window.addEventListener('mouseup', handleEnd);
window.addEventListener('touchmove', handleMove, { passive: true });
window.addEventListener('touchend', handleEnd);
};
const handleHeightResizeKeyDown = (event: React.KeyboardEvent) => {
if (isPanelPinned || isMobile) return;
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
const currentHeight = parseFloat(panelHeight) / 100;
const nextHeight =
event.key === 'ArrowDown'
? Math.min(MAX_FLOATING_PANEL_HEIGHT, currentHeight + 0.03)
: Math.max(MIN_FLOATING_PANEL_HEIGHT, currentHeight - 0.03);
setPanelHeight(toPanelVh(nextHeight));
requestAnimationFrame(() => updatePanelPosition(panelPositionRef.current));
event.preventDefault();
event.stopPropagation();
};
const moveFloatingPanelToDefault = useCallback(() => {
if (isPanelPinned || isMobile) return;
const panel = panelRef.current;
const width = panel?.offsetWidth || Math.round(window.innerWidth * 0.32);
updatePanelPosition({
x: window.innerWidth - width - FLOATING_PANEL_MARGIN,
y: panelTopInset + FLOATING_PANEL_MARGIN,
});
}, [isMobile, isPanelPinned, panelTopInset, updatePanelPosition]);
const handlePanelDragStart = (event: React.MouseEvent<HTMLElement>) => {
if (isPanelPinned || isMobile) return;
const target = event.target as HTMLElement | null;
if (target?.closest('button, input, textarea, select, a, .drag-bar')) return;
event.preventDefault();
const startX = event.clientX;
const startY = event.clientY;
const startPosition = panelPositionRef.current;
document.body.style.userSelect = 'none';
document.documentElement.style.cursor = 'move';
const handleMove = (moveEvent: MouseEvent) => {
updatePanelPosition({
x: startPosition.x + moveEvent.clientX - startX,
y: startPosition.y + moveEvent.clientY - startY,
});
};
const handleEnd = () => {
document.body.style.userSelect = '';
document.documentElement.style.cursor = '';
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleEnd);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleEnd);
};
useEffect(() => {
@@ -816,13 +648,6 @@ const ReviewPanel: React.FC = () => {
restoreReadingPositionAfterLayoutChange,
]);
useEffect(() => {
if (isPanelPinned || isMobile) return;
const handleResize = () => updatePanelPosition(panelPositionRef.current);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [isMobile, isPanelPinned, updatePanelPosition]);
useEffect(() => {
return () => {
if (restoreTimeoutRef.current) window.clearTimeout(restoreTimeoutRef.current);
@@ -915,13 +740,11 @@ const ReviewPanel: React.FC = () => {
};
const insertIntoCurrentHtml = (
buildInsert: (selectedText: string) =>
| {
html: string;
selectStart?: number;
selectEnd?: number;
}
| null,
buildInsert: (selectedText: string) => {
html: string;
selectStart?: number;
selectEnd?: number;
} | null,
) => {
const editor = cnEditorRef.current;
const source = edit.current_html;
@@ -1027,8 +850,9 @@ const ReviewPanel: React.FC = () => {
aria-label='调整审校面板宽度'
aria-orientation='horizontal'
aria-valuenow={parseFloat(panelWidth)}
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
onPointerDown={floatingPanelEnabled ? handleFloatingWidthPointerDown : undefined}
onMouseDown={floatingPanelEnabled ? undefined : handleDockedResizeStart}
onTouchStart={floatingPanelEnabled ? undefined : handleDockedResizeStart}
onKeyDown={handleResizeKeyDown}
/>
{!isMobile && !isPanelPinned ? (
@@ -1039,9 +863,8 @@ const ReviewPanel: React.FC = () => {
aria-label='调整审校面板高度'
aria-orientation='vertical'
aria-valuenow={parseFloat(panelHeight)}
onMouseDown={handleHeightResizeStart}
onTouchStart={handleHeightResizeStart}
onKeyDown={handleHeightResizeKeyDown}
onPointerDown={handleFloatingHeightPointerDown}
onKeyDown={handleFloatingHeightKeyDown}
/>
) : null}
<header
@@ -1049,7 +872,7 @@ const ReviewPanel: React.FC = () => {
'border-base-300 flex min-h-12 shrink-0 items-center gap-2 border-b px-3 py-2',
!isPanelPinned && !isMobile && 'cursor-move',
)}
onMouseDown={handlePanelDragStart}
onPointerDown={handlePanelDragStart}
>
<div className='min-w-0 flex-1'>
<h2 className='text-sm font-semibold break-words'></h2>
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react';
type Position = {
x: number;
y: number;
};
type UseReviewPanelDragOptions = {
enabled: boolean;
panelRef: RefObject<HTMLElement | null>;
margin: number;
defaultWidthRatio: number;
topInset: number;
};
const pointerTargetIsInteractive = (target: EventTarget | null) =>
target instanceof HTMLElement &&
Boolean(target.closest('button, input, textarea, select, a, .drag-bar'));
export const useReviewPanelDrag = ({
enabled,
panelRef,
margin,
defaultWidthRatio,
topInset,
}: UseReviewPanelDragOptions) => {
const initialPosition = () => ({
x:
typeof globalThis.window === 'undefined'
? margin
: Math.max(
margin,
globalThis.window.innerWidth -
Math.round(globalThis.window.innerWidth * defaultWidthRatio) -
margin,
),
y: typeof globalThis.window === 'undefined' ? margin : topInset + margin,
});
const panelPositionRef = useRef<Position>(initialPosition());
const [panelPosition, setPanelPosition] = useState(panelPositionRef.current);
const clampPanelPosition = useCallback(
(next: Position) => {
const panel = panelRef.current;
const width = panel?.offsetWidth || Math.round(window.innerWidth * defaultWidthRatio);
const height = panel?.offsetHeight || Math.round(window.innerHeight * 0.82);
return {
x: Math.min(Math.max(margin, next.x), Math.max(margin, window.innerWidth - width - margin)),
y: Math.min(
Math.max(margin, next.y),
Math.max(margin, window.innerHeight - height - margin),
),
};
},
[defaultWidthRatio, margin, panelRef],
);
const updatePanelPosition = useCallback(
(next: Position) => {
const clamped = clampPanelPosition(next);
panelPositionRef.current = clamped;
setPanelPosition(clamped);
},
[clampPanelPosition],
);
const moveToDefaultPosition = useCallback(() => {
if (!enabled) return;
const panel = panelRef.current;
const width = panel?.offsetWidth || Math.round(window.innerWidth * defaultWidthRatio);
updatePanelPosition({
x: window.innerWidth - width - margin,
y: topInset + margin,
});
}, [defaultWidthRatio, enabled, margin, panelRef, topInset, updatePanelPosition]);
const handlePanelDragStart = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
if (!enabled || pointerTargetIsInteractive(event.target)) return;
event.preventDefault();
const startX = event.clientX;
const startY = event.clientY;
const startPosition = panelPositionRef.current;
document.body.style.userSelect = 'none';
document.documentElement.style.cursor = 'move';
const handleMove = (moveEvent: PointerEvent) => {
updatePanelPosition({
x: startPosition.x + moveEvent.clientX - startX,
y: startPosition.y + moveEvent.clientY - startY,
});
};
const handleEnd = () => {
document.body.style.userSelect = '';
document.documentElement.style.cursor = '';
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleEnd);
window.removeEventListener('pointercancel', handleEnd);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleEnd);
window.addEventListener('pointercancel', handleEnd);
},
[enabled, updatePanelPosition],
);
useEffect(() => {
if (!enabled) return;
const handleResize = () => updatePanelPosition(panelPositionRef.current);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [enabled, updatePanelPosition]);
return {
panelPosition,
panelPositionRef,
updatePanelPosition,
moveToDefaultPosition,
handlePanelDragStart,
};
};
@@ -0,0 +1,197 @@
import { useCallback } from 'react';
import type {
KeyboardEvent as ReactKeyboardEvent,
MutableRefObject,
PointerEvent as ReactPointerEvent,
RefObject,
} from 'react';
type Position = {
x: number;
y: number;
};
type UseReviewPanelFloatingResizeOptions = {
enabled: boolean;
panelRef: RefObject<HTMLElement | null>;
panelPositionRef: MutableRefObject<Position>;
panelWidth: string;
panelHeight: string;
minWidth: number;
maxWidth: number;
minHeight: number;
maxHeight: number;
setPanelWidth: (width: string) => void;
setPanelHeight: (height: string) => void;
updatePanelPosition: (position: Position) => void;
};
const toPanelPercent = (fraction: number) => `${Math.round(fraction * 10000) / 100}%`;
const toPanelVh = (fraction: number) => `${Math.round(fraction * 10000) / 100}vh`;
export const useReviewPanelFloatingResize = ({
enabled,
panelRef,
panelPositionRef,
panelWidth,
panelHeight,
minWidth,
maxWidth,
minHeight,
maxHeight,
setPanelWidth,
setPanelHeight,
updatePanelPosition,
}: UseReviewPanelFloatingResizeOptions) => {
const resizeWidth = useCallback(
(clientX: number, rightEdge: number) => {
const fraction = Math.max(
minWidth,
Math.min(maxWidth, (rightEdge - clientX) / window.innerWidth),
);
const width = fraction * window.innerWidth;
setPanelWidth(toPanelPercent(fraction));
updatePanelPosition({ x: rightEdge - width, y: panelPositionRef.current.y });
},
[maxWidth, minWidth, panelPositionRef, setPanelWidth, updatePanelPosition],
);
const resizeHeight = useCallback(
(clientY: number, topEdge: number) => {
const fraction = Math.max(
minHeight,
Math.min(maxHeight, (clientY - topEdge) / window.innerHeight),
);
setPanelHeight(toPanelVh(fraction));
requestAnimationFrame(() => updatePanelPosition(panelPositionRef.current));
},
[maxHeight, minHeight, panelPositionRef, setPanelHeight, updatePanelPosition],
);
const handleWidthPointerDown = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
if (!enabled) return;
const panel = panelRef.current;
if (!panel) return;
event.preventDefault();
event.stopPropagation();
const rightEdge = panel.getBoundingClientRect().right;
document.body.style.pointerEvents = 'none';
document.body.style.userSelect = 'none';
document.documentElement.style.cursor = 'col-resize';
const handleMove = (moveEvent: PointerEvent) => {
resizeWidth(moveEvent.clientX, rightEdge);
};
const handleEnd = () => {
document.body.style.pointerEvents = '';
document.body.style.userSelect = '';
document.documentElement.style.cursor = '';
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleEnd);
window.removeEventListener('pointercancel', handleEnd);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleEnd);
window.addEventListener('pointercancel', handleEnd);
},
[enabled, panelRef, resizeWidth],
);
const handleHeightPointerDown = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
if (!enabled) return;
const panel = panelRef.current;
if (!panel) return;
event.preventDefault();
event.stopPropagation();
const topEdge = panel.getBoundingClientRect().top;
document.body.style.pointerEvents = 'none';
document.body.style.userSelect = 'none';
document.documentElement.style.cursor = 'row-resize';
const handleMove = (moveEvent: PointerEvent) => {
resizeHeight(moveEvent.clientY, topEdge);
};
const handleEnd = () => {
document.body.style.pointerEvents = '';
document.body.style.userSelect = '';
document.documentElement.style.cursor = '';
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleEnd);
window.removeEventListener('pointercancel', handleEnd);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleEnd);
window.addEventListener('pointercancel', handleEnd);
},
[enabled, panelRef, resizeHeight],
);
const handleWidthKeyDown = useCallback(
(event: ReactKeyboardEvent) => {
if (!enabled || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return;
const currentWidth = parseFloat(panelWidth) / 100;
const nextWidth =
event.key === 'ArrowLeft'
? Math.min(maxWidth, currentWidth + 0.02)
: Math.max(minWidth, currentWidth - 0.02);
const panel = panelRef.current;
const rightEdge =
panel?.getBoundingClientRect().right ||
panelPositionRef.current.x + currentWidth * window.innerWidth;
setPanelWidth(toPanelPercent(nextWidth));
updatePanelPosition({
x: rightEdge - nextWidth * window.innerWidth,
y: panelPositionRef.current.y,
});
event.preventDefault();
event.stopPropagation();
},
[
enabled,
maxWidth,
minWidth,
panelPositionRef,
panelRef,
panelWidth,
setPanelWidth,
updatePanelPosition,
],
);
const handleHeightKeyDown = useCallback(
(event: ReactKeyboardEvent) => {
if (!enabled || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return;
const currentHeight = parseFloat(panelHeight) / 100;
const nextHeight =
event.key === 'ArrowDown'
? Math.min(maxHeight, currentHeight + 0.03)
: Math.max(minHeight, currentHeight - 0.03);
setPanelHeight(toPanelVh(nextHeight));
requestAnimationFrame(() => updatePanelPosition(panelPositionRef.current));
event.preventDefault();
event.stopPropagation();
},
[
enabled,
maxHeight,
minHeight,
panelHeight,
panelPositionRef,
setPanelHeight,
updatePanelPosition,
],
);
return {
handleWidthPointerDown,
handleWidthKeyDown,
handleHeightPointerDown,
handleHeightKeyDown,
};
};
@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
export const useIsMobileViewport = (breakpoint = 640) => {
const readViewport = () =>
typeof window === 'undefined' ? false : window.innerWidth < breakpoint;
const [isMobileViewport, setIsMobileViewport] = useState(readViewport);
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
const update = () => setIsMobileViewport(mediaQuery.matches);
update();
mediaQuery.addEventListener('change', update);
return () => mediaQuery.removeEventListener('change', update);
}, [breakpoint]);
return isMobileViewport;
};
+135 -69
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import type {
ReviewGptConfig,
@@ -20,6 +21,13 @@ export type ReviewBookState = {
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;
@@ -57,6 +65,43 @@ const emptyBookState = (): ReviewBookState => ({
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,
@@ -69,74 +114,95 @@ const withBookState = (
},
});
export const useReviewModeStore = create<ReviewModeStore>((set, get) => ({
activeBookKey: null,
isPanelVisible: false,
isPanelPinned: false,
panelWidth: '32%',
panelHeight: '68vh',
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)),
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,
};
}),
};
}),
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),
}),
},
),
);
@@ -51,7 +51,7 @@ epub_review_sessions/
- `translated_outputs/` 保存 AI 翻译生成的 EPUB;生成后应创建独立 session,使 `/api/sessions` 和书架自然可见。
- Readest 桌面端入口委托 Tauri command `launch_epub_review_editor` 启动或复用本地 sidecar;dev-web 入口继续使用 `/api/review-editor/launch`。两者都应创建 `.venv`、安静检测 Flask 依赖、缺失时自动安装依赖、复用同版本且 `review_root` 一致的已运行服务或启动 `server.py --daemon`;该入口不得硬编码用户私有路径。桌面端从书架进入时,文件路径只交给 Tauri command 注册 sessionReadest 页面 URL 应改用 `session_id`,避免长期暴露本机 EPUB 路径。
- Readest `/review-editor` 的主路径必须是原生 React 功能块,校对和翻译功能直接调用本地 sidecar API;旧 `static/index.html` 界面只保留为独立启动、调试或应急 fallback,不再作为桌面迁移验收的主界面。若 React 直接访问 loopback sidecarsidecar 只能对本机 Readest/Tauri 来源返回受限 CORS,不得开放任意 Origin。
- Readest 原生阅读页可提供内嵌审校模式,但只能作为当前 EPUB 的阅读增强:顶栏“校”按钮启动/复用 sidecar、注册当前书 session、读取 `/api/rows``/api/gpt/config`,在 Foliate iframe 正文中只标记对应的中日双语段落;点击或选中日文/中文段落时打开同一条审校记录。右侧面板可复用旧审校器右侧栏能力(编辑中文、问题类型、严重程度、标签、备注、长期规则、GPT 配置、术语表、单段重翻、反馈生成、导出),但不显示独立段落列表;GPT 配置与术语表应放在面板底部,避免挤占逐段编辑区。中文编辑区的译文预览默认折叠;注音、注释和保存按钮应靠近“修改中文译文”标题,注音/注释操作应对当前选择位置插入可直接改写的内联标签并自动展开预览,便于保存前确认格式。桌面端右侧面板必须保留固定按钮:固定时作为阅读布局的一列停靠并压缩正文,不固定时作为浮动面板覆盖在正文上方,可横向/纵向拖动位置,并可调整宽度和高度;固定/取消固定或调整固定面板宽度后,应尽量恢复用户原先正在阅读的位置。面板内容在窄宽度下应换行和纵向堆叠,避免文字被截断。移动窄屏仍可用抽屉/底部面板。不得迁入整书翻译、书架管理等无关功能。
- Readest 原生阅读页可提供内嵌审校模式,但只能作为当前 EPUB 的阅读增强:顶栏“校”按钮启动/复用 sidecar、注册当前书 session、读取 `/api/rows``/api/gpt/config`,在 Foliate iframe 正文中只标记对应的中日双语段落;点击或选中日文/中文段落时打开同一条审校记录。右侧面板可复用旧审校器右侧栏能力(编辑中文、问题类型、严重程度、标签、备注、长期规则、GPT 配置、术语表、单段重翻、反馈生成、导出),但不显示独立段落列表;GPT 配置与术语表应放在面板底部,避免挤占逐段编辑区。中文编辑区的译文预览默认折叠;注音、注释和保存按钮应靠近“修改中文译文”标题,注音/注释操作应对当前选择位置插入可直接改写的内联标签并自动展开预览,便于保存前确认格式。桌面端右侧面板必须保留固定按钮:固定时作为阅读布局的一列停靠并压缩正文,不固定时作为浮动面板覆盖在正文上方,可横向/纵向拖动位置,并可调整宽度和高度;固定/取消固定或调整固定面板宽度后,应尽量恢复用户原先正在阅读的位置。面板内容在窄宽度下应换行和纵向堆叠,避免文字被截断。固定状态、宽度与浮动高度可作为本地布局偏好持久化,但不得把当前书审校行、session 数据或 API Key 写入该偏好。浮动模式拖动和宽高调整应使用统一的 pointer 事件逻辑,窗口尺寸或移动/桌面断点变化时要重新夹取到可视区域内。移动窄屏仍可用抽屉/底部面板。不得迁入整书翻译、书架管理等无关功能。
## 目录与插图
@@ -1,6 +1,6 @@
# 双语 EPUB 审校编辑器
当前版本:`0.16.4`
当前版本:`0.16.5`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
@@ -67,6 +67,8 @@
`0.16.4` 优化 Readest 原生审校面板的中文编辑区:译文预览默认折叠,中文编辑标题右侧新增注音、注释和保存按钮;注音/注释会在当前选择位置插入可直接改写的内联标签,并自动展开预览,便于保存前确认显示效果。
`0.16.5` 工程优化 Readest 原生审校面板布局:移动/桌面断点改为响应式监听,浮动面板拖动与宽高调整逻辑抽取为可复用 hook,并持久化固定状态、面板宽度和浮动高度;审校行数据仍只保存在当前会话中,不写入浏览器布局偏好。
## 独立安装
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub-review-editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
@@ -4,13 +4,13 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>双语 EPUB 审校编辑器</title>
<link rel="stylesheet" href="/static/style.css?v=0.16.4">
<link rel="stylesheet" href="/static/style.css?v=0.16.5">
</head>
<body>
<header class="topbar">
<button id="bookshelfBtn" class="bookshelfNavButton" type="button">书架</button>
<div class="brandBlock">
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.16.4</span></h1>
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.16.5</span></h1>
<p id="sessionMeta">正在载入……</p>
</div>
<div class="modeTabs" role="tablist" aria-label="审校模式">
@@ -509,6 +509,6 @@
</aside>
<div class="toast" id="toast" hidden></div>
<script src="/static/app.js?v=0.16.4"></script>
<script src="/static/app.js?v=0.16.5"></script>
</body>
</html>
@@ -1,4 +1,4 @@
version = "0.16.4"
version = "0.16.5"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",