feat(proofread): editable Find pattern and per-rule enable/disable toggle (#4859) (#4888)

* fix(proofread): keep disabled book rules visible in the manager list

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(proofread): add per-rule enable/disable toggle in the manager

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(proofread): allow editing Find pattern, regex, and case on existing rules

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n: add proofread edit and toggle strings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-02 22:38:53 +09:00
committed by GitHub
parent 8cd3cacbec
commit 5bc8eda50b
35 changed files with 515 additions and 57 deletions
@@ -1941,5 +1941,8 @@
"Granularity": "الدقة",
"Refresh Page": "تحديث الصفحة",
"Recently read": "المقروءة مؤخرًا",
"Show recently read": "إظهار المقروءة مؤخرًا"
"Show recently read": "إظهار المقروءة مؤخرًا",
"Find:": "بحث:",
"Enable rule": "تفعيل القاعدة",
"Disable rule": "تعطيل القاعدة"
}
@@ -1809,5 +1809,8 @@
"Granularity": "সূক্ষ্মতা",
"Refresh Page": "পৃষ্ঠা রিফ্রেশ করুন",
"Recently read": "সম্প্রতি পড়া",
"Show recently read": "সম্প্রতি পড়া দেখান"
"Show recently read": "সম্প্রতি পড়া দেখান",
"Find:": "খুঁজুন:",
"Enable rule": "নিয়ম সক্রিয় করুন",
"Disable rule": "নিয়ম নিষ্ক্রিয় করুন"
}
@@ -1770,5 +1770,8 @@
"Granularity": "ཞིབ་ཕྲའི་ཚད",
"Refresh Page": "ཤོག་ལྷེ་བསྐྱར་གསོ།",
"Recently read": "ཉེ་ཆར་བཀླགས་པ།",
"Show recently read": "ཉེ་ཆར་བཀླགས་པ་སྟོན་པ།"
"Show recently read": "ཉེ་ཆར་བཀླགས་པ་སྟོན་པ།",
"Find:": "འཚོལ་བཤེར།:",
"Enable rule": "སྲིད་སྒྲིག་སྤྱོད་པ།",
"Disable rule": "སྲིད་སྒྲིག་སྒོ་བརྒྱབ།"
}
@@ -1809,5 +1809,8 @@
"Granularity": "Granularität",
"Refresh Page": "Seite aktualisieren",
"Recently read": "Zuletzt gelesen",
"Show recently read": "Zuletzt gelesen anzeigen"
"Show recently read": "Zuletzt gelesen anzeigen",
"Find:": "Suchen:",
"Enable rule": "Regel aktivieren",
"Disable rule": "Regel deaktivieren"
}
@@ -1809,5 +1809,8 @@
"Granularity": "Διαβάθμιση",
"Refresh Page": "Ανανέωση σελίδας",
"Recently read": "Πρόσφατα αναγνωσμένα",
"Show recently read": "Εμφάνιση πρόσφατα αναγνωσμένων"
"Show recently read": "Εμφάνιση πρόσφατα αναγνωσμένων",
"Find:": "Εύρεση:",
"Enable rule": "Ενεργοποίηση κανόνα",
"Disable rule": "Απενεργοποίηση κανόνα"
}
@@ -1842,5 +1842,8 @@
"Granularity": "Granularidad",
"Refresh Page": "Actualizar página",
"Recently read": "Leídos recientemente",
"Show recently read": "Mostrar leídos recientemente"
"Show recently read": "Mostrar leídos recientemente",
"Find:": "Buscar:",
"Enable rule": "Activar regla",
"Disable rule": "Desactivar regla"
}
@@ -1809,5 +1809,8 @@
"Granularity": "دانه‌بندی",
"Refresh Page": "تازه‌سازی صفحه",
"Recently read": "اخیراً خوانده‌شده",
"Show recently read": "نمایش موارد اخیراً خوانده‌شده"
"Show recently read": "نمایش موارد اخیراً خوانده‌شده",
"Find:": "جستجو:",
"Enable rule": "فعال کردن قانون",
"Disable rule": "غیرفعال کردن قانون"
}
@@ -1842,5 +1842,8 @@
"Granularity": "Granularité",
"Refresh Page": "Actualiser la page",
"Recently read": "Lectures récentes",
"Show recently read": "Afficher les lectures récentes"
"Show recently read": "Afficher les lectures récentes",
"Find:": "Rechercher :",
"Enable rule": "Activer la règle",
"Disable rule": "Désactiver la règle"
}
@@ -1842,5 +1842,8 @@
"Granularity": "רמת פירוט",
"Refresh Page": "רענן דף",
"Recently read": "נקראו לאחרונה",
"Show recently read": "הצגת ספרים שנקראו לאחרונה"
"Show recently read": "הצגת ספרים שנקראו לאחרונה",
"Find:": "חיפוש:",
"Enable rule": "הפעל כלל",
"Disable rule": "השבת כלל"
}
@@ -1809,5 +1809,8 @@
"Granularity": "सूक्ष्मता",
"Refresh Page": "पृष्ठ ताज़ा करें",
"Recently read": "हाल ही में पढ़े गए",
"Show recently read": "हाल ही में पढ़े गए दिखाएँ"
"Show recently read": "हाल ही में पढ़े गए दिखाएँ",
"Find:": "खोजें:",
"Enable rule": "नियम सक्षम करें",
"Disable rule": "नियम अक्षम करें"
}
@@ -1809,5 +1809,8 @@
"Granularity": "Részletesség",
"Refresh Page": "Oldal frissítése",
"Recently read": "Nemrég olvasott",
"Show recently read": "Nemrég olvasottak megjelenítése"
"Show recently read": "Nemrég olvasottak megjelenítése",
"Find:": "Keresés:",
"Enable rule": "Szabály engedélyezése",
"Disable rule": "Szabály letiltása"
}
@@ -1776,5 +1776,8 @@
"Granularity": "Granularitas",
"Refresh Page": "Segarkan Halaman",
"Recently read": "Baru dibaca",
"Show recently read": "Tampilkan baru dibaca"
"Show recently read": "Tampilkan baru dibaca",
"Find:": "Cari:",
"Enable rule": "Aktifkan aturan",
"Disable rule": "Nonaktifkan aturan"
}
@@ -1842,5 +1842,8 @@
"Granularity": "Granularità",
"Refresh Page": "Aggiorna pagina",
"Recently read": "Letti di recente",
"Show recently read": "Mostra letti di recente"
"Show recently read": "Mostra letti di recente",
"Find:": "Trova:",
"Enable rule": "Attiva regola",
"Disable rule": "Disattiva regola"
}
@@ -1776,5 +1776,8 @@
"Granularity": "粒度",
"Refresh Page": "ページを更新",
"Recently read": "最近読んだ本",
"Show recently read": "最近読んだ本を表示"
"Show recently read": "最近読んだ本を表示",
"Find:": "検索:",
"Enable rule": "ルールを有効化",
"Disable rule": "ルールを無効化"
}
@@ -1776,5 +1776,8 @@
"Granularity": "단위",
"Refresh Page": "페이지 새로고침",
"Recently read": "최근 읽은 책",
"Show recently read": "최근 읽은 책 표시"
"Show recently read": "최근 읽은 책 표시",
"Find:": "찾기:",
"Enable rule": "규칙 활성화",
"Disable rule": "규칙 비활성화"
}
@@ -1776,5 +1776,8 @@
"Granularity": "Granulariti",
"Refresh Page": "Segarkan Halaman",
"Recently read": "Baru dibaca",
"Show recently read": "Tunjukkan baru dibaca"
"Show recently read": "Tunjukkan baru dibaca",
"Find:": "Cari:",
"Enable rule": "Dayakan peraturan",
"Disable rule": "Nyahdayakan peraturan"
}
@@ -1809,5 +1809,8 @@
"Granularity": "Granulariteit",
"Refresh Page": "Pagina vernieuwen",
"Recently read": "Onlangs gelezen",
"Show recently read": "Onlangs gelezen tonen"
"Show recently read": "Onlangs gelezen tonen",
"Find:": "Zoeken:",
"Enable rule": "Regel inschakelen",
"Disable rule": "Regel uitschakelen"
}
@@ -1875,5 +1875,8 @@
"Granularity": "Ziarnistość",
"Refresh Page": "Odśwież stronę",
"Recently read": "Ostatnio czytane",
"Show recently read": "Pokaż ostatnio czytane"
"Show recently read": "Pokaż ostatnio czytane",
"Find:": "Znajdź:",
"Enable rule": "Włącz regułę",
"Disable rule": "Wyłącz regułę"
}
@@ -1842,5 +1842,8 @@
"Granularity": "Granularidade",
"Refresh Page": "Atualizar página",
"Recently read": "Lidos recentemente",
"Show recently read": "Mostrar lidos recentemente"
"Show recently read": "Mostrar lidos recentemente",
"Find:": "Localizar:",
"Enable rule": "Ativar regra",
"Disable rule": "Desativar regra"
}
@@ -1842,5 +1842,8 @@
"Granularity": "Granularidade",
"Refresh Page": "Atualizar Página",
"Recently read": "Lidos recentemente",
"Show recently read": "Mostrar lidos recentemente"
"Show recently read": "Mostrar lidos recentemente",
"Find:": "Localizar:",
"Enable rule": "Ativar regra",
"Disable rule": "Desativar regra"
}
@@ -1842,5 +1842,8 @@
"Granularity": "Granularitate",
"Refresh Page": "Reîmprospătează pagina",
"Recently read": "Citite recent",
"Show recently read": "Afișează citite recent"
"Show recently read": "Afișează citite recent",
"Find:": "Găsește:",
"Enable rule": "Activează regula",
"Disable rule": "Dezactivează regula"
}
@@ -1875,5 +1875,8 @@
"Granularity": "Детализация",
"Refresh Page": "Обновить страницу",
"Recently read": "Недавно прочитанные",
"Show recently read": "Показывать недавно прочитанные"
"Show recently read": "Показывать недавно прочитанные",
"Find:": "Найти:",
"Enable rule": "Включить правило",
"Disable rule": "Отключить правило"
}
@@ -1809,5 +1809,8 @@
"Granularity": "විස්තර මට්ටම",
"Refresh Page": "පිටුව නැවුම් කරන්න",
"Recently read": "මෑතකදී කියවූ",
"Show recently read": "මෑතකදී කියවූ ඒවා පෙන්වන්න"
"Show recently read": "මෑතකදී කියවූ ඒවා පෙන්වන්න",
"Find:": "සොයන්න:",
"Enable rule": "නියමය සක්‍රීය කරන්න",
"Disable rule": "නියමය අක්‍රීය කරන්න"
}
@@ -1875,5 +1875,8 @@
"Granularity": "Granularnost",
"Refresh Page": "Osveži stran",
"Recently read": "Nedavno prebrano",
"Show recently read": "Pokaži nedavno prebrano"
"Show recently read": "Pokaži nedavno prebrano",
"Find:": "Najdi:",
"Enable rule": "Omogoči pravilo",
"Disable rule": "Onemogoči pravilo"
}
@@ -1809,5 +1809,8 @@
"Granularity": "Detaljnivå",
"Refresh Page": "Uppdatera sidan",
"Recently read": "Nyligen lästa",
"Show recently read": "Visa nyligen lästa"
"Show recently read": "Visa nyligen lästa",
"Find:": "Sök:",
"Enable rule": "Aktivera regel",
"Disable rule": "Inaktivera regel"
}
@@ -1809,5 +1809,8 @@
"Granularity": "நுண்மை",
"Refresh Page": "பக்கத்தைப் புதுப்பி",
"Recently read": "சமீபத்தில் படித்தவை",
"Show recently read": "சமீபத்தில் படித்தவற்றைக் காட்டு"
"Show recently read": "சமீபத்தில் படித்தவற்றைக் காட்டு",
"Find:": "தேடு:",
"Enable rule": "விதியை இயக்கு",
"Disable rule": "விதியை முடக்கு"
}
@@ -1776,5 +1776,8 @@
"Granularity": "ระดับความละเอียด",
"Refresh Page": "รีเฟรชหน้า",
"Recently read": "อ่านล่าสุด",
"Show recently read": "แสดงรายการอ่านล่าสุด"
"Show recently read": "แสดงรายการอ่านล่าสุด",
"Find:": "ค้นหา:",
"Enable rule": "เปิดใช้กฎ",
"Disable rule": "ปิดใช้กฎ"
}
@@ -1809,5 +1809,8 @@
"Granularity": "Ayrıntı düzeyi",
"Refresh Page": "Sayfayı Yenile",
"Recently read": "Son okunanlar",
"Show recently read": "Son okunanları göster"
"Show recently read": "Son okunanları göster",
"Find:": "Bul:",
"Enable rule": "Kuralı etkinleştir",
"Disable rule": "Kuralı devre dışı bırak"
}
@@ -1875,5 +1875,8 @@
"Granularity": "Деталізація",
"Refresh Page": "Оновити сторінку",
"Recently read": "Нещодавно прочитані",
"Show recently read": "Показувати нещодавно прочитані"
"Show recently read": "Показувати нещодавно прочитані",
"Find:": "Знайти:",
"Enable rule": "Увімкнути правило",
"Disable rule": "Вимкнути правило"
}
@@ -1809,5 +1809,8 @@
"Granularity": "Tafsilot darajasi",
"Refresh Page": "Sahifani yangilash",
"Recently read": "Yaqinda o'qilgan",
"Show recently read": "Yaqinda o'qilganlarni ko'rsatish"
"Show recently read": "Yaqinda o'qilganlarni ko'rsatish",
"Find:": "Topish:",
"Enable rule": "Qoidani yoqish",
"Disable rule": "Qoidani ochirish"
}
@@ -1776,5 +1776,8 @@
"Granularity": "Mức độ chi tiết",
"Refresh Page": "Làm mới trang",
"Recently read": "Đã đọc gần đây",
"Show recently read": "Hiển thị mục đã đọc gần đây"
"Show recently read": "Hiển thị mục đã đọc gần đây",
"Find:": "Tìm:",
"Enable rule": "Bật quy tắc",
"Disable rule": "Tắt quy tắc"
}
@@ -1776,5 +1776,8 @@
"Granularity": "粒度",
"Refresh Page": "刷新页面",
"Recently read": "最近阅读",
"Show recently read": "显示最近阅读"
"Show recently read": "显示最近阅读",
"Find:": "查找:",
"Enable rule": "启用规则",
"Disable rule": "禁用规则"
}
@@ -1776,5 +1776,8 @@
"Granularity": "粒度",
"Refresh Page": "重新整理頁面",
"Recently read": "最近閱讀",
"Show recently read": "顯示最近閱讀"
"Show recently read": "顯示最近閱讀",
"Find:": "尋找:",
"Enable rule": "啟用規則",
"Disable rule": "停用規則"
}
@@ -305,6 +305,271 @@ describe('ProofreadRulesManager', () => {
expect(screen.queryByText('deleted-pattern')).toBeNull();
});
it('keeps a disabled book rule visible so it can be re-enabled', async () => {
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } },
});
const disabledRule: ProofreadRule = {
id: 'd1',
scope: 'book',
pattern: 'disabled-pattern',
replacement: 'x',
enabled: false,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
};
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: { book1: { viewSettings: { proofreadRules: [disabledRule] } } },
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: { viewSettings: { proofreadRules: [disabledRule] } },
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
await screen.findByRole('dialog');
expect(screen.getByText('disabled-pattern')).toBeTruthy();
});
it('toggles a rule on/off via its switch', async () => {
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } },
});
const rule: ProofreadRule = {
id: 'b1',
scope: 'book',
pattern: 'toggle-me',
replacement: 'x',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
};
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: { book1: { viewSettings: { proofreadRules: [rule] } } },
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: { viewSettings: { proofreadRules: [rule] } },
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
const toggleSpy = vi
.spyOn(useProofreadStore.getState(), 'toggleRule')
.mockResolvedValue(undefined);
const recreateSpy = vi
.spyOn(useReaderStore.getState(), 'recreateViewer')
.mockResolvedValue(undefined as never);
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
await screen.findByRole('dialog');
const row = screen.getByText('toggle-me').closest('li');
const toggle = within(row!).getByLabelText('Disable rule');
fireEvent.click(toggle);
await new Promise((r) => setTimeout(r, 0));
expect(toggleSpy).toHaveBeenCalledWith(expect.anything(), 'book1', 'b1');
expect(recreateSpy).toHaveBeenCalled();
});
it('edits a book rule Find pattern and saves via updateRule', async () => {
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } },
});
const rule: ProofreadRule = {
id: 'b1',
scope: 'book',
pattern: 'old-find',
replacement: 'r',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
};
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: { book1: { viewSettings: { proofreadRules: [rule] } } },
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: { viewSettings: { proofreadRules: [rule] } },
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
const updateSpy = vi
.spyOn(useProofreadStore.getState(), 'updateRule')
.mockResolvedValue(undefined);
vi.spyOn(useReaderStore.getState(), 'recreateViewer').mockResolvedValue(undefined as never);
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
await screen.findByRole('dialog');
const row = screen.getByText('old-find').closest('li');
fireEvent.click(within(row!).getByLabelText('Edit'));
const findInput = screen.getByDisplayValue('old-find') as HTMLInputElement;
expect(findInput.disabled).toBe(false);
fireEvent.change(findInput, { target: { value: 'new-find' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await new Promise((r) => setTimeout(r, 0));
expect(updateSpy).toHaveBeenCalledWith(
expect.anything(),
'book1',
'b1',
expect.objectContaining({ pattern: 'new-find' }),
);
});
it('keeps Find read-only when editing a selection rule', async () => {
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } },
});
const sel: ProofreadRule = {
id: 's1',
scope: 'selection',
pattern: 'sel-text',
replacement: 'r',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)',
sectionHref: 'chapter1.html',
};
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: { book1: { viewSettings: { proofreadRules: [sel] } } },
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: { viewSettings: { proofreadRules: [sel] } },
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
await screen.findByRole('dialog');
const row = screen.getByText('sel-text').closest('li');
fireEvent.click(within(row!).getByLabelText('Edit'));
const findInput = screen.getByDisplayValue('sel-text') as HTMLInputElement;
expect(findInput.disabled).toBe(true);
});
it('warns and does not save when the edited regex is invalid', async () => {
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } },
});
const rule: ProofreadRule = {
id: 'b1',
scope: 'book',
pattern: 'valid',
replacement: 'r',
enabled: true,
isRegex: true,
caseSensitive: true,
order: 1,
wholeWord: true,
};
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: { book1: { viewSettings: { proofreadRules: [rule] } } },
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: { viewSettings: { proofreadRules: [rule] } },
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
const updateSpy = vi
.spyOn(useProofreadStore.getState(), 'updateRule')
.mockResolvedValue(undefined);
const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch');
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
await screen.findByRole('dialog');
const row = screen.getByText('valid').closest('li');
fireEvent.click(within(row!).getByLabelText('Edit'));
const findInput = screen.getByDisplayValue('valid') as HTMLInputElement;
fireEvent.change(findInput, { target: { value: '(' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await new Promise((r) => setTimeout(r, 0));
expect(updateSpy).not.toHaveBeenCalled();
expect(dispatchSpy).toHaveBeenCalledWith('toast', expect.objectContaining({ type: 'warning' }));
});
it('renders a drag handle for each reorderable rule', async () => {
const selectionRule: ProofreadRule = {
id: 's1',
@@ -68,12 +68,26 @@ const RuleItem: React.FC<{
rule: ProofreadRule;
scope: ProofreadScope;
isEditing: boolean;
editingData: { pattern: string; replacement: string; enabled: boolean };
editingData: {
pattern: string;
replacement: string;
enabled: boolean;
isRegex: boolean;
caseSensitive: boolean;
};
onEdit: () => void;
onDelete: () => void;
onToggle: () => void;
onSave: () => void;
onCancel: () => void;
onEditChange: (field: 'replacement', value: string) => void;
onEditChange: (
patch: Partial<{
pattern: string;
replacement: string;
isRegex: boolean;
caseSensitive: boolean;
}>,
) => void;
}> = ({
rule,
scope,
@@ -81,6 +95,7 @@ const RuleItem: React.FC<{
editingData,
onEdit,
onDelete,
onToggle,
onSave,
onCancel,
onEditChange,
@@ -96,14 +111,19 @@ const RuleItem: React.FC<{
};
if (isEditing) {
const isSelection = scope === 'selection';
return (
<div className='flex flex-col gap-3 p-3'>
<div className='flex flex-col gap-1.5'>
<label className='text-base-content/70 text-xs font-medium'>{_('Selected text:')}</label>
<label className='text-base-content/70 text-xs font-medium'>
{isSelection ? _('Selected text:') : _('Find:')}
</label>
<input
className='input input-sm bg-base-200 text-sm opacity-60'
className={clsx('input input-sm text-sm', isSelection && 'bg-base-200 opacity-60')}
value={editingData.pattern}
disabled
disabled={isSelection}
spellCheck='false'
onChange={isSelection ? undefined : (e) => onEditChange({ pattern: e.target.value })}
/>
</div>
@@ -112,10 +132,34 @@ const RuleItem: React.FC<{
<input
className='input input-sm text-sm'
value={editingData.replacement}
onChange={(e) => onEditChange('replacement', e.target.value)}
spellCheck='false'
onChange={(e) => onEditChange({ replacement: e.target.value })}
/>
</div>
{!isSelection && (
<div className='flex flex-wrap items-center gap-x-5 gap-y-3'>
<label className='flex cursor-pointer items-center gap-2'>
<span className='text-base-content/70 text-sm'>{_('Regex:')}</span>
<input
type='checkbox'
className='toggle toggle-sm'
checked={editingData.isRegex}
onChange={(e) => onEditChange({ isRegex: e.target.checked })}
/>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<span className='text-base-content/70 text-sm'>{_('Case sensitive:')}</span>
<input
type='checkbox'
className='toggle toggle-sm'
checked={editingData.caseSensitive}
onChange={(e) => onEditChange({ caseSensitive: e.target.checked })}
/>
</label>
</div>
)}
<div className='mt-1 flex gap-2'>
<button className='btn btn-primary btn-sm flex-1' onClick={onSave}>
{_('Save')}
@@ -130,8 +174,13 @@ const RuleItem: React.FC<{
return (
<div className='relative flex items-start justify-between gap-3 p-3'>
<div className='flex min-w-0 flex-1 flex-col gap-1.5'>
<div className='break-words pe-20 text-base font-medium leading-snug'>{rule.pattern}</div>
<div
className={clsx(
'flex min-w-0 flex-1 flex-col gap-1.5',
rule.enabled === false && 'opacity-40',
)}
>
<div className='break-words pe-28 text-base font-medium leading-snug'>{rule.pattern}</div>
<div className='text-base-content/70 break-words text-sm'>
<span className='text-base-content/80 mr-1.5 text-xs font-medium'>
{_('Replace with:')}
@@ -169,6 +218,13 @@ const RuleItem: React.FC<{
</div>
</div>
<div className='absolute right-2 top-2 flex items-center gap-1'>
<input
type='checkbox'
className='toggle toggle-sm'
checked={rule.enabled !== false}
onChange={onToggle}
aria-label={rule.enabled !== false ? _('Disable rule') : _('Enable rule')}
/>
<button
className='btn btn-ghost btn-sm h-8 w-8 p-0'
onClick={onEdit}
@@ -261,21 +317,17 @@ const useReplacementRules = (bookKey: string | null) => {
(r: ProofreadRule) => !r.deletedAt,
);
// Merge book-scoped and global rules
const globalRuleIds = new Set(globalRules.map((gr: ProofreadRule) => gr.id));
// Remove orphaned overrides (disabled global rules that no longer exist)
const validBookRules = bookScopedRules.filter(
(br: ProofreadRule) => br.enabled !== false || globalRuleIds.has(br.id),
);
// Merge book-scoped rules with global (library) rules. Keep disabled rules so
// the per-rule enable/disable toggle can turn them back on; book and library
// ids never collide (ensureRuleId folds scope into the id), so dedup-by-id
// here only guards a legacy shared id from listing the same rule twice.
// Sort by `order` so a drag-to-reorder (which rewrites the order field)
// persists visually; the stable sort keeps insertion order while every
// rule still shares the default order.
const mergedBookRules = validBookRules
// persists visually; the stable sort keeps insertion order while every rule
// still shares the default order.
const mergedBookRules = bookScopedRules
.concat(
globalRules.filter(
(gr: ProofreadRule) => !validBookRules.find((br: ProofreadRule) => br.id === gr.id),
(gr: ProofreadRule) => !bookScopedRules.find((br: ProofreadRule) => br.id === gr.id),
),
)
.sort(byOrder);
@@ -288,7 +340,7 @@ export const ProofreadRulesManager: React.FC = () => {
const { envConfig } = useEnv();
const { recreateViewer } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { addRule, updateRule, removeRule, reorderRules } = useProofreadStore();
const { addRule, updateRule, removeRule, reorderRules, toggleRule } = useProofreadStore();
// dnd-kit sensors mirror the dictionaries reorder list: a small pointer
// distance gate avoids hijacking handle clicks, a touch delay gives mobile
@@ -311,8 +363,19 @@ export const ProofreadRulesManager: React.FC = () => {
pattern: string;
replacement: string;
enabled: boolean;
isRegex: boolean;
caseSensitive: boolean;
onlyForTTS: boolean;
}>({ id: null, scope: null, pattern: '', replacement: '', enabled: true, onlyForTTS: false });
}>({
id: null,
scope: null,
pattern: '',
replacement: '',
enabled: true,
isRegex: false,
caseSensitive: true,
onlyForTTS: false,
});
const { singleRules, bookRules } = useReplacementRules(sideBarBookKey);
@@ -331,6 +394,8 @@ export const ProofreadRulesManager: React.FC = () => {
pattern: rule.pattern,
replacement: rule.replacement,
enabled: !!rule.enabled,
isRegex: !!rule.isRegex,
caseSensitive: rule.caseSensitive !== false,
onlyForTTS: !!rule.onlyForTTS,
});
};
@@ -342,6 +407,8 @@ export const ProofreadRulesManager: React.FC = () => {
pattern: '',
replacement: '',
enabled: true,
isRegex: false,
caseSensitive: true,
onlyForTTS: false,
});
};
@@ -349,10 +416,28 @@ export const ProofreadRulesManager: React.FC = () => {
const saveEdit = async () => {
if (!editing.id || !editing.scope || !sideBarBookKey) return;
// Selection rules keep a read-only Find (anchored to the selected text), so
// only validate the pattern when the user can actually edit it.
const isSelection = editing.scope === 'selection';
const pattern = isSelection ? editing.pattern : editing.pattern.trim();
if (!isSelection) {
const validation = validateReplacementRulePattern(pattern, editing.isRegex);
if (!validation.valid) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: pattern ? _('Invalid regular expression') : _('Find pattern cannot be empty'),
timeout: 3000,
});
return;
}
}
await updateRule(envConfig, sideBarBookKey, editing.id, {
scope: editing.scope,
pattern: editing.pattern,
pattern,
replacement: editing.replacement,
isRegex: editing.isRegex,
caseSensitive: editing.caseSensitive,
enabled: editing.enabled,
onlyForTTS: editing.onlyForTTS,
});
@@ -372,6 +457,14 @@ export const ProofreadRulesManager: React.FC = () => {
}
};
const handleToggle = async (rule: ProofreadRule) => {
if (!sideBarBookKey) return;
await toggleRule(envConfig, sideBarBookKey, rule.id);
if (!rule.onlyForTTS) {
recreateViewer(envConfig, sideBarBookKey);
}
};
const handleAddRule = async () => {
if (!sideBarBookKey) return;
const pattern = addPattern.trim();
@@ -447,9 +540,10 @@ export const ProofreadRulesManager: React.FC = () => {
editingData={editing}
onEdit={() => startEdit(rule)}
onDelete={() => deleteRule(rule)}
onToggle={() => handleToggle(rule)}
onSave={saveEdit}
onCancel={cancelEdit}
onEditChange={(_, value) => setEditing({ ...editing, replacement: value })}
onEditChange={(patch) => setEditing((prev) => ({ ...prev, ...patch }))}
/>
))}
</ul>