forked from akai/readest
fix: enable floating review panel height resize
This commit is contained in:
@@ -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 注册 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 配置与术语表应放在面板底部,避免挤占逐段编辑区。桌面端右侧面板必须保留固定按钮:固定时作为阅读布局的一列停靠并压缩正文,不固定时作为浮动面板覆盖在正文上方,可横向/纵向拖动位置,并可调整宽度和高度;固定/取消固定或调整固定面板宽度后,应尽量恢复用户原先正在阅读的位置。面板内容在窄宽度下应换行和纵向堆叠,避免文字被截断。移动窄屏仍可用抽屉/底部面板。不得迁入整书翻译、书架管理等无关功能。
|
||||
|
||||
## 目录与插图
|
||||
|
||||
|
||||
@@ -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 或兼容性小修",
|
||||
|
||||
Reference in New Issue
Block a user