|
|
|
@@ -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'>
|
|
|
|
|