@@ -209,8 +219,12 @@ export const generateReaderHtml = (options: HtmlTemplateOptions) => {
return true;
};
- var initialPageReaderConfig = ${safeJsonStringify(initialPageReaderConfig)};
- var initialReaderConfig = ${safeJsonStringify(initialReaderConfig)};
+ var initialPageReaderConfig = ${serializeInlineScriptValue(
+ initialPageReaderConfig,
+ )};
+ var initialReaderConfig = ${serializeInlineScriptValue(
+ initialReaderConfig,
+ )};
@@ -227,9 +241,7 @@ export const generateReaderHtml = (options: HtmlTemplateOptions) => {
${proxyFetchScript}
${corePlayerScripts}
${pluginJsScript}
-
+ ${customJsScript}
`;
};
diff --git a/src/screens/settings/SettingsReaderScreen/ReaderPreviewWebView.tsx b/src/screens/settings/SettingsReaderScreen/ReaderPreviewWebView.tsx
index 4def14da1d..3d7875233e 100644
--- a/src/screens/settings/SettingsReaderScreen/ReaderPreviewWebView.tsx
+++ b/src/screens/settings/SettingsReaderScreen/ReaderPreviewWebView.tsx
@@ -5,6 +5,11 @@ import {
} from '@hooks/persisted';
import type { ChapterReaderSettings } from '@hooks/persisted/useSettings';
import { getString } from '@strings/translations';
+import {
+ applyRegexReplacements,
+ composeCSS,
+ composeJS,
+} from '@utils/customCode';
import { showToast } from '@utils/showToast';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { StyleSheet } from 'react-native';
@@ -74,7 +79,18 @@ const ReaderPreviewWebView = () => {
const latestRef = useRef({ generalSettings, readerSettings });
latestRef.current = { generalSettings, readerSettings };
- const { customCSS, customJS } = readerSettings;
+ const customCSS = useMemo(
+ () => composeCSS(readerSettings.codeSnippetsCSS),
+ [readerSettings.codeSnippetsCSS],
+ );
+ const customJS = useMemo(
+ () => composeJS(readerSettings.codeSnippetsJS),
+ [readerSettings.codeSnippetsJS],
+ );
+ const processedPreviewHTML = useMemo(
+ () => applyRegexReplacements(previewHTML, readerSettings.regexReplacements),
+ [readerSettings.regexReplacements],
+ );
const source = useMemo(() => {
const latest = latestRef.current;
return {
@@ -83,34 +99,35 @@ const ReaderPreviewWebView = () => {
batteryLevel: getBatteryLevelSync(),
chapter: { ...previewChapter, progress: progressRef.current },
chapterGeneralSettings: latest.generalSettings,
- html: previewHTML,
+ html: processedPreviewHTML,
isSettingsPreview: true,
novel: previewNovel,
- readerSettings: {
- ...latest.readerSettings,
- customCSS,
- customJS,
- },
+ readerSettings: latest.readerSettings,
+ customCSS,
+ customJS,
strings: createReaderStrings(previewChapter.name),
theme,
}),
};
- }, [customCSS, customJS, theme]);
+ }, [customCSS, customJS, processedPreviewHTML, theme]);
const codeRef = useRef({
- css: readerSettings.customCSS,
- js: readerSettings.customJS,
+ css: customCSS,
+ js: customJS,
+ html: processedPreviewHTML,
});
useEffect(() => {
const changed =
- codeRef.current.css !== readerSettings.customCSS ||
- codeRef.current.js !== readerSettings.customJS;
+ codeRef.current.css !== customCSS ||
+ codeRef.current.js !== customJS ||
+ codeRef.current.html !== processedPreviewHTML;
codeRef.current = {
- css: readerSettings.customCSS,
- js: readerSettings.customJS,
+ css: customCSS,
+ js: customJS,
+ html: processedPreviewHTML,
};
if (changed) playback.stop();
- }, [playback, readerSettings.customCSS, readerSettings.customJS]);
+ }, [customCSS, customJS, playback, processedPreviewHTML]);
const handleMessage = useCallback(
(payload: string) => {
diff --git a/src/utils/__tests__/customCode.test.ts b/src/utils/__tests__/customCode.test.ts
new file mode 100644
index 0000000000..fb1db65010
--- /dev/null
+++ b/src/utils/__tests__/customCode.test.ts
@@ -0,0 +1,262 @@
+import {
+ applyRegexReplacements,
+ type CodeSnippet,
+ compileRegex,
+ composeCSS,
+ composeJS,
+ migrateLegacyCustomCode,
+ type RegexReplacement,
+ runRegexReplacement,
+} from '../customCode';
+
+const cssSnippets: CodeSnippet[] = [
+ { name: 'first', code: 'body { color: red; }', lang: 'css', active: true },
+ {
+ name: 'disabled',
+ code: 'body { color: blue; }',
+ lang: 'css',
+ active: false,
+ },
+ { name: 'last', code: 'p { margin: 0; }', lang: 'css', active: true },
+];
+
+describe('custom code snippets', () => {
+ it('composes active CSS snippets in their stored order', () => {
+ expect(composeCSS(cssSnippets)).toBe(
+ 'body { color: red; }\np { margin: 0; }',
+ );
+ });
+
+ it('isolates active JavaScript snippets and excludes disabled code', () => {
+ const result = composeJS([
+ { name: 'first', code: 'html += "a";', lang: 'js', active: true },
+ { name: 'syntax error', code: 'const = ;', lang: 'js', active: true },
+ { name: 'early return', code: 'return;', lang: 'js', active: true },
+ { name: 'off', code: 'html += "off";', lang: 'js', active: false },
+ { name: 'last', code: 'html += "b";', lang: 'js', active: true },
+ ]);
+ const chapterElement = { innerHTML: '' };
+ // Execute the generated WebView runner to verify snippet isolation.
+ // eslint-disable-next-line no-new-func
+ const execute = new Function(
+ 'chapterElement',
+ 'novelName',
+ 'chapterName',
+ 'sourceId',
+ 'chapterId',
+ 'novelId',
+ result,
+ );
+ const consoleError = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined);
+
+ execute(chapterElement, 'Novel', 'Chapter', 'source', 2, 1);
+
+ expect(chapterElement.innerHTML).toBe('ab');
+ expect(result).not.toContain('off');
+ expect(result.match(/try \{/g)).toHaveLength(4);
+ expect(consoleError).toHaveBeenCalledTimes(1);
+ consoleError.mockRestore();
+ });
+
+ it('preserves direct DOM changes and safely serializes script endings', () => {
+ const runtimeGlobal = globalThis as typeof globalThis & {
+ customCodeTestElement?: { innerHTML: string };
+ };
+ const chapterElement = { innerHTML: 'start' };
+ runtimeGlobal.customCodeTestElement = chapterElement;
+ const result = composeJS([
+ {
+ name: '',
+ code: 'globalThis.customCodeTestElement.innerHTML += "-dom";',
+ lang: 'js',
+ active: true,
+ },
+ {
+ name: 'html update',
+ code: 'html += "";',
+ lang: 'js',
+ active: true,
+ },
+ ]);
+ // Execute the generated WebView runner to verify safe serialization.
+ // eslint-disable-next-line no-new-func
+ const execute = new Function(
+ 'chapterElement',
+ 'novelName',
+ 'chapterName',
+ 'sourceId',
+ 'chapterId',
+ 'novelId',
+ result,
+ );
+
+ execute(chapterElement, 'Novel', 'Chapter', 'source', 2, 1);
+
+ expect(chapterElement.innerHTML).toBe('start-dom');
+ expect(result).not.toContain('');
+ delete runtimeGlobal.customCodeTestElement;
+ });
+});
+
+describe('legacy custom code migration', () => {
+ it('converts both legacy fields without mutating existing snippets', () => {
+ const existing: CodeSnippet = {
+ name: 'existing',
+ code: 'p { color: green; }',
+ lang: 'css',
+ active: false,
+ };
+
+ const result = migrateLegacyCustomCode({
+ customCSS: 'body { color: red; }',
+ customJS: 'html = html.trim();',
+ codeSnippetsCSS: [existing],
+ codeSnippetsJS: [],
+ });
+
+ expect(result.didMigrate).toBe(true);
+ expect(result.settings).not.toHaveProperty('customCSS');
+ expect(result.settings).not.toHaveProperty('customJS');
+ expect(result.settings.codeSnippetsCSS).toEqual([
+ existing,
+ {
+ name: 'Custom CSS',
+ code: 'body { color: red; }',
+ lang: 'css',
+ active: true,
+ },
+ ]);
+ expect(result.settings.codeSnippetsJS).toEqual([
+ {
+ name: 'Custom JS',
+ code: 'html = html.trim();',
+ lang: 'js',
+ active: true,
+ },
+ ]);
+ });
+
+ it('does not duplicate legacy code that is already a snippet', () => {
+ const existing: CodeSnippet = {
+ name: 'Imported earlier',
+ code: 'body { color: red; }',
+ lang: 'css',
+ active: false,
+ };
+
+ const result = migrateLegacyCustomCode({
+ customCSS: existing.code,
+ codeSnippetsCSS: [existing],
+ codeSnippetsJS: [],
+ });
+
+ expect(result.didMigrate).toBe(true);
+ expect(result.settings.codeSnippetsCSS).toEqual([existing]);
+ });
+
+ it('is a no-op after legacy fields have been removed', () => {
+ const result = migrateLegacyCustomCode({
+ codeSnippetsCSS: cssSnippets,
+ codeSnippetsJS: [],
+ });
+
+ expect(result.didMigrate).toBe(false);
+ expect(result.settings.codeSnippetsCSS).toBe(cssSnippets);
+ });
+
+ it('stays idempotent when the migrated settings are processed again', () => {
+ const first = migrateLegacyCustomCode({
+ customCSS: 'body { color: red; }',
+ codeSnippetsCSS: [],
+ codeSnippetsJS: [],
+ });
+ const second = migrateLegacyCustomCode(first.settings);
+
+ expect(second.didMigrate).toBe(false);
+ expect(second.settings.codeSnippetsCSS).toEqual(
+ first.settings.codeSnippetsCSS,
+ );
+ });
+});
+
+describe('regex replacements', () => {
+ it('compiles the supported JavaScript flags', () => {
+ expect(compileRegex('hello', 'gimsuy').flags).toBe('gimsuy');
+ });
+
+ it('rejects unsupported or duplicate flags', () => {
+ expect(() => compileRegex('hello', 'v')).toThrow('Unsupported regex flag');
+ expect(() => compileRegex('hello', 'gg')).toThrow();
+ });
+
+ it('uses JavaScript capture replacement semantics', () => {
+ const rule: RegexReplacement = {
+ title: 'swap',
+ pattern: '(first) (last)',
+ flags: 'g',
+ replacement: '$2, $1',
+ active: true,
+ };
+
+ expect(runRegexReplacement('first last', rule)).toBe('last, first');
+
+ expect(
+ runRegexReplacement('hello Yuneko', {
+ ...rule,
+ pattern: 'hello (?
\\w+)',
+ replacement: 'hi $',
+ }),
+ ).toBe('hi Yuneko');
+ });
+
+ it('treats an empty replacement as removal', () => {
+ const rule: RegexReplacement = {
+ title: 'remove digits',
+ pattern: '\\d+',
+ flags: 'g',
+ replacement: '',
+ active: true,
+ };
+
+ expect(runRegexReplacement('a1 b22', rule)).toBe('a b');
+ });
+
+ it('applies active rules sequentially and skips invalid rules', () => {
+ const rules: RegexReplacement[] = [
+ {
+ title: 'inactive',
+ pattern: 'ignored',
+ flags: 'g',
+ replacement: 'bad',
+ active: false,
+ },
+ {
+ title: 'invalid',
+ pattern: '[',
+ flags: 'g',
+ replacement: 'bad',
+ active: true,
+ },
+ {
+ title: 'first',
+ pattern: 'cat',
+ flags: 'gi',
+ replacement: 'dog',
+ active: true,
+ },
+ {
+ title: 'second',
+ pattern: 'dog',
+ flags: '',
+ replacement: 'fox',
+ active: true,
+ },
+ ];
+
+ expect(applyRegexReplacements('Cat cat ignored', rules)).toBe(
+ 'fox dog ignored',
+ );
+ });
+});
diff --git a/src/utils/customCode.ts b/src/utils/customCode.ts
new file mode 100644
index 0000000000..1edb93cf98
--- /dev/null
+++ b/src/utils/customCode.ts
@@ -0,0 +1,162 @@
+export type CodeSnippet = {
+ name: string;
+ code: string;
+ lang: 'css' | 'js';
+ active: boolean;
+};
+
+export type RegexReplacement = {
+ title: string;
+ pattern: string;
+ flags: string;
+ replacement: string;
+ active: boolean;
+};
+
+export const REGEX_FLAGS = ['g', 'i', 'm', 's', 'u', 'y'] as const;
+
+type LegacyCustomCodeSettings = {
+ customCSS?: string;
+ customJS?: string;
+ codeSnippetsCSS?: CodeSnippet[];
+ codeSnippetsJS?: CodeSnippet[];
+};
+
+type MigratedCustomCodeSettings = Omit<
+ T,
+ 'customCSS' | 'customJS' | 'codeSnippetsCSS' | 'codeSnippetsJS'
+> & {
+ codeSnippetsCSS: CodeSnippet[];
+ codeSnippetsJS: CodeSnippet[];
+};
+
+type LegacyCustomCodeMigration = {
+ settings: MigratedCustomCodeSettings;
+ didMigrate: boolean;
+};
+
+const appendLegacySnippet = (
+ snippets: CodeSnippet[],
+ code: string | undefined,
+ lang: CodeSnippet['lang'],
+): CodeSnippet[] => {
+ if (
+ !code?.trim() ||
+ snippets.some(snippet => snippet.code.trim() === code.trim())
+ ) {
+ return snippets;
+ }
+
+ return [
+ ...snippets,
+ {
+ name: lang === 'css' ? 'Custom CSS' : 'Custom JS',
+ code,
+ lang,
+ active: true,
+ },
+ ];
+};
+
+export const migrateLegacyCustomCode = (
+ settings: T,
+): LegacyCustomCodeMigration => {
+ const hasLegacyCSS = Object.prototype.hasOwnProperty.call(
+ settings,
+ 'customCSS',
+ );
+ const hasLegacyJS = Object.prototype.hasOwnProperty.call(
+ settings,
+ 'customJS',
+ );
+ const {
+ customCSS,
+ customJS,
+ codeSnippetsCSS = [],
+ codeSnippetsJS = [],
+ ...settingsWithoutLegacyCode
+ } = settings;
+
+ return {
+ settings: {
+ ...settingsWithoutLegacyCode,
+ codeSnippetsCSS: appendLegacySnippet(codeSnippetsCSS, customCSS, 'css'),
+ codeSnippetsJS: appendLegacySnippet(codeSnippetsJS, customJS, 'js'),
+ } as MigratedCustomCodeSettings,
+ didMigrate: hasLegacyCSS || hasLegacyJS,
+ };
+};
+
+export const composeCSS = (snippets: CodeSnippet[]): string =>
+ snippets
+ .filter(snippet => snippet.active)
+ .map(snippet => snippet.code)
+ .join('\n');
+
+export const serializeInlineScriptValue = (value: unknown): string =>
+ JSON.stringify(value)?.replace(/
+ snippets
+ .filter(snippet => snippet.active)
+ .map(
+ snippet => `try {
+ const previousHtml = chapterElement.innerHTML;
+ const executeSnippet = new Function(
+ 'html',
+ 'novelName',
+ 'chapterName',
+ 'sourceId',
+ 'chapterId',
+ 'novelId',
+ ${serializeInlineScriptValue(`${snippet.code}\nreturn html;`)},
+ );
+ const nextHtml = executeSnippet(
+ previousHtml,
+ novelName,
+ chapterName,
+ sourceId,
+ chapterId,
+ novelId,
+ );
+ if (typeof nextHtml === 'string' && nextHtml !== previousHtml) {
+ chapterElement.innerHTML = nextHtml;
+ }
+} catch (error) {
+ console.error(${serializeInlineScriptValue(
+ `Custom snippet "${snippet.name}" failed`,
+ )}, error);
+}`,
+ )
+ .join('\n');
+
+export const compileRegex = (pattern: string, flags: string): RegExp => {
+ const unsupportedFlag = [...flags].find(
+ flag => !REGEX_FLAGS.includes(flag as (typeof REGEX_FLAGS)[number]),
+ );
+ if (unsupportedFlag) {
+ throw new Error(`Unsupported regex flag: ${unsupportedFlag}`);
+ }
+
+ return new RegExp(pattern, flags);
+};
+
+export const runRegexReplacement = (
+ input: string,
+ rule: RegexReplacement,
+): string =>
+ input.replace(compileRegex(rule.pattern, rule.flags), rule.replacement);
+
+export const applyRegexReplacements = (
+ input: string,
+ rules: RegexReplacement[],
+): string =>
+ rules.reduce((result, rule) => {
+ if (!rule.active || !rule.pattern) return result;
+
+ try {
+ return runRegexReplacement(result, rule);
+ } catch {
+ return result;
+ }
+ }, input);
diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json
index a626ef9c4b..018b1fc394 100644
--- a/strings/languages/en/strings.json
+++ b/strings/languages/en/strings.json
@@ -756,6 +756,54 @@
"playbackError": "Unable to play text to speech"
}
},
+ "customCodeSettings": {
+ "regexFindReplace": "Regex Find & Replace",
+ "addRegexRule": "Add regex rule",
+ "editRegexRule": "Edit regex rule",
+ "ruleTitle": "Title",
+ "titleRequired": "Title is required",
+ "regexPattern": "Pattern",
+ "regexFlags": "Regex flags",
+ "regexFlag": {
+ "g": "g — Global",
+ "i": "i — Ignore case",
+ "m": "m — Multiline",
+ "s": "s — Dot all",
+ "u": "u — Unicode",
+ "y": "y — Sticky"
+ },
+ "patternRequired": "Pattern is required",
+ "replaceWith": "Replace with",
+ "emptyReplacementHint": "Leave empty to remove matches. JavaScript replacements such as $1, $& and named groups are supported.",
+ "testRegex": "Test regex",
+ "sampleInput": "Sample input",
+ "runTest": "Run test",
+ "testOutput": "Output",
+ "emptyOutput": "(empty output)",
+ "cssSnippets": "CSS Snippets",
+ "javascriptSnippets": "JavaScript Snippets",
+ "addSnippet": "Add snippet",
+ "editSnippet": "Edit snippet",
+ "snippetName": "Name",
+ "snippetCode": "Code",
+ "nameRequired": "Name is required",
+ "codeRequired": "Code is required",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "remove": "(remove)",
+ "noRegexRules": "No rules added. Rules apply to chapter content before rendering.",
+ "noSnippets": "No snippets added",
+ "editRule": "Edit rule",
+ "deleteRule": "Delete rule",
+ "editSnippetAction": "Edit snippet",
+ "deleteSnippetAction": "Delete snippet",
+ "deleteTitle": "Delete custom code?",
+ "deleteMessage": "This item will be permanently removed.",
+ "pluginCSS": "Enable plugin CSS",
+ "pluginCSSDescription": "Load custom.css supplied by the source plugin",
+ "pluginJS": "Enable plugin JavaScript",
+ "pluginJSDescription": "Run custom.js supplied by the source plugin"
+ },
"translateSettings": "Translate",
"translateSettingsScreen": {
"engineSection": "Translation engine",
diff --git a/strings/languages/vi_VN/strings.json b/strings/languages/vi_VN/strings.json
index 7d6e3f9959..fe18a537a0 100644
--- a/strings/languages/vi_VN/strings.json
+++ b/strings/languages/vi_VN/strings.json
@@ -755,6 +755,54 @@
"playbackError": "Không thể phát nội dung đọc văn bản"
}
},
+ "customCodeSettings": {
+ "regexFindReplace": "Tìm & Thay thế bằng Regex",
+ "addRegexRule": "Thêm quy tắc Regex",
+ "editRegexRule": "Sửa quy tắc Regex",
+ "ruleTitle": "Tiêu đề",
+ "titleRequired": "Tiêu đề là bắt buộc",
+ "regexPattern": "Mẫu Regex",
+ "regexFlags": "Cờ Regex",
+ "regexFlag": {
+ "g": "g — Toàn cục",
+ "i": "i — Không phân biệt hoa thường",
+ "m": "m — Nhiều dòng",
+ "s": "s — Dấu chấm khớp mọi ký tự",
+ "u": "u — Unicode",
+ "y": "y — Khớp tại vị trí hiện tại"
+ },
+ "patternRequired": "Mẫu Regex là bắt buộc",
+ "replaceWith": "Thay thế bằng",
+ "emptyReplacementHint": "Để trống để xóa phần khớp. Hỗ trợ cú pháp thay thế JavaScript như $1, $& và nhóm có tên.",
+ "testRegex": "Kiểm thử Regex",
+ "sampleInput": "Nội dung mẫu",
+ "runTest": "Chạy thử",
+ "testOutput": "Kết quả",
+ "emptyOutput": "(kết quả rỗng)",
+ "cssSnippets": "Đoạn mã CSS",
+ "javascriptSnippets": "Đoạn mã JavaScript",
+ "addSnippet": "Thêm đoạn mã",
+ "editSnippet": "Sửa đoạn mã",
+ "snippetName": "Tên",
+ "snippetCode": "Mã nguồn",
+ "nameRequired": "Tên là bắt buộc",
+ "codeRequired": "Mã nguồn là bắt buộc",
+ "enabled": "Đã bật",
+ "disabled": "Đã tắt",
+ "remove": "(xóa)",
+ "noRegexRules": "Chưa có quy tắc. Các quy tắc được áp dụng cho nội dung chương trước khi hiển thị.",
+ "noSnippets": "Chưa có đoạn mã",
+ "editRule": "Sửa quy tắc",
+ "deleteRule": "Xóa quy tắc",
+ "editSnippetAction": "Sửa đoạn mã",
+ "deleteSnippetAction": "Xóa đoạn mã",
+ "deleteTitle": "Xóa mã tùy chỉnh?",
+ "deleteMessage": "Mục này sẽ bị xóa vĩnh viễn.",
+ "pluginCSS": "Bật CSS của plugin",
+ "pluginCSSDescription": "Tải custom.css do plugin nguồn cung cấp",
+ "pluginJS": "Bật JavaScript của plugin",
+ "pluginJSDescription": "Chạy custom.js do plugin nguồn cung cấp"
+ },
"translateSettings": "Dịch thuật",
"translateSettingsScreen": {
"engineSection": "Công cụ dịch",
diff --git a/strings/types/index.ts b/strings/types/index.ts
index be023192f3..4786cc5b84 100644
--- a/strings/types/index.ts
+++ b/strings/types/index.ts
@@ -652,6 +652,50 @@ export interface StringMap {
'readerSettings.tts.voiceRequiredError': 'string';
'readerSettings.tts.engineUnavailableError': 'string';
'readerSettings.tts.playbackError': 'string';
+ 'customCodeSettings.regexFindReplace': 'string';
+ 'customCodeSettings.addRegexRule': 'string';
+ 'customCodeSettings.editRegexRule': 'string';
+ 'customCodeSettings.ruleTitle': 'string';
+ 'customCodeSettings.titleRequired': 'string';
+ 'customCodeSettings.regexPattern': 'string';
+ 'customCodeSettings.regexFlags': 'string';
+ 'customCodeSettings.regexFlag.g': 'string';
+ 'customCodeSettings.regexFlag.i': 'string';
+ 'customCodeSettings.regexFlag.m': 'string';
+ 'customCodeSettings.regexFlag.s': 'string';
+ 'customCodeSettings.regexFlag.u': 'string';
+ 'customCodeSettings.regexFlag.y': 'string';
+ 'customCodeSettings.patternRequired': 'string';
+ 'customCodeSettings.replaceWith': 'string';
+ 'customCodeSettings.emptyReplacementHint': 'string';
+ 'customCodeSettings.testRegex': 'string';
+ 'customCodeSettings.sampleInput': 'string';
+ 'customCodeSettings.runTest': 'string';
+ 'customCodeSettings.testOutput': 'string';
+ 'customCodeSettings.emptyOutput': 'string';
+ 'customCodeSettings.cssSnippets': 'string';
+ 'customCodeSettings.javascriptSnippets': 'string';
+ 'customCodeSettings.addSnippet': 'string';
+ 'customCodeSettings.editSnippet': 'string';
+ 'customCodeSettings.snippetName': 'string';
+ 'customCodeSettings.snippetCode': 'string';
+ 'customCodeSettings.nameRequired': 'string';
+ 'customCodeSettings.codeRequired': 'string';
+ 'customCodeSettings.enabled': 'string';
+ 'customCodeSettings.disabled': 'string';
+ 'customCodeSettings.remove': 'string';
+ 'customCodeSettings.noRegexRules': 'string';
+ 'customCodeSettings.noSnippets': 'string';
+ 'customCodeSettings.editRule': 'string';
+ 'customCodeSettings.deleteRule': 'string';
+ 'customCodeSettings.editSnippetAction': 'string';
+ 'customCodeSettings.deleteSnippetAction': 'string';
+ 'customCodeSettings.deleteTitle': 'string';
+ 'customCodeSettings.deleteMessage': 'string';
+ 'customCodeSettings.pluginCSS': 'string';
+ 'customCodeSettings.pluginCSSDescription': 'string';
+ 'customCodeSettings.pluginJS': 'string';
+ 'customCodeSettings.pluginJSDescription': 'string';
'translateSettings': 'string';
'translateSettingsScreen.engineSection': 'string';
'translateSettingsScreen.languagesSection': 'string';
From c5f05508fecd5e0a258d87dfa2724c8d2caf7d52 Mon Sep 17 00:00:00 2001
From: Elysia <71698422+aiko-chan-ai@users.noreply.github.com>
Date: Sun, 19 Jul 2026 03:16:21 +0700
Subject: [PATCH 07/18] 5th
---
.../__tests__/KeyboardAvoidingModal.test.tsx | 12 +-
.../__tests__/KeyboardAwareModal.test.tsx | 101 ++++++-
.../Modal/useKeyboardModalDismiss.ts | 40 ++-
.../components/CustomCodeCard.tsx | 112 +++----
.../ReaderSettings/components/RegexDialog.tsx | 276 +++++++++++-------
.../components/SnippetDialog.tsx | 90 +++---
.../__tests__/CustomCodeCard.test.tsx | 21 ++
.../__tests__/CustomCodeDialogs.test.tsx | 162 ++++++++++
.../ReaderSettings/tabs/AdvancedTab.tsx | 41 ++-
9 files changed, 627 insertions(+), 228 deletions(-)
create mode 100644 src/screens/reader/components/ReaderSettings/components/__tests__/CustomCodeDialogs.test.tsx
diff --git a/src/components/Modal/__tests__/KeyboardAvoidingModal.test.tsx b/src/components/Modal/__tests__/KeyboardAvoidingModal.test.tsx
index 076838b02d..d687511fdc 100644
--- a/src/components/Modal/__tests__/KeyboardAvoidingModal.test.tsx
+++ b/src/components/Modal/__tests__/KeyboardAvoidingModal.test.tsx
@@ -1,8 +1,14 @@
-import { act, render, screen } from '@testing-library/react-native';
+import { act, render, screen, waitFor } from '@testing-library/react-native';
import React from 'react';
import KeyboardAvoidingModal from '../KeyboardAvoidingModal';
+jest.mock('react-native-keyboard-controller', () => ({
+ KeyboardController: {
+ dismiss: jest.fn(() => Promise.resolve()),
+ },
+}));
+
const mockButtonPresses = new Map unknown>();
type MockButtonProps = {
@@ -206,7 +212,7 @@ describe('KeyboardAvoidingModal', () => {
});
});
- it('runs reset without dismissing and cancel before one dismiss', () => {
+ it('runs reset without dismissing and cancel before one dismiss', async () => {
const onReset = jest.fn();
const onCancel = jest.fn();
const onDismiss = jest.fn();
@@ -228,6 +234,6 @@ describe('KeyboardAvoidingModal', () => {
act(() => pressButton('common.cancel'));
expect(onCancel).toHaveBeenCalledTimes(1);
- expect(onDismiss).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(onDismiss).toHaveBeenCalledTimes(1));
});
});
diff --git a/src/components/Modal/__tests__/KeyboardAwareModal.test.tsx b/src/components/Modal/__tests__/KeyboardAwareModal.test.tsx
index e038708772..5d7fc72e85 100644
--- a/src/components/Modal/__tests__/KeyboardAwareModal.test.tsx
+++ b/src/components/Modal/__tests__/KeyboardAwareModal.test.tsx
@@ -1,6 +1,13 @@
-import { fireEvent, render, screen } from '@testing-library/react-native';
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react-native';
import React from 'react';
import { Keyboard, StyleProp, StyleSheet, Text, ViewStyle } from 'react-native';
+import { KeyboardController } from 'react-native-keyboard-controller';
import KeyboardAwareModal from '../KeyboardAwareModal';
@@ -19,6 +26,9 @@ jest.mock('@hooks/persisted', () => ({
}));
jest.mock('react-native-keyboard-controller', () => ({
+ KeyboardController: {
+ dismiss: jest.fn(() => Promise.resolve()),
+ },
useAnimatedKeyboard: () => ({
height: { value: 0 },
state: { value: 0 },
@@ -50,11 +60,19 @@ jest.mock('react-native-paper', () => {
});
describe('KeyboardAwareModal', () => {
+ const keyboardControllerDismiss =
+ KeyboardController.dismiss as jest.MockedFunction<
+ typeof KeyboardController.dismiss
+ >;
+
beforeEach(() => {
+ jest.clearAllMocks();
jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => undefined);
+ keyboardControllerDismiss.mockReset();
+ keyboardControllerDismiss.mockResolvedValue();
});
- it('renders its regions, merges wrapper styles and centralizes dismiss', () => {
+ it('renders its regions, merges wrapper styles and centralizes dismiss', async () => {
const onDismiss = jest.fn();
render(
@@ -82,6 +100,85 @@ describe('KeyboardAwareModal', () => {
fireEvent.press(screen.getByTestId('dismiss-modal'));
+ await waitFor(() => {
+ expect(Keyboard.dismiss).toHaveBeenCalledTimes(1);
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('dismisses the controller keyboard before hiding the modal', async () => {
+ let resolveKeyboardDismiss: (() => void) | undefined;
+ keyboardControllerDismiss.mockImplementation(
+ () =>
+ new Promise(resolve => {
+ resolveKeyboardDismiss = resolve;
+ }),
+ );
+ const onDismiss = jest.fn();
+
+ render(
+
+ Modal body
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('dismiss-modal'));
+
+ expect(keyboardControllerDismiss).toHaveBeenCalledWith({
+ animated: false,
+ });
+ expect(onDismiss).not.toHaveBeenCalled();
+
+ await act(async () => resolveKeyboardDismiss?.());
+
+ expect(Keyboard.dismiss).toHaveBeenCalledTimes(1);
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back when the controller keyboard never settles', async () => {
+ jest.useFakeTimers();
+ keyboardControllerDismiss.mockImplementation(() => new Promise(() => {}));
+ const onDismiss = jest.fn();
+
+ render(
+
+ Modal body
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('dismiss-modal'));
+ expect(onDismiss).not.toHaveBeenCalled();
+
+ await act(async () => jest.runAllTimers());
+
+ expect(Keyboard.dismiss).toHaveBeenCalledTimes(1);
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ jest.useRealTimers();
+ });
+
+ it('ignores repeated dismiss requests while one is in flight', async () => {
+ let resolveKeyboardDismiss: (() => void) | undefined;
+ keyboardControllerDismiss.mockImplementation(
+ () =>
+ new Promise(resolve => {
+ resolveKeyboardDismiss = resolve;
+ }),
+ );
+ const onDismiss = jest.fn();
+
+ render(
+
+ Modal body
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId('dismiss-modal'));
+ fireEvent.press(screen.getByTestId('dismiss-modal'));
+
+ expect(keyboardControllerDismiss).toHaveBeenCalledTimes(1);
+
+ await act(async () => resolveKeyboardDismiss?.());
+
expect(Keyboard.dismiss).toHaveBeenCalledTimes(1);
expect(onDismiss).toHaveBeenCalledTimes(1);
});
diff --git a/src/components/Modal/useKeyboardModalDismiss.ts b/src/components/Modal/useKeyboardModalDismiss.ts
index d2edf9e2a6..1bf4bfd875 100644
--- a/src/components/Modal/useKeyboardModalDismiss.ts
+++ b/src/components/Modal/useKeyboardModalDismiss.ts
@@ -1,10 +1,42 @@
-import { useCallback } from 'react';
+import { useCallback, useRef } from 'react';
import { Keyboard } from 'react-native';
+import { KeyboardController } from 'react-native-keyboard-controller';
+
+const KEYBOARD_DISMISS_TIMEOUT_MS = 250;
+
+const dismissControllerKeyboard = async () => {
+ let timeout: ReturnType | undefined;
+
+ try {
+ await Promise.race([
+ KeyboardController.dismiss({ animated: false }),
+ new Promise(resolve => {
+ timeout = setTimeout(resolve, KEYBOARD_DISMISS_TIMEOUT_MS);
+ }),
+ ]);
+ } catch {
+ // React Native's keyboard API remains the fallback.
+ } finally {
+ if (timeout) clearTimeout(timeout);
+ }
+};
+
+const useKeyboardModalDismiss = (onDismiss: () => void) => {
+ const dismissing = useRef(false);
+
+ return useCallback(async () => {
+ if (dismissing.current) return;
+ dismissing.current = true;
-const useKeyboardModalDismiss = (onDismiss: () => void) =>
- useCallback(() => {
Keyboard.dismiss();
- onDismiss();
+ await dismissControllerKeyboard();
+
+ try {
+ onDismiss();
+ } finally {
+ dismissing.current = false;
+ }
}, [onDismiss]);
+};
export default useKeyboardModalDismiss;
diff --git a/src/screens/reader/components/ReaderSettings/components/CustomCodeCard.tsx b/src/screens/reader/components/ReaderSettings/components/CustomCodeCard.tsx
index cc30496d45..0970d438dc 100644
--- a/src/screens/reader/components/ReaderSettings/components/CustomCodeCard.tsx
+++ b/src/screens/reader/components/ReaderSettings/components/CustomCodeCard.tsx
@@ -4,15 +4,15 @@ import {
type GestureResponderEvent,
Pressable,
StyleSheet,
- Text,
View,
} from 'react-native';
-import { Card, IconButton } from 'react-native-paper';
+import { Card, IconButton, Text } from 'react-native-paper';
type Props = {
title: string;
description: string;
detail?: string;
+ showDetail?: boolean;
active: boolean;
editLabel: string;
deleteLabel: string;
@@ -26,6 +26,7 @@ const CustomCodeCard = ({
title,
description,
detail,
+ showDetail = false,
active,
editLabel,
deleteLabel,
@@ -54,50 +55,59 @@ const CustomCodeCard = ({
accessibilityState={{ checked: active }}
android_ripple={{ color: theme.rippleColor }}
onPress={onToggle}
- style={styles.content}
>
-
-
- {title}
-
-
- {description}
-
- {detail ? (
+
+
- {detail}
+ {title}
- ) : null}
-
-
-
-
+
+ {description}
+
+ {showDetail && detail ? (
+
+ {detail}
+
+ ) : null}
+
+
+
+
+
@@ -107,21 +117,17 @@ const CustomCodeCard = ({
export default React.memo(CustomCodeCard);
const styles = StyleSheet.create({
- actions: { flexDirection: 'row', marginEnd: -8 },
- card: { borderRadius: 16, marginBottom: 8 },
+ action: { margin: 0 },
+ actions: { flexDirection: 'row' },
+ card: { borderRadius: 12, marginVertical: 4, overflow: 'hidden' },
content: {
alignItems: 'center',
flexDirection: 'row',
- minHeight: 88,
- padding: 12,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
},
+ contentCompact: { minHeight: 72 },
+ contentWithDetail: { minHeight: 88 },
copy: { flex: 1, minWidth: 0 },
- description: { fontSize: 13, lineHeight: 18, marginTop: 2 },
- detail: {
- fontFamily: 'monospace',
- fontSize: 12,
- lineHeight: 17,
- marginTop: 2,
- },
- title: { fontSize: 16, lineHeight: 22 },
+ detail: { fontFamily: 'monospace', marginTop: 2 },
});
diff --git a/src/screens/reader/components/ReaderSettings/components/RegexDialog.tsx b/src/screens/reader/components/ReaderSettings/components/RegexDialog.tsx
index 7f7d7d45b5..6a99884f9f 100644
--- a/src/screens/reader/components/ReaderSettings/components/RegexDialog.tsx
+++ b/src/screens/reader/components/ReaderSettings/components/RegexDialog.tsx
@@ -15,8 +15,8 @@ import {
runRegexReplacement,
} from '@utils/customCode';
import React, { useEffect, useState } from 'react';
-import { Pressable, StyleSheet, Text, View } from 'react-native';
-import { HelperText } from 'react-native-paper';
+import { Pressable, StyleSheet, View } from 'react-native';
+import { HelperText, Icon, Text } from 'react-native-paper';
type Props = {
visible: boolean;
@@ -49,6 +49,16 @@ const RegexDialog = ({ visible, initialRule, onDismiss, onSave }: Props) => {
const [testOutput, setTestOutput] = useState();
const [regexError, setRegexError] = useState('');
const [submitted, setSubmitted] = useState(false);
+ const inputTheme = {
+ colors: {
+ background: theme.surface,
+ error: theme.error,
+ onSurface: theme.onSurface,
+ onSurfaceVariant: theme.onSurfaceVariant,
+ outline: theme.outline,
+ primary: theme.primary,
+ },
+ };
useEffect(() => {
if (visible) {
@@ -127,120 +137,156 @@ const RegexDialog = ({ visible, initialRule, onDismiss, onSave }: Props) => {
)}
visible={visible}
>
-
-
- {getString('customCodeSettings.titleRequired')}
-
+
+
+ {submitted && !title.trim() ? (
+
+ {getString('customCodeSettings.titleRequired')}
+
+ ) : null}
+
-
- /
+
+
+
+ /
+
+ {
+ setPattern(value);
+ setRegexError('');
+ setTestOutput(undefined);
+ }}
+ spellCheck={false}
+ style={[styles.input, styles.pattern]}
+ textColor={theme.onSurface}
+ theme={inputTheme}
+ value={pattern}
+ />
+
+
+ {(submitted && !pattern) || regexError ? (
+
+ {regexError || getString('customCodeSettings.patternRequired')}
+
+ ) : null}
+
+
+
{
- setPattern(value);
- setRegexError('');
+ setReplacement(value);
setTestOutput(undefined);
}}
- spellCheck={false}
- style={styles.pattern}
- value={pattern}
+ style={styles.input}
+ textColor={theme.onSurface}
+ theme={inputTheme}
+ value={replacement}
/>
-
+ {getString('customCodeSettings.emptyReplacementHint')}
+
-
- {regexError || getString('customCodeSettings.patternRequired')}
-
-
- {
- setReplacement(value);
- setTestOutput(undefined);
- }}
- value={replacement}
- />
-
- {getString('customCodeSettings.emptyReplacementHint')}
-
-
- {getString('customCodeSettings.testRegex')}
-
- {
- setSampleInput(value);
- setTestOutput(undefined);
- }}
- value={sampleInput}
- />
-
- {testOutput !== undefined ? (
-
-
- {getString('customCodeSettings.testOutput')}
-
-
- {testOutput || getString('customCodeSettings.emptyOutput')}
+
+
+
+
+ {getString('customCodeSettings.testRegex')}
- ) : null}
+ {
+ setSampleInput(value);
+ setTestOutput(undefined);
+ }}
+ style={[styles.input, styles.sampleInput]}
+ textColor={theme.onSurface}
+ theme={inputTheme}
+ value={sampleInput}
+ />
+
+ {testOutput !== undefined ? (
+
+
+ {getString('customCodeSettings.testOutput')}
+
+
+ {testOutput || getString('customCodeSettings.emptyOutput')}
+
+
+ ) : null}
+
);
};
@@ -248,19 +294,23 @@ const RegexDialog = ({ visible, initialRule, onDismiss, onSave }: Props) => {
export default React.memo(RegexDialog);
const styles = StyleSheet.create({
- content: { gap: 4 },
- expressionRow: { alignItems: 'center', flexDirection: 'row' },
- flags: { fontFamily: 'monospace', fontSize: 16, fontWeight: '600' },
+ content: { gap: 16 },
+ expressionRow: { alignItems: 'center', flexDirection: 'row', gap: 8 },
+ field: { gap: 0 },
flagsAnchor: {
- borderRadius: 8,
+ borderRadius: 12,
justifyContent: 'center',
- minHeight: 48,
- minWidth: 48,
- paddingHorizontal: 8,
+ minHeight: 56,
+ minWidth: 52,
+ overflow: 'hidden',
+ paddingHorizontal: 10,
},
- output: { borderRadius: 12, gap: 4, padding: 12 },
- outputLabel: { fontSize: 12, fontWeight: '600' },
+ input: { backgroundColor: 'transparent' },
+ output: { borderRadius: 12, gap: 6, padding: 12 },
pattern: { flex: 1 },
- slash: { fontFamily: 'monospace', fontSize: 20, marginEnd: 8 },
- testTitle: { fontSize: 16, fontWeight: '600', marginTop: 8 },
+ sampleInput: { minHeight: 96 },
+ slash: { marginTop: 10, fontSize: 16 },
+ supportingText: { marginStart: 16, marginTop: 16, marginEnd: 16 },
+ testHeader: { alignItems: 'center', flexDirection: 'row', gap: 8 },
+ testSection: { borderRadius: 16, gap: 12, padding: 16 },
});
diff --git a/src/screens/reader/components/ReaderSettings/components/SnippetDialog.tsx b/src/screens/reader/components/ReaderSettings/components/SnippetDialog.tsx
index 184ec92f4f..fc0fed360d 100644
--- a/src/screens/reader/components/ReaderSettings/components/SnippetDialog.tsx
+++ b/src/screens/reader/components/ReaderSettings/components/SnippetDialog.tsx
@@ -6,7 +6,7 @@ import type { CodeSnippet } from '@utils/customCode';
import { showToast } from '@utils/showToast';
import * as DocumentPicker from 'expo-document-picker';
import React, { useEffect, useState } from 'react';
-import { StyleSheet } from 'react-native';
+import { StyleSheet, View } from 'react-native';
import { HelperText } from 'react-native-paper';
type Props = {
@@ -28,6 +28,16 @@ const SnippetDialog = ({
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [submitted, setSubmitted] = useState(false);
+ const inputTheme = {
+ colors: {
+ background: theme.surface,
+ error: theme.error,
+ onSurface: theme.onSurface,
+ onSurfaceVariant: theme.onSurfaceVariant,
+ outline: theme.outline,
+ primary: theme.primary,
+ },
+ };
useEffect(() => {
if (visible) {
@@ -93,41 +103,46 @@ const SnippetDialog = ({
)}
visible={visible}
>
-
-
- {getString('customCodeSettings.nameRequired')}
-
-
-
- {getString('customCodeSettings.codeRequired')}
-
+
+
+ {submitted && !name.trim() ? (
+
+ {getString('customCodeSettings.nameRequired')}
+
+ ) : null}
+
+
+
+ {submitted && !code.trim() ? (
+
+ {getString('customCodeSettings.codeRequired')}
+
+ ) : null}
+