fix: restore review panel pin behavior

This commit is contained in:
akai
2026-07-09 16:04:08 +08:00
parent 987067414c
commit 0d8b2f9167
5 changed files with 279 additions and 58 deletions
@@ -1,7 +1,14 @@
import clsx from 'clsx';
import DOMPurify from 'dompurify';
import { Check, Download, FileText, RefreshCw, Save, Sparkles, X } from 'lucide-react';
import React, { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Check, Download, FileText, Pin, PinOff, RefreshCw, Save, Sparkles, X } from 'lucide-react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { Overlay } from '@/components/Overlay';
import { useEnv } from '@/context/EnvContext';
@@ -32,6 +39,7 @@ import {
const MIN_REVIEW_PANEL_WIDTH = 0.24;
const MAX_REVIEW_PANEL_WIDTH = 0.48;
const FLOATING_PANEL_MARGIN = 12;
const cnHtmlPurifyOptions = {
ALLOWED_TAGS: [
@@ -119,7 +127,8 @@ const PanelButton = ({
onClick={onClick}
disabled={disabled}
className={clsx(
'btn h-9 min-h-9 rounded-md px-3 text-sm',
'btn h-auto min-h-9 max-w-full whitespace-normal rounded-md px-3 py-2 text-start text-sm leading-snug break-words',
'[&>svg]:shrink-0',
variant === 'primary' ? 'btn-primary' : 'btn-ghost eink-bordered',
disabled && 'opacity-60',
)}
@@ -180,12 +189,12 @@ 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-2 sm:grid-cols-2'>
<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
<input
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
value={form.base_url}
onChange={(event) =>
setForm((current) => ({ ...current, base_url: event.target.value }))
@@ -196,7 +205,7 @@ function GptConfigPanel({
<label className='grid gap-1 text-xs font-medium'>
<input
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
value={form.model}
onChange={(event) =>
setForm((current) => ({ ...current, model: event.target.value }))
@@ -208,7 +217,7 @@ function GptConfigPanel({
<label className='grid gap-1 text-xs font-medium'>
API Key
<input
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
value={form.api_key}
type='password'
onChange={(event) =>
@@ -220,7 +229,7 @@ function GptConfigPanel({
<label className='grid gap-1 text-xs font-medium'>
<input
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
value={form.glossary_path}
onChange={(event) =>
setForm((current) => ({ ...current, glossary_path: event.target.value }))
@@ -231,7 +240,7 @@ function GptConfigPanel({
<label className='grid gap-1 text-xs font-medium'>
<textarea
className='textarea textarea-bordered eink-bordered min-h-20 rounded-md text-sm'
className='textarea textarea-bordered eink-bordered min-h-20 min-w-0 rounded-md text-sm'
value={form.translation_prompt}
onChange={(event) =>
setForm((current) => ({ ...current, translation_prompt: event.target.value }))
@@ -241,7 +250,7 @@ function GptConfigPanel({
<label className='grid gap-1 text-xs font-medium'>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 rounded-md text-sm'
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
value={form.format_prompt}
onChange={(event) =>
setForm((current) => ({ ...current, format_prompt: event.target.value }))
@@ -251,7 +260,7 @@ function GptConfigPanel({
<label className='grid gap-1 text-xs font-medium'>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 rounded-md text-sm'
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
value={form.character_prompt}
onChange={(event) =>
setForm((current) => ({ ...current, character_prompt: event.target.value }))
@@ -382,7 +391,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 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'
@@ -409,7 +418,7 @@ function GlossaryPanel({
</div>
<p className='text-base-content/60 text-xs'>{message}</p>
<input
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder='搜索术语或译名'
@@ -422,10 +431,7 @@ function GlossaryPanel({
<p className='text-base-content/60 p-2 text-sm'></p>
) : (
filteredEntries.slice(0, 80).map(({ entry, index }) => (
<div
key={`${index}-${entry.source}`}
className='grid grid-cols-[1fr_1fr_auto] gap-2'
>
<div key={`${index}-${entry.source}`} className='grid gap-2'>
<input
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
value={entry.source}
@@ -461,8 +467,10 @@ const ReviewPanel: React.FC = () => {
const { safeAreaInsets, systemUIVisible, statusBarHeight } = useThemeStore();
const activeBookKey = useReviewModeStore((state) => state.activeBookKey);
const isPanelVisible = useReviewModeStore((state) => state.isPanelVisible);
const isPanelPinned = useReviewModeStore((state) => state.isPanelPinned);
const panelWidth = useReviewModeStore((state) => state.panelWidth);
const setPanelVisible = useReviewModeStore((state) => state.setPanelVisible);
const togglePanelPin = useReviewModeStore((state) => state.togglePanelPin);
const setPanelWidth = useReviewModeStore((state) => state.setPanelWidth);
const updateRow = useReviewModeStore((state) => state.updateRow);
const setBookData = useReviewModeStore((state) => state.setBookData);
@@ -470,6 +478,8 @@ const ReviewPanel: React.FC = () => {
activeBookKey ? state.books[activeBookKey] : null,
);
const getBookData = useBookDataStore((state) => state.getBookData);
const getProgress = useReaderStore((state) => state.getProgress);
const getView = useReaderStore((state) => state.getView);
const viewSettings = useReaderStore((state) =>
activeBookKey ? state.viewStates[activeBookKey]?.viewSettings : null,
);
@@ -483,7 +493,24 @@ const ReviewPanel: React.FC = () => {
const [saving, setSaving] = useState(false);
const [retranslating, setRetranslating] = useState(false);
const isMobile = window.innerWidth < 640;
const isDocked = !isMobile;
const panelTopInset = getPanelTopInset({
isMobile,
isFullHeightInMobile: true,
systemUIVisible,
statusBarHeight,
safeAreaInsets,
});
const isDocked = !isMobile && isPanelPinned;
const panelRef = useRef<HTMLElement>(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,
@@ -519,13 +546,195 @@ const ReviewPanel: React.FC = () => {
setRetranslateInstruction('');
}, [selectedRow]);
const { handleResizeStart, handleResizeKeyDown } = usePanelResize({
side: 'end',
minWidth: MIN_REVIEW_PANEL_WIDTH,
maxWidth: MAX_REVIEW_PANEL_WIDTH,
getWidth: () => panelWidth,
onResize: setPanelWidth,
});
const restoreReadingPositionAfterLayoutChange = useCallback(() => {
if (!activeBookKey) return;
const view = getView(activeBookKey);
const cfi = view?.lastLocation?.cfi || getProgress(activeBookKey)?.location;
if (!view || !cfi) return;
if (restoreTimeoutRef.current) window.clearTimeout(restoreTimeoutRef.current);
restoreTimeoutRef.current = window.setTimeout(() => {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => view.goTo(cfi));
});
}, 160);
}, [activeBookKey, getProgress, getView]);
const handleTogglePanelPin = () => {
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 updatePanelPosition = useCallback(
(next: { x: number; y: number }) => {
const clamped = clampPanelPosition(next);
panelPositionRef.current = clamped;
setPanelPosition(clamped);
},
[clampPanelPosition],
);
const toPanelWidthPercent = (fraction: number) => `${Math.round(fraction * 10000) / 100}%`;
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(toPanelWidthPercent(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 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) {
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(toPanelWidthPercent(nextWidth));
updatePanelPosition({
x: rightEdge - nextWidth * window.innerWidth,
y: panelPositionRef.current.y,
});
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(() => {
moveFloatingPanelToDefault();
}, [moveFloatingPanelToDefault]);
useEffect(() => {
if (!isPanelVisible || isMobile) return;
restoreReadingPositionAfterLayoutChange();
}, [isMobile, isPanelPinned, isPanelVisible, restoreReadingPositionAfterLayoutChange]);
useEffect(() => {
if (!isPanelVisible || isMobile || !isPanelPinned) return;
restoreReadingPositionAfterLayoutChange();
}, [isMobile, isPanelPinned, isPanelVisible, panelWidth, 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);
};
}, []);
if (!isPanelVisible || !activeBookKey || !bookState) return null;
@@ -612,14 +821,6 @@ const ReviewPanel: React.FC = () => {
}
};
const panelTopInset = getPanelTopInset({
isMobile,
isFullHeightInMobile: true,
systemUIVisible,
statusBarHeight,
safeAreaInsets,
});
return (
<>
{isMobile && (
@@ -629,8 +830,9 @@ const ReviewPanel: React.FC = () => {
/>
)}
<aside
ref={panelRef}
className={clsx(
'review-panel-container end-0 flex min-w-72 select-none flex-col',
'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',
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
@@ -645,7 +847,10 @@ const ReviewPanel: React.FC = () => {
style={{
width: isMobile ? '100%' : panelWidth,
maxWidth: isMobile ? '100%' : `${MAX_REVIEW_PANEL_WIDTH * 100}%`,
position: isMobile ? 'fixed' : 'relative',
position: isMobile ? 'fixed' : isPanelPinned ? 'relative' : 'absolute',
left: !isMobile && !isPanelPinned ? `${panelPosition.x}px` : undefined,
top: !isMobile && !isPanelPinned ? `${panelPosition.y}px` : undefined,
right: isMobile ? 0 : undefined,
paddingTop: `${panelTopInset}px`,
}}
>
@@ -663,17 +868,31 @@ const ReviewPanel: React.FC = () => {
onTouchStart={handleResizeStart}
onKeyDown={handleResizeKeyDown}
/>
<header className='border-base-300 flex h-12 shrink-0 items-center gap-2 border-b px-3'>
<header
className={clsx(
'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}
>
<div className='min-w-0 flex-1'>
<h2 className='truncate text-sm font-semibold'></h2>
<p className='text-base-content/60 truncate text-xs'>
<h2 className='text-sm font-semibold break-words'></h2>
<p className='text-base-content/60 text-xs break-words'>
{bookTitle} · {rows.length} · {bookState.session?.touched_count || 0} · {' '}
{bookState.session?.marked_count || 0}
</p>
</div>
<button
type='button'
className='btn btn-ghost h-8 min-h-8 w-8 rounded-full p-0'
className='btn btn-ghost h-8 min-h-8 w-8 shrink-0 rounded-full p-0'
title={isPanelPinned ? '取消固定:浮动面板不挤开正文' : '固定面板:挤开正文'}
onClick={handleTogglePanelPin}
>
{isPanelPinned ? <PinOff className='h-4 w-4' /> : <Pin className='h-4 w-4' />}
</button>
<button
type='button'
className='btn btn-ghost h-8 min-h-8 w-8 shrink-0 rounded-full p-0'
title={_('Close')}
onClick={() => setPanelVisible(false)}
>
@@ -739,16 +958,16 @@ const ReviewPanel: React.FC = () => {
) : (
<>
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
<div className='text-base-content/60 mb-2 flex items-center justify-between gap-2 text-xs'>
<span className='truncate'>
<div className='text-base-content/60 mb-2 flex flex-wrap items-center justify-between gap-2 text-xs'>
<span className='min-w-0 break-words'>
{selectedRow.id} · {rowTitle(selectedRow)}
</span>
<span className='shrink-0'>
<span className='shrink-0 break-words'>
JP P{selectedRow.ja_p_index} / CN P{selectedRow.cn_p_index}
</span>
</div>
<div
className='prose prose-sm max-w-none select-text leading-8'
className='prose prose-sm max-w-none select-text leading-8 break-words'
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(selectedRow.jp_html) }}
/>
</section>
@@ -758,7 +977,7 @@ const ReviewPanel: React.FC = () => {
<h3 className='text-sm font-semibold'></h3>
</div>
<textarea
className='textarea textarea-bordered eink-bordered min-h-44 rounded-md font-mono text-sm'
className='textarea textarea-bordered eink-bordered min-h-44 min-w-0 rounded-md font-mono text-sm'
value={edit.current_html}
onChange={(event) =>
setEdit((current) => ({ ...current, current_html: event.target.value }))
@@ -767,7 +986,7 @@ const ReviewPanel: React.FC = () => {
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
<div className='text-base-content/60 mb-2 text-xs'></div>
<div
className='prose prose-sm max-w-none select-text leading-7'
className='prose prose-sm max-w-none select-text leading-7 break-words'
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(edit.current_html) }}
/>
</div>
@@ -791,7 +1010,7 @@ const ReviewPanel: React.FC = () => {
</label>
</div>
<div className='grid grid-cols-2 gap-2'>
<div className='grid gap-2 sm:grid-cols-2'>
<label className='grid gap-1 text-xs font-medium'>
<select
@@ -835,7 +1054,7 @@ const ReviewPanel: React.FC = () => {
<label className='grid gap-1 text-xs font-medium'>
<input
className='input input-bordered eink-bordered h-9 rounded-md text-sm'
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
value={edit.tags}
onChange={(event) =>
setEdit((current) => ({ ...current, tags: event.target.value }))
@@ -846,7 +1065,7 @@ const ReviewPanel: React.FC = () => {
<label className='grid gap-1 text-xs font-medium'>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 rounded-md text-sm'
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
value={edit.comment}
onChange={(event) =>
setEdit((current) => ({ ...current, comment: event.target.value }))
@@ -857,7 +1076,7 @@ const ReviewPanel: React.FC = () => {
<label className='grid gap-1 text-xs font-medium'>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 rounded-md text-sm'
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
value={edit.learn_note}
onChange={(event) =>
setEdit((current) => ({ ...current, learn_note: event.target.value }))
@@ -879,7 +1098,7 @@ const ReviewPanel: React.FC = () => {
<section className='border-base-300 grid gap-3 border-t pt-3'>
<h3 className='text-sm font-semibold'>API </h3>
<textarea
className='textarea textarea-bordered eink-bordered min-h-16 rounded-md text-sm'
className='textarea textarea-bordered eink-bordered min-h-16 min-w-0 rounded-md text-sm'
value={retranslateInstruction}
onChange={(event) => setRetranslateInstruction(event.target.value)}
placeholder='额外要求,可留空'
@@ -894,7 +1113,7 @@ const ReviewPanel: React.FC = () => {
{candidateHtml ? (
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
<div
className='prose prose-sm max-w-none select-text leading-7'
className='prose prose-sm max-w-none select-text leading-7 break-words'
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(candidateHtml) }}
/>
<div className='mt-3'>
@@ -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.1`
当前版本:`0.16.2`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
@@ -61,6 +61,8 @@
`0.16.1` 优化 Readest 原生阅读页审校面板:桌面端右侧面板改为停靠布局,打开后压缩正文而不是遮挡右侧文字;移除面板内独立段落列表,把 API 与提示词、术语表放到面板底部,并明确区分标签、本段备注和长期规则建议的用途。
`0.16.2` 恢复 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.1">
<link rel="stylesheet" href="/static/style.css?v=0.16.2">
</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.1</span></h1>
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.16.2</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.1"></script>
<script src="/static/app.js?v=0.16.2"></script>
</body>
</html>
@@ -1,4 +1,4 @@
version = "0.16.1"
version = "0.16.2"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",