+
${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/CodeSnippetsScreen.tsx b/src/screens/settings/SettingsCustomCodeScreen/CodeSnippetsScreen.tsx
new file mode 100644
index 0000000000..e0f51cc64a
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/CodeSnippetsScreen.tsx
@@ -0,0 +1,174 @@
+import React from 'react';
+import {
+ NavigationState,
+ SceneRendererProps,
+ TabBar,
+ TabView,
+} from 'react-native-tab-view';
+import { StyleSheet, useWindowDimensions } from 'react-native';
+import Color from 'color';
+
+import { Appbar, IconButtonV2, SafeAreaView } from '@components';
+import { useTheme } from '@hooks/persisted';
+import { showToast } from '@utils/showToast';
+import { getString } from '@strings/translations';
+import SnippetEditor, { SnippetEditorHandle } from './SnippetEditor';
+import SettingsReaderWebView from '../SettingsReaderScreen/components/SettingsReaderWebView';
+import { CodeSnippetsScreenProps } from '@navigators/types';
+import NativeFile from '@specs/NativeFile';
+import * as DocumentPicker from 'expo-document-picker';
+
+type State = NavigationState<{
+ key: string;
+ title: string;
+}>;
+
+const routes = [
+ { key: 'code', title: getString('common.code') },
+ { key: 'example', title: getString('common.example') },
+];
+
+const CodeSnippetsScreen: React.FC = ({
+ navigation,
+ route,
+}) => {
+ const snippetIndex = route?.params?.snippetIndex;
+ const isJS = route?.params?.isJS;
+ const language = isJS === false ? 'css' : 'js';
+ const theme = useTheme();
+ const layout = useWindowDimensions();
+
+ const [index, setIndex] = React.useState(0);
+ const editorRef = React.useRef(null);
+
+ const renderScene = ({
+ route: r,
+ }: SceneRendererProps & {
+ route: {
+ key: string;
+ title: string;
+ };
+ }) => {
+ switch (r.key) {
+ case 'code':
+ return (
+
+ );
+ case 'example':
+ return ;
+ default:
+ return null;
+ }
+ };
+
+ const renderTabBar = React.useCallback(
+ (props: SceneRendererProps & { navigationState: State }) => (
+
+ ),
+ [
+ theme.isDark,
+ theme.primary,
+ theme.rippleColor,
+ theme.secondary,
+ theme.surface,
+ ],
+ );
+
+ const handleImport = async () => {
+ try {
+ const mimeType =
+ language === 'css' ? 'text/css' : 'application/javascript';
+ const file = await DocumentPicker.getDocumentAsync({
+ copyToCacheDirectory: false,
+ type: mimeType,
+ });
+
+ if (file.assets) {
+ const tempPath =
+ NativeFile.getConstants().ExternalCachesDirectoryPath +
+ '/imported_custom.' +
+ language;
+ NativeFile.copyFile(file.assets[0].uri, tempPath);
+ const content = NativeFile.readFile(tempPath);
+ NativeFile.unlink(tempPath);
+
+ editorRef.current?.setCode(content.trim());
+ showToast(getString('customCodeSettings.imported'));
+ }
+ } catch (error: any) {
+ showToast(error.message);
+ }
+ };
+
+ return (
+
+ navigation.goBack()}
+ theme={theme}
+ mode="small"
+ >
+
+ editorRef.current?.save()}
+ theme={theme}
+ />
+
+
+
+ );
+};
+
+export default CodeSnippetsScreen;
+
+const styles = StyleSheet.create({
+ tabBar: {
+ borderBottomWidth: 1,
+ elevation: 0,
+ },
+ tabBarIndicator: {
+ height: 3,
+ },
+ flex: {
+ flex: 1,
+ },
+});
diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx
new file mode 100644
index 0000000000..7104125ec4
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx
@@ -0,0 +1,217 @@
+import React from 'react';
+import { PixelRatio, StyleSheet, Text, View } from 'react-native';
+import { useTheme } from '@hooks/persisted';
+import { getString } from '@strings/translations';
+import {
+ SimpleCodeEditor,
+ MemoizedHighlightedCode,
+ HighlightMode,
+ useStableLineModels,
+} from './SimpleCodeEditor';
+import { Portal } from 'react-native-paper';
+import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
+
+const FONT_SIZE = 14;
+const LINE_HEIGHT = Math.ceil(FONT_SIZE * PixelRatio.getFontScale() * 1.2);
+const MIN_LINES = 16;
+
+const MD3_DEFAULT_APPBAR_HEIGHT = 64;
+
+type CodeInputProps = {
+ language: 'css' | 'js';
+ code: string;
+ setCode: (code: string) => void;
+ highlightMode?: HighlightMode;
+ error?: 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 CodeInput = ({
+ language,
+ code,
+ setCode,
+ highlightMode,
+ onFocus,
+ onBlur,
+}: CodeInputProps) => {
+ const theme = useTheme();
+
+ const codeFieldStyle = React.useMemo(
+ () => ({
+ color: theme.onBackground,
+ backgroundColor: theme.background,
+ }),
+ [theme],
+ );
+
+ const startValue = language === 'js' ? START_JS_CODE : START_CSS_CODE;
+
+ const lines = useStableLineModels(code);
+ const startLines = useStableLineModels(startValue);
+ const debounce = React.useRef(null);
+ const [error, setError] = React.useState(undefined);
+
+ function setAndAnalyzeCode(val: string) {
+ if (language === 'js') {
+ debounce.current && clearTimeout(debounce.current);
+ debounce.current = setTimeout(() => analyzeCode(val), 500);
+ }
+ setCode(val);
+ }
+ function analyzeCode(val: string) {
+ try {
+ // eslint-disable-next-line no-new-func, no-new
+ new Function(val);
+ setError(undefined);
+ } catch (e: unknown) {
+ setError(
+ (e as Error).message.replace(
+ /^(\d+)/,
+ (_, i) => Number(i) + startLines.length + '',
+ ),
+ );
+ }
+ }
+
+ return (
+
+
+ {!error ? null : (
+
+
+ {error}
+
+
+ )}
+
+
+
+ {language !== 'js' ? null : (
+
+ )}
+
+ );
+};
+export default CodeInput;
+
+const styles = StyleSheet.create({
+ error: {
+ width: '100%',
+ position: 'absolute',
+ top: MD3_DEFAULT_APPBAR_HEIGHT * 1.3,
+ padding: 8,
+ transform: [{ translateY: 20 }],
+ zIndex: 9999,
+ backgroundColor: 'red',
+ },
+ errorText: {
+ textAlign: 'center',
+ },
+ container: {
+ flex: 1,
+ marginVertical: 8,
+ paddingBottom: 8,
+ },
+ rowContainer: {
+ paddingVertical: 8,
+ alignItems: 'flex-start',
+ },
+ codeContainer: {
+ flex: 1,
+ },
+ lines: {
+ paddingRight: 4,
+ paddingTop: 0,
+ textAlign: 'right',
+ minWidth: 32,
+ },
+ fontStyle: {
+ fontSize: FONT_SIZE,
+ lineHeight: LINE_HEIGHT,
+ fontFamily: 'monospace',
+ margin: 0,
+ marginBottom: 0,
+ marginTop: 0,
+ padding: 0,
+ paddingBottom: 0,
+ paddingTop: 0,
+ },
+ fakeTextInput: {
+ opacity: 0.6,
+ },
+ topField: {
+ flex: 1,
+ },
+ codeField: {
+ verticalAlign: 'top',
+ paddingTop: 0,
+ flex: 1,
+ minHeight: LINE_HEIGHT * MIN_LINES,
+ },
+ bottomField: {
+ flex: 1,
+ borderTopLeftRadius: 0,
+ borderTopRightRadius: 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..62bf676692
--- /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 = Math.ceil(fontSize * fontScale * 1.2);
+export const LIST_ITEM_HEIGHT = LIST_ITEM_LINE_HEIGHT + 16;
+
+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',
+ },
+ 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/SimpleCodeEditor.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/SimpleCodeEditor.tsx
new file mode 100644
index 0000000000..070d2800ed
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/Components/SimpleCodeEditor.tsx
@@ -0,0 +1,591 @@
+import { Row } from '@components/Common';
+import React, { memo, useCallback, useMemo, useRef } from 'react';
+import {
+ AnimatableNumericValue,
+ Animated,
+ ColorValue,
+ PixelRatio,
+ Platform,
+ StyleProp,
+ StyleSheet,
+ Text,
+ TextInput,
+ TextInputProps,
+ TextStyle,
+ View,
+ ViewStyle,
+} from 'react-native';
+import { PrismLight as Light } from 'react-syntax-highlighter';
+import css from 'react-syntax-highlighter/dist/esm/languages/prism/css';
+import js from 'react-syntax-highlighter/dist/esm/languages/prism/javascript';
+import materialDark from 'react-syntax-highlighter/dist/esm/styles/prism/material-dark';
+import materialLight from 'react-syntax-highlighter/dist/esm/styles/prism/material-light';
+
+Light.registerLanguage('javascript', js);
+Light.registerLanguage('css', css);
+
+const LANG_MAP = {
+ js: 'javascript',
+ css: 'css',
+} as const;
+
+type SupportedMode = keyof typeof LANG_MAP;
+type HLStyleValue = string | number;
+type HLStyle = Record;
+type RNStylesheet = Record;
+
+interface RendererNode {
+ type?: 'element' | 'text';
+ value?: string | number;
+ properties?: {
+ className?: string[];
+ [key: string]: unknown;
+ };
+ children?: RendererNode[];
+}
+
+export type HighlightMode = 'off' | 'on' | 'combined';
+
+type SimpleCodeEditorProps = Omit<
+ TextInputProps,
+ 'value' | 'defaultValue' | 'children' | 'onChangeText'
+> & {
+ highlightMode?: HighlightMode;
+ onChangeText?: (text: string) => void;
+ containerStyle?: StyleProp;
+};
+
+interface LineModel {
+ id: string;
+ code: string;
+}
+
+interface HighlightedLineProps {
+ code: string;
+ isDark?: boolean;
+ mode: SupportedMode;
+ textStyle: TextStyle;
+ lineHeight: number;
+ hide: boolean;
+}
+
+const stylesheetCache = new WeakMap();
+
+function Passthrough({
+ children,
+}: {
+ children?: React.ReactNode;
+ [_key: string]: unknown;
+}) {
+ return <>{children}>;
+}
+
+function cssToTextStyle(cssStyle: HLStyle): TextStyle {
+ const rn: TextStyle = {};
+
+ for (const [key, value] of Object.entries(cssStyle)) {
+ switch (key) {
+ case 'background':
+ case 'backgroundColor':
+ rn.backgroundColor = String(value);
+ break;
+
+ case 'color':
+ rn.color = String(value);
+ break;
+
+ case 'fontStyle':
+ //rn.fontStyle = value as TextStyle['fontStyle'];
+ break;
+
+ case 'fontWeight':
+ rn.fontWeight = String(value) as TextStyle['fontWeight'];
+ break;
+
+ case 'textDecoration':
+ case 'textDecorationLine':
+ rn.textDecorationLine = value as TextStyle['textDecorationLine'];
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ return rn;
+}
+type StyleSheet = {
+ [key: string]: React.CSSProperties;
+};
+function getRNStylesheet(stylesheet: StyleSheet): RNStylesheet {
+ const cached = stylesheetCache.get(stylesheet);
+
+ if (cached) {
+ return cached;
+ }
+
+ const rn: RNStylesheet = {};
+
+ for (const [key, value] of Object.entries(stylesheet)) {
+ rn[key] = cssToTextStyle(value as HLStyle);
+ }
+
+ stylesheetCache.set(stylesheet, rn);
+
+ return rn;
+}
+
+function getStylesForNode(
+ node: RendererNode,
+ rnStylesheet: RNStylesheet,
+): TextStyle {
+ const result: TextStyle = {};
+
+ for (const className of node.properties?.className ?? []) {
+ const classStyle = rnStylesheet[className];
+
+ if (classStyle) {
+ Object.assign(result, classStyle);
+ }
+ }
+
+ return result;
+}
+
+function stripLineBreaks(value: string | number): string {
+ return String(value).replace(/\r?\n/g, '');
+}
+
+function renderInlineNodes(
+ nodes: RendererNode[],
+ rnStylesheet: RNStylesheet,
+ defaultColor: ColorValue,
+ keyPrefix = 'n',
+): React.ReactNode[] {
+ const result: React.ReactNode[] = [];
+
+ nodes.forEach((node, index) => {
+ const key = `${keyPrefix}_${index}`;
+
+ if (node.children?.length) {
+ result.push(
+
+ {renderInlineNodes(
+ node.children,
+ rnStylesheet,
+ defaultColor,
+ `${key}_c`,
+ )}
+ ,
+ );
+ }
+
+ if (node.value != null) {
+ result.push(stripLineBreaks(node.value));
+ }
+ });
+
+ return result;
+}
+
+function lineHighlightRenderer(raw: rendererProps): React.ReactNode {
+ const { rows, stylesheet } = raw;
+ const rnStylesheet = getRNStylesheet(stylesheet);
+ const defaultColor = rnStylesheet.hljs?.color ?? '#abb2bf';
+ const result: React.ReactNode[] = [];
+
+ rows.forEach((row, rowIndex) => {
+ if (row.children?.length) {
+ result.push(
+ ...renderInlineNodes(
+ row.children,
+ rnStylesheet,
+ defaultColor,
+ `r_${rowIndex}`,
+ ),
+ );
+ } else if (row.value != null) {
+ result.push(stripLineBreaks(row.value));
+ }
+ });
+
+ return result;
+}
+
+function shallowEqualTextStyle(a: TextStyle, b: TextStyle): boolean {
+ const aKeys = Object.keys(a) as Array;
+ const bKeys = Object.keys(b) as Array;
+
+ if (aKeys.length !== bKeys.length) {
+ return false;
+ }
+
+ return aKeys.every(key => a[key] === b[key]);
+}
+
+const HighlightedLine = memo(
+ function HighlightedLine({
+ code,
+ mode,
+ isDark = true,
+ textStyle,
+ lineHeight,
+ hide,
+ }: HighlightedLineProps) {
+ const style = {
+ minHeight: lineHeight,
+ flex: 1,
+ opacity: hide ? 0 : 1,
+ lineHeight,
+ };
+ return (
+
+ {code.length === 0 ? (
+ '\u200B'
+ ) : (
+
+ {code}
+
+ )}
+
+ );
+ },
+ (prev, next) => {
+ return (
+ prev.code === next.code &&
+ prev.mode === next.mode &&
+ prev.lineHeight === next.lineHeight &&
+ shallowEqualTextStyle(prev.textStyle, next.textStyle)
+ );
+ },
+);
+
+function splitLines(value: string): string[] {
+ return value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
+}
+
+export function useStableLineModels(value: string): LineModel[] {
+ const previousRef = useRef<{
+ lines: string[];
+ models: LineModel[];
+ } | null>(null);
+
+ const nextIdRef = useRef(0);
+
+ return useMemo(() => {
+ const newLines = splitLines(value);
+ const previous = previousRef.current;
+
+ if (!previous) {
+ const models = newLines.map(line => ({
+ id: `line_${nextIdRef.current++}`,
+ code: line,
+ }));
+
+ previousRef.current = {
+ lines: newLines,
+ models,
+ };
+
+ return models;
+ }
+
+ const oldLines = previous.lines;
+ const oldModels = previous.models;
+
+ let prefix = 0;
+
+ while (
+ prefix < oldLines.length &&
+ prefix < newLines.length &&
+ oldLines[prefix] === newLines[prefix]
+ ) {
+ prefix += 1;
+ }
+
+ let oldSuffix = oldLines.length - 1;
+ let newSuffix = newLines.length - 1;
+
+ while (
+ oldSuffix >= prefix &&
+ newSuffix >= prefix &&
+ oldLines[oldSuffix] === newLines[newSuffix]
+ ) {
+ oldSuffix -= 1;
+ newSuffix -= 1;
+ }
+
+ const models: LineModel[] = [];
+
+ for (let i = 0; i < prefix; i += 1) {
+ models.push(oldModels[i]);
+ }
+
+ for (let i = prefix; i <= newSuffix; i += 1) {
+ models.push({
+ id: `line_${nextIdRef.current++}`,
+ code: newLines[i],
+ });
+ }
+
+ const suffixCount = oldLines.length - 1 - oldSuffix;
+
+ for (let i = suffixCount; i > 0; i -= 1) {
+ const oldIndex = oldLines.length - i;
+ models.push(oldModels[oldIndex]);
+ }
+
+ previousRef.current = {
+ lines: newLines,
+ models,
+ };
+
+ return models;
+ }, [value]);
+}
+
+function extractOpacityStyle(style: StyleProp): {
+ opacity: AnimatableNumericValue;
+} {
+ const flat = StyleSheet.flatten(style) ?? {};
+
+ return {
+ opacity: flat.opacity ?? 1,
+ };
+}
+
+function extractTextStyle(style: StyleProp): TextStyle {
+ const flat = StyleSheet.flatten(style) ?? {};
+
+ return {
+ color: '#abb2bf',
+ fontFamily: flat.fontFamily,
+ fontSize: flat.fontSize,
+ fontStyle: flat.fontStyle,
+ fontWeight: flat.fontWeight,
+ letterSpacing: flat.letterSpacing,
+ lineHeight: flat.lineHeight,
+ };
+}
+
+function extractContentPadding(style: StyleProp): ViewStyle {
+ const flat = StyleSheet.flatten(style) ?? {};
+
+ return {
+ padding: flat.padding,
+ paddingBottom: flat.paddingBottom,
+ paddingEnd: flat.paddingEnd,
+ paddingHorizontal: flat.paddingHorizontal,
+ paddingLeft: flat.paddingLeft,
+ paddingRight: flat.paddingRight,
+ paddingStart: flat.paddingStart,
+ paddingTop: flat.paddingTop,
+ paddingVertical: flat.paddingVertical,
+ };
+}
+
+function getLineHeight(style: StyleProp): number {
+ const flat = StyleSheet.flatten(style) ?? {};
+
+ if (typeof flat.lineHeight === 'number') {
+ return flat.lineHeight;
+ }
+
+ if (typeof flat.fontSize === 'number') {
+ return Math.ceil(flat.fontSize * 1.4);
+ }
+
+ return 20;
+}
+
+type _MemoizedHighlightedCode = (Lines extends false
+ ? { value: string; lines?: never }
+ : { lines: LineModel[]; value?: never }) & {
+ mode: SupportedMode;
+ style?: StyleProp;
+ hide?: boolean;
+ isDark?: boolean;
+ setLines?: (num: number) => void;
+ startLine?: number;
+};
+export type MemoizedHighlightedCode =
+ | _MemoizedHighlightedCode
+ | _MemoizedHighlightedCode;
+
+export function MemoizedHighlightedCode({
+ lines,
+ value,
+ mode,
+ style,
+ hide,
+ setLines,
+ isDark = false,
+ startLine = 0,
+}: MemoizedHighlightedCode) {
+ const opacityStyle = useMemo(() => extractOpacityStyle(style), [style]);
+ const textStyle = useMemo(() => extractTextStyle(style), [style]);
+ const contentPadding = useMemo(() => extractContentPadding(style), [style]);
+ const lineHeight = useMemo(() => getLineHeight(style), [style]);
+ if (!lines) {
+ // Value and lines prop can't be switched
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ lines = useStableLineModels(value!);
+ setLines?.(lines.length);
+ }
+ return (
+
+ {lines.map((line, i) => (
+
+
+ {i + 1 + startLine}
+
+
+
+
+ ))}
+
+ );
+}
+
+export function SimpleCodeEditor({
+ highlightMode = 'combined',
+ onChangeText,
+ containerStyle,
+ scrollEnabled = true,
+ lines,
+ value,
+ mode,
+ style,
+ isDark,
+ setLines,
+ startLine,
+ ...props
+}: SimpleCodeEditorProps & Omit) {
+ const hideHighlight = highlightMode === 'off';
+ const showInput = highlightMode !== 'on';
+ const scrollY = useRef(new Animated.Value(0)).current;
+
+ const highlightedCodeProps = {
+ lines,
+ value,
+ mode,
+ style,
+ hide: hideHighlight,
+ isDark,
+ setLines,
+ startLine,
+ };
+
+ const negativeScrollY = useMemo(() => {
+ return Animated.multiply(scrollY, -1);
+ }, [scrollY]);
+
+ const handleChangeText = useCallback(
+ (text: string) => {
+ onChangeText?.(text);
+ },
+ [onChangeText],
+ );
+
+ const color = {
+ color: showInput ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.01)',
+ };
+
+ return (
+
+
+ {/*@ts-expect-error*/}
+
+
+
+
+ );
+}
+
+const FONT_SIZE = 14;
+const LINE_HEIGHT = Math.ceil(FONT_SIZE * PixelRatio.getFontScale() * 1.2);
+const styles = StyleSheet.create({
+ container: {
+ position: 'relative',
+ overflow: 'hidden',
+ },
+ highlightLayer: {
+ zIndex: 0,
+ },
+ input: {
+ zIndex: 1,
+ backgroundColor: 'transparent',
+ width: 'auto',
+ textAlignVertical: 'top',
+ marginLeft: 32,
+ },
+ inputVisible: {
+ color: '#abb2bf',
+ },
+ androidInput: {
+ includeFontPadding: false,
+ },
+ lines: {
+ textAlign: 'right',
+ width: 32,
+ fontSize: FONT_SIZE,
+ lineHeight: LINE_HEIGHT,
+ height: '100%',
+ fontFamily: 'monospace',
+ margin: 0,
+ marginBottom: 0,
+ marginTop: 0,
+ padding: 0,
+ paddingRight: 4,
+ paddingBottom: 0,
+ paddingTop: 0,
+ },
+ lineContainer: { width: '100%', position: 'relative' },
+});
diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/Snippet.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/Snippet.tsx
new file mode 100644
index 0000000000..054716a447
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/Components/Snippet.tsx
@@ -0,0 +1,70 @@
+import { IconButtonV2, SwitchItem } from '@components';
+import { useTheme } from '@hooks/persisted';
+import { CodeSnippet } from '@utils/customCode';
+import { memo } from 'react';
+import { StyleSheet, View } from 'react-native';
+
+function Snippet({
+ delete: _delete,
+ edit,
+ rename,
+ snippet,
+ index,
+ toggle,
+}: {
+ delete: (index: number, isJS: boolean) => void;
+ edit: (index: number, isJS: boolean) => void;
+ rename: (index: number, isJS: boolean, name: string) => void;
+ toggle: (index: number, isJS: boolean) => void;
+ index: number;
+ snippet: CodeSnippet;
+}) {
+ const theme = useTheme();
+ const isJs = snippet.lang === 'js';
+ return (
+
+ toggle(index, isJs)}
+ onLongPress={() => rename(index, isJs, snippet.name)}
+ theme={theme}
+ style={styles.switchItem}
+ />
+
+ edit(index, isJs)}
+ theme={theme}
+ />
+ _delete(index, isJs)}
+ theme={theme}
+ />
+
+
+ );
+}
+export default memo(Snippet);
+
+const styles = StyleSheet.create({
+ 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/Modals/ReplaceItemModal.tsx b/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx
new file mode 100644
index 0000000000..5d54e87983
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx
@@ -0,0 +1,303 @@
+import { AnimatedIconButton, List } from '@components';
+import { getString } from '@strings/translations';
+import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal';
+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,
+ useWindowDimensions,
+} from 'react-native';
+import { TextInput } from 'react-native-paper';
+import Animated, {
+ FadeIn,
+ FadeOut,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
+import {
+ LIST_ITEM_HEIGHT,
+ RemoveItem,
+ ReplaceItem,
+} from '../Components/ListItems';
+import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
+
+const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
+const LIST_CLOSED_HEIGHT = LIST_ITEM_HEIGHT * 3;
+
+type ReplaceItemModalProps = {
+ showReplace?: boolean;
+ listExpanded: boolean;
+ toggleList: () => void;
+};
+
+const ReplaceItemModal = ({
+ showReplace = false,
+ listExpanded = false,
+ toggleList,
+}: ReplaceItemModalProps) => {
+ const theme = useTheme();
+ const { height: windowHeight } = useWindowDimensions();
+ 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(LIST_CLOSED_HEIGHT, arrayLength * LIST_ITEM_HEIGHT),
+ );
+ 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] = getString('customCodeSettings.enterAMatch');
+ }
+ if (!replacementText) {
+ e[1] = getString('customCodeSettings.enterAReplace');
+ }
+ 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([getString('customCodeSettings.itemAlreadyExists'), '']);
+ return false;
+ }
+ setSettings({ removeText: removeText });
+ }
+ cancel();
+ modal.setFalse();
+ 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(
+ windowHeight * 0.6,
+ arrayLength * LIST_ITEM_HEIGHT,
+ );
+ } else {
+ listSize.value = Math.min(
+ LIST_CLOSED_HEIGHT,
+ arrayLength * LIST_ITEM_HEIGHT,
+ );
+ }
+ },
+ [
+ arrayLength,
+ iconRotation,
+ listExpanded,
+ listSize,
+ toggleList,
+ windowHeight,
+ ],
+ );
+ 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 ? (
+ (
+
+ )}
+ />
+ ) : (
+ (
+
+ )}
+ />
+ )}
+
+ {arrayLength > 3 ? (
+
+ ) : null}
+ {
+ modal.setFalse();
+ setError(undefined);
+ }}
+ onSave={save}
+ onCancel={cancel}
+ title={getString('customCodeSettings.editReplace')}
+ >
+
+ {!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/SnippetEditor.tsx b/src/screens/settings/SettingsCustomCodeScreen/SnippetEditor.tsx
new file mode 100644
index 0000000000..881d68c38c
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/SnippetEditor.tsx
@@ -0,0 +1,180 @@
+import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal';
+import { getString } from '@strings/translations';
+import React from 'react';
+import { StyleSheet, View } from 'react-native';
+import CodeInput from './Components/CodeInput';
+import { showToast } from '@utils/showToast';
+import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
+import { TextInput as PaperTextInput } from 'react-native-paper';
+
+import { useNavigation } from '@react-navigation/native';
+import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
+import { IconButtonV2 } from '@components';
+import type { HighlightMode } from './Components/SimpleCodeEditor';
+export type SnippetEditorHandle = {
+ save: () => void;
+ setCode: (val: string) => void;
+};
+
+type SnippetEditorProps = {
+ snippetIndex?: number;
+ language: 'css' | 'js';
+};
+
+const SnippetEditor = React.forwardRef(
+ ({ snippetIndex, language }, ref) => {
+ const navigation = useNavigation();
+ const theme = useTheme();
+ const {
+ codeSnippetsJS,
+ codeSnippetsCSS,
+ setChapterReaderSettings: setSettings,
+ } = useChapterReaderSettings();
+
+ const isEditing = snippetIndex !== undefined && snippetIndex >= 0;
+ const snippets = language === 'js' ? codeSnippetsJS : codeSnippetsCSS;
+ const snippet = isEditing ? snippets[snippetIndex!] : null;
+
+ const [code, setCode] = React.useState(snippet?.code ?? '');
+ const [error, setError] = React.useState({ code: false });
+ const [highlightMode, setHighlightMode] =
+ React.useState('combined');
+ const [snippetName, setSnippetName] = React.useState('');
+
+ const [showNameModal, setShowNameModal] = React.useState(false);
+
+ const save = React.useCallback(() => {
+ setError({ code: false });
+ if (!code.trim()) {
+ setError({ code: true });
+ return;
+ }
+ if (isEditing) {
+ const newSnippets = [...snippets];
+ newSnippets[snippetIndex!].code = code;
+ setSettings({
+ [language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']:
+ newSnippets,
+ });
+ showToast(getString('customCodeSettings.snippetUpdated'));
+ navigation.goBack();
+ } else {
+ setShowNameModal(true);
+ }
+ }, [
+ code,
+ snippets,
+ setSettings,
+ isEditing,
+ snippetIndex,
+ language,
+ navigation,
+ ]);
+ const handleNameModalSave = React.useCallback(() => {
+ if (!snippetName.trim()) return false;
+ const newSnippets = [...snippets];
+ newSnippets.push({
+ name: snippetName.trim(),
+ code,
+ active: true,
+ lang: language,
+ });
+ setSettings({
+ [language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']: newSnippets,
+ });
+ showToast(getString('customCodeSettings.snippetSaved'));
+ setShowNameModal(false);
+ navigation.goBack();
+ return true;
+ }, [snippetName, code, language, snippets, setSettings, navigation]);
+
+ const handleNameModalCancel = React.useCallback(() => {
+ setShowNameModal(false);
+ setSnippetName('');
+ }, []);
+
+ React.useImperativeHandle(ref, () => ({ save, setCode }), [save, setCode]);
+
+ return (
+ <>
+
+
+ setHighlightMode(prev =>
+ prev === 'off'
+ ? 'combined'
+ : prev === 'combined'
+ ? 'on'
+ : 'off',
+ )
+ }
+ style={{ position: 'absolute', end: 8, top: 8, zIndex: 2 }}
+ />
+
+
+
+
+
+
+
+ >
+ );
+ },
+);
+
+export default React.memo(SnippetEditor);
+
+const styles = StyleSheet.create({
+ flexGrow: { flexGrow: 1 },
+ mb16: { marginBottom: 16 },
+ toolbar: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ },
+ scrollContainer: {
+ paddingHorizontal: 2,
+ },
+ scrollContent: {},
+ button: {
+ marginHorizontal: 8,
+ flexBasis: '40%',
+ flex: 1,
+ },
+});
diff --git a/src/screens/settings/SettingsCustomCodeScreen/index.tsx b/src/screens/settings/SettingsCustomCodeScreen/index.tsx
new file mode 100644
index 0000000000..826a7b60be
--- /dev/null
+++ b/src/screens/settings/SettingsCustomCodeScreen/index.tsx
@@ -0,0 +1,235 @@
+import { Appbar, List, SafeAreaView } from '@components';
+import { CustomCodeSettingsScreenProps } from '@navigators/types';
+import React from 'react';
+import { Keyboard, ScrollView, StyleSheet, View } from 'react-native';
+import { TextInput } from 'react-native-paper';
+import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal';
+import ReplaceItemModal from './Modals/ReplaceItemModal';
+import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
+import { getString } from '@strings/translations';
+import Snippet from './Components/Snippet';
+
+const SettingsCustomCode = ({ navigation }: CustomCodeSettingsScreenProps) => {
+ const theme = useTheme();
+ const {
+ codeSnippetsJS,
+ codeSnippetsCSS,
+ setChapterReaderSettings: setSettings,
+ } = useChapterReaderSettings();
+ const [renameSnippet, setRenameSnippet] = React.useState<{
+ index: number;
+ isJS: boolean;
+ name: string;
+ } | null>(null);
+ const [extended, setExtended] = React.useState([false, false, false, false]);
+
+ const toggleExtended = React.useCallback(
+ (index: number) => {
+ const newExtended = [false, false, false, false];
+ 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 handleEditSnippet = (snippetIndex: number, isJS: boolean) => {
+ navigation.navigate('CodeSnippets', {
+ snippetIndex,
+ isJS,
+ });
+ };
+
+ const handleRenameSave = React.useCallback(() => {
+ if (!renameSnippet || !renameSnippet.name.trim()) return false;
+ const snippets = renameSnippet.isJS
+ ? [...codeSnippetsJS]
+ : [...codeSnippetsCSS];
+ snippets[renameSnippet.index].name = renameSnippet.name.trim();
+ setSettings({
+ [renameSnippet.isJS ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets,
+ });
+ setRenameSnippet(null);
+ return true;
+ }, [renameSnippet, codeSnippetsJS, codeSnippetsCSS, setSettings]);
+
+ const handleRenameCancel = React.useCallback(() => {
+ setRenameSnippet(null);
+ }, []);
+
+ return (
+
+ {
+ Keyboard.dismiss();
+ navigation.goBack();
+ }}
+ theme={theme}
+ />
+
+
+
+ {getString('customCodeSettings.textManipulation')}
+
+ toggleExtended(0)}
+ listExpanded={extended[0]}
+ />
+ toggleExtended(1)}
+ listExpanded={extended[1]}
+ />
+
+
+ {getString('customCodeSettings.codeSnippets')}
+
+ {/* CSS Snippets */}
+
+
+ {getString('customCodeSettings.cssSnippets')}
+
+
+ {codeSnippetsCSS.length > 0 &&
+ codeSnippetsCSS.map((snippet, index) => (
+
+ setRenameSnippet({
+ index,
+ isJS,
+ name,
+ })
+ }
+ edit={handleEditSnippet}
+ delete={deleteSnippet}
+ index={index}
+ snippet={snippet}
+ />
+ ))}
+ handleEditSnippet(-1, false)}
+ />
+ {codeSnippetsCSS.length === 0 && (
+
+ )}
+
+ {/* JS Snippets */}
+
+
+ {getString('customCodeSettings.javascriptSnippets')}
+
+
+ {codeSnippetsJS.length > 0 &&
+ codeSnippetsJS.map((snippet, index) => (
+
+ setRenameSnippet({
+ index,
+ isJS,
+ name,
+ })
+ }
+ edit={handleEditSnippet}
+ delete={deleteSnippet}
+ index={index}
+ snippet={snippet}
+ />
+ ))}
+ handleEditSnippet(-1, true)}
+ />
+ {codeSnippetsJS.length === 0 && (
+
+ )}
+
+
+
+ {
+ if (renameSnippet) {
+ setRenameSnippet({ ...renameSnippet, name: text });
+ }
+ }}
+ autoFocus
+ mode="outlined"
+ style={styles.mb16}
+ theme={{ colors: theme }}
+ />
+
+
+ );
+};
+
+export default SettingsCustomCode;
+
+const styles = StyleSheet.create({
+ mb16: { marginBottom: 16 },
+ 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/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..f7a098cd62
--- /dev/null
+++ b/src/screens/settings/SettingsReaderScreen/components/SettingsReaderWebView.tsx
@@ -0,0 +1,261 @@
+import { StatusBar, StyleSheet } from 'react-native';
+import React, { 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';
+import {
+ composeCSS,
+ composeJS,
+ applyTextModifications,
+} from '@utils/customCode';
+
+type WebViewPostEvent = {
+ type: string;
+ data?: { [key: string]: string | number };
+ msg?: string;
+};
+
+const SettingsReaderWebView = ({ onPress }: { onPress?: () => void } = {}) => {
+ 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(
+ () => composeJS(readerSettings.codeSnippetsJS),
+ [readerSettings.codeSnippetsJS],
+ );
+
+ const customCSS = useMemo(
+ () => composeCSS(readerSettings.codeSnippetsCSS),
+ [readerSettings.codeSnippetsCSS],
+ );
+
+ const assetsUriPrefix = useMemo(
+ () => (__DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'),
+ [],
+ );
+ const webViewCSS = `
+
+
+
+
+
+
+
+ `;
+
+ const readerBackgroundColor = readerSettings.theme;
+
+ useEffect(() => {
+ return () => {
+ Speech.stop();
+ };
+ }, []);
+
+ const preparedDummyHTML = useMemo(
+ () =>
+ applyTextModifications(
+ dummyHTML,
+ readerSettings.removeText,
+ readerSettings.replaceText,
+ ),
+ [readerSettings.removeText, readerSettings.replaceText],
+ );
+
+ return (
+ {
+ const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data);
+ switch (event.type) {
+ case 'hide':
+ onPress?.();
+ 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}
+ />
+ snippets
+ .filter(s => s.active)
+ .map(s => s.code)
+ .join('\n');
+
+/**
+ * Build a concatenated JS string from active snippets,
+ * each wrapped in try/catch so one failure doesn't stop the rest.
+ */
+export const composeJS = (snippets: CodeSnippet[]): string =>
+ snippets
+ .filter(s => s.active)
+ .map(
+ s => `
+ try {
+ ${s.code}
+ } catch (error) {
+ alert(\`Error executing ${JSON.stringify(s.name)}:\n\` + error);
+ }
+ `,
+ )
+ .join('\n');
+
+/**
+ * Safely apply a RegExp match to a text string.
+ *
+ * The match is expected to have been produced by /^\/(.*)\/([gmiyuvsd]*)$/.
+ * Returns the original text when the pattern is invalid or the match fails.
+ */
+export const safeApplyRegex = (
+ match: RegExpMatchArray,
+ text: string,
+ replacement: string = '',
+): string => {
+ const validFlags = new Set(['g', 'm', 'i', 'y', 'u', 'v', 's', 'd']);
+ const flags = match[2] ?? '';
+ const hasInvalidFlags = [...flags].some(f => !validFlags.has(f));
+ if (hasInvalidFlags) {
+ /* eslint-disable-next-line no-console */
+ console.warn('Invalid regex flags:', match[0]);
+ return text;
+ }
+ try {
+ const r = new RegExp(match[1], flags);
+ return text.replace(r, replacement);
+ } catch {
+ /* eslint-disable-next-line no-console */
+ console.warn('Invalid regex pattern:', match[0]);
+ }
+ return text;
+};
+
+/**
+ * Test whether `input` looks like a `/pattern/flags` regex string.
+ */
+const isRegexString = (input: string): RegExpMatchArray | null =>
+ input.match(/^\/(.*)\/([gmiyuvsd]*)$/);
+
+/**
+ * Apply all remove-text and replace-text entries to `html`.
+ *
+ * Each entry in `removeText` or a key in `replaceText` can be:
+ * - a literal string (simple split/join)
+ * - a `/pattern/flags` regex string
+ */
+export const applyTextModifications = (
+ html: string,
+ removeText: string[],
+ replaceText: Record,
+): string => {
+ let result = html;
+
+ for (const text of removeText) {
+ const m = isRegexString(text);
+ if (m) {
+ result = safeApplyRegex(m, result);
+ } else {
+ result = result.split(text).join('');
+ }
+ }
+
+ for (const [text, replacement] of Object.entries(replaceText)) {
+ if (!text) continue;
+ const m = isRegexString(text);
+ if (m) {
+ result = safeApplyRegex(m, result, replacement);
+ } else {
+ result = result.split(text).join(replacement);
+ }
+ }
+
+ return result;
+};
diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json
index 3293b344ee..d2c593f9f2 100644
--- a/strings/languages/en/strings.json
+++ b/strings/languages/en/strings.json
@@ -201,6 +201,11 @@
"categories": "Categories",
"chapters": "Chapters",
"clear": "Clear",
+ "code": "Code",
+ "custom_code": "Custom Code",
+ "replaceText": "Replace Text",
+ "textToReplace": "Text to replace",
+ "replaceWith": "Replace with",
"copiedToClipboard": "Copied to clipboard: %{name}",
"delete": "Delete",
"deleted": "Deleted %{name}",
@@ -575,5 +580,25 @@
"LOCAL_RESTORE": "Local Restore",
"MIGRATE_NOVEL": "Migrating Novel",
"DOWNLOAD_CHAPTER": "Downloading Chapter"
+ },
+ "customCodeSettings": {
+ "textManipulation": "Text manipulation",
+ "codeSnippets": "Code Snippets",
+ "createNewSnippet": "Create new snippet",
+ "addCssOrJsCode": "Add CSS or JavaScript code",
+ "cssSnippets": "CSS Snippets",
+ "javascriptSnippets": "JavaScript Snippets",
+ "createJSSnippet": "Create JS Snippet",
+ "addJavascriptCode": "Add custom JavaScript code",
+ "replace": "Replace",
+ "removeText": "Remove text",
+ "enterAMatch": "Enter a match",
+ "enterAReplace": "Enter a replace",
+ "itemAlreadyExists": "Item already exists",
+ "editReplace": "Edit Replace",
+ "yourCodeHere": "Your code here",
+ "imported": "Imported",
+ "snippetUpdated": "Snippet updated successfully",
+ "snippetSaved": "Snippet saved successfully"
}
}
diff --git a/strings/types/index.ts b/strings/types/index.ts
index 1301e6f2ea..cc3302990e 100644
--- a/strings/types/index.ts
+++ b/strings/types/index.ts
@@ -174,6 +174,11 @@ export interface StringMap {
'common.categories': 'string';
'common.chapters': 'string';
'common.clear': 'string';
+ 'common.code': 'string';
+ 'common.custom_code': 'string';
+ 'common.replaceText': 'string';
+ 'common.textToReplace': 'string';
+ 'common.replaceWith': 'string';
'common.copiedToClipboard': 'string';
'common.delete': 'string';
'common.deleted': 'string';
@@ -242,6 +247,10 @@ export interface StringMap {
'generalSettingsScreen.disableLoadingAnimationsDesc': 'string';
'generalSettingsScreen.disableHapticFeedback': 'string';
'generalSettingsScreen.disableHapticFeedbackDescription': 'string';
+ 'generalSettingsScreen.chapterDownloadCooldown': 'string';
+ 'generalSettingsScreen.chapterDownloadCooldownDesc': 'string';
+ 'generalSettingsScreen.chapterDownloadCooldownPlaceholder': 'string';
+ 'generalSettingsScreen.chapterDownloadCooldownWarning': 'string';
'generalSettingsScreen.displayMode': 'string';
'generalSettingsScreen.downloadNewChapters': 'string';
'generalSettingsScreen.epub': 'string';
@@ -472,4 +481,22 @@ export interface StringMap {
'notifications.LOCAL_RESTORE': 'string';
'notifications.MIGRATE_NOVEL': 'string';
'notifications.DOWNLOAD_CHAPTER': 'string';
+ 'customCodeSettings.textManipulation': 'string';
+ 'customCodeSettings.codeSnippets': 'string';
+ 'customCodeSettings.createNewSnippet': 'string';
+ 'customCodeSettings.addCssOrJsCode': 'string';
+ 'customCodeSettings.cssSnippets': 'string';
+ 'customCodeSettings.javascriptSnippets': 'string';
+ 'customCodeSettings.createJSSnippet': 'string';
+ 'customCodeSettings.addJavascriptCode': 'string';
+ 'customCodeSettings.replace': 'string';
+ 'customCodeSettings.removeText': 'string';
+ 'customCodeSettings.enterAMatch': 'string';
+ 'customCodeSettings.enterAReplace': 'string';
+ 'customCodeSettings.itemAlreadyExists': 'string';
+ 'customCodeSettings.editReplace': 'string';
+ 'customCodeSettings.yourCodeHere': 'string';
+ 'customCodeSettings.imported': 'string';
+ 'customCodeSettings.snippetUpdated': 'string';
+ 'customCodeSettings.snippetSaved': 'string';
}
diff --git a/tsconfig.json b/tsconfig.json
index 62eddd3349..20ded07686 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -14,7 +14,6 @@
"module": "ES2022",
"lib": ["ES2022", "DOM"],
"target": "ES2022",
- "baseUrl": ".",
"paths": {
"@components": ["./src/components/index"],
"@components/*": ["./src/components/*"],