+
${chapter.name}
- ${html}
+ ${html}
@@ -539,14 +286,60 @@ const WebViewReader: React.FC
= ({ onPress }) => {
+
+ function fn(){
+ let novelName = "${novel.name}";
+ let chapterName = "${chapter.name}";
+ let sourceId = "${novel.pluginId}";
+ let chapterId =${chapter.id};
+ let novelId =${chapter.novelId};
+ const qs = (s) => document.querySelector(s);
+ let html = qs("#LNReader-chapter").innerHTML;
+ ${customJS}
+ qs("#LNReader-chapter").innerHTML = html;
+ }
+ document.addEventListener("DOMContentLoaded", fn);
+
`,
- }}
- />
+ }}
+ />
+ setReplaceModalVisible(false)}
+ onSave={handleReplaceSave}
+ onCancel={handleReplaceCancel}
+ title={getString('common.replaceText')}
+ >
+
+
+
+ >
);
};
+const styles = StyleSheet.create({
+ textInput: {
+ marginBottom: 16,
+ },
+});
+
export default memo(WebViewReader);
diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx
new file mode 100644
index 0000000000..4cb41e50a2
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx
@@ -0,0 +1,177 @@
+import { TextInput } from '@components';
+import { WINDOW_HEIGHT } from '@gorhom/bottom-sheet';
+import React from 'react';
+import { PixelRatio, StyleSheet } from 'react-native';
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
+import { useAnimatedKeyboard } from 'react-native-keyboard-controller';
+import { useTheme } from '@hooks/persisted';
+
+const FONT_SIZE = 14;
+const LINE_HEIGHT = FONT_SIZE * PixelRatio.getFontScale() * 1.2;
+
+type CodeInputProps = {
+ language: 'css' | 'js';
+ code: string;
+ setCode: (code: string) => void;
+ error?: boolean;
+ forceFocused?: boolean;
+ onFocus?: () => void;
+ onBlur?: () => void;
+};
+
+const START_JS_CODE = `const qs = (s) => document.querySelector(s);
+let html = qs("#LNReader-chapter").innerHTML;`;
+const START_CSS_CODE = `:root {
+ --StatusBar-currentHeight: number px;
+ --readerSettings-theme: color;
+ --readerSettings-padding: number px;
+ --readerSettings-textSize: number px;
+ --readerSettings-textColor: color;
+ --readerSettings-textAlign: alignment;
+ --readerSettings-lineHeight: number;
+ --readerSettings-fontFamily: font;
+ --theme-primary: color;
+ --theme-onPrimary: color;
+ --theme-secondary: color;
+ --theme-tertiary: color;
+ --theme-onTertiary: color;
+ --theme-onSecondary: color;
+ --theme-surface: color;
+ --theme-surface-0-9: color;
+ --theme-onSurface: color;
+ --theme-surfaceVariant: color;
+ --theme-onSurfaceVariant: color;
+ --theme-outline: color;
+ --theme-rippleColor: color;
+}`;
+const END_JS_CODE = 'qs("#LNReader-chapter").innerHTML = html;';
+
+const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
+
+const CodeInput = ({
+ language,
+ code,
+ setCode,
+ error,
+ forceFocused,
+ onFocus,
+ onBlur,
+}: CodeInputProps) => {
+ const theme = useTheme();
+ const { height: keyboardHeight } = useAnimatedKeyboard();
+ const expanded = useSharedValue(0);
+
+ const maxHeight = useAnimatedStyle(() => {
+ const availableHeight = WINDOW_HEIGHT - keyboardHeight.value - 200;
+ return {
+ maxHeight: Math.min(Math.max(availableHeight, 300), WINDOW_HEIGHT / 2),
+ };
+ });
+ const maxHeightTop = useAnimatedStyle(() => {
+ if (expanded.value !== 1) {
+ return { maxHeight: withTiming(2 * LINE_HEIGHT + 18, { duration: 250 }) };
+ }
+ return { maxHeight: withTiming(WINDOW_HEIGHT / 2, { duration: 250 }) };
+ }, [expanded]);
+ const maxHeightBottom = useAnimatedStyle(() => {
+ if (expanded.value !== 2) {
+ return { maxHeight: withTiming(2 * LINE_HEIGHT + 18, { duration: 250 }) };
+ }
+ return { maxHeight: withTiming(WINDOW_HEIGHT / 2, { duration: 250 }) };
+ });
+
+ const codeColor = React.useMemo(
+ () => ({ borderColor: theme.outline, color: theme.onSurfaceDisabled }),
+ [theme],
+ );
+
+ return (
+ <>
+ {
+ if (expanded.value === 1) {
+ expanded.value = 0;
+ } else {
+ expanded.value = 1;
+ }
+ }}
+ >
+ {language === 'js' ? START_JS_CODE : START_CSS_CODE}
+
+ {
+ expanded.value = 0;
+ }}
+ error={error}
+ />
+ {
+ if (expanded.value === 2) {
+ expanded.value = 0;
+ } else {
+ expanded.value = 2;
+ }
+ }}
+ >
+ {language === 'js' ? END_JS_CODE : ''}
+
+ >
+ );
+};
+
+export default CodeInput;
+
+const styles = StyleSheet.create({
+ fakeTextInput: {
+ borderRadius: 4,
+ borderWidth: 1,
+ borderStyle: 'solid',
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ marginHorizontal: 1,
+ marginVertical: 2,
+ fontSize: FONT_SIZE,
+ lineHeight: LINE_HEIGHT,
+ },
+ topField: {
+ borderBottomLeftRadius: 0,
+ borderBottomRightRadius: 0,
+ borderBottomWidth: 0,
+ flexGrow: 1,
+ },
+ codeField: {
+ verticalAlign: 'top',
+ flexGrow: 1,
+ borderRadius: 0,
+ borderTopWidth: 0,
+ borderBottomWidth: 0,
+ minHeight: LINE_HEIGHT * 8,
+ },
+ bottomField: {
+ flexGrow: 1,
+ borderTopLeftRadius: 0,
+ borderTopRightRadius: 0,
+ borderTopWidth: 0,
+ },
+});
diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/ListItems.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/ListItems.tsx
new file mode 100644
index 0000000000..c779094c6b
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/Components/ListItems.tsx
@@ -0,0 +1,123 @@
+import { useTheme } from '@hooks/persisted';
+import { useMemo } from 'react';
+import { PixelRatio, Pressable, StyleSheet, View } from 'react-native';
+import Icon from '@react-native-vector-icons/material-design-icons';
+import { Text } from 'react-native-paper';
+
+const fontScale = PixelRatio.getFontScale();
+const fontSize = 14;
+export const LIST_ITEM_LINE_HEIGHT = fontSize * fontScale * 1.2;
+
+export const ReplaceItem = ({
+ item,
+ removeItem,
+ editItem,
+}: {
+ item: [string, string];
+ removeItem: (identifier: string | number) => void;
+ editItem: (item: string[]) => void;
+}) => {
+ const theme = useTheme();
+ const colorTheme = useMemo(() => {
+ return { colors: theme };
+ }, [theme]);
+ return (
+ editItem(item)}
+ >
+
+ {item[0]}
+
+
+
+
+ {item[1]}
+
+ {
+ e.stopPropagation();
+ removeItem(item[0]);
+ }}
+ />
+
+
+ );
+};
+
+export const RemoveItem = ({
+ item,
+ index,
+ removeItem,
+ editItem,
+}: {
+ item: string;
+ index: number;
+ removeItem: (identifier: string | number) => void;
+ editItem: (item: string[]) => void;
+}) => {
+ const theme = useTheme();
+ const colorTheme = useMemo(() => {
+ return { colors: theme };
+ }, [theme]);
+ return (
+ editItem([item])}
+ >
+
+ {item}
+
+ removeItem(index)}
+ />
+
+ );
+};
+
+const styles = StyleSheet.create({
+ textfield: {
+ marginBottom: 16,
+ },
+ row: {
+ flexDirection: 'row',
+ gap: 8,
+ alignItems: 'center',
+ verticalAlign: 'middle',
+ },
+ itemRow: {
+ justifyContent: 'space-between',
+ marginHorizontal: 24,
+ marginVertical: 8,
+ height: LIST_ITEM_LINE_HEIGHT,
+ },
+ textItem: {
+ flexGrow: 1,
+ flexBasis: '40%',
+ overflow: 'hidden',
+ fontSize,
+ lineHeight: LIST_ITEM_LINE_HEIGHT,
+ },
+ textItemRight: {
+ textAlign: 'right',
+ },
+ spaceItem: {
+ flexShrink: 1,
+ textAlign: 'center',
+ flexBasis: '10%',
+ },
+});
diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/SelfHidingAppbar.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/SelfHidingAppbar.tsx
new file mode 100644
index 0000000000..155e8a36c8
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/Components/SelfHidingAppbar.tsx
@@ -0,0 +1,50 @@
+import { Appbar } from '@components';
+import { ThemeColors } from '@theme/types';
+import React from 'react';
+import Animated, {
+ Easing,
+ SharedValue,
+ useAnimatedStyle,
+ withTiming,
+} from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+type SelfHidingAppBarProps = {
+ title: string;
+ handleGoBack?: () => void;
+ theme: ThemeColors;
+ mode?: 'small' | 'medium' | 'large' | 'center-aligned';
+ children?: React.ReactNode;
+ hiddenState: SharedValue;
+ animationDuration?: SharedValue;
+};
+
+const APP_BAR_HEIGHT = 150;
+
+const SelfHidingAppBar = ({
+ hiddenState,
+ animationDuration,
+ ...props
+}: SelfHidingAppBarProps) => {
+ const { top } = useSafeAreaInsets();
+
+ const hideAppBar = useAnimatedStyle(() => {
+ // The animation now depends on the shared value's .value
+ const isHidden = hiddenState.value === 1;
+ return {
+ height: withTiming(isHidden ? top : APP_BAR_HEIGHT, {
+ duration: animationDuration?.value ?? 250,
+ easing: Easing.out(Easing.cubic),
+ }),
+ overflow: 'hidden',
+ };
+ });
+
+ return (
+
+
+
+ );
+};
+
+export default SelfHidingAppBar;
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..8e05835076
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx
@@ -0,0 +1,305 @@
+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 {
+ Keyboard,
+ KeyboardAvoidingView,
+ Platform,
+ 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';
+import { useKeyboardHeight } from '@hooks/common/useKeyboardHeight';
+
+type CodeRouteProps = {
+ language?: 'css' | 'js';
+ snippetIndex?: number;
+ jumpTo: (key: string) => void;
+ editingSnippet?: {
+ index: number;
+ isJS: boolean;
+ } | null;
+ onSnippetSaved?: () => void;
+ isActive: boolean;
+};
+
+const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
+
+const CodeRoute = ({
+ language: dLang,
+ snippetIndex,
+ jumpTo,
+ editingSnippet,
+ onSnippetSaved,
+ isActive,
+}: 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('');
+ const [focusedField, setFocusedField] = React.useState<
+ 'title' | 'code' | null
+ >(null);
+ const keyboardHeightValue = useKeyboardHeight();
+ const keyboardHeightRef = React.useRef(0);
+ const focusTimeoutRef = React.useRef | null>(
+ null,
+ );
+
+ // Update title, code, and reset errors when snippet changes
+ React.useEffect(() => {
+ setTitle(snippet?.name ?? '');
+ setCode(snippet?.code ?? '');
+ setError({ title: false, code: false });
+ }, [snippet]);
+
+ React.useEffect(() => {
+ keyboardHeightRef.current = keyboardHeightValue;
+ if (keyboardHeightValue > 0 && focusTimeoutRef.current) {
+ clearTimeout(focusTimeoutRef.current);
+ focusTimeoutRef.current = null;
+ }
+ return () => {
+ if (focusTimeoutRef.current) {
+ clearTimeout(focusTimeoutRef.current);
+ }
+ };
+ }, [keyboardHeightValue]);
+
+ React.useEffect(() => {
+ if (!isActive) {
+ Keyboard.dismiss();
+ setFocusedField(null);
+ }
+ }, [isActive]);
+
+ const { height: keyboardHeight } = useAnimatedKeyboard();
+ const ScrollViewRef = React.useRef(null);
+
+ const maxHeightScrollView = useAnimatedStyle(() => {
+ return {
+ maxHeight: WINDOW_HEIGHT - keyboardHeight.value,
+ };
+ });
+
+ 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;
+ }
+ const newSnippets = [...snippets];
+
+ // Editing existing snippet
+ if (isEditing && editIndex !== undefined && editIndex !== -1) {
+ newSnippets[editIndex].name = title;
+ newSnippets[editIndex].code = code;
+ setSettings({
+ [language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']: newSnippets,
+ });
+ showToast('Snippet updated successfully');
+ onSnippetSaved?.();
+ return;
+ }
+
+ // Creating new snippet
+ newSnippets.push({
+ name: title,
+ code,
+ active: true,
+ lang: language,
+ });
+ setSettings({
+ [language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']: newSnippets,
+ });
+ 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}
+ />
+
+ {
+ setFocusedField('title');
+ if (focusTimeoutRef.current) {
+ clearTimeout(focusTimeoutRef.current);
+ }
+ focusTimeoutRef.current = setTimeout(() => {
+ if (keyboardHeightRef.current === 0) {
+ setFocusedField(null);
+ }
+ }, 250);
+ }}
+ style={styles.snippetName}
+ error={error.title}
+ />
+ {
+ setFocusedField('code');
+ if (focusTimeoutRef.current) {
+ clearTimeout(focusTimeoutRef.current);
+ }
+ focusTimeoutRef.current = setTimeout(() => {
+ if (keyboardHeightRef.current === 0) {
+ setFocusedField(null);
+ }
+ }, 250);
+ }}
+ />
+
+
+
+
+
+
+ );
+};
+
+export default React.memo(CodeRoute);
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ scrollContainer: {
+ paddingHorizontal: 16,
+ },
+ scrollContent: {},
+ 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..a1d4c5bf96
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/Routes/SettingsRoute.tsx
@@ -0,0 +1,201 @@
+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';
+import { getString } from '@strings/translations';
+
+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 (
+
+
+
+ {getString('customCodeSettings.textManipulation')}
+
+ toggleExtended(0)}
+ listExpanded={extended[0]}
+ />
+ toggleExtended(1)}
+ listExpanded={extended[1]}
+ />
+
+
+ {getString('customCodeSettings.codeSnippets')}
+
+ onEditSnippet?.(-1, true)} // -1 indicates new snippet
+ />
+
+ {/* CSS Snippets */}
+ {codeSnippetsCSS.length > 0 && (
+ <>
+
+
+ {getString('customCodeSettings.cssSnippets')}
+
+
+ {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 && (
+ <>
+
+
+ {getString('customCodeSettings.javascriptSnippets')}
+
+
+ {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..aa504cf806
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/index.tsx
@@ -0,0 +1,179 @@
+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 { useAnimatedReaction, useSharedValue } from 'react-native-reanimated';
+import { useTheme } from '@hooks/persisted';
+import { useAnimatedKeyboard } from 'react-native-keyboard-controller';
+import SettingsReaderWebView from '../SettingsReaderScreen/components/SettingsReaderWebView';
+import { getString } from '@strings/translations';
+
+const routes = [
+ { key: 'first', title: getString('common.settings') },
+ { key: 'second', title: getString('common.code') },
+ { key: 'third', title: getString('common.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);
+ const appBarAnimationDuration = useSharedValue(250);
+ const tabIndex = useSharedValue(0);
+ const { height: keyboardHeight } = useAnimatedKeyboard();
+
+ // State for editing snippets
+ const [editingSnippet, setEditingSnippet] = React.useState<{
+ index: number;
+ isJS: boolean;
+ } | null>(null);
+
+ const handleTabChange = (newIndex: number) => {
+ tabIndex.value = newIndex;
+ setIndex(newIndex);
+ // Clear editing state when manually switching tabs
+ if (newIndex !== 1) {
+ setEditingSnippet(null);
+ }
+ };
+
+ const handleEditSnippet = (snippetIndex: number, isJS: boolean) => {
+ setEditingSnippet({
+ index: snippetIndex,
+ isJS: snippetIndex === -1 ? true : isJS, // Default to JS for new snippets, use passed value for editing
+ });
+ tabIndex.value = 1;
+ setIndex(1); // Switch to Code tab
+ };
+
+ const handleSnippetSaved = () => {
+ setEditingSnippet(null);
+ tabIndex.value = 0;
+ setIndex(0); // Switch back to Settings tab
+ };
+
+ useAnimatedReaction(
+ () => tabIndex.value === 1 && keyboardHeight.value > 0,
+ isHidden => {
+ appBarAnimationDuration.value = keyboardHeight.value > 0 ? 150 : 250;
+ appBarHiddenState.value = isHidden ? 1 : 0;
+ },
+ );
+
+ 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}
+ animationDuration={appBarAnimationDuration}
+ />
+
+
+
+ );
+};
+
+export default SettingsCustomCode;
+
+const styles = StyleSheet.create({
+ tabBar: {
+ borderBottomWidth: 1,
+ elevation: 0,
+ },
+ tabBarIndicator: {
+ height: 3,
+ },
+ flex: {
+ flex: 1,
+ },
+});
diff --git a/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx b/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx
index 6cad220489..be68fd2846 100644
--- a/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx
+++ b/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx
@@ -1,24 +1,16 @@
-import { View, StatusBar, StyleSheet, useWindowDimensions } from 'react-native';
-import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { View, StyleSheet, useWindowDimensions } from 'react-native';
+import React, { useEffect, useRef, useState } from 'react';
import { BottomSheetModal } from '@gorhom/bottom-sheet';
import { useNavigation } from '@react-navigation/native';
-import WebView from 'react-native-webview';
import { FAB } from 'react-native-paper';
-import { dummyHTML } from './utils';
import { Appbar, SafeAreaView } from '@components/index';
import BottomSheet from '@components/BottomSheet/BottomSheet';
-import {
- useChapterGeneralSettings,
- useChapterReaderSettings,
- useTheme,
-} from '@hooks/persisted';
+import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import color from 'color';
-import { useBatteryLevel } from 'react-native-device-info';
import * as Speech from 'expo-speech';
import TabBar, { Tab } from './components/TabBar';
@@ -26,7 +18,7 @@ import DisplayTab from './tabs/DisplayTab';
import ThemeTab from './tabs/ThemeTab';
import NavigationTab from './tabs/NavigationTab';
import AccessibilityTab from './tabs/AccessibilityTab';
-import AdvancedTab from './tabs/AdvancedTab';
+import SettingsReaderWebView from './components/SettingsReaderWebView';
export type TextAlignments =
| 'left'
@@ -36,15 +28,9 @@ export type TextAlignments =
| 'justify'
| undefined;
-type WebViewPostEvent = {
- type: string;
- data?: { [key: string]: string | number };
-};
-
const SettingsReaderScreen = () => {
const theme = useTheme();
const navigation = useNavigation();
- const webViewRef = useRef(null);
const bottomSheetRef = useRef(null);
const { bottom, right } = useSafeAreaInsets();
const { height: screenHeight } = useWindowDimensions();
@@ -55,88 +41,10 @@ const SettingsReaderScreen = () => {
{ id: 'theme', label: 'Theme', icon: 'palette-outline' },
{ id: 'navigation', label: 'Navigation', icon: 'gesture-swipe-horizontal' },
{ id: 'accessibility', label: 'Accessibility', icon: 'account-voice' },
- { id: 'advanced', label: 'Advanced', icon: 'code-braces' },
];
- const novel = {
- 'artist': null,
- 'author': 'LNReader-kun',
- 'cover':
- 'file:///storage/emulated/0/Android/data/com.rajarsheechatterjee.LNReader/files/Novels/lightnovelcave/16/cover.png?1717862123181',
- 'genres': 'Action,Hero',
- 'id': 16,
- 'inLibrary': 1,
- 'isLocal': 0,
- 'name': 'Preview Man (LN)',
- 'path': 'novel/preview-man-16091321',
- 'pluginId': 'lightnovelcave',
- 'status': 'Ongoing',
- 'summary':
- 'To preview or not preview. A question that bothered humanity for a long time, until one day… Preview Man appeared.Show More',
- 'totalPages': 8,
- };
- const chapter = {
- 'bookmark': 0,
- 'chapterNumber': 1,
- 'id': 3722,
- 'isDownloaded': 1,
- 'name': 'Chapter 1 - The rise of Preview Man',
- 'novelId': 16,
- 'page': '2',
- 'path': 'novel/preview-man/chapter-1',
- 'position': 0,
- 'progress': 3,
- 'readTime': '2100-01-01 00:00:00',
- 'releaseTime': 'January 1, 2100',
- 'unread': 1,
- 'updatedTime': null,
- };
- const [hidden, setHidden] = useState(true);
- const batteryLevel = useBatteryLevel();
const readerSettings = useChapterReaderSettings();
- const chapterGeneralSettings = useChapterGeneralSettings();
-
const BOTTOM_SHEET_HEIGHT = screenHeight * 0.7;
- const assetsUriPrefix = useMemo(
- () => (__DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'),
- [],
- );
- const webViewCSS = `
-
-
-
-
- `;
const readerBackgroundColor = readerSettings.theme;
@@ -160,8 +68,6 @@ const SettingsReaderScreen = () => {
return ;
case 'accessibility':
return ;
- case 'advanced':
- return ;
default:
return ;
}
@@ -181,96 +87,7 @@ const SettingsReaderScreen = () => {
{/* Large Preview Area */}
- {
- const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data);
- switch (event.type) {
- case 'hide':
- if (hidden) {
- webViewRef.current?.injectJavaScript(
- 'reader.hidden.val = true',
- );
- } else {
- webViewRef.current?.injectJavaScript(
- 'reader.hidden.val = false',
- );
- }
- setHidden(!hidden);
- break;
- case 'speak':
- if (event.data && typeof event.data === 'string') {
- Speech.speak(event.data, {
- onDone() {
- webViewRef.current?.injectJavaScript('tts.next?.()');
- },
- voice: readerSettings.tts?.voice?.identifier,
- pitch: readerSettings.tts?.pitch || 1,
- rate: readerSettings.tts?.rate || 1,
- });
- } else {
- webViewRef.current?.injectJavaScript('tts.next?.()');
- }
- break;
- case 'stop-speak':
- Speech.stop();
- break;
- }
- }}
- source={{
- html: `
-
-
-
- ${webViewCSS}
-
-
-
- ${dummyHTML}
-
-
-
-
-
-
-
-
-
-
-
- `,
- }}
- />
+
{/* Floating Action Button to Open Bottom Sheet */}
@@ -355,7 +172,4 @@ const styles = StyleSheet.create({
previewContainer: {
flex: 1,
},
- webView: {
- flex: 1,
- },
});
diff --git a/src/screens/settings/SettingsReaderScreen/components/SettingsReaderWebView.tsx b/src/screens/settings/SettingsReaderScreen/components/SettingsReaderWebView.tsx
new file mode 100644
index 0000000000..a65f53f8cf
--- /dev/null
+++ b/src/screens/settings/SettingsReaderScreen/components/SettingsReaderWebView.tsx
@@ -0,0 +1,310 @@
+import { StatusBar, StyleSheet } from 'react-native';
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import WebView from 'react-native-webview';
+import { dummyHTML } from './dummy';
+
+import {
+ useChapterGeneralSettings,
+ useChapterReaderSettings,
+ useTheme,
+} from '@hooks/persisted';
+import { getString } from '@strings/translations';
+
+import color from 'color';
+import { useBatteryLevel } from 'react-native-device-info';
+import * as Speech from 'expo-speech';
+
+type WebViewPostEvent = {
+ type: string;
+ data?: { [key: string]: string | number };
+ msg?: string;
+};
+
+const SettingsReaderWebView = () => {
+ const theme = useTheme();
+ const webViewRef = useRef(null);
+
+ const novel = {
+ 'artist': null,
+ 'author': 'LNReader-kun',
+ 'cover':
+ 'file:///storage/emulated/0/Android/data/com.rajarsheechatterjee.LNReader/files/Novels/lightnovelcave/16/cover.png?1717862123181',
+ 'genres': 'Action,Hero',
+ 'id': 16,
+ 'inLibrary': 1,
+ 'isLocal': 0,
+ 'name': 'Preview Man (LN)',
+ 'path': 'novel/preview-man-16091321',
+ 'pluginId': 'lightnovelcave',
+ 'status': 'Ongoing',
+ 'summary':
+ 'To preview or not preview. A question that bothered humanity for a long time, until one day… Preview Man appeared.Show More',
+ 'totalPages': 8,
+ };
+ const chapter = {
+ 'bookmark': 0,
+ 'chapterNumber': 1,
+ 'id': 3722,
+ 'isDownloaded': 1,
+ 'name': 'Chapter 1 - The rise of Preview Man',
+ 'novelId': 16,
+ 'page': '2',
+ 'path': 'novel/preview-man/chapter-1',
+ 'position': 0,
+ 'progress': 3,
+ 'readTime': '2100-01-01 00:00:00',
+ 'releaseTime': 'January 1, 2100',
+ 'unread': 1,
+ 'updatedTime': null,
+ };
+ const [hidden, setHidden] = useState(true);
+ const batteryLevel = useBatteryLevel();
+ const readerSettings = useChapterReaderSettings();
+ const chapterGeneralSettings = useChapterGeneralSettings();
+
+ const customJS = useMemo(() => {
+ return readerSettings.codeSnippetsJS
+ .map(snippet => {
+ if (!snippet.active) return null;
+ return `
+ try {
+ ${snippet.code}
+ } catch (error) {
+ alert('Error loading executing ${JSON.stringify(
+ 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 assetsUriPrefix = useMemo(
+ () => (__DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'),
+ [],
+ );
+ const webViewCSS = `
+
+
+
+
+
+
+
+ `;
+
+ const readerBackgroundColor = readerSettings.theme;
+
+ useEffect(() => {
+ return () => {
+ Speech.stop();
+ };
+ }, []);
+
+ const saveRegex = useCallback(
+ (regex: RegExpMatchArray, text: string, replacement: string = '') => {
+ const validFlags = new Set(['g', 'm', 'i', 'y', 'u', 'v', 's', 'd']);
+ const flags = regex[2] ?? '';
+ const hasInvalidFlags = [...flags].some(f => !validFlags.has(f));
+ if (hasInvalidFlags) {
+ return text;
+ }
+ try {
+ const r = new RegExp(regex[1], flags);
+ return text.replace(r, replacement);
+ } catch {
+ return text;
+ }
+ },
+ [],
+ );
+
+ const preparedDummyHTML = useMemo(() => {
+ let chText = dummyHTML;
+ readerSettings.removeText.forEach(text => {
+ // test if text is regex
+ const m = text.match(/^\/(.*)\/([gmiyuvsd]*)$/);
+ if (m) {
+ chText = saveRegex(m, chText);
+ } else {
+ chText = chText.split(text).join('');
+ }
+ });
+ Object.entries(readerSettings.replaceText).forEach(
+ ([text, replacement]) => {
+ const m = text.match(/^\/(.*)\/([gmiyuvsd]*)$/);
+ if (m) {
+ chText = saveRegex(m, chText, replacement);
+ } else {
+ chText = chText.split(text).join(replacement);
+ }
+ },
+ );
+ return chText;
+ }, [readerSettings.removeText, readerSettings.replaceText, saveRegex]);
+
+ return (
+ {
+ const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data);
+ switch (event.type) {
+ case 'hide':
+ if (hidden) {
+ webViewRef.current?.injectJavaScript('reader.hidden.val = true');
+ } else {
+ webViewRef.current?.injectJavaScript('reader.hidden.val = false');
+ }
+ setHidden(!hidden);
+ break;
+ case 'speak':
+ if (event.data && typeof event.data === 'string') {
+ Speech.speak(event.data, {
+ onDone() {
+ webViewRef.current?.injectJavaScript('tts.next?.()');
+ },
+ voice: readerSettings.tts?.voice?.identifier,
+ pitch: readerSettings.tts?.pitch || 1,
+ rate: readerSettings.tts?.rate || 1,
+ });
+ } else {
+ webViewRef.current?.injectJavaScript('tts.next?.()');
+ }
+ break;
+ case 'stop-speak':
+ Speech.stop();
+ break;
+ case 'console':
+ /* eslint-disable no-console */
+ console.info(`[Console] ${JSON.stringify(event.msg, null, 2)}`);
+ }
+ }}
+ source={{
+ html: `
+
+
+
+ ${webViewCSS}
+
+
+
+ ${preparedDummyHTML}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ }}
+ />
+ );
+};
+
+export default SettingsReaderWebView;
+
+const styles = StyleSheet.create({
+ webView: {
+ flex: 1,
+ },
+});
diff --git a/src/screens/settings/SettingsReaderScreen/utils.ts b/src/screens/settings/SettingsReaderScreen/components/dummy.ts
similarity index 100%
rename from src/screens/settings/SettingsReaderScreen/utils.ts
rename to src/screens/settings/SettingsReaderScreen/components/dummy.ts
diff --git a/src/screens/settings/SettingsReaderScreen/tabs/AdvancedTab.tsx b/src/screens/settings/SettingsReaderScreen/tabs/AdvancedTab.tsx
deleted file mode 100644
index 5f416bf76c..0000000000
--- a/src/screens/settings/SettingsReaderScreen/tabs/AdvancedTab.tsx
+++ /dev/null
@@ -1,392 +0,0 @@
-import React, { useState } from 'react';
-import {
- View,
- StyleSheet,
- Text,
- Pressable,
- KeyboardAvoidingView,
- Platform,
-} from 'react-native';
-import { BottomSheetScrollView } from '@gorhom/bottom-sheet';
-import { TextInput, Portal } from 'react-native-paper';
-import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
-import * as DocumentPicker from 'expo-document-picker';
-import NativeFile from '@specs/NativeFile';
-import { useTheme, useChapterReaderSettings } from '@hooks/persisted';
-import { getString } from '@strings/translations';
-import { ThemeColors } from '@theme/types';
-import { Button, ConfirmationDialog } from '@components/index';
-import { showToast } from '@utils/showToast';
-import { useBoolean } from '@hooks';
-
-type CodeTab = 'css' | 'js';
-
-const AdvancedTab: React.FC = () => {
- const theme = useTheme();
- const styles = createStyles(theme);
- const { customCSS, customJS, setChapterReaderSettings } =
- useChapterReaderSettings();
-
- const [activeCodeTab, setActiveCodeTab] = useState('css');
- const [cssValue, setCssValue] = useState(customCSS || '');
- const [jsValue, setJsValue] = useState(customJS || '');
-
- const clearCSSModal = useBoolean();
- const clearJSModal = useBoolean();
-
- const customCSSPlaceholder = `/* Custom CSS for your reader */
-
-body {
- margin: 16px;
- line-height: 1.8;
-}
-
-h1, h2, h3 {
- margin-top: 1.5em;
- margin-bottom: 0.5em;
- font-weight: bold;
-}
-
-p {
- text-indent: 1em;
- margin-bottom: 1em;
-}
-
-/* Target specific sources */
-#sourceId-example {
- font-family: serif;
-}`;
-
- const customJSPlaceholder = `// Custom JavaScript for your reader
-// Available variables:
-// - html, novelName, chapterName
-// - sourceId, chapterId, novelId
-
-// Example: Remove elements
-document.querySelectorAll('.ads').forEach(el => el.remove());
-
-// Example: Modify content
-const title = document.querySelector('h1');
-if (title) {
- title.style.color = '#FF6B6B';
-}`;
-
- const handleSave = () => {
- if (activeCodeTab === 'css') {
- setChapterReaderSettings({ customCSS: cssValue });
- } else {
- setChapterReaderSettings({ customJS: jsValue });
- }
- showToast('Saved');
- };
-
- const handleReset = () => {
- if (activeCodeTab === 'css') {
- clearCSSModal.setTrue();
- } else {
- clearJSModal.setTrue();
- }
- };
-
- const confirmResetCSS = () => {
- setCssValue('');
- setChapterReaderSettings({ customCSS: '' });
- clearCSSModal.setFalse();
- };
-
- const confirmResetJS = () => {
- setJsValue('');
- setChapterReaderSettings({ customJS: '' });
- clearJSModal.setFalse();
- };
-
- const handleImport = async () => {
- try {
- const mimeType =
- activeCodeTab === 'css' ? 'text/css' : 'application/javascript';
- const file = await DocumentPicker.getDocumentAsync({
- copyToCacheDirectory: false,
- type: mimeType,
- });
-
- if (file.assets) {
- const tempPath =
- NativeFile.getConstants().ExternalCachesDirectoryPath +
- '/imported_custom.' +
- activeCodeTab;
- NativeFile.copyFile(file.assets[0].uri, tempPath);
- const content = NativeFile.readFile(tempPath);
- NativeFile.unlink(tempPath);
-
- if (activeCodeTab === 'css') {
- setCssValue(content.trim());
- setChapterReaderSettings({ customCSS: content.trim() });
- } else {
- setJsValue(content.trim());
- setChapterReaderSettings({ customJS: content.trim() });
- }
- showToast('Imported');
- }
- } catch (error: any) {
- showToast(error.message);
- }
- };
-
- return (
-
-
- {/* Tab Selector */}
-
- setActiveCodeTab('css')}
- android_ripple={{ color: theme.rippleColor }}
- >
-
-
- CSS
-
-
-
- setActiveCodeTab('js')}
- android_ripple={{ color: theme.rippleColor }}
- >
-
-
- JS
-
-
-
-
- {/* Code Editor */}
-
-
- activeCodeTab === 'css' ? setCssValue(text) : setJsValue(text)
- }
- placeholder={
- activeCodeTab === 'css'
- ? customCSSPlaceholder
- : customJSPlaceholder
- }
- multiline
- numberOfLines={12}
- autoCorrect={false}
- autoCapitalize="none"
- spellCheck={false}
- style={[styles.codeEditor, { backgroundColor: theme.surface2 }]}
- activeUnderlineColor={theme.primary}
- contentStyle={styles.codeEditorContent}
- textColor={theme.onSurface}
- placeholderTextColor={theme.onSurfaceVariant}
- />
-
-
- {/* Hint */}
-
-
-
- {activeCodeTab === 'css'
- ? getString('readerSettings.cssHint')
- : getString('readerSettings.jsHint')}
-
-
-
- {/* Action Buttons */}
-
-
-
-
-
-
-
-
-
- {/* Confirmation Dialogs */}
-
-
-
-
-
- );
-};
-
-export default AdvancedTab;
-
-const createStyles = (theme: ThemeColors) =>
- StyleSheet.create({
- container: {
- flex: 1,
- },
- scrollContainer: {
- flex: 1,
- },
- contentContainer: {
- paddingBottom: 24,
- },
- tabContainer: {
- flexDirection: 'row',
- borderBottomWidth: 1,
- borderBottomColor: 'rgba(0, 0, 0, 0.12)',
- },
- activeTab: {
- borderBottomColor: theme.primary,
- borderBottomWidth: 2,
- },
- tab: {
- flex: 1,
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- paddingVertical: 12,
- minHeight: 48,
- },
- tabIcon: {
- marginEnd: 8,
- },
- tabLabel: {
- fontSize: 14,
- letterSpacing: 0.5,
- fontWeight: '400',
- },
- activeTabLabel: {
- fontWeight: '500',
- },
- editorContainer: {
- marginHorizontal: 16,
- marginTop: 16,
- minHeight: 300,
- },
- codeEditor: {
- minHeight: 300,
- maxHeight: 400,
- },
- codeEditorContent: {
- fontFamily: 'monospace',
- fontSize: 13,
- lineHeight: 20,
- paddingTop: 12,
- paddingBottom: 12,
- },
- hint: {
- flexDirection: 'row',
- alignItems: 'flex-start',
- padding: 12,
- borderRadius: 8,
- marginHorizontal: 16,
- marginTop: 16,
- gap: 8,
- },
- hintIcon: {
- marginTop: 2,
- },
- hintText: {
- flex: 1,
- fontSize: 12,
- lineHeight: 18,
- },
- actionButtons: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- marginHorizontal: 16,
- marginTop: 16,
- gap: 8,
- },
- button: {
- flex: 1,
- },
- bottomSpacing: {
- height: 24,
- },
- });
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}
+ />