feat(proofread): add an option for whole word replacement, closes #2934 (#2938)

This commit is contained in:
Huang Xin
2026-01-13 15:35:57 +01:00
committed by GitHub
parent aaee04c290
commit 434a44e62c
33 changed files with 217 additions and 67 deletions
@@ -8,7 +8,7 @@ import { CreateProofreadRuleOptions, useProofreadStore } from '@/store/proofread
import { ProofreadScope } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
import { Position, TextSelection } from '@/utils/sel';
import { isWholeWord } from '@/utils/word';
import { isPunctuationOnly, isWholeWord } from '@/utils/word';
import Select from '@/components/Select';
import Popup from '@/components/Popup';
@@ -41,11 +41,17 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
const [replacementText, setReplacementText] = useState('');
const [caseSensitive, setCaseSensitive] = useState(true);
const [wholeWord, setWholeWord] = useState(!isPunctuationOnly(selection?.text || ''));
const [scope, setScope] = useState<ProofreadScope>('selection');
const inputRef = useRef<HTMLInputElement>(null);
useAutoFocus<HTMLInputElement>({ ref: inputRef });
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value;
setReplacementText(text);
};
const handleScopeChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setScope(event.target.value as ProofreadScope);
};
@@ -58,10 +64,10 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
if (range) {
const isValidWholeWord = isWholeWord(range, selection?.text || '');
if (!isValidWholeWord) {
if (wholeWord && !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.`,
message: 'Please select a whole word or uncheck the "Whole word" option.',
timeout: 5000,
});
return;
@@ -73,7 +79,7 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
range.insertNode(textNode);
}
const options = {
const options: CreateProofreadRuleOptions = {
scope,
pattern: selection.text,
replacement: replacementText.trim(),
@@ -82,7 +88,7 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
isRegex: false,
enabled: true,
caseSensitive,
wholeWord: true,
wholeWord: wholeWord,
};
onConfirm?.(options);
@@ -111,14 +117,14 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
width={popupWidth}
minHeight={popupHeight}
position={position}
className='flex flex-col justify-between rounded-lg bg-gray-700 text-gray-400'
className='not-eink:text-gray-400 flex flex-col justify-between rounded-lg bg-gray-700'
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'>
<div className='not-eink:text-gray-400 flex gap-1 text-xs'>
<span>{_('Selected text:')}</span>
<span className='line-clamp-1 select-text break-words text-yellow-300'>
<span className='not-eink:text-yellow-300 line-clamp-1 select-text break-words font-medium'>
&quot;{selection?.text || ''}&quot;
</span>
</div>
@@ -131,20 +137,23 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
ref={inputRef}
type='text'
value={replacementText}
onChange={(e) => setReplacementText(e.target.value)}
onChange={handleInputChange}
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'
className={clsx(
'w-full flex-1 rounded-md p-2 text-sm placeholder-gray-400 focus:outline-none focus:ring-0',
'not-eink:bg-gray-600 not-eink:text-white eink:border eink:border-base-content',
)}
/>
<button
onClick={handleApply}
disabled={!replacementText.trim()}
className={clsx(
'btn btn-sm btn-ghost text-blue-600 disabled:text-gray-600',
'btn btn-sm btn-ghost btn-primary disabled:text-base-content/75 text-blue-600 disabled:opacity-75',
'bg-transparent hover:bg-transparent disabled:bg-transparent',
)}
>
@@ -153,14 +162,14 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
</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'>
<div className='flex items-center gap-8 p-4'>
<label className='flex 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'
className='toggle toggle-sm not-eink:bg-gray-500 checked:bg-black hover:bg-gray-500 hover:checked:bg-black'
style={
{
'--tglbg': '#4B5563',
@@ -171,17 +180,33 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
/>
</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}
<label className='flex cursor-pointer items-center gap-2'>
<span className='line-clamp-1 text-xs' title={_('Whole word:')}>
{_('Whole word:')}
</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={wholeWord}
onChange={(e) => setWholeWord(e.target.checked)}
/>
</div>
</label>
</div>
<div className='flex flex-1 items-center justify-between gap-2 p-4'>
<label htmlFor='scope-select' className='line-clamp-1 text-xs' title={_('Scope:')}>
{_('Scope:')}
</label>
<Select
className='not-eink:bg-gray-600 eink:bg-base-100 not-eink:text-white max-w-[85%]'
value={scope}
onChange={handleScopeChange}
options={scopeOptions}
/>
</div>
</Popup>
</div>
@@ -141,7 +141,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
minHeight={popupHeight}
maxHeight={720}
position={position}
className='grid h-full select-text grid-rows-[1fr,auto,1fr] bg-gray-600 text-white'
className='not-eink:text-white grid h-full select-text grid-rows-[1fr,auto,1fr] bg-gray-600'
triangleClassName='text-gray-600'
onDismiss={onDismiss}
>
@@ -149,7 +149,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
<div className='mb-2 flex items-center justify-between'>
<h1 className='text-sm font-normal'>{_('Original Text')}</h1>
<Select
className='bg-gray-600 text-white/75'
className='not-eink:bg-gray-600 not-eink:text-white eink:bg-base-100'
value={sourceLang}
onChange={handleSourceLangChange}
options={[
@@ -167,7 +167,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
]}
/>
</div>
<p className='text-base text-white/90'>{text}</p>
<p className='not-eink:text-white/90 text-base'>{text}</p>
</div>
<div className='mx-4 flex-shrink-0 border-t border-gray-500/30'></div>
@@ -176,7 +176,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
<div className='mb-2 flex items-center justify-between'>
<h2 className='text-sm font-normal'>{_('Translated Text')}</h2>
<Select
className='bg-gray-600 text-white/75'
className='not-eink:bg-gray-600 not-eink:text-white eink:bg-base-100'
value={targetLang}
onChange={handleTargetLangChange}
options={[
@@ -194,7 +194,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
{error ? (
<p className='text-base text-red-600'>{error}</p>
) : (
<p className='text-base text-white/90'>
<p className='not-eink:text-white/90 text-base'>
{translation || _('No translation available.')}
</p>
)}
@@ -211,7 +211,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
})}
</div>
<Select
className='bg-gray-600 text-white/75'
className='not-eink:bg-gray-600 not-eink:text-white eink:bg-base-100'
value={provider}
onChange={handleProviderChange}
options={providers.map(({ name: value, label }) => ({ value, label }))}
@@ -1,6 +1,7 @@
import React, { useEffect, useRef } from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
import { useTranslation } from '@/hooks/useTranslation';
interface WikipediaPopupProps {
text: string;
@@ -21,6 +22,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
popupHeight,
onDismiss,
}) => {
const _ = useTranslation();
const isLoading = useRef(false);
useEffect(() => {
@@ -85,12 +87,14 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
const errorDiv = document.createElement('div');
const h1 = document.createElement('h1');
h1.innerText = 'Error';
h1.innerText = _('Error');
const errorMsg = document.createElement('p');
errorMsg.innerHTML = `Unable to load the article. Try searching directly on <a href="https://${language}.wikipedia.org/w/index.php?search=${encodeURIComponent(
query,
)}" target="_blank" rel="noopener noreferrer" class="text-primary underline">Wikipedia</a>.`;
errorMsg.innerHTML = _('Unable to load the article. Try searching directly on {{link}}.', {
link: `<a href="https://${language}.wikipedia.org/w/index.php?search=${encodeURIComponent(
query,
)}" target="_blank" rel="noopener noreferrer" class="text-primary underline">Wikipedia</a>`,
});
errorDiv.append(h1, errorMsg);
main.appendChild(errorDiv);
@@ -101,7 +105,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
const bookLang = typeof lang === 'string' ? lang : lang?.[0];
const langCode = bookLang ? bookLang.split('-')[0]! : 'en';
fetchSummary(text, langCode);
}, [text, lang]);
}, [_, text, lang]);
return (
<div>
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Position } from '@/utils/sel';
import { useTranslation } from '@/hooks/useTranslation';
import Popup from '@/components/Popup';
type Definition = {
@@ -32,6 +33,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
popupHeight,
onDismiss,
}) => {
const _ = useTranslation();
const [lookupWord, setLookupWord] = useState(word);
const isLookingUp = useRef(false);
@@ -141,13 +143,15 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
'flex flex-col items-center justify-center w-full h-full text-center absolute inset-0';
const h1 = document.createElement('h1');
h1.innerText = 'Error';
h1.innerText = _('Error');
h1.className = 'text-lg font-bold';
const p = document.createElement('p');
p.innerHTML = `Unable to load the word. Try searching directly on <a href="https://en.wiktionary.org/w/index.php?search=${encodeURIComponent(
word,
)}" target="_blank" rel="noopener noreferrer" class="text-primary underline">Wiktionary</a>.`;
p.innerHTML = _('Unable to load the word. Try searching directly on {{link}}.', {
link: `<a href="https://en.wiktionary.org/w/index.php?search=${encodeURIComponent(
word,
)}" target="_blank" rel="noopener noreferrer" class="text-primary underline">Wiktionary</a>`,
});
div.append(h1, p);
main.append(div);
@@ -156,7 +160,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
const langCode = typeof lang === 'string' ? lang : lang?.[0];
fetchDefinitions(lookupWord, langCode);
}, [lookupWord, lang]);
}, [_, lookupWord, lang]);
return (
<div>