Files
readest/apps/readest-app/src/app/reader/components/ReviewPanel.tsx
T
akai 75293bd1a4
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
refactor: harden inline review panel layout
2026-07-09 19:16:52 +08:00

1184 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import clsx from 'clsx';
import DOMPurify from 'dompurify';
import {
Check,
Download,
FileText,
Languages,
MessageSquarePlus,
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';
import { useIsMobileViewport } from '@/hooks/useIsMobileViewport';
import { useTranslation } from '@/hooks/useTranslation';
import { usePanelResize } from '@/hooks/usePanelResize';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
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,
loadReviewGlossary,
retranslateReviewRow,
saveReviewGlossary,
saveReviewGptConfig,
saveReviewRow,
sidecarApi,
switchReviewGlossaryPath,
type ReviewEditPayload,
type ReviewGlossaryEntry,
type ReviewGlossaryPayload,
type ReviewGptConfig,
type ReviewSessionPayload,
} from '@/services/reviewEditorService';
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 DEFAULT_REVIEW_PANEL_WIDTH = 0.32;
const cnHtmlPurifyOptions = {
ALLOWED_TAGS: [
'ruby',
'rb',
'rt',
'rp',
'span',
'mark',
'br',
'em',
'strong',
'b',
'i',
'u',
's',
'sub',
'sup',
],
ALLOWED_ATTR: ['class', 'title'],
ALLOW_DATA_ATTR: false,
};
type GptForm = {
base_url: string;
model: string;
api_key: string;
glossary_path: string;
translation_prompt: string;
format_prompt: string;
character_prompt: string;
};
const emptyEditState: ReviewEditPayload = {
current_html: '',
marked: false,
issue_type: '',
severity: '',
tags: '',
comment: '',
learn_note: '',
};
const emptyGptForm: GptForm = {
base_url: '',
model: '',
api_key: '',
glossary_path: '',
translation_prompt: '',
format_prompt: '',
character_prompt: '',
};
const sanitizeInlineHtml = (html: string) => DOMPurify.sanitize(html || '', cnHtmlPurifyOptions);
const escapeInlineHtml = (value: string) =>
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;
const toGptForm = (config: ReviewGptConfig | null | undefined): GptForm => ({
base_url: config?.base_url || '',
model: config?.model || '',
api_key: '',
glossary_path: config?.glossary_path || '',
translation_prompt:
config?.translation_prompt || config?.prompt_defaults?.translation_prompt || '',
format_prompt: config?.format_prompt || config?.prompt_defaults?.format_prompt || '',
character_prompt: config?.character_prompt || config?.prompt_defaults?.character_prompt || '',
});
const PanelButton = ({
children,
icon,
onClick,
disabled,
variant = 'ghost',
}: {
children: ReactNode;
icon?: ReactNode;
onClick: () => void;
disabled?: boolean;
variant?: 'primary' | 'ghost';
}) => (
<button
type='button'
onClick={onClick}
disabled={disabled}
className={clsx(
'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',
)}
>
{icon}
{children}
</button>
);
function GptConfigPanel({
baseUrl,
sessionId,
form,
setForm,
onSaved,
}: {
baseUrl: string;
sessionId: string | null | undefined;
form: GptForm;
setForm: React.Dispatch<React.SetStateAction<GptForm>>;
onSaved: (config: ReviewGptConfig) => void;
}) {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState('');
const save = async () => {
setSaving(true);
setMessage('');
try {
const config = await saveReviewGptConfig(baseUrl, sessionId, {
base_url: form.base_url || undefined,
model: form.model || undefined,
api_key: form.api_key,
glossary_path: form.glossary_path,
translation_prompt: form.translation_prompt,
format_prompt: form.format_prompt,
character_prompt: form.character_prompt,
});
setForm((current) => ({ ...current, api_key: '' }));
onSaved(config);
setMessage('已保存 API、提示词与术语表路径。');
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setSaving(false);
}
};
return (
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border'>
<button
type='button'
className='hover:bg-base-200 flex w-full items-center justify-between rounded-md px-3 py-2 text-start'
onClick={() => setOpen((value) => !value)}
>
<span className='text-sm font-semibold'>API 与提示词</span>
<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'>
<label className='grid gap-1 text-xs font-medium'>
Base URL
<input
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 }))
}
placeholder='https://api.openai.com/v1'
/>
</label>
<label className='grid gap-1 text-xs font-medium'>
模型
<input
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 }))
}
placeholder='gpt-4.1'
/>
</label>
</div>
<label className='grid gap-1 text-xs font-medium'>
API Key
<input
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
value={form.api_key}
type='password'
onChange={(event) =>
setForm((current) => ({ ...current, api_key: event.target.value }))
}
placeholder='留空表示沿用已保存密钥'
/>
</label>
<label className='grid gap-1 text-xs font-medium'>
术语表路径
<input
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 }))
}
placeholder='mingcibiao.json'
/>
</label>
<label className='grid gap-1 text-xs font-medium'>
翻译内容提示词
<textarea
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 }))
}
/>
</label>
<label className='grid gap-1 text-xs font-medium'>
格式与标点提示词
<textarea
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 }))
}
/>
</label>
<label className='grid gap-1 text-xs font-medium'>
角色状态与口吻提示词
<textarea
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 }))
}
/>
</label>
<div className='flex flex-wrap items-center gap-2'>
<PanelButton
onClick={save}
disabled={saving || !baseUrl}
icon={<Save className='h-4 w-4' />}
variant='primary'
>
{saving ? '保存中' : '保存设置'}
</PanelButton>
{message ? <span className='text-base-content/70 text-xs'>{message}</span> : null}
</div>
</div>
) : null}
</section>
);
}
function GlossaryPanel({
baseUrl,
sessionId,
path,
setPath,
}: {
baseUrl: string;
sessionId: string | null | undefined;
path: string;
setPath: (path: string) => void;
}) {
const [open, setOpen] = useState(false);
const [entries, setEntries] = useState<ReviewGlossaryEntry[]>([]);
const [loaded, setLoaded] = useState(false);
const [dirty, setDirty] = useState(false);
const [query, setQuery] = useState('');
const [message, setMessage] = useState('未读取术语表');
const [busy, setBusy] = useState(false);
const filteredEntries = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return entries.map((entry, index) => ({ entry, index }));
return entries
.map((entry, index) => ({ entry, index }))
.filter(
({ entry }) =>
entry.source.toLowerCase().includes(needle) ||
entry.target.toLowerCase().includes(needle),
);
}, [entries, query]);
const applyPayload = (payload: ReviewGlossaryPayload) => {
setEntries(payload.entries || []);
setLoaded(true);
setDirty(false);
if (payload.path) setPath(payload.path);
setMessage(
`当前术语表:${payload.path || path || '未设置'} · ${payload.count ?? payload.entries?.length ?? 0} 条`,
);
};
const load = async (forcePath = false) => {
setBusy(true);
try {
const payload =
forcePath && path
? await switchReviewGlossaryPath(baseUrl, sessionId, path)
: await loadReviewGlossary(baseUrl, sessionId);
applyPayload(payload);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
};
const save = async () => {
setBusy(true);
try {
const payload = await saveReviewGlossary(baseUrl, sessionId, path, entries);
applyPayload(payload);
setMessage('术语表已保存,下一次重翻会实时使用最新术语。');
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
};
const updateEntry = (index: number, patch: Partial<ReviewGlossaryEntry>) => {
setEntries((current) =>
current.map((entry, itemIndex) => (itemIndex === index ? { ...entry, ...patch } : entry)),
);
setDirty(true);
};
const removeEntry = (index: number) => {
setEntries((current) => current.filter((_entry, itemIndex) => itemIndex !== index));
setDirty(true);
};
const addEntry = () => {
setOpen(true);
setLoaded(true);
setQuery('');
setEntries((current) => [{ source: '', target: '' }, ...current]);
setDirty(true);
};
return (
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border'>
<button
type='button'
className='hover:bg-base-200 flex w-full items-center justify-between rounded-md px-3 py-2 text-start'
onClick={() => {
const nextOpen = !open;
setOpen(nextOpen);
if (nextOpen && !loaded) void load(false);
}}
>
<span className='text-sm font-semibold'>术语表</span>
<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'>
<input
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'
/>
<div className='flex flex-wrap gap-2'>
<PanelButton
onClick={() => void load(true)}
disabled={busy}
icon={<RefreshCw className='h-4 w-4' />}
>
读取
</PanelButton>
<PanelButton onClick={addEntry} disabled={busy}>
新增术语
</PanelButton>
<PanelButton
onClick={() => void save()}
disabled={busy || !loaded || !dirty}
icon={<Save className='h-4 w-4' />}
variant='primary'
>
保存术语表
</PanelButton>
</div>
<p className='text-base-content/60 text-xs'>{message}</p>
<input
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder='搜索术语或译名'
/>
</div>
<div className='grid max-h-72 gap-2 overflow-auto'>
{!loaded ? (
<p className='text-base-content/60 p-2 text-sm'>展开后会读取术语表。</p>
) : filteredEntries.length === 0 ? (
<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 gap-2'>
<input
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
value={entry.source}
onChange={(event) => updateEntry(index, { source: event.target.value })}
placeholder='原文'
/>
<input
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
value={entry.target}
onChange={(event) => updateEntry(index, { target: event.target.value })}
placeholder='译名'
/>
<button
type='button'
className='btn btn-ghost eink-bordered h-9 min-h-9 rounded-md px-2 text-xs'
onClick={() => removeEntry(index)}
>
删除
</button>
</div>
))
)}
</div>
</div>
) : null}
</section>
);
}
const ReviewPanel: React.FC = () => {
const _ = useTranslation();
const { appService } = useEnv();
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 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) =>
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,
);
const [edit, setEdit] = useState<ReviewEditPayload>(emptyEditState);
const [gptForm, setGptForm] = useState<GptForm>(emptyGptForm);
const [candidateHtml, setCandidateHtml] = useState('');
const [retranslateInstruction, setRetranslateInstruction] = useState('');
const [message, setMessage] = useState('');
const [exportInfo, setExportInfo] = useState('');
const [saving, setSaving] = useState(false);
const [retranslating, setRetranslating] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const isMobile = useIsMobileViewport();
const panelTopInset = getPanelTopInset({
isMobile,
isFullHeightInMobile: true,
systemUIVisible,
statusBarHeight,
safeAreaInsets,
});
const isDocked = !isMobile && isPanelPinned;
const panelRef = useRef<HTMLElement>(null);
const cnEditorRef = useRef<HTMLTextAreaElement>(null);
const restoreTimeoutRef = useRef<number | null>(null);
const selectedRow = useMemo(
() => bookState?.rows.find((row) => row.id === bookState.selectedRowId) || null,
[bookState?.rows, bookState?.selectedRowId],
);
const canUseRowActions = Boolean(selectedRow && bookState?.baseUrl);
const bookTitle = activeBookKey
? getBookData(activeBookKey)?.book?.title || selectedRow?.document_title || '审校'
: '审校';
useEffect(() => {
setGptForm(toGptForm(bookState?.gptConfig));
}, [bookState?.gptConfig]);
useEffect(() => {
if (!selectedRow) {
setEdit(emptyEditState);
setCandidateHtml('');
setRetranslateInstruction('');
return;
}
setEdit({
current_html: selectedRow.current_html || selectedRow.cn_html || '',
marked: Boolean(selectedRow.marked),
issue_type: selectedRow.issue_type || '',
severity: selectedRow.severity || '',
tags: selectedRow.tags || '',
comment: selectedRow.comment || '',
learn_note: selectedRow.learn_note || '',
});
setCandidateHtml('');
setRetranslateInstruction('');
setPreviewOpen(false);
}, [selectedRow]);
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 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 {
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,
handleResizeKeyDown: handleDockedResizeKeyDown,
} = usePanelResize({
side: 'end',
minWidth: MIN_REVIEW_PANEL_WIDTH,
maxWidth: MAX_REVIEW_PANEL_WIDTH,
getWidth: () => panelWidth,
onResize: setPanelWidth,
});
const handleResizeKeyDown = (event: React.KeyboardEvent) => {
if (floatingPanelEnabled) {
handleFloatingWidthKeyDown(event);
} else {
handleDockedResizeKeyDown(event);
}
};
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(() => {
return () => {
if (restoreTimeoutRef.current) window.clearTimeout(restoreTimeoutRef.current);
};
}, []);
if (!isPanelVisible || !activeBookKey || !bookState) return null;
const baseUrl = bookState.baseUrl;
const sessionId = bookState.sessionId;
const rows = bookState.rows;
const saveCurrentRow = async () => {
if (!selectedRow || !baseUrl) return;
setSaving(true);
setMessage('');
try {
const payload = await saveReviewRow(baseUrl, sessionId, selectedRow.id, edit);
const savedHtml = payload.current_html || sanitizeInlineHtml(edit.current_html);
const nextRow = {
...selectedRow,
...edit,
current_html: savedHtml,
edited: savedHtml !== selectedRow.cn_html,
updated_at: payload.updated_at || selectedRow.updated_at,
};
updateRow(activeBookKey, nextRow);
setEdit((current) => ({ ...current, current_html: savedHtml }));
const session = await sidecarApi<ReviewSessionPayload>(
baseUrl,
'/api/session',
{},
sessionId,
);
setBookData(activeBookKey, { session });
setMessage(`已保存 ${selectedRow.id}`);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setSaving(false);
}
};
const retranslateCurrentRow = async () => {
if (!selectedRow || !baseUrl) return;
setRetranslating(true);
setMessage('');
setCandidateHtml('');
try {
const payload = await retranslateReviewRow(
baseUrl,
sessionId,
selectedRow.id,
retranslateInstruction,
);
setCandidateHtml(payload.candidate_html || '');
setMessage(`重翻完成,命中术语 ${payload.glossary_matches?.length || 0} 条。`);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setRetranslating(false);
}
};
const generateFeedback = async () => {
if (!baseUrl) return;
setMessage('');
try {
const payload = await generateReviewFeedback(baseUrl, sessionId);
setMessage(`反馈已生成:${payload.feedback_md || payload.feedback_jsonl || ''}`);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
}
};
const exportEpub = async () => {
if (!baseUrl) return;
setExportInfo('');
setMessage('');
try {
const payload = await exportReviewedEpub(baseUrl, sessionId);
const downloadUrl = payload.download_url
? new URL(payload.download_url, baseUrl).toString()
: '';
setExportInfo(downloadUrl || payload.output || '');
setMessage(`已导出:${payload.output || downloadUrl}`);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
}
};
const insertIntoCurrentHtml = (
buildInsert: (selectedText: string) => {
html: string;
selectStart?: number;
selectEnd?: number;
} | null,
) => {
const editor = cnEditorRef.current;
const source = edit.current_html;
const selectionStart = editor?.selectionStart ?? source.length;
const selectionEnd = editor?.selectionEnd ?? source.length;
const selectedText = source.slice(selectionStart, selectionEnd);
const insert = buildInsert(selectedText);
if (insert === null) return;
const nextHtml = `${source.slice(0, selectionStart)}${insert.html}${source.slice(selectionEnd)}`;
setEdit((current) => ({ ...current, current_html: nextHtml }));
setPreviewOpen(true);
requestAnimationFrame(() => {
editor?.focus();
const selectStart = selectionStart + (insert.selectStart ?? insert.html.length);
const selectEnd = selectionStart + (insert.selectEnd ?? insert.html.length);
editor?.setSelectionRange(selectStart, selectEnd);
});
};
const insertRuby = () => {
insertIntoCurrentHtml((selectedText) => {
const baseText = selectedText || '文字';
const rubyText = '注音';
const baseHtml = escapeInlineHtml(baseText);
const prefix = `<ruby><rb>${baseHtml}</rb><rt>`;
const html = `${prefix}${rubyText}</rt></ruby>`;
if (selectedText) {
return {
html,
selectStart: prefix.length,
selectEnd: prefix.length + rubyText.length,
};
}
const baseStart = '<ruby><rb>'.length;
return {
html,
selectStart: baseStart,
selectEnd: baseStart + baseText.length,
};
});
};
const insertAnnotation = () => {
insertIntoCurrentHtml((selectedText) => {
const noteText = '注释';
const baseHtml = selectedText ? escapeInlineHtml(selectedText) : '';
const prefix = `${baseHtml}<span class="review-annotation">(注:`;
const html = `${prefix}${noteText}</span>`;
return {
html,
selectStart: prefix.length,
selectEnd: prefix.length + noteText.length,
};
});
};
return (
<>
{isMobile && (
<Overlay
className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/50 sm:bg-black/20')}
onDismiss={() => setPanelVisible(false)}
/>
)}
<aside
ref={panelRef}
className={clsx(
'review-panel-container flex min-w-72 select-none flex-col',
isDocked && 'shrink-0',
'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',
isDocked && 'border-base-300 border-s',
!isDocked && viewSettings?.isEink && 'border-base-content border-s',
isMobile && 'bottom-0 max-h-[92vh] rounded-t-2xl',
)}
role='group'
aria-label='审校'
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,
right: isMobile ? 0 : undefined,
paddingTop: `${panelTopInset}px`,
}}
>
<div
className={clsx(
'drag-bar absolute -start-2 top-0 h-full w-0.5 cursor-col-resize bg-transparent p-2',
isMobile && 'hidden',
)}
role='slider'
tabIndex={0}
aria-label='调整审校面板宽度'
aria-orientation='horizontal'
aria-valuenow={parseFloat(panelWidth)}
onPointerDown={floatingPanelEnabled ? handleFloatingWidthPointerDown : undefined}
onMouseDown={floatingPanelEnabled ? undefined : handleDockedResizeStart}
onTouchStart={floatingPanelEnabled ? undefined : handleDockedResizeStart}
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)}
onPointerDown={handleFloatingHeightPointerDown}
onKeyDown={handleFloatingHeightKeyDown}
/>
) : null}
<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',
)}
onPointerDown={handlePanelDragStart}
>
<div className='min-w-0 flex-1'>
<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 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)}
>
<X className='h-4 w-4' />
</button>
</header>
<div className='min-h-0 flex-1 overflow-auto p-3'>
<div className='grid gap-3'>
{bookState.loading ? (
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
正在启动审校器并读取当前 EPUB……
</div>
) : null}
{bookState.error ? (
<div className='eink-bordered border-error bg-base-100 text-error rounded-md border p-3 text-sm'>
{bookState.error}
</div>
) : null}
<div className='flex flex-wrap gap-2'>
<PanelButton
onClick={generateFeedback}
icon={<FileText className='h-4 w-4' />}
disabled={!baseUrl}
>
生成反馈
</PanelButton>
<PanelButton
onClick={exportEpub}
icon={<Download className='h-4 w-4' />}
disabled={!baseUrl}
variant='primary'
>
导出 EPUB
</PanelButton>
</div>
{message ? (
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
{message}
</div>
) : null}
{exportInfo ? (
<button
type='button'
className='eink-bordered border-base-300 bg-base-100 hover:bg-base-200 rounded-md border p-3 text-start text-sm'
onClick={() => openExternalUrl(exportInfo)}
>
<span className='font-semibold'>打开导出文件</span>
<span className='text-base-content/60 mt-1 block break-all text-xs'>
{exportInfo}
</span>
</button>
) : null}
{!selectedRow ? (
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-5 text-center text-sm'>
{rows.length
? '请选择一段开始审校。'
: '当前 EPUB 没有识别到可审校的中日双语段落。'}
</div>
) : (
<>
<section className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
<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 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 break-words'
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(selectedRow.jp_html) }}
/>
</section>
<section className='grid gap-3'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<h3 className='text-sm font-semibold'>修改中文译文</h3>
<div className='flex flex-wrap justify-end gap-2'>
<PanelButton
onClick={insertRuby}
disabled={!canUseRowActions}
icon={<Languages className='h-4 w-4' />}
>
注音
</PanelButton>
<PanelButton
onClick={insertAnnotation}
disabled={!canUseRowActions}
icon={<MessageSquarePlus className='h-4 w-4' />}
>
注释
</PanelButton>
<PanelButton
onClick={saveCurrentRow}
disabled={!canUseRowActions || saving}
icon={<Save className='h-4 w-4' />}
variant='primary'
>
{saving ? '保存中' : '保存'}
</PanelButton>
</div>
</div>
<textarea
ref={cnEditorRef}
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 }))
}
/>
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3'>
<button
type='button'
className='flex w-full items-center justify-between gap-2 text-start'
onClick={() => setPreviewOpen((value) => !value)}
>
<span className='text-base-content/60 text-xs'>译文预览</span>
<span className='text-base-content/60 text-xs'>
{previewOpen ? '收起' : '展开'}
</span>
</button>
{previewOpen ? (
<div
className='prose prose-sm mt-2 max-w-none select-text leading-7 break-words'
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(edit.current_html) }}
/>
) : null}
</div>
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<h4 className='text-sm font-semibold'>问题记录</h4>
<p className='text-base-content/60 mt-1 text-xs'>
标签用于归类,备注只写本段,长期规则建议只写可复用口径。
</p>
</div>
<label className='flex shrink-0 items-center gap-2 text-xs'>
<input
type='checkbox'
className='checkbox checkbox-sm'
checked={edit.marked}
onChange={(event) =>
setEdit((current) => ({ ...current, marked: event.target.checked }))
}
/>
纳入反馈
</label>
</div>
<div className='grid gap-2 sm:grid-cols-2'>
<label className='grid gap-1 text-xs font-medium'>
问题类型
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
value={edit.issue_type}
onChange={(event) =>
setEdit((current) => ({
...current,
issue_type: event.target.value,
}))
}
>
<option value=''>未选择</option>
<option value='误译'>误译</option>
<option value='漏译'>漏译</option>
<option value='术语不一致'>术语不一致</option>
<option value='人名/地名问题'>人名/地名问题</option>
<option value='翻译腔'>翻译腔</option>
<option value='角色口吻不对'>角色口吻不对</option>
<option value='语序不顺'>语序不顺</option>
<option value='标点/格式'>标点/格式</option>
<option value='其他'>其他</option>
</select>
</label>
<label className='grid gap-1 text-xs font-medium'>
严重程度
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
value={edit.severity}
onChange={(event) =>
setEdit((current) => ({ ...current, severity: event.target.value }))
}
>
<option value=''>未选择</option>
<option value='轻微'>轻微</option>
<option value='中等'>中等</option>
<option value='严重'>严重</option>
</select>
</label>
</div>
<label className='grid gap-1 text-xs font-medium'>
标签
<input
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 }))
}
placeholder='检索归类:术语、口吻、格式,可用逗号分隔'
/>
</label>
<label className='grid gap-1 text-xs font-medium'>
本段备注
<textarea
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 }))
}
placeholder='只记录当前段哪里有问题、为什么这样改'
/>
</label>
<label className='grid gap-1 text-xs font-medium'>
长期规则建议
<textarea
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 }))
}
placeholder='只写后续段落也应遵守的规则或口吻偏好'
/>
</label>
</section>
</section>
<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 min-w-0 rounded-md text-sm'
value={retranslateInstruction}
onChange={(event) => setRetranslateInstruction(event.target.value)}
placeholder='额外要求,可留空'
/>
<PanelButton
onClick={retranslateCurrentRow}
disabled={!canUseRowActions || retranslating}
icon={<Sparkles className='h-4 w-4' />}
>
{retranslating ? '重翻中' : '重翻本段'}
</PanelButton>
{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 break-words'
dangerouslySetInnerHTML={{ __html: sanitizeInlineHtml(candidateHtml) }}
/>
<div className='mt-3'>
<PanelButton
onClick={() =>
setEdit((current) => ({ ...current, current_html: candidateHtml }))
}
icon={<Check className='h-4 w-4' />}
variant='primary'
>
应用候选到编辑框
</PanelButton>
</div>
</div>
) : null}
</section>
</>
)}
<GptConfigPanel
baseUrl={baseUrl}
sessionId={sessionId}
form={gptForm}
setForm={setGptForm}
onSaved={(config) => setBookData(activeBookKey, { gptConfig: config })}
/>
<GlossaryPanel
baseUrl={baseUrl}
sessionId={sessionId}
path={gptForm.glossary_path}
setPath={(path) => setGptForm((current) => ({ ...current, glossary_path: path }))}
/>
</div>
</div>
</aside>
</>
);
};
export default ReviewPanel;