refactor(proofread): refactor UI and i18n for proofread tool (#2783)
This commit is contained in:
@@ -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'>
|
||||
"{selection?.text || ''}"
|
||||
</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'>"{selectedText}"</span>
|
||||
</p>
|
||||
<p className='mb-1 text-gray-300'>
|
||||
With: <span className='text-green-300'>"{replacementText}"</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'>
|
||||
"{selectedText.length > 50 ? selectedText.substring(0, 50) + '...' : selectedText}
|
||||
"
|
||||
</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;
|
||||
Reference in New Issue
Block a user