refactor(proofread): refactor UI and i18n for proofread tool (#2783)

This commit is contained in:
Huang Xin
2025-12-24 21:46:33 +08:00
committed by GitHub
parent 3abef6ea75
commit 6984393ed1
54 changed files with 3022 additions and 2965 deletions
@@ -8,7 +8,7 @@ import { RiDeleteBinLine } from 'react-icons/ri';
import { BsTranslate } from 'react-icons/bs';
import { TbHexagonLetterD } from 'react-icons/tb';
import { FaHeadphones } from 'react-icons/fa6';
import { MdBuildCircle } from 'react-icons/md';
import { IoIosBuild } from 'react-icons/io';
import * as CFI from 'foliate-js/epubcfi.js';
import { Overlayer } from 'foliate-js/overlayer.js';
@@ -30,15 +30,13 @@ import { findTocItemBS } from '@/utils/toc';
import { throttle } from '@/utils/throttle';
import { runSimpleCC } from '@/utils/simplecc';
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import { addReplacementRule } from '@/services/transformers/replacement';
import { getWordCount } from '@/utils/word';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
import TranslatorPopup from './TranslatorPopup';
import useShortcuts from '@/hooks/useShortcuts';
import ReplacementOptions from './ReplacementOptions';
import { isWordLimitExceeded } from '@/utils/wordLimit';
import ProofreadPopup from './ProofreadPopup';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -64,11 +62,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
const [showWikipediaPopup, setShowWikipediaPopup] = useState(false);
const [showDeepLPopup, setShowDeepLPopup] = useState(false);
const [showReplacementOptions, setShowReplacementOptions] = useState(false);
const [showProofreadPopup, setShowProofreadPopup] = useState(false);
const [trianglePosition, setTrianglePosition] = useState<Position>();
const [annotPopupPosition, setAnnotPopupPosition] = useState<Position>();
const [dictPopupPosition, setDictPopupPosition] = useState<Position>();
const [translatorPopupPosition, setTranslatorPopupPosition] = useState<Position>();
const [proofreadPopupPosition, setProofreadPopupPosition] = useState<Position>();
const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false);
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(
@@ -85,6 +84,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const dictPopupHeight = Math.min(300, maxHeight);
const transPopupWidth = Math.min(480, maxWidth);
const transPopupHeight = Math.min(265, maxHeight);
const proofreadPopupWidth = Math.min(440, maxWidth);
const proofreadPopupHeight = Math.min(200, maxHeight);
const annotPopupWidth = Math.min(useResponsiveSize(300), maxWidth);
const annotPopupHeight = useResponsiveSize(44);
const androidSelectionHandlerHeight = 0;
@@ -121,10 +122,18 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
transPopupHeight,
popupPadding,
);
const proofreadPopupPos = getPopupPosition(
triangPos,
rect,
proofreadPopupWidth,
proofreadPopupHeight,
popupPadding,
);
if (triangPos.point.x == 0 || triangPos.point.y == 0) return;
setAnnotPopupPosition(annotPopupPos);
setDictPopupPosition(dictPopupPos);
setTranslatorPopupPosition(transPopupPos);
setProofreadPopupPosition(proofreadPopupPos);
setTrianglePosition(triangPos);
}, [
selection,
@@ -138,6 +147,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
dictPopupHeight,
transPopupWidth,
transPopupHeight,
proofreadPopupWidth,
proofreadPopupHeight,
]);
useEffect(() => {
@@ -156,7 +167,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
setShowDeepLPopup(false);
setShowReplacementOptions(false);
setShowProofreadPopup(false);
}, 500),
[],
);
@@ -213,7 +224,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const range = sel.getRangeAt(0);
const text = sel.toString();
if (text.trim()) {
setSelection({ key: bookKey, text, range, index });
setSelection({ key: bookKey, text, range, index, cfi: view?.getCFI(index, range) });
// Show translation popup preferentially for PDF right-click
setShowAnnotPopup(false);
setShowDeepLPopup(true);
@@ -270,7 +281,14 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
);
const annotation = annotations.find((annotation) => annotation.cfi === cfi);
if (!annotation) return;
const selection = { key: bookKey, annotated: true, text: annotation.text ?? '', range, index };
const selection = {
key: bookKey,
annotated: true,
text: annotation.text ?? '',
cfi: view?.getCFI(index, range),
range,
index,
};
setSelectedStyle(annotation.style!);
setSelectedColor(annotation.color!);
setSelection(selection);
@@ -280,16 +298,28 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation });
useEffect(() => {
handleShowPopup(showAnnotPopup || showWiktionaryPopup || showWikipediaPopup || showDeepLPopup);
handleShowPopup(
showAnnotPopup ||
showWiktionaryPopup ||
showWikipediaPopup ||
showDeepLPopup ||
showProofreadPopup,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showAnnotPopup, showWiktionaryPopup, showWikipediaPopup, showDeepLPopup]);
}, [showAnnotPopup, showWiktionaryPopup, showWikipediaPopup, showDeepLPopup, showProofreadPopup]);
// When popups are visible, update their positions on scroll events
useEffect(() => {
const view = getView(bookKey);
if (!view?.renderer) return;
const onScroll = () => {
if (showAnnotPopup || showWiktionaryPopup || showWikipediaPopup || showDeepLPopup) {
if (
showAnnotPopup ||
showWiktionaryPopup ||
showWikipediaPopup ||
showDeepLPopup ||
showProofreadPopup
) {
repositionPopups();
}
};
@@ -304,6 +334,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
showWiktionaryPopup,
showWikipediaPopup,
showDeepLPopup,
showProofreadPopup,
repositionPopups,
]);
@@ -347,10 +378,18 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
transPopupHeight,
popupPadding,
);
const proofreadPopupPos = getPopupPosition(
triangPos,
rect,
proofreadPopupWidth,
proofreadPopupHeight,
popupPadding,
);
if (triangPos.point.x == 0 || triangPos.point.y == 0) return;
setAnnotPopupPosition(annotPopupPos);
setDictPopupPosition(dictPopupPos);
setTranslatorPopupPosition(transPopupPos);
setProofreadPopupPosition(proofreadPopupPos);
setTrianglePosition(triangPos);
handleShowAnnotPopup();
}
@@ -389,13 +428,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowWikipediaPopup(false);
};
const handleCopy = (copyToNotebook = true) => {
const handleCopy = (copyToNotebook = true, dismissPopup = true) => {
if (!selection || !selection.text) return;
setTimeout(() => {
// Delay to ensure it won't be overridden by system clipboard actions
navigator.clipboard?.writeText(selection.text);
}, 100);
handleDismissPopupAndSelection();
if (dismissPopup) {
handleDismissPopupAndSelection();
}
if (!copyToNotebook) return;
@@ -474,7 +515,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
} else {
annotations.push(annotation);
views.forEach((view) => view?.addAnnotation(annotation));
setSelection({ ...selection, annotated: true });
setSelection({ ...selection, cfi, annotated: true });
}
const updatedConfig = updateBooknotes(bookKey, annotations);
@@ -529,286 +570,19 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range });
};
// Import type for ReplacementConfig
type ReplacementConfig = {
replacementText: string;
caseSensitive: boolean;
scope: 'once' | 'book' | 'library';
};
// Helper to check if selected text is a whole word (has word boundaries on both sides)
// Updated to be more lenient: allows phrases and lines, only prevents partial word matches
const isWholeWord = (range: Range, selectedText: string): boolean => {
try {
if (!selectedText || selectedText.trim().length === 0) return false;
// Verify the selection contains word characters
const hasWordCharInSelection = /[a-zA-Z0-9_]/.test(selectedText);
if (!hasWordCharInSelection) {
return false;
}
// If the selection contains spaces, punctuation, or multiple words, it's a phrase
// Phrases (including lines with quotes) are always allowed for single-instance replacements
const hasSpaces = /\s/.test(selectedText);
const hasPunctuation = /[^\w\s]/.test(selectedText);
const isPhrase = hasSpaces || hasPunctuation;
// Also allow selections that start or end with punctuation (e.g., "'tis", "off;", "look,")
// These are valid selections where the user intentionally includes punctuation
const startsWithPunctuation = /^[^\w\s]/.test(selectedText);
const endsWithPunctuation = /[^\w\s]$/.test(selectedText);
const hasBoundaryPunctuation = startsWithPunctuation || endsWithPunctuation;
if (isPhrase || hasBoundaryPunctuation) {
// For phrases or selections with boundary punctuation, we allow them
// The only thing we want to prevent is selecting "and" inside "England"
return true;
}
// For single words, check boundaries to prevent partial word matches
// Get characters immediately before and after the selection
let charBefore = '';
let charAfter = '';
try {
// Get character before
const startNode = range.startContainer;
if (startNode.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
const textNode = startNode as Text;
charBefore = textNode.textContent?.charAt(range.startOffset - 1) || '';
} else if (startNode.nodeType === Node.TEXT_NODE && range.startOffset === 0) {
// Check previous sibling text node
let prevSibling = startNode.previousSibling;
while (prevSibling && prevSibling.nodeType !== Node.TEXT_NODE) {
prevSibling = prevSibling.previousSibling;
}
if (prevSibling && prevSibling.nodeType === Node.TEXT_NODE) {
const prevText = (prevSibling as Text).textContent || '';
charBefore = prevText.charAt(prevText.length - 1);
}
}
// Get character after
const endNode = range.endContainer;
if (endNode.nodeType === Node.TEXT_NODE) {
const textNode = endNode as Text;
const textContent = textNode.textContent || '';
if (range.endOffset < textContent.length) {
charAfter = textContent.charAt(range.endOffset);
} else {
// Check next sibling text node
let nextSibling = textNode.nextSibling;
while (nextSibling && nextSibling.nodeType !== Node.TEXT_NODE) {
nextSibling = nextSibling.nextSibling;
}
if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE) {
const nextText = (nextSibling as Text).textContent || '';
charAfter = nextText.charAt(0);
}
}
}
} catch (e) {
// If we can't determine boundaries for a single word, be lenient
// This handles edge cases with complex HTML
console.warn('[isWholeWord] Error checking boundaries:', e);
return true; // Allow if we can't verify (better to allow than reject valid selections)
}
// Word characters are: letters, digits, and underscore [a-zA-Z0-9_]
const isWordChar = (char: string) => /[a-zA-Z0-9_]/.test(char);
// Check boundaries for single words
// Empty means we're at start/end of text (valid boundary)
const hasBoundaryBefore = !charBefore || !isWordChar(charBefore);
const hasBoundaryAfter = !charAfter || !isWordChar(charAfter);
const isValid = hasBoundaryBefore && hasBoundaryAfter;
if (!isValid) {
console.log('[isWholeWord] Not a whole word:', {
selectedText,
charBefore: charBefore || '(start)',
charAfter: charAfter || '(end)',
hasBoundaryBefore,
hasBoundaryAfter,
});
}
return isValid;
} catch (e) {
console.warn('Failed to check whole word:', e);
// On error, be lenient - allow selections with word characters
// This prevents false rejections for complex selections (quotes, multi-node, etc.)
return /[a-zA-Z0-9_]/.test(selectedText);
}
};
// Helper to count which occurrence of a pattern was selected (using whole-word matching)
const getOccurrenceIndex = (range: Range, pattern: string): number => {
try {
const doc = range.startContainer.ownerDocument;
if (!doc || !doc.body) return 0;
// Create a range from start of body to start of selection
const beforeRange = doc.createRange();
beforeRange.setStart(doc.body, 0);
beforeRange.setEnd(range.startContainer, range.startOffset);
// Get text before selection and count occurrences using whole-word matching
const textBefore = beforeRange.toString();
// Escape pattern and add word boundaries for whole-word matching
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wholeWordPattern = `\\b${escapedPattern}\\b`;
const regex = new RegExp(wholeWordPattern, 'g');
const matches = textBefore.match(regex);
return matches ? matches.length : 0;
} catch (e) {
console.warn('Failed to get occurrence index:', e);
return 0;
}
};
const handleReplacementConfirm = async (config: ReplacementConfig) => {
const handleShowProofreadOptions = () => {
if (!selection || !selection.text) return;
setShowAnnotPopup(false);
setShowProofreadPopup(true);
const { replacementText, caseSensitive, scope } = config;
console.log('Replacement confirmed:', {
originalText: selection.text,
replacementText,
caseSensitive,
scope,
});
try {
if (scope === 'once') {
// For single-instance: direct DOM modification + persistent rule
const range = selection.range;
if (range) {
// Validate that the selection is a whole word
// Single-instance replacements only work on whole words to prevent
// replacing substrings inside larger words (e.g., "and" in "England")
const isValidWholeWord = isWholeWord(range, selection.text);
if (!isValidWholeWord) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: `Cannot replace "${selection.text}" - please select a complete word. Partial word selections (like "and" in "England" or "errand") are not supported.`,
timeout: 5000,
});
return;
}
// Get which occurrence this is BEFORE modifying the DOM
// Use whole-word matching to count occurrences correctly
const occurrenceIndex = getOccurrenceIndex(range, selection.text);
const sectionHref = progress?.sectionHref;
// Directly modify DOM for immediate effect
// Note: createTextNode automatically escapes HTML entities, so angle brackets will be preserved
range.deleteContents();
const textNode = document.createTextNode(replacementText);
range.insertNode(textNode);
// Create rule with occurrence tracking for persistence
await addReplacementRule(
envConfig,
bookKey,
{
pattern: selection.text,
replacement: replacementText,
isRegex: false,
enabled: true,
caseSensitive,
singleInstance: true,
sectionHref,
occurrenceIndex,
},
'single',
);
eventDispatcher.dispatch('toast', {
type: 'success',
message: 'Replacement applied! Will persist on refresh.',
timeout: 3000,
});
setShowReplacementOptions(false);
handleDismissPopupAndSelection();
}
} else {
// For book-wide and global: use the transformer approach
const backendScope = scope === 'book' ? 'book' : 'global';
const range = selection.range;
const isValidWholeWord = range ? isWholeWord(range, selection.text) : false;
if (!isValidWholeWord) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: `Cannot replace "${selection.text}" - please select a complete word. Partial word selections (like "and" in "England" or "errand") are not supported.`,
timeout: 5000,
});
return;
}
await addReplacementRule(
envConfig,
bookKey,
{
pattern: selection.text,
replacement: replacementText,
isRegex: false,
enabled: true,
caseSensitive,
singleInstance: false,
wholeWord: true,
},
backendScope as 'book' | 'global',
);
const scopeLabels = {
book: 'this book',
library: 'your library',
};
eventDispatcher.dispatch('toast', {
type: 'success',
message: `Replacement applied to ${scopeLabels[scope]}! Reloading...`,
timeout: 3000,
});
setShowReplacementOptions(false);
handleDismissPopupAndSelection();
// Reload the book view to apply the replacement
const { recreateViewer } = useReaderStore.getState();
await recreateViewer(envConfig, bookKey);
}
} catch (error) {
console.error('Failed to apply replacement:', error);
eventDispatcher.dispatch('toast', {
type: 'error',
message: 'Failed to apply replacement. Please try again.',
timeout: 3000,
});
}
};
const handleShowReplacementOptions = () => {
if (!selection || !selection.text) {
return;
}
if (isWordLimitExceeded(selection.text)) {
if (getWordCount(selection.text) > 30) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: 'Word limit exceeded. Please select 30 words or fewer.',
message: _('Word limit of 30 words exceeded.'),
timeout: 3000,
});
return;
}
setShowReplacementOptions(!showReplacementOptions);
};
// Keyboard shortcuts: trigger actions only if there's an active selection and popup hidden
@@ -827,7 +601,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
handleSearch();
},
onCopySelection: () => {
handleCopy(false);
handleCopy(false, false);
},
onTranslateSelection: () => {
handleTranslation();
@@ -961,11 +735,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
disabled: bookData.book?.format === 'PDF',
},
{
tooltipText: 'Text Replacement',
Icon: MdBuildCircle,
onClick: handleShowReplacementOptions,
tooltipText: _('Proofread'),
Icon: IoIosBuild,
onClick: handleShowProofreadOptions,
disabled: bookData.book?.format !== 'EPUB',
visible: false,
},
];
@@ -1019,20 +792,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onDismiss={handleDismissPopupAndSelection}
/>
)}
{showReplacementOptions && trianglePosition && annotPopupPosition && (
<ReplacementOptions
isVertical={viewSettings.vertical}
style={{
height: 'auto',
left: `${annotPopupPosition.point.x}px`,
top: `${
annotPopupPosition.point.y +
(annotPopupHeight + 16) * (trianglePosition.dir === 'up' ? -1 : 1)
}px`,
}}
selectedText={selection?.text || ''}
onConfirm={handleReplacementConfirm}
onClose={() => setShowReplacementOptions(false)}
{showProofreadPopup && trianglePosition && proofreadPopupPosition && selection && (
<ProofreadPopup
bookKey={bookKey}
selection={selection}
position={proofreadPopupPosition}
trianglePosition={trianglePosition}
popupWidth={proofreadPopupWidth}
popupHeight={proofreadPopupHeight}
onDismiss={handleDismissPopupAndSelection}
/>
)}
</div>
@@ -24,7 +24,7 @@ const PopupButton: React.FC<PopupButtonProps> = ({
return (
<div
className='lg:tooltip lg:tooltip-bottom'
data-tip={!buttonClicked && showTooltip ? tooltipText : null}
title={!buttonClicked && showTooltip ? tooltipText : undefined}
>
<button
onClick={handleClick}
@@ -0,0 +1,191 @@
import clsx from 'clsx';
import React, { useRef, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useAutoFocus } from '@/hooks/useAutoFocus';
import { CreateProofreadRuleOptions, useProofreadStore } from '@/store/proofreadStore';
import { ProofreadScope } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
import { Position, TextSelection } from '@/utils/sel';
import { isWholeWord } from '@/utils/word';
import Select from '@/components/Select';
import Popup from '@/components/Popup';
interface ProofreadPopupProps {
bookKey: string;
selection?: TextSelection;
position: Position;
trianglePosition: Position;
popupWidth: number;
popupHeight: number;
onConfirm?: (options: CreateProofreadRuleOptions) => void;
onDismiss: () => void;
}
const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
bookKey,
selection,
position,
trianglePosition,
popupWidth,
popupHeight,
onConfirm,
onDismiss,
}) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getProgress, getView, recreateViewer } = useReaderStore();
const { addRule } = useProofreadStore();
const progress = getProgress(bookKey)!;
const [replacementText, setReplacementText] = useState('');
const [caseSensitive, setCaseSensitive] = useState(true);
const [scope, setScope] = useState<ProofreadScope>('selection');
const inputRef = useRef<HTMLInputElement>(null);
useAutoFocus<HTMLInputElement>({ ref: inputRef });
const handleScopeChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setScope(event.target.value as ProofreadScope);
};
const handleApply = async () => {
if (!selection) return;
const range = selection?.range;
if (range) {
const isValidWholeWord = isWholeWord(range, selection?.text || '');
if (!isValidWholeWord) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: `Cannot replace "${selection.text}" - please select a complete word. Partial word selections (like "and" in "England" or "errand") are not supported.`,
timeout: 5000,
});
return;
}
if (scope === 'selection') {
range.deleteContents();
const textNode = document.createTextNode(replacementText);
range.insertNode(textNode);
}
const options = {
scope,
pattern: selection.text,
replacement: replacementText.trim(),
cfi: selection.cfi,
sectionHref: progress?.sectionHref,
isRegex: false,
enabled: true,
caseSensitive,
wholeWord: true,
};
onConfirm?.(options);
await addRule(envConfig, bookKey, options);
onDismiss();
if (scope !== 'selection') {
if (getView(bookKey)) {
recreateViewer(envConfig, bookKey);
}
}
}
};
const scopeOptions = [
{ value: 'selection', label: _('Current selection') },
{ value: 'book', label: _('All occurrences in this book') },
{ value: 'library', label: _('All occurrences in your library') },
];
return (
<div>
<Popup
trianglePosition={trianglePosition}
width={popupWidth}
minHeight={popupHeight}
position={position}
className='flex flex-col justify-between rounded-lg bg-gray-700 text-gray-400'
triangleClassName='text-gray-700'
onDismiss={onDismiss}
>
<div className='flex flex-col gap-6 p-4'>
<div className='flex gap-1 text-xs text-gray-400'>
<span>{_('Selected text:')}</span>
<span className='line-clamp-1 select-text break-words text-yellow-300'>
&quot;{selection?.text || ''}&quot;
</span>
</div>
<div className='flex items-center justify-between gap-2'>
<label htmlFor='replacement-input' className='text-xs'>
{_('Replace with:')}
</label>
<input
ref={inputRef}
type='text'
value={replacementText}
onChange={(e) => setReplacementText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && replacementText.trim()) {
handleApply();
}
}}
placeholder={_('Enter text...')}
className='w-full flex-1 rounded-md bg-gray-600 p-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-0'
/>
<button
onClick={handleApply}
disabled={!replacementText.trim()}
className={clsx(
'btn btn-sm btn-ghost text-blue-600 disabled:text-gray-600',
'bg-transparent hover:bg-transparent disabled:bg-transparent',
)}
>
{_('Apply')}
</button>
</div>
</div>
<div className='flex items-center justify-between gap-4 p-4'>
<label className='flex max-w-[30%] cursor-pointer items-center gap-2'>
<span className='line-clamp-1 text-xs' title={_('Case sensitive:')}>
{_('Case sensitive:')}
</span>
<input
type='checkbox'
className='toggle toggle-sm bg-gray-500 checked:bg-black hover:bg-gray-500 hover:checked:bg-black'
style={
{
'--tglbg': '#4B5563',
} as React.CSSProperties
}
checked={caseSensitive}
onChange={(e) => setCaseSensitive(e.target.checked)}
/>
</label>
<div className='flex max-w-[65%] flex-1 items-center justify-between gap-2'>
<label htmlFor='scope-select' className='line-clamp-1 text-xs' title={_('Scope:')}>
{_('Scope:')}
</label>
<Select
className='max-w-[50%] bg-gray-600 text-white'
value={scope}
onChange={handleScopeChange}
options={scopeOptions}
/>
</div>
</div>
</Popup>
</div>
);
};
export default ProofreadPopup;
@@ -1,284 +0,0 @@
'use client';
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
export interface ReplacementConfig {
replacementText: string;
caseSensitive: boolean;
scope: 'once' | 'book' | 'library';
}
interface ReplacementOptionsProps {
isVertical: boolean;
style: React.CSSProperties;
selectedText: string;
onConfirm: (config: ReplacementConfig) => void;
onClose: () => void;
}
const ReplacementOptions: React.FC<ReplacementOptionsProps> = ({
style,
isVertical,
selectedText,
onConfirm,
onClose,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [replacementText, setReplacementText] = useState('');
const [caseSensitive, setCaseSensitive] = useState(true);
const [selectedScope, setSelectedScope] = useState<'once' | 'book' | 'library' | null>(null);
const [showConfirmation, setShowConfirmation] = useState(false);
const [adjustedStyle, setAdjustedStyle] = useState<React.CSSProperties | null>(null);
const [isPositioned, setIsPositioned] = useState(false);
const hasAdjusted = useRef(false);
// Adjust position to stay within viewport - only once on initial render
useEffect(() => {
// Only adjust once to prevent jumping when other UI elements appear
if (menuRef.current && !hasAdjusted.current) {
// Use requestAnimationFrame to ensure the element is rendered before measuring
requestAnimationFrame(() => {
if (menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const padding = 10;
const newStyle = { ...style };
// Check if popup extends beyond bottom of viewport
if (rect.bottom > viewportHeight - padding) {
const currentTop = parseFloat(String(style.top)) || 0;
// Move popup above the selection instead
newStyle.top = `${Math.max(padding, currentTop - rect.height - 40)}px`;
}
// Check if popup extends beyond right of viewport
if (rect.right > viewportWidth - padding) {
newStyle.left = `${Math.max(padding, viewportWidth - rect.width - padding)}px`;
}
// Check if popup extends beyond left of viewport
if (rect.left < padding) {
newStyle.left = `${padding}px`;
}
setAdjustedStyle(newStyle);
hasAdjusted.current = true;
setIsPositioned(true);
}
});
}
}, [style]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
// Focus input on mount
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
const handleScopeClick = (scope: 'once' | 'book' | 'library') => {
if (!replacementText.trim()) {
// Show error if no replacement text
return;
}
setSelectedScope(scope);
setShowConfirmation(true);
};
const handleConfirm = () => {
if (selectedScope && replacementText.trim()) {
onConfirm({
replacementText: replacementText.trim(),
caseSensitive,
scope: selectedScope,
});
}
};
const handleCancelConfirmation = () => {
setShowConfirmation(false);
setSelectedScope(null);
};
const handleCancel = () => {
onClose();
};
const getScopeLabel = (scope: 'once' | 'book' | 'library' | null) => {
switch (scope) {
case 'once':
return 'this instance';
case 'book':
return 'all instances in this book';
case 'library':
return 'all instances in your library';
default:
return '';
}
};
// Secondary confirmation dialog
if (showConfirmation) {
return (
<div
ref={menuRef}
className={clsx(
'replacement-options absolute flex flex-col gap-3 rounded-lg bg-gray-700 p-4',
)}
style={{
...(adjustedStyle || style),
minWidth: '320px',
maxHeight: 'calc(100vh - 40px)',
overflowY: 'auto',
visibility: isPositioned ? 'visible' : 'hidden',
}}
>
<div className='text-sm text-white'>
<p className='mb-2 font-semibold'>Confirm Replacement</p>
<p className='mb-1 text-gray-300'>
Replace: <span className='text-yellow-300'>&quot;{selectedText}&quot;</span>
</p>
<p className='mb-1 text-gray-300'>
With: <span className='text-green-300'>&quot;{replacementText}&quot;</span>
</p>
<p className='mb-1 text-gray-300'>
Scope: <span className='text-blue-300'>{getScopeLabel(selectedScope)}</span>
</p>
<p className='text-gray-300'>
Case sensitive: <span className='text-purple-300'>{caseSensitive ? 'Yes' : 'No'}</span>
</p>
</div>
<div className='mt-2 flex gap-2'>
<button
onClick={handleCancelConfirmation}
className='flex-1 rounded-md bg-gray-600 px-3 py-2 text-sm text-white transition-colors hover:bg-gray-500'
>
Back
</button>
<button
onClick={handleConfirm}
className='flex-1 rounded-md bg-green-600 px-3 py-2 text-sm text-white transition-colors hover:bg-green-500'
>
Confirm
</button>
</div>
</div>
);
}
return (
<div
ref={menuRef}
className={clsx(
'replacement-options absolute flex flex-col gap-3 rounded-lg bg-gray-700 p-4',
isVertical ? 'flex-col' : 'flex-col',
)}
style={{
...(adjustedStyle || style),
minWidth: '280px',
maxHeight: 'calc(100vh - 40px)',
overflowY: 'auto',
visibility: isPositioned ? 'visible' : 'hidden',
}}
>
{/* Selected text preview */}
<div className='text-xs text-gray-400'>
<span>Selected: </span>
<span className='break-words text-yellow-300'>
&quot;{selectedText.length > 50 ? selectedText.substring(0, 50) + '...' : selectedText}
&quot;
</span>
</div>
{/* Replacement text input */}
<div className='flex flex-col gap-1'>
<label htmlFor='replacement-input' className='text-xs text-gray-400'>
Replace with:
</label>
<input
ref={inputRef}
id='replacement-input'
type='text'
value={replacementText}
onChange={(e) => setReplacementText(e.target.value)}
placeholder='Enter replacement text...'
className='w-full rounded-md bg-gray-600 px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500'
/>
</div>
{/* Case sensitivity checkbox */}
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={caseSensitive}
onChange={(e) => setCaseSensitive(e.target.checked)}
className='h-4 w-4 rounded border-gray-500 bg-gray-600 text-blue-500 focus:ring-blue-500 focus:ring-offset-gray-700'
/>
<span className='text-sm text-white'>Case Sensitive</span>
</label>
{/* Scope buttons */}
<div className='mt-1 flex flex-col gap-1'>
<button
onClick={() => handleScopeClick('once')}
disabled={!replacementText.trim()}
className={clsx(
'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm text-white transition-colors',
replacementText.trim() ? 'hover:bg-base-content/10' : 'cursor-not-allowed opacity-50',
)}
>
Fix this once
</button>
<button
onClick={() => handleScopeClick('book')}
disabled={!replacementText.trim()}
className={clsx(
'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm text-white transition-colors',
replacementText.trim() ? 'hover:bg-base-content/10' : 'cursor-not-allowed opacity-50',
)}
>
Fix in this book
</button>
<button
onClick={() => handleScopeClick('library')}
disabled={!replacementText.trim()}
className={clsx(
'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm text-white transition-colors',
replacementText.trim() ? 'hover:bg-base-content/10' : 'cursor-not-allowed opacity-50',
)}
>
Fix in library
</button>
</div>
{/* Cancel button */}
<button
onClick={handleCancel}
className='mt-2 rounded-md bg-gray-600 px-3 py-2 text-sm text-white transition-colors hover:bg-gray-500'
>
Cancel
</button>
</div>
);
};
export default ReplacementOptions;