+ ${preparedDummyHTML}
+
+ {
+ __DEV__ && onLogMessage(ev);
+ const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data);
+ switch (event.type) {
+ case 'hide':
+ break;
+ case 'next':
+ break;
+ case 'prev':
+ break;
+ case 'save':
+ break;
+ case 'speak':
+ if (event.data && typeof event.data === 'string') {
+ Speech.speak(event.data, {
+ onDone() {
+ webViewRef.current?.injectJavaScript('tts.next?.()');
+ },
+ voice: settings.tts?.voice?.identifier,
+ pitch: settings.tts?.pitch || 1,
+ rate: settings.tts?.rate || 1,
+ });
+ } else {
+ webViewRef.current?.injectJavaScript('tts.next?.()');
+ }
+ break;
+ case 'stop-speak':
+ Speech.stop();
+ break;
+ }
+ }}
+ source={webViewSource}
+ />
+ );
+};
+
+export default memo(SettingsWebView);
diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/dummies.ts b/src/screens/settings/SettingsCustomCodeScreen/Components/dummies.ts
new file mode 100644
index 0000000000..0ff72acd18
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/Components/dummies.ts
@@ -0,0 +1,88 @@
+export const dummyHTML = `
+Lorem ipsum dolor sit amet consectetuer adipiscing
+elit
+Lorem ipsum dolor sit amet, consectetuer adipiscing
+elit. Aenean commodo ligula eget dolor. Aenean massa
+strong . Cum sociis natoque penatibus
+et magnis dis parturient montes, nascetur ridiculus
+mus. Donec quam felis, ultricies nec, pellentesque
+eu, pretium quis, sem. Nulla consequat massa quis
+enim. Donec pede justo, fringilla vel, aliquet nec,
+vulputate eget, arcu. In enim justo, rhoncus ut,
+imperdiet a, venenatis vitae, justo. Nullam dictum
+felis eu pede link
+mollis pretium. Integer tincidunt. Cras dapibus.
+Vivamus elementum semper nisi. Aenean vulputate
+eleifend tellus. Aenean leo ligula, porttitor eu,
+consequat vitae, eleifend ac, enim. Aliquam lorem ante,
+dapibus in, viverra quis, feugiat a, tellus. Phasellus
+viverra nulla ut metus varius laoreet. Quisque rutrum.
+Aenean imperdiet. Etiam ultricies nisi vel augue.
+Curabitur ullamcorper ultricies nisi.
+Lorem ipsum dolor sit amet consectetuer adipiscing
+elit
+Aenean commodo ligula eget dolor aenean massa
+Lorem ipsum dolor sit amet, consectetuer adipiscing
+elit. Aenean commodo ligula eget dolor. Aenean massa.
+Cum sociis natoque penatibus et magnis dis parturient
+montes, nascetur ridiculus mus. Donec quam felis,
+ultricies nec, pellentesque eu, pretium quis, sem.
+
+Aenean commodo ligula eget dolor aenean massa
+Lorem ipsum dolor sit amet, consectetuer adipiscing
+elit. Aenean commodo ligula eget dolor. Aenean massa.
+Cum sociis natoque penatibus et magnis dis parturient
+montes, nascetur ridiculus mus. Donec quam felis,
+ultricies nec, pellentesque eu, pretium quis, sem.
+
+ Lorem ipsum dolor sit amet consectetuer.
+ Aenean commodo ligula eget dolor.
+ Aenean massa cum sociis natoque penatibus.
+
+Lorem ipsum dolor sit amet, consectetuer adipiscing
+elit. Aenean commodo ligula eget dolor. Aenean massa.
+Cum sociis natoque penatibus et magnis dis parturient
+montes, nascetur ridiculus mus. Donec quam felis,
+ultricies nec, pellentesque eu, pretium quis, sem.
+
+Lorem ipsum dolor sit amet, consectetuer adipiscing
+elit. Aenean commodo ligula eget dolor. Aenean massa.
+Cum sociis natoque penatibus et magnis dis parturient
+montes, nascetur ridiculus mus. Donec quam felis,
+ultricies nec, pellentesque eu, pretium quis, sem.
+
+
+ Entry Header 1
+ Entry Header 2
+ Entry Header 3
+ Entry Header 4
+
+
+ Entry First Line 1
+ Entry First Line 2
+ Entry First Line 3
+ Entry First Line 4
+
+
+ Entry Line 1
+ Entry Line 2
+ Entry Line 3
+ Entry Line 4
+
+
+ Entry Last Line 1
+ Entry Last Line 2
+ Entry Last Line 3
+ Entry Last Line 4
+
+
+Lorem ipsum dolor sit amet, consectetuer adipiscing
+elit. Aenean commodo ligula eget dolor. Aenean massa.
+Cum sociis natoque penatibus et magnis dis parturient
+montes, nascetur ridiculus mus. Donec quam felis,
+ultricies nec, pellentesque eu, pretium quis, sem.
+
+`;
+
+export const dummyText =
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean lobortis, diam sed malesuada bibendum, nulla libero scelerisque sapien, nec interdum nisl ipsum ac ipsum. In lacinia eros ut quam commodo, in finibus augue ultricies. Vestibulum ex purus, condimentum eget sem at, molestie semper mi. Mauris ac feugiat quam. Pellentesque sagittis bibendum nibh eu lacinia. Aenean rhoncus, velit sit amet mollis egestas, diam turpis ornare velit, a dictum elit velit in erat. Quisque luctus in sem a vulputate.\n\nPellentesque id tempus orci, non finibus tortor. Suspendisse in neque non eros eleifend hendrerit vitae a lacus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae;';
diff --git a/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx b/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx
new file mode 100644
index 0000000000..695f1702f3
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx
@@ -0,0 +1,280 @@
+import { AnimatedIconButton, List } from '@components';
+import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal';
+import { WINDOW_HEIGHT } from '@gorhom/bottom-sheet';
+import { useBoolean } from '@hooks/index';
+import { FlashList } from '@shopify/flash-list';
+import { LinearGradient } from 'expo-linear-gradient';
+import React, { useCallback, useEffect, useMemo, useRef } from 'react';
+import { TextInput as RNTextInput, StyleSheet } from 'react-native';
+import { TextInput } from 'react-native-paper';
+import Animated, {
+ FadeIn,
+ FadeOut,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
+import {
+ LIST_ITEM_LINE_HEIGHT,
+ RemoveItem,
+ ReplaceItem,
+} from '../Components/ListItems';
+import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
+
+const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
+
+type ReplaceItemModalProps = {
+ showReplace?: boolean;
+ listExpanded: boolean;
+ toggleList: () => void;
+};
+
+const ReplaceItemModal = ({
+ showReplace = false,
+ listExpanded = false,
+ toggleList,
+}: ReplaceItemModalProps) => {
+ const theme = useTheme();
+ const modal = useBoolean(false);
+ const {
+ setChapterReaderSettings: setSettings,
+ replaceText,
+ removeText,
+ } = useChapterReaderSettings();
+ const replaceArray = useMemo(() => {
+ return Object.entries(replaceText);
+ }, [replaceText]);
+
+ const arrayLength = showReplace ? replaceArray.length : removeText.length;
+
+ const textRef = useRef(null);
+ const replaceTextRef = useRef(null);
+
+ const [text, setText] = React.useState('');
+ const [replacementText, setReplacementText] = React.useState('');
+ const [editing, setEditing] = React.useState();
+ const [error, setError] = React.useState<[string, string] | undefined>();
+
+ const listSize = useSharedValue(
+ Math.min(110, arrayLength * 48),
+ );
+ const iconRotation = useSharedValue(0);
+
+ const cancel = () => {
+ setError(undefined);
+ textRef.current?.clear();
+ setText('');
+ setEditing(undefined);
+ if (showReplace) {
+ replaceTextRef.current?.clear();
+ setReplacementText('');
+ }
+ };
+
+ const save = () => {
+ if (!text || (showReplace && !replacementText)) {
+ const e: [string, string] = ['', ''];
+ if (!text) {
+ e[0] = 'Enter a match';
+ }
+ if (!replacementText) {
+ e[1] = 'Enter a replace';
+ }
+ setError(e);
+ return false;
+ }
+
+ if (showReplace) {
+ if (editing && editing !== text) delete replaceText[editing];
+ replaceText[text] = replacementText;
+ setSettings({ replaceText: replaceText });
+ } else {
+ if (editing) {
+ const i = removeText.findIndex(v => v === editing);
+ removeText[i] = text;
+ } else if (!removeText.includes(text)) {
+ removeText.push(text);
+ } else {
+ setError(['Item already exists', '']);
+ return false;
+ }
+ setSettings({ removeText: removeText });
+ }
+ cancel();
+ return true;
+ };
+
+ const removeItem = useCallback(
+ (identifier: string | number) => {
+ if (showReplace) {
+ delete replaceText[identifier];
+ setSettings({ replaceText: replaceText });
+ } else {
+ removeText.splice(identifier as number, 1);
+ setSettings({ removeText: removeText });
+ }
+ },
+ [removeText, replaceText, setSettings, showReplace],
+ );
+
+ const editItem = useCallback(
+ (item: string[]) => {
+ setEditing(item[0]);
+ setText(item[0]);
+ if (showReplace) {
+ setReplacementText(item[1]);
+ }
+ modal.setTrue();
+ },
+ [modal, showReplace],
+ );
+
+ const colorTheme = useMemo(() => {
+ return { colors: theme };
+ }, [theme]);
+
+ const calcListSize = useCallback(
+ (toggle: boolean = true) => {
+ if (toggle) {
+ toggleList();
+ iconRotation.value = listExpanded ? 0 : 180;
+ }
+ if (listExpanded) {
+ listSize.value = Math.min(
+ WINDOW_HEIGHT * 0.6,
+ arrayLength * (LIST_ITEM_LINE_HEIGHT + 16),
+ );
+ } else {
+ listSize.value = Math.min(
+ 110,
+ arrayLength * (LIST_ITEM_LINE_HEIGHT + 16),
+ );
+ }
+ },
+ [arrayLength, iconRotation, listExpanded, listSize, toggleList],
+ );
+ useEffect(() => {
+ calcListSize(false);
+ }, [replaceArray, removeText, calcListSize]);
+ useEffect(() => {
+ iconRotation.value = !listExpanded ? 0 : 180;
+ }, [iconRotation, listExpanded]);
+
+ const animatedListSize = useAnimatedStyle(() => ({
+ height: withTiming(listSize.value, { duration: 250 }),
+ overflow: 'hidden',
+ position: 'relative',
+ }));
+
+ return (
+ <>
+
+
+ {arrayLength <= 3 || listExpanded ? null : (
+ calcListSize()}
+ />
+ )}
+ {showReplace ? (
+ (
+
+ )}
+ />
+ ) : (
+ (
+
+ )}
+ />
+ )}
+
+
+ {
+ modal.setFalse();
+ setError(undefined);
+ }}
+ onSave={save}
+ onCancel={cancel}
+ title="Edit Replace"
+ >
+
+ {!showReplace ? null : (
+
+ )}
+
+ >
+ );
+};
+
+export default ReplaceItemModal;
+
+const styles = StyleSheet.create({
+ textfield: {
+ marginBottom: 16,
+ },
+ bottom: {
+ marginBottom: 24,
+ },
+ marginHorizontal: {
+ marginHorizontal: 16,
+ },
+ gradient: {
+ position: 'absolute',
+ top: 0,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ zIndex: 1,
+ },
+});
diff --git a/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx b/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx
new file mode 100644
index 0000000000..4e179a1056
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx
@@ -0,0 +1,235 @@
+import { Button, TextInput } from '@components';
+import { Row } from '@components/Common';
+import { ToggleButton } from '@components/Common/ToggleButton';
+import { WINDOW_HEIGHT } from '@gorhom/bottom-sheet';
+import { getString } from '@strings/translations';
+import React from 'react';
+import { StyleSheet } from 'react-native';
+import { ScrollView } from 'react-native-gesture-handler';
+import { Text } from 'react-native-paper';
+import Animated, { useAnimatedStyle } from 'react-native-reanimated';
+import CodeInput from '../Components/CodeInput';
+import { showToast } from '@utils/showToast';
+import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
+import { useAnimatedKeyboard } from 'react-native-keyboard-controller';
+
+type CodeRouteProps = {
+ language?: 'css' | 'js';
+ snippetIndex?: number;
+ jumpTo: (key: string) => void;
+ editingSnippet?: {
+ index: number;
+ isJS: boolean;
+ } | null;
+ onSnippetSaved?: () => void;
+};
+
+const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
+
+const CodeRoute = ({
+ language: dLang,
+ snippetIndex,
+ jumpTo,
+ editingSnippet,
+ onSnippetSaved,
+}: CodeRouteProps) => {
+ const theme = useTheme();
+ const {
+ codeSnippetsJS,
+ codeSnippetsCSS,
+ setChapterReaderSettings: setSettings,
+ } = useChapterReaderSettings();
+ const [error, setError] = React.useState({ title: false, code: false });
+
+ // Use editingSnippet if provided, otherwise fall back to old props
+ const isEditing = editingSnippet !== null && editingSnippet !== undefined;
+ const editIndex = isEditing ? editingSnippet.index : snippetIndex;
+ const editIsJS = isEditing ? editingSnippet.isJS : dLang === 'js';
+
+ const [language, setLanguage] = React.useState<'js' | 'css'>('js');
+
+ // Update language when editing state changes
+ React.useEffect(() => {
+ if (isEditing) {
+ setLanguage(editIsJS ? 'js' : 'css');
+ } else {
+ setLanguage('js'); // Default to JS for new snippets
+ }
+ }, [isEditing, editIsJS]);
+
+ const snippets = language === 'js' ? codeSnippetsJS : codeSnippetsCSS;
+ const snippet =
+ editIndex === undefined || editIndex === -1 ? null : snippets[editIndex];
+
+ const [title, setTitle] = React.useState('');
+ const [code, setCode] = React.useState('');
+
+ // Update title, code, and reset errors when snippet changes
+ React.useEffect(() => {
+ setTitle(snippet?.name ?? '');
+ setCode(snippet?.code ?? '');
+ setError({ title: false, code: false });
+ }, [snippet]);
+
+ const { height: keyboardHeight } = useAnimatedKeyboard();
+
+ const ScrollViewRef = React.useRef(null);
+
+ const maxHeightScrollView = useAnimatedStyle(() => {
+ return {
+ maxHeight: WINDOW_HEIGHT - keyboardHeight.value - 26,
+ };
+ });
+
+ const colors = React.useMemo(
+ () => ({
+ colors: theme,
+ }),
+ [theme],
+ );
+
+ const save = React.useCallback(() => {
+ setError({ title: false, code: false });
+ if (!code.trim() || !title.trim()) {
+ setError({ title: !title.trim(), code: !code.trim() });
+ return;
+ }
+
+ // Editing existing snippet
+ if (isEditing && editIndex !== undefined && editIndex !== -1) {
+ snippets[editIndex].name = title;
+ snippets[editIndex].code = code;
+ setSettings({
+ [language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets,
+ });
+ showToast('Snippet updated successfully');
+ onSnippetSaved?.();
+ return;
+ }
+
+ // Creating new snippet
+ snippets.push({
+ name: title,
+ code,
+ active: true,
+ lang: language,
+ });
+ setSettings({
+ [language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets,
+ });
+ showToast('Snippet saved successfully');
+ jumpTo('first'); // Go back to settings tab
+ }, [
+ language,
+ snippets,
+ title,
+ code,
+ setSettings,
+ jumpTo,
+ isEditing,
+ editIndex,
+ onSnippetSaved,
+ ]);
+
+ return (
+
+
+
+ {'Select CSS or JS'}
+
+ setLanguage('css')}
+ disabled={isEditing}
+ />
+ setLanguage('js')}
+ disabled={isEditing}
+ />
+
+
+
+
+
+
+
+
+ );
+};
+
+export default React.memo(CodeRoute);
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ paddingHorizontal: 16,
+ },
+ text: {
+ flex: 1,
+ },
+ button: {
+ marginHorizontal: 8,
+ flexBasis: '40%',
+ flex: 1,
+ },
+ snippetName: {
+ marginTop: 8,
+ marginBottom: 16,
+ },
+ fakeTextInput: {
+ borderRadius: 4,
+ borderWidth: 1,
+ borderStyle: 'solid',
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ marginHorizontal: 1,
+ marginVertical: 2,
+ },
+ topField: {
+ borderBottomLeftRadius: 0,
+ borderBottomRightRadius: 0,
+ borderBottomWidth: 0,
+ flexGrow: 1,
+ },
+ codeField: {
+ verticalAlign: 'top',
+ flexGrow: 1,
+ borderRadius: 0,
+ borderTopWidth: 0,
+ borderBottomWidth: 0,
+ },
+ bottomField: {
+ flexGrow: 1,
+ borderTopLeftRadius: 0,
+ borderTopRightRadius: 0,
+ borderTopWidth: 0,
+ },
+});
diff --git a/src/screens/settings/SettingsCustomCodeScreen/Routes/SettingsRoute.tsx b/src/screens/settings/SettingsCustomCodeScreen/Routes/SettingsRoute.tsx
new file mode 100644
index 0000000000..d843864e32
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/Routes/SettingsRoute.tsx
@@ -0,0 +1,191 @@
+import React from 'react';
+import { ScrollView, StyleSheet, View } from 'react-native';
+import ReplaceItemModal from '../Modals/ReplaceItemModal';
+import { List, SwitchItem, IconButtonV2 } from '@components';
+import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
+
+type SettingsRouteProps = {
+ onEditSnippet?: (index: number, isJS: boolean) => void;
+};
+
+const defaultExtended = [false, false, false, false];
+
+const SettingsRoute: React.FC = ({ onEditSnippet }) => {
+ const theme = useTheme();
+ const {
+ codeSnippetsJS,
+ codeSnippetsCSS,
+ setChapterReaderSettings: setSettings,
+ } = useChapterReaderSettings();
+ const [extended, setExtended] = React.useState(defaultExtended);
+ const toggleExtended = React.useCallback(
+ (index: number) => {
+ const newExtended = [...defaultExtended];
+ newExtended[index] = !extended[index];
+ setExtended(newExtended);
+ },
+ [extended],
+ );
+
+ const toggleSnippet = React.useCallback(
+ (index: number, isJS: boolean) => {
+ const snippets = isJS ? [...codeSnippetsJS] : [...codeSnippetsCSS];
+ snippets[index].active = !snippets[index].active;
+ setSettings({
+ [isJS ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets,
+ });
+ },
+ [codeSnippetsJS, codeSnippetsCSS, setSettings],
+ );
+
+ const deleteSnippet = React.useCallback(
+ (index: number, isJS: boolean) => {
+ const snippets = isJS ? [...codeSnippetsJS] : [...codeSnippetsCSS];
+ snippets.splice(index, 1);
+ setSettings({
+ [isJS ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets,
+ });
+ },
+ [codeSnippetsJS, codeSnippetsCSS, setSettings],
+ );
+
+ const editSnippet = React.useCallback(
+ (index: number, isJS: boolean) => {
+ onEditSnippet?.(index, isJS);
+ },
+ [onEditSnippet],
+ );
+
+ return (
+
+
+ {'Text manipulation'}
+ toggleExtended(0)}
+ listExpanded={extended[0]}
+ />
+ toggleExtended(1)}
+ listExpanded={extended[1]}
+ />
+
+ {'Code Snippets'}
+ onEditSnippet?.(-1, true)} // -1 indicates new snippet
+ />
+
+ {/* CSS Snippets */}
+ {codeSnippetsCSS.length > 0 && (
+ <>
+
+ {'CSS Snippets'}
+
+ {codeSnippetsCSS.map((snippet, index) => (
+
+ 50 ? '...' : '')
+ }
+ onPress={() => toggleSnippet(index, false)}
+ theme={theme}
+ style={styles.switchItem}
+ />
+
+ editSnippet(index, false)}
+ theme={theme}
+ />
+ deleteSnippet(index, false)}
+ theme={theme}
+ />
+
+
+ ))}
+ >
+ )}
+
+ {/* JS Snippets */}
+ {codeSnippetsJS.length > 0 && (
+ <>
+
+
+ {'JavaScript Snippets'}
+
+
+ {codeSnippetsJS.map((snippet, index) => (
+
+ 50 ? '...' : '')
+ }
+ onPress={() => toggleSnippet(index, true)}
+ theme={theme}
+ style={styles.switchItem}
+ />
+
+ editSnippet(index, true)}
+ theme={theme}
+ />
+ deleteSnippet(index, true)}
+ theme={theme}
+ />
+
+
+ ))}
+ >
+ )}
+
+ {codeSnippetsCSS.length === 0 && codeSnippetsJS.length === 0 && (
+
+ )}
+
+
+ );
+};
+
+export default SettingsRoute;
+
+const styles = StyleSheet.create({
+ paddingBottom: { paddingBottom: 40 },
+ subSubHeader: {
+ fontSize: 14,
+ marginTop: 8,
+ marginBottom: 4,
+ },
+ snippetRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ },
+ switchItem: {
+ flex: 1,
+ paddingHorizontal: 0,
+ },
+ actionButtons: {
+ flexDirection: 'row',
+ gap: 8,
+ marginLeft: 8,
+ },
+});
diff --git a/src/screens/settings/SettingsCustomCodeScreen/index.tsx b/src/screens/settings/SettingsCustomCodeScreen/index.tsx
new file mode 100644
index 0000000000..dbb7af323c
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/index.tsx
@@ -0,0 +1,163 @@
+import { SafeAreaView } from '@components';
+import { CustomCodeSettingsScreenProps } from '@navigators/types';
+import React from 'react';
+import { StyleSheet, useWindowDimensions } from 'react-native';
+import {
+ NavigationState,
+ SceneRendererProps,
+ TabBar,
+ TabView,
+} from 'react-native-tab-view';
+import SettingsRoute from './Routes/SettingsRoute';
+import Color from 'color';
+import CodeRoute from './Routes/CodeRoute';
+import SelfHidingAppBar from './Components/SelfHidingAppbar';
+import { useSharedValue } from 'react-native-reanimated';
+import { useTheme } from '@hooks/persisted';
+import SettingsWebView from './Components/SettingsWebView';
+
+const routes = [
+ { key: 'first', title: 'Settings' },
+ { key: 'second', title: 'Code' },
+ { key: 'third', title: 'Example' },
+];
+
+type State = NavigationState<{
+ key: string;
+ title: string;
+}>;
+
+const SettingsCustomCode = ({ navigation }: CustomCodeSettingsScreenProps) => {
+ const theme = useTheme();
+ const [index, setIndex] = React.useState(0);
+ const layout = useWindowDimensions();
+
+ // 0 = visible, 1 = hidden. Using a number is flexible.
+ const appBarHiddenState = useSharedValue(0);
+
+ // State for editing snippets
+ const [editingSnippet, setEditingSnippet] = React.useState<{
+ index: number;
+ isJS: boolean;
+ } | null>(null);
+
+ const handleTabChange = (newIndex: number) => {
+ appBarHiddenState.value = newIndex ? 1 : 0;
+ setIndex(newIndex);
+ // Clear editing state when manually switching tabs
+ if (newIndex !== 1) {
+ setEditingSnippet(null);
+ }
+ };
+
+ const handleEditSnippet = (snippetIndex: number, isJS: boolean) => {
+ appBarHiddenState.value = 1;
+ setEditingSnippet({
+ index: snippetIndex,
+ isJS: snippetIndex === -1 ? true : isJS, // Default to JS for new snippets, use passed value for editing
+ });
+ setIndex(1); // Switch to Code tab
+ };
+
+ const handleSnippetSaved = () => {
+ setEditingSnippet(null);
+ setIndex(0); // Switch back to Settings tab
+ };
+
+ const renderScene = ({
+ route,
+ jumpTo,
+ }: SceneRendererProps & {
+ route: {
+ key: string;
+ title: string;
+ };
+ }) => {
+ switch (route.key) {
+ case 'first':
+ return ;
+ case 'second':
+ return (
+
+ );
+ case 'third':
+ return ;
+ default:
+ return null;
+ }
+ };
+
+ const renderTabBar = React.useCallback(
+ (props: SceneRendererProps & { navigationState: State }) => {
+ return (
+
+ );
+ },
+ [
+ theme.isDark,
+ theme.primary,
+ theme.rippleColor,
+ theme.secondary,
+ theme.surface,
+ ],
+ );
+ return (
+
+ navigation.goBack()}
+ theme={theme}
+ hiddenState={appBarHiddenState}
+ />
+
+
+
+ );
+};
+
+export default SettingsCustomCode;
+
+const styles = StyleSheet.create({
+ tabBar: {
+ borderBottomWidth: 1,
+ elevation: 0,
+ },
+ tabBarIndicator: {
+ height: 3,
+ },
+ flex: {
+ flex: 1,
+ },
+});
diff --git a/src/screens/settings/SettingsScreen.tsx b/src/screens/settings/SettingsScreen.tsx
index 32cc50ff08..788f3d4de1 100644
--- a/src/screens/settings/SettingsScreen.tsx
+++ b/src/screens/settings/SettingsScreen.tsx
@@ -58,6 +58,12 @@ const SettingsScreen = ({ navigation }: SettingsScreenProps) => {
}
theme={theme}
/>
+ navigation.navigate('CustomCode')}
+ theme={theme}
+ />
Date: Sat, 28 Feb 2026 11:07:21 +0100
Subject: [PATCH 02/28] clean up WebVieweReader
---
android/app/src/main/assets/js/textRemover.js | 181 +++++
.../reader/components/Hooks/useCustomCode.ts | 40 ++
src/screens/reader/components/Hooks/useTTS.ts | 303 +++++++++
.../components/Hooks/useTextModifications.ts | 104 +++
.../reader/components/WebViewReader.tsx | 626 ++++--------------
5 files changed, 767 insertions(+), 487 deletions(-)
create mode 100644 android/app/src/main/assets/js/textRemover.js
create mode 100644 src/screens/reader/components/Hooks/useCustomCode.ts
create mode 100644 src/screens/reader/components/Hooks/useTTS.ts
create mode 100644 src/screens/reader/components/Hooks/useTextModifications.ts
diff --git a/android/app/src/main/assets/js/textRemover.js b/android/app/src/main/assets/js/textRemover.js
new file mode 100644
index 0000000000..30321e8e75
--- /dev/null
+++ b/android/app/src/main/assets/js/textRemover.js
@@ -0,0 +1,181 @@
+// Text selection functionality
+window.textRemover = new (function () {
+ let selectionUI = null;
+ let isUIActive = false;
+ this.hidden = van.state(true);
+
+ function createSelectionUI() {
+ if (selectionUI) return selectionUI;
+
+ const { div, button } = van.tags;
+ selectionUI = div(
+ {
+ id: 'text-selection-ui',
+ style:
+ 'position: fixed; background: var(--theme-surface); border: 1px solid var(--theme-outline); border-radius: 8px; padding: 8px; z-index: 100000; display: none; box-shadow: 0 4px 12px rgba(0,0,0,0.25); pointer-events: auto;',
+ },
+ button(
+ {
+ style:
+ 'background: var(--theme-secondary); color: var(--theme-onSecondary); border: none; padding: 6px 12px; margin: 2px; border-radius: 4px; font-size: 12px; cursor: pointer;',
+ onclick: () => removeSelectedText(),
+ },
+ 'Remove',
+ ),
+ button(
+ {
+ style:
+ 'background: var(--theme-primary); color: var(--theme-onPrimary); border: none; padding: 6px 12px; margin: 2px; border-radius: 4px; font-size: 12px; cursor: pointer;',
+ onclick: () => replaceSelectedText(),
+ },
+ 'Replace',
+ ),
+ );
+
+ document.body.appendChild(selectionUI);
+ return selectionUI;
+ }
+
+ function showSelectionUI() {
+ const ui = createSelectionUI();
+
+ // Get selection bounds
+ const selection = window.getSelection();
+ if (selection.rangeCount > 0) {
+ const range = selection.getRangeAt(0);
+ const rect = range.getBoundingClientRect();
+
+ // Get UI element heights from CSS variables (with fallbacks)
+ const statusBarHeight =
+ parseInt(
+ getComputedStyle(document.documentElement).getPropertyValue(
+ '--StatusBar-currentHeight',
+ ),
+ ) || 24;
+ const readerPadding =
+ parseInt(
+ getComputedStyle(document.documentElement).getPropertyValue(
+ '--readerSettings-padding',
+ ),
+ ) || 16;
+ const uiHeight = 50; // Approximate height of our UI
+
+ // Calculate available space
+ const viewportHeight = window.innerHeight;
+ const selectionCenterY = rect.top + rect.height / 2;
+ const topSafeArea = statusBarHeight + readerPadding + uiHeight + 10;
+ const bottomSafeArea = readerPadding + uiHeight + 10;
+
+ // Position UI based on selection location
+ let topPosition;
+ if (selectionCenterY < viewportHeight / 2) {
+ // Selection is in top half, position UI at bottom
+ //TODO: make this dynamic
+ const avoidUI = !reader.hidden.val ? 58 : 0;
+ const avoidScrollbar = reader.generalSettings.val.verticalSeekbar
+ ? 0
+ : 20;
+ topPosition =
+ viewportHeight - bottomSafeArea - avoidUI - avoidScrollbar;
+ ui.style.top = topPosition + 'px';
+ ui.style.bottom = 'auto';
+ } else {
+ // Selection is in bottom half, position UI at top (accounting for status bar)
+ topPosition = Math.max(
+ statusBarHeight + readerPadding + 10,
+ statusBarHeight + 20,
+ );
+ const avoidUI = !reader.hidden.val ? 32 : 0;
+ ui.style.top = topPosition + avoidUI + 'px';
+ ui.style.bottom = 'auto';
+ }
+
+ // Center horizontally
+ ui.style.left = '50%';
+ ui.style.transform = 'translateX(-50%)';
+ } else {
+ // Fallback: position at top if no selection rect available
+ ui.style.top = '20px';
+ ui.style.left = '50%';
+ ui.style.transform = 'translateX(-50%)';
+ ui.style.bottom = 'auto';
+ }
+
+ ui.style.display = 'block';
+ isUIActive = true;
+ }
+
+ function hideSelectionUI() {
+ if (selectionUI) {
+ selectionUI.style.display = 'none';
+ }
+ isUIActive = false;
+ }
+
+ function getSelectedText() {
+ const selection = window.getSelection();
+ if (selection.rangeCount > 0) {
+ return selection.toString().trim();
+ }
+ return '';
+ }
+
+ function removeSelectedText() {
+ const selectedText = getSelectedText();
+ if (selectedText) {
+ reader.post({
+ type: 'text-action',
+ data: { remove: selectedText },
+ });
+ }
+ hideSelectionUI();
+ window.getSelection().removeAllRanges();
+ }
+
+ function replaceSelectedText() {
+ const selectedText = getSelectedText();
+ if (selectedText) {
+ // For replace, we need user input, so send a different message
+ reader.post({
+ type: 'text-action',
+ data: { replace: selectedText },
+ });
+ }
+ hideSelectionUI();
+ window.getSelection().removeAllRanges();
+ }
+
+ // Handle text selection
+ document.addEventListener('selectionchange', function () {
+ const selectedText = getSelectedText();
+ if (selectedText) {
+ showSelectionUI();
+ } else if (!isUIActive) {
+ hideSelectionUI();
+ }
+ });
+
+ // Hide UI when clicking/tapping elsewhere
+ document.addEventListener('touchstart', function (e) {
+ if (isUIActive && selectionUI && !selectionUI.contains(e.target)) {
+ const selectedText = getSelectedText();
+ if (!selectedText) {
+ hideSelectionUI();
+ }
+ }
+ });
+
+ document.addEventListener('click', function (e) {
+ if (isUIActive && selectionUI && !selectionUI.contains(e.target)) {
+ const selectedText = getSelectedText();
+ if (!selectedText) {
+ hideSelectionUI();
+ }
+ }
+ });
+
+ // Hide UI on scroll
+ window.addEventListener('scroll', function () {
+ hideSelectionUI();
+ });
+})();
diff --git a/src/screens/reader/components/Hooks/useCustomCode.ts b/src/screens/reader/components/Hooks/useCustomCode.ts
new file mode 100644
index 0000000000..22fa31dee6
--- /dev/null
+++ b/src/screens/reader/components/Hooks/useCustomCode.ts
@@ -0,0 +1,40 @@
+import { ChapterReaderSettings } from '@hooks/persisted/useSettings';
+import { useMemo } from 'react';
+
+export default function useCustomCode(
+ readerSettings: Pick<
+ ChapterReaderSettings,
+ 'codeSnippetsJS' | 'codeSnippetsCSS'
+ >,
+) {
+ const customJS = useMemo(() => {
+ return readerSettings.codeSnippetsJS
+ .map(snippet => {
+ if (!snippet.active) return null;
+ return `
+ try {
+ ${snippet.code}
+ } catch (error) {
+ alert(\`Error loading executing ${snippet.name}:\n\` + error);
+ }
+ `;
+ })
+ .filter(Boolean)
+ .join('\n');
+ }, [readerSettings.codeSnippetsJS]);
+
+ const customCSS = useMemo(() => {
+ return readerSettings.codeSnippetsCSS
+ .map(snippet => {
+ if (!snippet.active) return null;
+ return snippet.code;
+ })
+ .filter(Boolean)
+ .join('\n');
+ }, [readerSettings.codeSnippetsCSS]);
+
+ return {
+ customJS,
+ customCSS,
+ };
+}
diff --git a/src/screens/reader/components/Hooks/useTTS.ts b/src/screens/reader/components/Hooks/useTTS.ts
new file mode 100644
index 0000000000..5e11fd64c2
--- /dev/null
+++ b/src/screens/reader/components/Hooks/useTTS.ts
@@ -0,0 +1,303 @@
+import React, { useEffect, useRef } from 'react';
+import { AppState, NativeEventEmitter, NativeModules } from 'react-native';
+import WebView from 'react-native-webview';
+
+import { MMKVStorage } from '@utils/mmkv/mmkv';
+import {
+ CHAPTER_GENERAL_SETTINGS,
+ CHAPTER_READER_SETTINGS,
+ useChapterReaderSettings,
+} from '@hooks/persisted/useSettings';
+import * as Speech from 'expo-speech';
+import {
+ updateTTSNotification,
+ dismissTTSNotification,
+ ttsMediaEmitter,
+ showTTSNotification,
+ updateTTSProgress,
+ updateTTSPlaybackState,
+} from '@utils/ttsNotification';
+import { ChapterInfo, NovelInfo } from '@database/types';
+import { WebViewPostEvent } from '../WebViewReader';
+
+const { RNDeviceInfo } = NativeModules;
+const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo);
+
+export default function useTTS(
+ webViewRef: React.RefObject,
+ novel: NovelInfo,
+ chapter: ChapterInfo,
+) {
+ const { setChapterReaderSettings, ...readerSettings } =
+ useChapterReaderSettings();
+ const isTTSReadingRef = useRef(false);
+ const autoStartTTSRef = useRef(false);
+ const ttsQueueRef = useRef([]);
+ const ttsQueueIndexRef = useRef(0);
+ const appStateRef = useRef(AppState.currentState);
+
+ useEffect(() => {
+ const playListener = ttsMediaEmitter.addListener('TTSPlay', () => {
+ webViewRef.current?.injectJavaScript(`
+ if (window.tts && !tts.reading) { tts.resume(); }
+ `);
+ });
+ const pauseListener = ttsMediaEmitter.addListener('TTSPause', () => {
+ webViewRef.current?.injectJavaScript(`
+ if (window.tts && tts.reading) { tts.pause(); }
+ `);
+ });
+ const stopListener = ttsMediaEmitter.addListener('TTSStop', () => {
+ webViewRef.current?.injectJavaScript(`
+ if (window.tts) { tts.stop(); }
+ `);
+ });
+ const rewindListener = ttsMediaEmitter.addListener('TTSRewind', () => {
+ webViewRef.current?.injectJavaScript(`
+ if (window.tts && tts.started) { tts.rewind(); }
+ `);
+ });
+ const prevListener = ttsMediaEmitter.addListener('TTSPrev', () => {
+ webViewRef.current?.injectJavaScript(`
+ if (window.tts && window.reader && window.reader.prevChapter) {
+ window.reader.post({ type: 'prev', autoStartTTS: true });
+ }
+ `);
+ });
+ const nextListener = ttsMediaEmitter.addListener('TTSNext', () => {
+ webViewRef.current?.injectJavaScript(`
+ if (window.tts && window.reader && window.reader.nextChapter) {
+ window.reader.post({ type: 'next', autoStartTTS: true });
+ }
+ `);
+ });
+ const seekToListener = ttsMediaEmitter.addListener(
+ 'TTSSeekTo',
+ (event: { position: number }) => {
+ const position = event.position;
+ webViewRef.current?.injectJavaScript(`
+ if (window.tts && tts.started) { tts.seekTo(${position}); }
+ `);
+ },
+ );
+ return () => {
+ playListener.remove();
+ pauseListener.remove();
+ stopListener.remove();
+ rewindListener.remove();
+ prevListener.remove();
+ nextListener.remove();
+ seekToListener.remove();
+ };
+ }, [webViewRef]);
+
+ useEffect(() => {
+ if (isTTSReadingRef.current) {
+ updateTTSNotification({
+ novelName: novel?.name || 'Unknown',
+ chapterName: chapter.name,
+ coverUri: novel?.cover || '',
+ isPlaying: isTTSReadingRef.current,
+ });
+ }
+ return () => {
+ dismissTTSNotification();
+ };
+ }, [novel?.name, novel?.cover, chapter.name]);
+
+ useEffect(() => {
+ const mmkvListener = MMKVStorage.addOnValueChangedListener(key => {
+ switch (key) {
+ case CHAPTER_READER_SETTINGS:
+ // Stop any currently playing speech
+ Speech.stop();
+
+ // Update WebView settings
+ webViewRef.current?.injectJavaScript(
+ `
+ reader.readerSettings.val = ${MMKVStorage.getString(
+ CHAPTER_READER_SETTINGS,
+ )};
+ // Auto-restart TTS if currently reading
+ if (window.tts && tts.reading) {
+ const currentElement = tts.currentElement;
+ const wasReading = tts.reading;
+ tts.stop();
+ if (wasReading) {
+ setTimeout(() => {
+ tts.start(currentElement);
+ }, 100);
+ }
+ }
+ `,
+ );
+ break;
+ case CHAPTER_GENERAL_SETTINGS:
+ webViewRef.current?.injectJavaScript(
+ `reader.generalSettings.val = ${MMKVStorage.getString(
+ CHAPTER_GENERAL_SETTINGS,
+ )}`,
+ );
+ break;
+ }
+ });
+
+ const subscription = deviceInfoEmitter.addListener(
+ 'RNDeviceInfo_batteryLevelDidChange',
+ (level: number) => {
+ webViewRef.current?.injectJavaScript(
+ `reader.batteryLevel.val = ${level}`,
+ );
+ },
+ );
+ return () => {
+ subscription.remove();
+ mmkvListener.remove();
+ };
+ }, [setChapterReaderSettings, webViewRef]);
+
+ useEffect(() => {
+ const subscription = AppState.addEventListener('change', nextState => {
+ appStateRef.current = nextState;
+ if (nextState === 'active' && isTTSReadingRef.current) {
+ const index = ttsQueueIndexRef.current;
+ webViewRef.current?.injectJavaScript(`
+ if (window.tts && window.tts.allReadableElements) {
+ const idx = ${index};
+ if (idx < tts.allReadableElements.length) {
+ tts.elementsRead = idx;
+ tts.currentElement = tts.allReadableElements[idx];
+ tts.prevElement = null;
+ tts.started = true;
+ tts.reading = true;
+ tts.scrollToElement(tts.currentElement);
+ tts.currentElement.classList.add('highlight');
+ }
+ }
+ `);
+ }
+ });
+
+ return () => subscription.remove();
+ }, [webViewRef]);
+
+ const speakText = (text: string) => {
+ Speech.speak(text, {
+ onDone() {
+ const isBackground =
+ appStateRef.current === 'background' ||
+ appStateRef.current === 'inactive';
+
+ if (
+ isBackground &&
+ ttsQueueRef.current.length > 0 &&
+ ttsQueueIndexRef.current + 1 < ttsQueueRef.current.length
+ ) {
+ const nextIndex = ttsQueueIndexRef.current + 1;
+ const nextText = ttsQueueRef.current[nextIndex];
+ if (nextText) {
+ ttsQueueIndexRef.current = nextIndex;
+ speakText(nextText);
+ return;
+ }
+ }
+
+ if (isBackground) {
+ isTTSReadingRef.current = false;
+ dismissTTSNotification();
+ webViewRef.current?.injectJavaScript('tts.stop?.()');
+ return;
+ }
+
+ webViewRef.current?.injectJavaScript('tts.next?.()');
+ },
+ voice: readerSettings.tts?.voice?.identifier,
+ pitch: readerSettings.tts?.pitch || 1,
+ rate: readerSettings.tts?.rate || 1,
+ });
+ };
+
+ function eventTTSQueue(event: WebViewPostEvent) {
+ const payload = event.data as
+ | { queue?: unknown; startIndex?: unknown }
+ | undefined;
+ const queue = Array.isArray(payload?.queue)
+ ? payload?.queue.filter(
+ (item): item is string =>
+ typeof item === 'string' && item.trim().length > 0,
+ )
+ : [];
+ ttsQueueRef.current = queue;
+ if (typeof payload?.startIndex === 'number') {
+ ttsQueueIndexRef.current = payload.startIndex;
+ } else {
+ ttsQueueIndexRef.current = 0;
+ }
+ }
+
+ function eventTTSSpeak(event: WebViewPostEvent) {
+ if (event.data && typeof event.data === 'string') {
+ if (typeof event.index === 'number') {
+ ttsQueueIndexRef.current = event.index;
+ }
+ if (!isTTSReadingRef.current) {
+ isTTSReadingRef.current = true;
+ showTTSNotification({
+ novelName: novel?.name || 'Unknown',
+ chapterName: chapter.name,
+ coverUri: novel?.cover || '',
+ isPlaying: true,
+ });
+ } else {
+ updateTTSNotification({
+ novelName: novel?.name || 'Unknown',
+ chapterName: chapter.name,
+ coverUri: novel?.cover || '',
+ isPlaying: true,
+ });
+ }
+ if (
+ typeof event.index === 'number' &&
+ typeof event.total === 'number' &&
+ event.total > 0
+ ) {
+ updateTTSProgress(event.index, event.total);
+ }
+ speakText(event.data);
+ } else {
+ webViewRef.current?.injectJavaScript('tts.next?.()');
+ }
+ }
+ function eventTTSPauseSpeak() {
+ Speech.stop();
+ }
+ function eventTTSStopSpeak() {
+ Speech.stop();
+ if (!autoStartTTSRef.current) {
+ isTTSReadingRef.current = false;
+ ttsQueueRef.current = [];
+ ttsQueueIndexRef.current = 0;
+ dismissTTSNotification();
+ }
+ }
+ function eventTTSState(event: WebViewPostEvent) {
+ if (event.data && typeof event.data === 'object') {
+ const data = event.data as { isReading?: boolean };
+ const isReading = data.isReading === true;
+ isTTSReadingRef.current = isReading;
+ updateTTSPlaybackState(isReading);
+ }
+ }
+
+ return {
+ isTTSReadingRef,
+ autoStartTTSRef,
+ appStateRef,
+ speakText,
+ eventTTSQueue,
+ eventTTSSpeak,
+ eventTTSPauseSpeak,
+ eventTTSStopSpeak,
+ eventTTSState,
+ };
+}
diff --git a/src/screens/reader/components/Hooks/useTextModifications.ts b/src/screens/reader/components/Hooks/useTextModifications.ts
new file mode 100644
index 0000000000..6854aa5887
--- /dev/null
+++ b/src/screens/reader/components/Hooks/useTextModifications.ts
@@ -0,0 +1,104 @@
+import { useChapterReaderSettings } from '@hooks/persisted/useSettings';
+import React, { useMemo, useState } from 'react';
+import { WebViewPostEvent } from '../WebViewReader';
+
+export default function useTextModifications(chapterText: string) {
+ // Replace modal state
+ const [replaceModalVisible, setReplaceModalVisible] = useState(false);
+ const [selectedTextForReplace, setSelectedTextForReplace] = useState('');
+ const [replacementText, setReplacementText] = useState('');
+
+ const { setChapterReaderSettings, ...readerSettings } =
+ useChapterReaderSettings();
+
+ const html = useMemo(() => {
+ let chText = chapterText;
+ readerSettings.removeText.forEach(text => {
+ // test if text is regex
+ const m = text.match(/^\/(.*)\/([gmiyuvsd]*)$/);
+ if (m) {
+ const regex = new RegExp(m[1], m[2] ?? '');
+ chText = chText.replace(regex, '');
+ } else {
+ chText = chText.split(text).join('');
+ }
+ });
+ Object.entries(readerSettings.replaceText).forEach(
+ ([text, replacement]) => {
+ const m = text.match(/^\/(.*)\/([gmiyuvsd]*)$/);
+ if (m) {
+ const regex = new RegExp(m[1], m[2] ?? '');
+ chText = chText.replace(regex, replacement);
+ } else {
+ chText = chText.split(text).join(replacement);
+ }
+ },
+ );
+ return chText;
+ }, [chapterText, readerSettings.removeText, readerSettings.replaceText]);
+
+ const handleTextAction = React.useCallback(
+ (action: string, text: string) => {
+ if (!text) return;
+
+ if (action === 'remove') {
+ // Add to removeText array if not already present
+ const newRemoveText = [...readerSettings.removeText];
+ if (!newRemoveText.includes(text)) {
+ newRemoveText.push(text);
+ setChapterReaderSettings({ removeText: newRemoveText });
+ }
+ } else if (action === 'replace') {
+ // Show modal for user to enter replacement text
+ setSelectedTextForReplace(text);
+ setReplacementText('');
+ setReplaceModalVisible(true);
+ }
+ },
+ [readerSettings.removeText, setChapterReaderSettings],
+ );
+
+ const handleReplaceSave = React.useCallback(() => {
+ if (!selectedTextForReplace) return false;
+
+ const newReplaceText = { ...readerSettings.replaceText };
+ if (!(selectedTextForReplace in newReplaceText)) {
+ newReplaceText[selectedTextForReplace] = replacementText;
+ setChapterReaderSettings({ replaceText: newReplaceText });
+ }
+ setReplaceModalVisible(false);
+ return true;
+ }, [
+ selectedTextForReplace,
+ readerSettings.replaceText,
+ replacementText,
+ setChapterReaderSettings,
+ ]);
+
+ const handleReplaceCancel = React.useCallback(() => {
+ setReplaceModalVisible(false);
+ setSelectedTextForReplace('');
+ setReplacementText('');
+ }, []);
+
+ function eventTextAction(event: WebViewPostEvent) {
+ if (event.data) {
+ const action = Object.keys(event.data)[0];
+ const text = event.data[action];
+ handleTextAction(action as string, String(text));
+ }
+ }
+
+ return {
+ html,
+ replaceModalVisible,
+ setReplaceModalVisible,
+ selectedTextForReplace,
+ setSelectedTextForReplace,
+ replacementText,
+ setReplacementText,
+ handleReplaceSave,
+ handleReplaceCancel,
+ eventTextAction,
+ };
+}
diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx
index 0afe991720..1dc9ae28f0 100644
--- a/src/screens/reader/components/WebViewReader.tsx
+++ b/src/screens/reader/components/WebViewReader.tsx
@@ -1,10 +1,5 @@
-import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
-import {
- AppState,
- NativeEventEmitter,
- NativeModules,
- StatusBar,
-} from 'react-native';
+import React, { memo, useMemo, useRef } from 'react';
+import { StatusBar, StyleSheet } from 'react-native';
import WebView from 'react-native-webview';
import color from 'color';
@@ -12,29 +7,20 @@ import { useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
import { getPlugin } from '@plugins/pluginManager';
-import { MMKVStorage, getMMKVObject } from '@utils/mmkv/mmkv';
import {
- CHAPTER_GENERAL_SETTINGS,
- CHAPTER_READER_SETTINGS,
- ChapterGeneralSettings,
- ChapterReaderSettings,
- initialChapterGeneralSettings,
- initialChapterReaderSettings,
+ useChapterGeneralSettings,
+ useChapterReaderSettings,
} from '@hooks/persisted/useSettings';
import { getBatteryLevelSync } from 'react-native-device-info';
-import * as Speech from 'expo-speech';
import { PLUGIN_STORAGE } from '@utils/Storages';
import { useChapterContext } from '../ChapterContext';
-import {
- showTTSNotification,
- updateTTSNotification,
- updateTTSPlaybackState,
- updateTTSProgress,
- dismissTTSNotification,
- ttsMediaEmitter,
-} from '@utils/ttsNotification';
+import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal';
+import { TextInput } from 'react-native-paper';
+import useTTS from './Hooks/useTTS';
+import useCustomCode from './Hooks/useCustomCode';
+import useTextModifications from './Hooks/useTextModifications';
-type WebViewPostEvent = {
+export type WebViewPostEvent = {
type: string;
data?: { [key: string]: unknown };
autoStartTTS?: boolean;
@@ -56,9 +42,6 @@ const onLogMessage = (payload: { nativeEvent: { data: string } }) => {
}
};
-const { RNDeviceInfo } = NativeModules;
-const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo);
-
const assetsUriPrefix = __DEV__
? 'http://localhost:8081/assets'
: 'file:///android_asset';
@@ -67,7 +50,7 @@ const WebViewReader: React.FC = ({ onPress }) => {
const {
novel,
chapter,
- chapterText: html,
+ chapterText,
navigateChapter,
saveProgress,
nextChapter,
@@ -76,27 +59,29 @@ const WebViewReader: React.FC = ({ onPress }) => {
} = useChapterContext();
const theme = useTheme();
// Use state for settings so they update when MMKV changes
- const [readerSettings, setReaderSettings] = useState(
- () =>
- getMMKVObject(CHAPTER_READER_SETTINGS) ||
- initialChapterReaderSettings,
- );
- const chapterGeneralSettings = useMemo(
- () =>
- getMMKVObject(CHAPTER_GENERAL_SETTINGS) ||
- initialChapterGeneralSettings,
- // needed to preserve settings during chapter change
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [chapter.id],
- );
+ const readerSettings = useChapterReaderSettings();
+ const chapterGeneralSettings = useChapterGeneralSettings();
- // Update readerSettings when chapter changes
- useEffect(() => {
- setReaderSettings(
- getMMKVObject(CHAPTER_READER_SETTINGS) ||
- initialChapterReaderSettings,
- );
- }, [chapter.id]);
+ const {
+ autoStartTTSRef,
+ eventTTSQueue,
+ eventTTSSpeak,
+ eventTTSPauseSpeak,
+ eventTTSStopSpeak,
+ eventTTSState,
+ } = useTTS(webViewRef, novel, chapter);
+ const {
+ html,
+ replaceModalVisible,
+ setReplaceModalVisible,
+ selectedTextForReplace,
+ replacementText,
+ setReplacementText,
+ handleReplaceSave,
+ handleReplaceCancel,
+ eventTextAction,
+ } = useTextModifications(chapterText);
+ const { customJS, customCSS } = useCustomCode(readerSettings);
// Update battery level when chapter changes to ensure fresh value on navigation
const batteryLevel = useMemo(() => getBatteryLevelSync(), []);
@@ -104,343 +89,34 @@ const WebViewReader: React.FC = ({ onPress }) => {
const pluginCustomJS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.js`;
const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`;
const nextChapterScreenVisible = useRef(false);
- const autoStartTTSRef = useRef(false);
- const isTTSReadingRef = useRef(false);
- const readerSettingsRef = useRef(readerSettings);
- const appStateRef = useRef(AppState.currentState);
- const ttsQueueRef = useRef([]);
- const ttsQueueIndexRef = useRef(0);
-
- // Replace modal state
- const [replaceModalVisible, setReplaceModalVisible] = useState(false);
- const [selectedTextForReplace, setSelectedTextForReplace] = useState('');
- const [replacementText, setReplacementText] = useState('');
-
- const handleTextAction = React.useCallback(
- (action: string, text: string) => {
- if (!text) return;
-
- const { setSettings } = settings;
- if (action === 'remove') {
- // Add to removeText array if not already present
- const newRemoveText = [...settings.removeText];
- if (!newRemoveText.includes(text)) {
- newRemoveText.push(text);
- setSettings({ removeText: newRemoveText });
- }
- } else if (action === 'replace-prompt') {
- // Show modal for user to enter replacement text
- setSelectedTextForReplace(text);
- setReplacementText('');
- setReplaceModalVisible(true);
- }
- },
- [settings],
- );
-
- const handleReplaceSave = React.useCallback(() => {
- if (!selectedTextForReplace) return false;
-
- const { setSettings } = settings;
- const newReplaceText = { ...settings.replaceText };
- if (!(selectedTextForReplace in newReplaceText)) {
- newReplaceText[selectedTextForReplace] = replacementText;
- setSettings({ replaceText: newReplaceText });
- }
- setReplaceModalVisible(false);
- return true;
- }, [selectedTextForReplace, replacementText, settings]);
- const handleReplaceCancel = React.useCallback(() => {
- setReplaceModalVisible(false);
- setSelectedTextForReplace('');
- setReplacementText('');
- }, []);
-
- const html = useMemo(() => {
- let chText = chapterText;
- settings.removeText.forEach(text => {
- const m = text.match(/^\/(.*)\/([gmiyuvsd]*)$/);
- if (m) {
- const regex = new RegExp(m[1], m[2] ?? '');
- chText = chText.replace(regex, '');
- } else {
- chText = chText.split(text).join('');
- }
- });
- Object.entries(settings.replaceText).forEach(([text, replacement]) => {
- const m = text.match(/^\/(.*)\/([gmiyuvsd]*)$/);
- if (m) {
- const regex = new RegExp(m[1], m[2] ?? '');
- chText = chText.replace(regex, replacement);
- } else {
- chText = chText.split(text).join(replacement);
- }
- });
- return chText;
- }, [chapterText, settings.removeText, settings.replaceText]);
-
- if (chapterText === undefined) {
- }
-
- useEffect(() => {
- readerSettingsRef.current = readerSettings;
- }, [readerSettings]);
-
- useEffect(() => {
- const playListener = ttsMediaEmitter.addListener('TTSPlay', () => {
- webViewRef.current?.injectJavaScript(`
- if (window.tts && !tts.reading) { tts.resume(); }
- `);
- });
- const pauseListener = ttsMediaEmitter.addListener('TTSPause', () => {
- webViewRef.current?.injectJavaScript(`
- if (window.tts && tts.reading) { tts.pause(); }
- `);
- });
- const stopListener = ttsMediaEmitter.addListener('TTSStop', () => {
- webViewRef.current?.injectJavaScript(`
- if (window.tts) { tts.stop(); }
- `);
- });
- const rewindListener = ttsMediaEmitter.addListener('TTSRewind', () => {
- webViewRef.current?.injectJavaScript(`
- if (window.tts && tts.started) { tts.rewind(); }
- `);
- });
- const prevListener = ttsMediaEmitter.addListener('TTSPrev', () => {
- webViewRef.current?.injectJavaScript(`
- if (window.tts && window.reader && window.reader.prevChapter) {
- window.reader.post({ type: 'prev', autoStartTTS: true });
- }
- `);
- });
- const nextListener = ttsMediaEmitter.addListener('TTSNext', () => {
- webViewRef.current?.injectJavaScript(`
- if (window.tts && window.reader && window.reader.nextChapter) {
- window.reader.post({ type: 'next', autoStartTTS: true });
- }
- `);
- });
- const seekToListener = ttsMediaEmitter.addListener(
- 'TTSSeekTo',
- (event: { position: number }) => {
- const position = event.position;
- webViewRef.current?.injectJavaScript(`
- if (window.tts && tts.started) { tts.seekTo(${position}); }
- `);
- },
- );
- return () => {
- playListener.remove();
- pauseListener.remove();
- stopListener.remove();
- rewindListener.remove();
- prevListener.remove();
- nextListener.remove();
- seekToListener.remove();
- };
- }, [webViewRef]);
-
- useEffect(() => {
- if (isTTSReadingRef.current) {
- updateTTSNotification({
- novelName: novel?.name || 'Unknown',
- chapterName: chapter.name,
- coverUri: novel?.cover || '',
- isPlaying: isTTSReadingRef.current,
- });
- }
- }, [novel?.name, novel?.cover, chapter.name]);
-
- useEffect(() => {
- return () => {
- dismissTTSNotification();
- };
- }, []);
-
- useEffect(() => {
- const mmkvListener = MMKVStorage.addOnValueChangedListener(key => {
- switch (key) {
- case CHAPTER_READER_SETTINGS:
- // Update local state with new settings
- const newSettings =
- getMMKVObject(CHAPTER_READER_SETTINGS) ||
- initialChapterReaderSettings;
- setReaderSettings(newSettings);
-
- // Stop any currently playing speech
- Speech.stop();
-
- // Update WebView settings
- webViewRef.current?.injectJavaScript(
- `
- reader.readerSettings.val = ${MMKVStorage.getString(
- CHAPTER_READER_SETTINGS,
- )};
- // Auto-restart TTS if currently reading
- if (window.tts && tts.reading) {
- const currentElement = tts.currentElement;
- const wasReading = tts.reading;
- tts.stop();
- if (wasReading) {
- setTimeout(() => {
- tts.start(currentElement);
- }, 100);
- }
- }
- `,
- );
- break;
- case CHAPTER_GENERAL_SETTINGS:
- webViewRef.current?.injectJavaScript(
- `reader.generalSettings.val = ${MMKVStorage.getString(
- CHAPTER_GENERAL_SETTINGS,
- )}`,
- );
- break;
- }
- });
-
- const subscription = deviceInfoEmitter.addListener(
- 'RNDeviceInfo_batteryLevelDidChange',
- (level: number) => {
- webViewRef.current?.injectJavaScript(
- `reader.batteryLevel.val = ${level}`,
- );
- },
- );
- return () => {
- subscription.remove();
- mmkvListener.remove();
- };
- }, [webViewRef]);
-
- useEffect(() => {
- const subscription = AppState.addEventListener('change', nextState => {
- appStateRef.current = nextState;
- if (nextState === 'active' && isTTSReadingRef.current) {
- const index = ttsQueueIndexRef.current;
- webViewRef.current?.injectJavaScript(`
- if (window.tts && window.tts.allReadableElements) {
- const idx = ${index};
- if (idx < tts.allReadableElements.length) {
- tts.elementsRead = idx;
- tts.currentElement = tts.allReadableElements[idx];
- tts.prevElement = null;
- tts.started = true;
- tts.reading = true;
- tts.scrollToElement(tts.currentElement);
- tts.currentElement.classList.add('highlight');
- }
- }
- `);
- }
- });
-
- return () => subscription.remove();
- }, [webViewRef]);
-
- const speakText = (text: string) => {
- Speech.speak(text, {
- onDone() {
- const isBackground =
- appStateRef.current === 'background' ||
- appStateRef.current === 'inactive';
-
- if (
- isBackground &&
- ttsQueueRef.current.length > 0 &&
- ttsQueueIndexRef.current + 1 < ttsQueueRef.current.length
- ) {
- const nextIndex = ttsQueueIndexRef.current + 1;
- const nextText = ttsQueueRef.current[nextIndex];
- if (nextText) {
- ttsQueueIndexRef.current = nextIndex;
- speakText(nextText);
- return;
- }
- }
-
- if (isBackground) {
- isTTSReadingRef.current = false;
- dismissTTSNotification();
- webViewRef.current?.injectJavaScript('tts.stop?.()');
- return;
- }
-
- webViewRef.current?.injectJavaScript('tts.next?.()');
- },
- voice: readerSettingsRef.current.tts?.voice?.identifier,
- pitch: readerSettingsRef.current.tts?.pitch || 1,
- rate: readerSettingsRef.current.tts?.rate || 1,
- });
- };
const isRTL = plugin?.lang === 'Arabic' || plugin?.lang === 'Hebrew';
const readerDir = isRTL ? 'rtl' : 'ltr';
- const customJS = useMemo(() => {
- return readerSettings.codeSnippetsJS
- .map(snippet => {
- if (!snippet.active) return null;
- return `
- try {
- ${snippet.code}
- } catch (error) {
- alert(\`Error loading executing ${snippet.name}:\n\` + error);
- }
- `;
- })
- .filter(Boolean)
- .join('\n');
- }, [readerSettings.codeSnippetsJS]);
-
- const customCSS = useMemo(() => {
- return readerSettings.codeSnippetsCSS
- .map(snippet => {
- if (!snippet.active) return null;
- return snippet.code;
- })
- .filter(Boolean)
- .join('\n');
- }, [readerSettings.codeSnippetsCSS]);
-
- const preparedHTML = useMemo(() => {
- let resultHtml = html;
- readerSettings.removeText.forEach(text => {
- resultHtml = resultHtml.replace(text, '');
- });
- Object.entries(readerSettings.replaceText).forEach(
- ([text, replacement]) => {
- resultHtml = resultHtml.replace(text, replacement);
- },
- );
- return resultHtml;
- }, [html, readerSettings.removeText, readerSettings.replaceText]);
-
return (
- {
- // Update battery level when WebView finishes loading
- const currentBatteryLevel = getBatteryLevelSync();
- webViewRef.current?.injectJavaScript(
- `if (window.reader && window.reader.batteryLevel) {
+ <>
+ {
+ // Update battery level when WebView finishes loading
+ const currentBatteryLevel = getBatteryLevelSync();
+ webViewRef.current?.injectJavaScript(
+ `if (window.reader && window.reader.batteryLevel) {
window.reader.batteryLevel.val = ${currentBatteryLevel};
}`,
- );
+ );
- if (autoStartTTSRef.current) {
- autoStartTTSRef.current = false;
- setTimeout(() => {
- webViewRef.current?.injectJavaScript(`
+ if (autoStartTTSRef.current) {
+ autoStartTTSRef.current = false;
+ setTimeout(() => {
+ webViewRef.current?.injectJavaScript(`
(function() {
if (window.tts && reader.generalSettings.val.TTSEnable) {
setTimeout(() => {
@@ -453,118 +129,61 @@ const WebViewReader: React.FC = ({ onPress }) => {
}
})();
`);
- }, 300);
- }
- }}
- onMessage={(ev: { nativeEvent: { data: string } }) => {
- __DEV__ && onLogMessage(ev);
- const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data);
- switch (event.type) {
- case 'tts-queue': {
- const payload = event.data as
- | { queue?: unknown; startIndex?: unknown }
- | undefined;
- const queue = Array.isArray(payload?.queue)
- ? payload?.queue.filter(
- (item): item is string =>
- typeof item === 'string' && item.trim().length > 0,
- )
- : [];
- ttsQueueRef.current = queue;
- if (typeof payload?.startIndex === 'number') {
- ttsQueueIndexRef.current = payload.startIndex;
- } else {
- ttsQueueIndexRef.current = 0;
- }
- break;
+ }, 300);
}
- case 'hide':
- onPress();
- break;
- case 'next':
- nextChapterScreenVisible.current = true;
- if (event.autoStartTTS) {
- autoStartTTSRef.current = true;
- }
- navigateChapter('NEXT');
- break;
- case 'prev':
- if (event.autoStartTTS) {
- autoStartTTSRef.current = true;
- }
- navigateChapter('PREV');
- break;
- case 'save':
- if (event.data && typeof event.data === 'number') {
- saveProgress(event.data);
- }
- break;
- case 'speak':
- if (event.data && typeof event.data === 'string') {
- if (typeof event.index === 'number') {
- ttsQueueIndexRef.current = event.index;
+ }}
+ onMessage={(ev: { nativeEvent: { data: string } }) => {
+ __DEV__ && onLogMessage(ev);
+ const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data);
+ switch (event.type) {
+ case 'hide':
+ onPress();
+ break;
+ case 'next':
+ nextChapterScreenVisible.current = true;
+ if (event.autoStartTTS) {
+ autoStartTTSRef.current = true;
}
- if (!isTTSReadingRef.current) {
- isTTSReadingRef.current = true;
- showTTSNotification({
- novelName: novel?.name || 'Unknown',
- chapterName: chapter.name,
- coverUri: novel?.cover || '',
- isPlaying: true,
- });
- } else {
- updateTTSNotification({
- novelName: novel?.name || 'Unknown',
- chapterName: chapter.name,
- coverUri: novel?.cover || '',
- isPlaying: true,
- });
+ navigateChapter('NEXT');
+ break;
+ case 'prev':
+ if (event.autoStartTTS) {
+ autoStartTTSRef.current = true;
}
- if (
- typeof event.index === 'number' &&
- typeof event.total === 'number' &&
- event.total > 0
- ) {
- updateTTSProgress(event.index, event.total);
+ navigateChapter('PREV');
+ break;
+ case 'save':
+ if (event.data && typeof event.data === 'number') {
+ saveProgress(event.data);
}
- speakText(event.data);
- } else {
- webViewRef.current?.injectJavaScript('tts.next?.()');
- }
- break;
- case 'pause-speak':
- Speech.stop();
- break;
- case 'stop-speak':
- Speech.stop();
- if (!autoStartTTSRef.current) {
- isTTSReadingRef.current = false;
- ttsQueueRef.current = [];
- ttsQueueIndexRef.current = 0;
- dismissTTSNotification();
- }
- break;
- case 'tts-state':
- if (event.data && typeof event.data === 'object') {
- const data = event.data as { isReading?: boolean };
- const isReading = data.isReading === true;
- isTTSReadingRef.current = isReading;
- updateTTSPlaybackState(isReading);
+ break;
+ case 'tts-queue': {
+ eventTTSQueue(event);
+ break;
}
- break;
- case 'text-action':
- if (event.action && event.text) {
- handleTextAction(event.action as string, String(event.text));
- }
- break;
- }
- }}
- source={{
- baseUrl: !chapter.isDownloaded ? plugin?.site : undefined,
- headers: plugin?.imageRequestInit?.headers,
- method: plugin?.imageRequestInit?.method,
- body: plugin?.imageRequestInit?.body,
- html: `
+ case 'speak':
+ eventTTSSpeak(event);
+ break;
+ case 'pause-speak':
+ eventTTSPauseSpeak();
+ break;
+ case 'stop-speak':
+ eventTTSStopSpeak();
+ break;
+ case 'tts-state':
+ eventTTSState(event);
+ break;
+ case 'text-action':
+ eventTextAction(event);
+ break;
+ }
+ }}
+ source={{
+ baseUrl: !chapter.isDownloaded ? plugin?.site : undefined,
+ headers: plugin?.imageRequestInit?.headers,
+ method: plugin?.imageRequestInit?.method,
+ body: plugin?.imageRequestInit?.body,
+ html: `
@@ -622,7 +241,7 @@ const WebViewReader: React.FC = ({ onPress }) => {
${chapterGeneralSettings.pageReader ? '' : 'display: none'}"
">${chapter.name}