From ebae9103d2dc4c2260788bbf0470ff9bc34d187a Mon Sep 17 00:00:00 2001 From: akai Date: Thu, 9 Jul 2026 16:30:52 +0800 Subject: [PATCH] fix: enable floating review panel height resize --- .../__tests__/store/review-mode-store.test.ts | 1 + .../src/app/reader/components/ReviewPanel.tsx | 117 ++++++++++++++++-- apps/readest-app/src/store/reviewModeStore.ts | 4 + .../tools/epub-review-editor/MAINTENANCE.md | 2 +- .../tools/epub-review-editor/README.md | 4 +- .../epub-review-editor/static/index.html | 6 +- .../tools/epub-review-editor/version.py | 2 +- 7 files changed, 117 insertions(+), 19 deletions(-) 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 bd46c594..2af098f3 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 @@ -8,6 +8,7 @@ beforeEach(() => { isPanelVisible: false, isPanelPinned: false, panelWidth: '32%', + panelHeight: '68vh', books: {}, }); }); diff --git a/apps/readest-app/src/app/reader/components/ReviewPanel.tsx b/apps/readest-app/src/app/reader/components/ReviewPanel.tsx index 13e73226..1b8ed8fa 100644 --- a/apps/readest-app/src/app/reader/components/ReviewPanel.tsx +++ b/apps/readest-app/src/app/reader/components/ReviewPanel.tsx @@ -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 ? ( +
+ ) : null}
; 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((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 }), diff --git a/apps/readest-app/tools/epub-review-editor/MAINTENANCE.md b/apps/readest-app/tools/epub-review-editor/MAINTENANCE.md index 303b31d4..39a971cb 100644 --- a/apps/readest-app/tools/epub-review-editor/MAINTENANCE.md +++ b/apps/readest-app/tools/epub-review-editor/MAINTENANCE.md @@ -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 注册 session,Readest 页面 URL 应改用 `session_id`,避免长期暴露本机 EPUB 路径。 - Readest `/review-editor` 的主路径必须是原生 React 功能块,校对和翻译功能直接调用本地 sidecar API;旧 `static/index.html` 界面只保留为独立启动、调试或应急 fallback,不再作为桌面迁移验收的主界面。若 React 直接访问 loopback sidecar,sidecar 只能对本机 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 配置与术语表应放在面板底部,避免挤占逐段编辑区。桌面端右侧面板必须保留固定按钮:固定时作为阅读布局的一列停靠并压缩正文,不固定时作为浮动面板覆盖在正文上方,可横向/纵向拖动位置,并可调整宽度和高度;固定/取消固定或调整固定面板宽度后,应尽量恢复用户原先正在阅读的位置。面板内容在窄宽度下应换行和纵向堆叠,避免文字被截断。移动窄屏仍可用抽屉/底部面板。不得迁入整书翻译、书架管理等无关功能。 ## 目录与插图 diff --git a/apps/readest-app/tools/epub-review-editor/README.md b/apps/readest-app/tools/epub-review-editor/README.md index 8a839274..c9b5768f 100644 --- a/apps/readest-app/tools/epub-review-editor/README.md +++ b/apps/readest-app/tools/epub-review-editor/README.md @@ -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` 目录复制到其他电脑或服务器后,在该目录里安装依赖: diff --git a/apps/readest-app/tools/epub-review-editor/static/index.html b/apps/readest-app/tools/epub-review-editor/static/index.html index c6f76679..a0a10f06 100644 --- a/apps/readest-app/tools/epub-review-editor/static/index.html +++ b/apps/readest-app/tools/epub-review-editor/static/index.html @@ -4,13 +4,13 @@ 双语 EPUB 审校编辑器 - +
-

双语 EPUB 审校编辑器 v0.16.2

+

双语 EPUB 审校编辑器 v0.16.3

正在载入……

@@ -509,6 +509,6 @@ - + diff --git a/apps/readest-app/tools/epub-review-editor/version.py b/apps/readest-app/tools/epub-review-editor/version.py index cd73fed9..075ee5b6 100644 --- a/apps/readest-app/tools/epub-review-editor/version.py +++ b/apps/readest-app/tools/epub-review-editor/version.py @@ -1,4 +1,4 @@ -version = "0.16.2" +version = "0.16.3" VERSION_RULES = { "PATCH": "修复 bug 或兼容性小修",