diff --git a/android/app/src/main/assets/css/index.css b/android/app/src/main/assets/css/index.css
index 2129cff27d..17fd928e54 100644
--- a/android/app/src/main/assets/css/index.css
+++ b/android/app/src/main/assets/css/index.css
@@ -42,6 +42,12 @@ body:not(.page-reader) .transition-chapter {
padding-right: var(--readerSettings-padding);
}
+#LNReader-chapter p {
+ margin-block-start: var(--readerSettings-paragraphSpacing);
+ margin-block-end: var(--readerSettings-paragraphSpacing);
+ text-indent: var(--readerSettings-paragraphIndent);
+}
+
hr {
margin-top: 20px;
margin-bottom: 20px;
diff --git a/android/app/src/main/assets/js/core.js b/android/app/src/main/assets/js/core.js
index 5817e1da6e..28ff277419 100644
--- a/android/app/src/main/assets/js/core.js
+++ b/android/app/src/main/assets/js/core.js
@@ -131,9 +131,10 @@ function defineSafeProp(globalName, getParent, key) {
});
}
-defineSafeProp('novelName', () => window.reader?.novel, 'name');
-defineSafeProp('chapterName', () => window.reader?.chapter, 'name');
-defineSafeProp('sourceId', () => window.reader?.novel, 'pluginId');
-defineSafeProp('chapterId', () => window.reader?.chapter, 'id');
-defineSafeProp('novelId', () => window.reader?.novel, 'id');
-defineSafeProp('html', () => window.chapterElement, 'innerHTML');
\ No newline at end of file
+defineSafeProp('novelName', () => window.reader?.novel, 'name');
+defineSafeProp('chapterName', () => window.reader?.chapter, 'name');
+defineSafeProp('sourceId', () => window.reader?.novel, 'pluginId');
+defineSafeProp('chapterId', () => window.reader?.chapter, 'id');
+defineSafeProp('novelId', () => window.reader?.novel, 'id');
+defineSafeProp('chapterElement', () => window.reader, 'chapterElement');
+defineSafeProp('html', () => window.chapterElement, 'innerHTML');
diff --git a/android/app/src/main/assets/js/theme.js b/android/app/src/main/assets/js/theme.js
index 993c8c7fdb..6b493c1109 100644
--- a/android/app/src/main/assets/js/theme.js
+++ b/android/app/src/main/assets/js/theme.js
@@ -12,6 +12,14 @@
'--readerSettings-padding',
settings.padding + 'px',
);
+ document.documentElement.style.setProperty(
+ '--readerSettings-paragraphIndent',
+ (settings.paragraphIndent ?? 0) + 'em',
+ );
+ document.documentElement.style.setProperty(
+ '--readerSettings-paragraphSpacing',
+ (settings.paragraphSpacing ?? 1) + 'em',
+ );
document.documentElement.style.setProperty(
'--readerSettings-textSize',
settings.textSize + 'px',
@@ -50,30 +58,36 @@
// text options
(() => {
- van.derive(() => {
- let html = reader.rawHTML;
- if (reader.generalSettings.val.bionicReading) {
- html = textVide.textVide(reader.rawHTML);
- }
+ const { bionicReading, removeExtraParagraphSpacing } =
+ reader.generalSettings.val;
+ let html = reader.rawHTML;
+ if (bionicReading) {
+ html = textVide.textVide(reader.rawHTML);
+ }
- if (reader.generalSettings.val.removeExtraParagraphSpacing) {
- html = html
- .replace(/(?: \s*|[\u200b]\s*)+(?=<\/?p[> ])/g, '')
- .replace(/
\s*
\s*(?:
\s*)+/g, '
') //force max 2 consecutive
, chaining regex
- .replace(
- /
\s*
[^]+/,
- _ =>
- `${
- /\/p>/.test(_)
- ? _.replace(
- /
\s*
(?:(?=\s*<\/?p[> ])|(?<=<\/?p(?:>| [^>]+>)
\s*
))\s*/g,
- '',
- )
- : _
- }`,
- ) //if p found, delete all double br near p
- .replace(/
(?:(?=\s*<\/?p[> ])|(?<=<\/?p(?:>| [^>]+>)(?:<[^>]+>)*\s*
))\s*/g, '');
- }
+ if (removeExtraParagraphSpacing) {
+ html = html
+ .replace(/(?: \s*|[\u200b]\s*)+(?=<\/?p[> ])/g, '')
+ .replace(/
\s*
\s*(?:
\s*)+/g, '
') //force max 2 consecutive
, chaining regex
+ .replace(
+ /
\s*
[^]+/,
+ _ =>
+ `${
+ /\/p>/.test(_)
+ ? _.replace(
+ /
\s*
(?:(?=\s*<\/?p[> ])|(?<=<\/?p(?:>| [^>]+>)
\s*
))\s*/g,
+ '',
+ )
+ : _
+ }`,
+ ) //if p found, delete all double br near p
+ .replace(
+ /
(?:(?=\s*<\/?p[> ])|(?<=<\/?p(?:>| [^>]+>)(?:<[^>]+>)*\s*
))\s*/g,
+ '',
+ );
+ }
+
+ if (bionicReading || removeExtraParagraphSpacing) {
reader.chapterElement.innerHTML = html;
- });
+ }
})();
diff --git a/android/app/src/main/java/com/rajarsheechatterjee/TikTokTTS/TikTokTTSModule.kt b/android/app/src/main/java/com/rajarsheechatterjee/TikTokTTS/TikTokTTSModule.kt
index 3dc626fc2f..2152e7453f 100644
--- a/android/app/src/main/java/com/rajarsheechatterjee/TikTokTTS/TikTokTTSModule.kt
+++ b/android/app/src/main/java/com/rajarsheechatterjee/TikTokTTS/TikTokTTSModule.kt
@@ -153,9 +153,6 @@ class TikTokTTSModule(private val reactContext: ReactApplicationContext) :
openWebSockets.add(webSocket)
val message = createTikTokMessage(text, voice)
webSocket.send(message)
- if (waitingForHash == hash) {
- sendEvent("TikTokTTS_onStart", null)
- }
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
@@ -341,5 +338,6 @@ class TikTokTTSModule(private val reactContext: ReactApplicationContext) :
})
audioTrack?.play()
+ sendEvent("TikTokTTS_onStart", null)
}
}
diff --git a/src/components/ColorPickerModal/ColorPickerModal.tsx b/src/components/ColorPickerModal/ColorPickerModal.tsx
index 3769c63746..7c31f0680b 100644
--- a/src/components/ColorPickerModal/ColorPickerModal.tsx
+++ b/src/components/ColorPickerModal/ColorPickerModal.tsx
@@ -75,6 +75,8 @@ const ColorPickerModal: React.FC = ({
= ({
({
+ opacity: disabled ? 0.6 : 1,
+});
+
+const getToggleButtonContainerStyle = (
selected: boolean,
theme: ThemeColors,
- disabled?: boolean,
) => ({
- opacity: disabled ? 0.6 : 1,
backgroundColor: selected
? Color(theme.primary).alpha(0.12).string()
- : 'transparent',
+ : theme.surface,
});
const getToggleColorButtonPressableStyle = (backgroundColor: string) => ({
@@ -42,12 +44,17 @@ export const ToggleButton: React.FC = ({
onPress,
disabled,
}) => (
-
+
({
+ 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/hooks/persisted/__mocks__/useSettings.ts b/src/hooks/persisted/__mocks__/useSettings.ts
index 6be60639bf..91fcba1490 100644
--- a/src/hooks/persisted/__mocks__/useSettings.ts
+++ b/src/hooks/persisted/__mocks__/useSettings.ts
@@ -65,10 +65,15 @@ export const initialChapterReaderSettings = {
textSize: 16,
textAlign: 'left',
padding: 16,
+ paragraphIndent: 0,
+ paragraphSpacing: 1,
fontFamily: '',
lineHeight: 1.5,
- customCSS: '',
- customJS: '',
+ codeSnippetsCSS: [],
+ codeSnippetsJS: [],
+ regexReplacements: [],
+ pluginUseCustomCSS: true,
+ pluginUseCustomJS: true,
customThemes: [],
tts: {
rate: 1,
diff --git a/src/hooks/persisted/useSettings.ts b/src/hooks/persisted/useSettings.ts
index 61448e0148..1a3502c5ea 100644
--- a/src/hooks/persisted/useSettings.ts
+++ b/src/hooks/persisted/useSettings.ts
@@ -4,9 +4,14 @@ import {
LibraryFilter,
LibrarySortOrder,
} from '@screens/library/constants/constants';
+import {
+ type CodeSnippet,
+ migrateLegacyCustomCode,
+ type RegexReplacement,
+} from '@utils/customCode';
import { getMMKVObject } from '@utils/mmkv/mmkv';
import { Voice } from 'expo-speech';
-import { useCallback, useMemo } from 'react';
+import { useCallback, useEffect, useMemo } from 'react';
import { useMMKVObject } from 'react-native-mmkv';
import type { DohProviderId } from '../../services/network/doh';
@@ -37,6 +42,8 @@ export const CHAPTER_READER_SETTINGS = 'CHAPTER_READER_SETTINGS';
export const TRANSLATE_SETTINGS = 'TRANSLATE_SETTINGS';
export const SECURITY_SETTINGS = 'SECURITY_SETTINGS';
+export type { CodeSnippet, RegexReplacement } from '@utils/customCode';
+
export type SwipeAction = 'disabled' | 'bookmark' | 'markAsRead' | 'download';
export interface AppSettings {
@@ -153,10 +160,15 @@ export interface ChapterReaderSettings {
textSize: number;
textAlign: string;
padding: number;
+ paragraphIndent: number;
+ paragraphSpacing: number;
fontFamily: string;
lineHeight: number;
- customCSS: string;
- customJS: string;
+ codeSnippetsCSS: CodeSnippet[];
+ codeSnippetsJS: CodeSnippet[];
+ regexReplacements: RegexReplacement[];
+ pluginUseCustomCSS: boolean;
+ pluginUseCustomJS: boolean;
customThemes: ReaderTheme[];
tts?: {
engine?: 'native' | 'tiktok';
@@ -312,10 +324,15 @@ export const initialChapterReaderSettings: ChapterReaderSettings = {
textSize: 16,
textAlign: 'left',
padding: 16,
+ paragraphIndent: 0,
+ paragraphSpacing: 1,
fontFamily: '',
lineHeight: 1.5,
- customCSS: '',
- customJS: '',
+ codeSnippetsCSS: [],
+ codeSnippetsJS: [],
+ regexReplacements: [],
+ pluginUseCustomCSS: true,
+ pluginUseCustomJS: true,
customThemes: [],
tts: {
engine: 'native',
@@ -455,28 +472,52 @@ export const useChapterGeneralSettings = () => {
);
};
+type MigrationChapterReaderSettings = ChapterReaderSettings & {
+ customCSS?: string;
+ customJS?: string;
+};
+
export const useChapterReaderSettings = () => {
- const [storedSettings = initialChapterReaderSettings, setSettings] =
- useMMKVObject(CHAPTER_READER_SETTINGS);
+ const [storedSettings, setSettings] =
+ useMMKVObject(CHAPTER_READER_SETTINGS);
- // Ensure TTS settings have proper defaults (migration for existing users)
- const chapterReaderSettings = useMemo(
- () => ({
- ...storedSettings,
+ const mergedSettings = useMemo(
+ () => ({ ...initialChapterReaderSettings, ...storedSettings }),
+ [storedSettings],
+ );
+ const legacyMigration = useMemo(
+ () => migrateLegacyCustomCode(mergedSettings),
+ [mergedSettings],
+ );
+
+ // Merge new defaults and expose migrated snippets on the first render.
+ const chapterReaderSettings = useMemo(() => {
+ const migratedSettings = legacyMigration.settings;
+
+ return {
+ ...migratedSettings,
+ textSize: Math.max(
+ 8,
+ mergedSettings.textSize ?? initialChapterReaderSettings.textSize,
+ ),
tts: {
...initialChapterReaderSettings.tts,
- ...storedSettings.tts,
- // Explicitly ensure these defaults if undefined
- autoPageAdvance: storedSettings.tts?.autoPageAdvance ?? false,
- scrollToTop: storedSettings.tts?.scrollToTop ?? true,
- rate: storedSettings.tts?.rate ?? 1,
- pitch: storedSettings.tts?.pitch ?? 1,
- engine: storedSettings.tts?.engine ?? 'native',
- queueSize: storedSettings.tts?.queueSize ?? 3,
+ ...mergedSettings.tts,
+ autoPageAdvance: mergedSettings.tts?.autoPageAdvance ?? false,
+ scrollToTop: mergedSettings.tts?.scrollToTop ?? true,
+ rate: mergedSettings.tts?.rate ?? 1,
+ pitch: mergedSettings.tts?.pitch ?? 1,
+ engine: mergedSettings.tts?.engine ?? 'native',
+ queueSize: mergedSettings.tts?.queueSize ?? 3,
},
- }),
- [storedSettings],
- );
+ } satisfies ChapterReaderSettings;
+ }, [legacyMigration, mergedSettings]);
+
+ useEffect(() => {
+ if (legacyMigration.didMigrate) {
+ setSettings(chapterReaderSettings);
+ }
+ }, [chapterReaderSettings, legacyMigration.didMigrate, setSettings]);
const setChapterReaderSettings = useCallback(
(values: Partial) =>
diff --git a/src/modules/epub-constructor/src/__tests__/main.test.ts b/src/modules/epub-constructor/src/__tests__/main.test.ts
new file mode 100644
index 0000000000..531016b3fc
--- /dev/null
+++ b/src/modules/epub-constructor/src/__tests__/main.test.ts
@@ -0,0 +1,78 @@
+import EpubFile from '../main';
+
+jest.mock('react-native-quick-crypto', () => ({
+ randomUUID: jest.fn(() => 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
+}));
+
+describe('EpubFile.constructEpub', () => {
+ it('wires custom JavaScript only into scripted EPUB chapter artifacts', async () => {
+ const customJavaScript = 'document.body.dataset.ready = "true";';
+ const scriptedFiles = await new EpubFile({
+ title: 'Scripted Book',
+ bookId: 'scripted-book-id',
+ js: customJavaScript,
+ chapters: [
+ {
+ title: 'Chapter One',
+ htmlBody: 'Chapter body
',
+ },
+ ],
+ }).constructEpub();
+
+ expect(
+ scriptedFiles.find(file => file.path === 'EPUB/script.js')?.content,
+ ).toBe(`function fnEpub(){${customJavaScript}}`);
+
+ const scriptedChapters = scriptedFiles.filter(file =>
+ /^EPUB\/content\/.*\.xhtml$/.test(file.path),
+ );
+ expect(scriptedChapters).toHaveLength(1);
+ scriptedChapters.forEach(chapter => {
+ const chapterBodyIndex = chapter.content.indexOf(
+ 'Chapter body
',
+ );
+ const scriptReferenceIndex = chapter.content.indexOf(
+ '',
+ );
+ const invocationIndex = chapter.content.indexOf(
+ '',
+ );
+ const bodyCloseIndex = chapter.content.indexOf('