fix: enable floating review panel height resize

This commit is contained in:
akai
2026-07-09 16:30:52 +08:00
parent 0d8b2f9167
commit ebae9103d2
7 changed files with 117 additions and 19 deletions
@@ -8,6 +8,7 @@ beforeEach(() => {
isPanelVisible: false,
isPanelPinned: false,
panelWidth: '32%',
panelHeight: '68vh',
books: {},
});
});
@@ -39,6 +39,8 @@ import {
const MIN_REVIEW_PANEL_WIDTH = 0.24;
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 cnHtmlPurifyOptions = {
@@ -469,9 +471,11 @@ const ReviewPanel: React.FC = () => {
const isPanelVisible = useReviewModeStore((state) => state.isPanelVisible);
const isPanelPinned = useReviewModeStore((state) => state.isPanelPinned);
const panelWidth = useReviewModeStore((state) => state.panelWidth);
const panelHeight = useReviewModeStore((state) => state.panelHeight);
const setPanelVisible = useReviewModeStore((state) => state.setPanelVisible);
const togglePanelPin = useReviewModeStore((state) => state.togglePanelPin);
const setPanelWidth = useReviewModeStore((state) => state.setPanelWidth);
const setPanelHeight = useReviewModeStore((state) => state.setPanelHeight);
const updateRow = useReviewModeStore((state) => state.updateRow);
const setBookData = useReviewModeStore((state) => state.setBookData);
const bookState = useReviewModeStore((state) =>
@@ -588,7 +592,8 @@ const ReviewPanel: React.FC = () => {
[clampPanelPosition],
);
const toPanelWidthPercent = (fraction: number) => `${Math.round(fraction * 10000) / 100}%`;
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) => {
@@ -597,20 +602,34 @@ const ReviewPanel: React.FC = () => {
Math.min(MAX_REVIEW_PANEL_WIDTH, (rightEdge - clientX) / window.innerWidth),
);
const width = fraction * window.innerWidth;
setPanelWidth(toPanelWidthPercent(fraction));
setPanelWidth(toPanelPercent(fraction));
updatePanelPosition({ x: rightEdge - width, y: panelPositionRef.current.y });
},
[setPanelWidth, updatePanelPosition],
);
const { handleResizeStart: handleDockedResizeStart, handleResizeKeyDown: handleDockedResizeKeyDown } =
usePanelResize({
side: 'end',
minWidth: MIN_REVIEW_PANEL_WIDTH,
maxWidth: MAX_REVIEW_PANEL_WIDTH,
getWidth: () => panelWidth,
onResize: setPanelWidth,
});
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 {
handleResizeStart: handleDockedResizeStart,
handleResizeKeyDown: handleDockedResizeKeyDown,
} = usePanelResize({
side: 'end',
minWidth: MIN_REVIEW_PANEL_WIDTH,
maxWidth: MAX_REVIEW_PANEL_WIDTH,
getWidth: () => panelWidth,
onResize: setPanelWidth,
});
const handleResizeStart = (event: React.MouseEvent | React.TouchEvent) => {
if (isPanelPinned || isMobile) {
@@ -661,7 +680,7 @@ const ReviewPanel: React.FC = () => {
const rightEdge =
panel?.getBoundingClientRect().right ||
panelPositionRef.current.x + currentWidth * window.innerWidth;
setPanelWidth(toPanelWidthPercent(nextWidth));
setPanelWidth(toPanelPercent(nextWidth));
updatePanelPosition({
x: rightEdge - nextWidth * window.innerWidth,
y: panelPositionRef.current.y,
@@ -670,6 +689,53 @@ const ReviewPanel: React.FC = () => {
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;
@@ -721,7 +787,13 @@ const ReviewPanel: React.FC = () => {
useEffect(() => {
if (!isPanelVisible || isMobile || !isPanelPinned) return;
restoreReadingPositionAfterLayoutChange();
}, [isMobile, isPanelPinned, isPanelVisible, panelWidth, restoreReadingPositionAfterLayoutChange]);
}, [
isMobile,
isPanelPinned,
isPanelVisible,
panelWidth,
restoreReadingPositionAfterLayoutChange,
]);
useEffect(() => {
if (isPanelPinned || isMobile) return;
@@ -834,7 +906,8 @@ const ReviewPanel: React.FC = () => {
className={clsx(
'review-panel-container flex min-w-72 select-none flex-col',
isDocked && 'shrink-0',
'full-height font-sans text-base font-normal transition-[padding-top] duration-300 sm:text-sm',
'font-sans text-base font-normal transition-[padding-top] duration-300 sm:text-sm',
(isDocked || isMobile) && 'full-height',
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
isDocked ? 'z-20' : 'z-[45] shadow-2xl',
@@ -847,6 +920,11 @@ const ReviewPanel: React.FC = () => {
style={{
width: isMobile ? '100%' : panelWidth,
maxWidth: isMobile ? '100%' : `${MAX_REVIEW_PANEL_WIDTH * 100}%`,
height: !isMobile && !isPanelPinned ? panelHeight : undefined,
maxHeight:
!isMobile && !isPanelPinned
? `calc(100vh - ${FLOATING_PANEL_MARGIN * 2}px)`
: undefined,
position: isMobile ? 'fixed' : isPanelPinned ? 'relative' : 'absolute',
left: !isMobile && !isPanelPinned ? `${panelPosition.x}px` : undefined,
top: !isMobile && !isPanelPinned ? `${panelPosition.y}px` : undefined,
@@ -868,6 +946,19 @@ const ReviewPanel: React.FC = () => {
onTouchStart={handleResizeStart}
onKeyDown={handleResizeKeyDown}
/>
{!isMobile && !isPanelPinned ? (
<div
className='drag-bar absolute inset-x-0 -bottom-2 h-0.5 cursor-row-resize bg-transparent p-2'
role='slider'
tabIndex={0}
aria-label='调整审校面板高度'
aria-orientation='vertical'
aria-valuenow={parseFloat(panelHeight)}
onMouseDown={handleHeightResizeStart}
onTouchStart={handleHeightResizeStart}
onKeyDown={handleHeightResizeKeyDown}
/>
) : null}
<header
className={clsx(
'border-base-300 flex min-h-12 shrink-0 items-center gap-2 border-b px-3 py-2',
@@ -25,12 +25,14 @@ type ReviewModeStore = {
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;
@@ -72,12 +74,14 @@ export const useReviewModeStore = create<ReviewModeStore>((set, get) => ({
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 }),
@@ -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 配置与术语表应放在面板底部,避免挤占逐段编辑区。桌面端右侧面板必须保留固定按钮:固定时作为阅读布局的一列停靠并压缩正文,不固定时作为浮动面板覆盖在正文上方,可横向/纵向拖动位置,并可调整宽度和高度;固定/取消固定或调整固定面板宽度后,应尽量恢复用户原先正在阅读的位置。面板内容在窄宽度下应换行和纵向堆叠,避免文字被截断。移动窄屏仍可用抽屉/底部面板。不得迁入整书翻译、书架管理等无关功能。
## 目录与插图
@@ -1,6 +1,6 @@
# 双语 EPUB 审校编辑器
当前版本:`0.16.2`
当前版本:`0.16.3`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
@@ -63,6 +63,8 @@
`0.16.2` 恢复 Readest 原生审校面板固定按钮:固定时面板停靠并挤开正文,取消固定时变为可拖动浮动面板;固定状态切换和固定宽度调整后会恢复原阅读位置,窄面板下控件和文本改为换行/纵向堆叠,避免内容被截断。
`0.16.3` 修复 Readest 原生审校面板浮动模式交互:取消固定后面板不再占满整页高度,可在正文上方横向和纵向拖动,并支持分别调整宽度与高度。
## 独立安装
这个审校器现在可以脱离 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.2">
<link rel="stylesheet" href="/static/style.css?v=0.16.3">
</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.2</span></h1>
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.16.3</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.2"></script>
<script src="/static/app.js?v=0.16.3"></script>
</body>
</html>
@@ -1,4 +1,4 @@
version = "0.16.2"
version = "0.16.3"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",