From 9c7800bb7a5ec4456e5654068d464015a51c021b Mon Sep 17 00:00:00 2001 From: CD-Z <69157453+CD-Z@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:30:13 +0100 Subject: [PATCH 01/28] copied old code --- App.tsx | 19 +- package.json | 1 + pnpm-lock.yaml | 7 + src/components/Common.tsx | 19 +- src/components/Common/ToggleButton.tsx | 3 + .../IconButtonV2/AnimatedIconButton.tsx | 90 +++++ .../Modal/KeyboardAvoidingModal.tsx | 166 ++++++++ src/components/TextInput/index.tsx | 71 ++++ src/components/index.ts | 2 + src/hooks/common/useKeyboardHeight.ts | 31 ++ src/hooks/persisted/useSettings.ts | 21 + src/navigators/MoreStack.tsx | 2 + src/navigators/types/index.ts | 5 + .../reader/components/WebViewReader.tsx | 217 +++++++++-- .../Components/CodeInput.tsx | 180 +++++++++ .../Components/ListItems.tsx | 123 ++++++ .../Components/SelfHidingAppbar.tsx | 43 +++ .../Components/SettingsWebView.tsx | 363 ++++++++++++++++++ .../Components/dummies.ts | 88 +++++ .../Modals/ReplaceItemModal.tsx | 280 ++++++++++++++ .../Routes/CodeRoute.tsx | 235 ++++++++++++ .../Routes/SettingsRoute.tsx | 191 +++++++++ .../SettingsCustomCodeScreen/index.tsx | 163 ++++++++ src/screens/settings/SettingsScreen.tsx | 6 + 24 files changed, 2276 insertions(+), 50 deletions(-) create mode 100644 src/components/IconButtonV2/AnimatedIconButton.tsx create mode 100644 src/components/Modal/KeyboardAvoidingModal.tsx create mode 100644 src/components/TextInput/index.tsx create mode 100644 src/hooks/common/useKeyboardHeight.ts create mode 100644 src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx create mode 100644 src/screens/settings/SettingsCustomCodeScreen/Components/ListItems.tsx create mode 100644 src/screens/settings/SettingsCustomCodeScreen/Components/SelfHidingAppbar.tsx create mode 100644 src/screens/settings/SettingsCustomCodeScreen/Components/SettingsWebView.tsx create mode 100644 src/screens/settings/SettingsCustomCodeScreen/Components/dummies.ts create mode 100644 src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx create mode 100644 src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx create mode 100644 src/screens/settings/SettingsCustomCodeScreen/Routes/SettingsRoute.tsx create mode 100644 src/screens/settings/SettingsCustomCodeScreen/index.tsx diff --git a/App.tsx b/App.tsx index 9800c978cb..b53820e0be 100644 --- a/App.tsx +++ b/App.tsx @@ -10,6 +10,7 @@ import LottieSplashScreen from 'react-native-lottie-splash-screen'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { Provider as PaperProvider } from 'react-native-paper'; import * as Notifications from 'expo-notifications'; +import { KeyboardProvider } from 'react-native-keyboard-controller'; import AppErrorBoundary, { ErrorFallback, @@ -31,7 +32,6 @@ Notifications.setNotificationHandler({ }, }); - const App = () => { const state = useInitDatabase(); @@ -51,12 +51,17 @@ const App = () => { - - - -
- - + + + + +
+ + + diff --git a/package.json b/package.json index f5ca6e0fe1..5116f261ed 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "react-native-error-boundary": "^3.1.0", "react-native-file-access": "^4.0.2", "react-native-gesture-handler": "^2.30.1", + "react-native-keyboard-controller": "^1.20.7", "react-native-lottie-splash-screen": "^1.1.2", "react-native-mmkv": "^4.3.0", "react-native-nitro-modules": "^0.35.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 720c12ba6f..c996bdd3c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5283,6 +5283,13 @@ packages: react: '*' react-native: '*' + react-native-keyboard-controller@1.20.7: + resolution: {integrity: sha512-G8S5jz1FufPrcL1vPtReATx+jJhT/j+sTqxMIb30b1z7cYEfMlkIzOCyaHgf6IMB2KA9uBmnA5M6ve2A9Ou4kw==} + peerDependencies: + react: '*' + react-native: '*' + react-native-reanimated: '>=3.0.0' + react-native-linear-gradient@2.8.3: resolution: {integrity: sha512-KflAXZcEg54PXkLyflaSZQ3PJp4uC4whM7nT/Uot9m0e/qxFV3p6uor1983D1YOBJbJN7rrWdqIjq0T42jOJyA==} peerDependencies: diff --git a/src/components/Common.tsx b/src/components/Common.tsx index a2b45d2e24..8e02960d9e 100644 --- a/src/components/Common.tsx +++ b/src/components/Common.tsx @@ -4,10 +4,27 @@ import { View, StyleSheet } from 'react-native'; const Row = ({ children, style = {}, + horizontalSpacing, + verticalSpacing, }: { children?: React.ReactNode; style?: any; -}) => {children}; + horizontalSpacing?: number | `${number}%`; + verticalSpacing?: number | `${number}%`; +}) => ( + + {children} + +); export { Row }; diff --git a/src/components/Common/ToggleButton.tsx b/src/components/Common/ToggleButton.tsx index 0726ed47f4..dcb74fdfec 100644 --- a/src/components/Common/ToggleButton.tsx +++ b/src/components/Common/ToggleButton.tsx @@ -28,6 +28,7 @@ interface ToggleButtonProps { theme: ThemeColors; color?: string; onPress: () => void; + disabled?: boolean; } export const ToggleButton: React.FC = ({ @@ -36,6 +37,7 @@ export const ToggleButton: React.FC = ({ theme, color, onPress, + disabled, }) => ( = ({ getToggleButtonPressableStyle(selected, theme), ]} onPress={onPress} + disabled={disabled} > void; + theme: ThemeColors; + style?: ViewStyle; + rotation?: SharedValue; + scale?: SharedValue; +}; + +const AnimatedIconButton: React.FC = ({ + name, + color, + size = 24, + padding = 8, + onPress, + disabled, + theme, + style, + rotation, + scale: _scale, +}) => { + const IconStyle = useAnimatedStyle(() => { + const rotate = rotation + ? withTiming(rotation.value + 'deg', { duration: 250 }) + : '0deg'; + const scale = _scale ? withTiming(_scale.value, { duration: 250 }) : 1; + return { + textAlign: 'center', + transform: [ + { + rotate, + }, + { + scale, + }, + ], + }; + }); + return ( + + + + + + ); +}; +export default React.memo(AnimatedIconButton); + +const styles = StyleSheet.create({ + container: { + borderRadius: 50, + overflow: 'hidden', + }, + pressable: { + padding: 8, + }, +}); diff --git a/src/components/Modal/KeyboardAvoidingModal.tsx b/src/components/Modal/KeyboardAvoidingModal.tsx new file mode 100644 index 0000000000..a8de2e813e --- /dev/null +++ b/src/components/Modal/KeyboardAvoidingModal.tsx @@ -0,0 +1,166 @@ +import React, { useEffect } from 'react'; +import { Modal, ModalProps, overlay, Portal } from 'react-native-paper'; +import { StyleSheet, Text, View } from 'react-native'; +import Button from '@components/Button/Button'; +import { ThemeColors } from '@theme/types'; +import { getString } from '@strings/translations'; +import Animated, { + measure, + useAnimatedRef, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { WINDOW_HEIGHT } from '@gorhom/bottom-sheet'; +import SafeAreaView from '@components/SafeAreaView/SafeAreaView'; +import { getRuntimeKind } from 'react-native-worklets'; +import { useTheme } from '@hooks/persisted'; +import { useKeyboardHeight } from '@hooks/common/useKeyboardHeight'; + +// --- Dynamic style helpers --- +const getModalTitleColor = (theme: ThemeColors) => ({ color: theme.onSurface }); + +export type DefaultModalProps = { + /** + * Title of the modal + */ + title: string; + /** + * Dismisses the modal with onDismiss and calls onSave. + * If onSave returns false, the modal will not be dismissed. + */ + onSave: () => void | boolean; + /** + * The function to dismiss the modal + */ + onDismiss: () => void; + /** + * Dismisses the modal with onDismiss and calls onCancel + */ + onCancel?: () => void; + /** + * The function to reset the values + */ + onReset?: () => void; +} & Omit; + +const KeyboardAvoidingModal: React.FC = ({ + visible, + onDismiss, + onSave, + onCancel, + onReset, + title, + children, + ...props +}) => { + const theme = useTheme(); + const kH = useKeyboardHeight(); + + const animatedRef = useAnimatedRef(); + + const keyboardHeight = useSharedValue(0); + + useEffect(() => { + if (!kH) { + keyboardHeight.value = 0; + } else { + keyboardHeight.value = kH; + } + }, [kH, keyboardHeight]); + + const AvoidKeyboard = useAnimatedStyle(() => { + let m: { height: number; pageY: number } | null = null; + if (getRuntimeKind() !== 1) { + try { + m = measure(animatedRef); + } catch {} + } + + if (!m) { + m = { + height: 0, + pageY: 0, + }; + } + const newWindowHeight = WINDOW_HEIGHT - keyboardHeight.value; + const dif = Math.min(newWindowHeight - (m.height + m.pageY), 0); + + return { + maxHeight: withTiming(newWindowHeight, { + duration: 150, + }), + transform: [ + { + translateY: withTiming(dif, { duration: 150 }), + }, + ], + }; + }); + + const dismiss = (op?: () => void | boolean) => { + if (op?.() === false) return; + onDismiss(); + }; + const save = () => dismiss(onSave); + const cancel = () => dismiss(onCancel); + + return ( + + + + + + {title} + + {children} + + {!onReset ? null : ( + + )} + + + + + + + + + ); +}; + +export default KeyboardAvoidingModal; + +const styles = StyleSheet.create({ + contentContainer: { + paddingHorizontal: 24, + }, + modalContainer: { + maxHeight: WINDOW_HEIGHT, + borderRadius: 28, + padding: 24, + shadowColor: 'transparent', // Modal weird shadow fix + }, + modalTitle: { + fontSize: 24, + marginBottom: 16, + }, + buttonRow: { + flexDirection: 'row', + }, + flex: { + flex: 1, + }, +}); diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx new file mode 100644 index 0000000000..07169006dc --- /dev/null +++ b/src/components/TextInput/index.tsx @@ -0,0 +1,71 @@ +import { useTheme } from '@hooks/persisted'; +import React, { useState } from 'react'; +import { + StyleSheet, + TextInput as RNTextInput, + TextInputProps as RNTextInputProps, +} from 'react-native'; + +interface TextInputProps extends RNTextInputProps { + error?: boolean; + value?: never; +} + +const TextInput = ({ + onBlur, + onFocus, + error, + style, + ...props +}: TextInputProps) => { + const theme = useTheme(); + + const [inputFocused, setInputFocused] = useState(false); + + const _onFocus: RNTextInputProps['onFocus'] = e => { + setInputFocused(true); + onFocus?.(e); + }; + const _onBlur: RNTextInputProps['onBlur'] = e => { + setInputFocused(false); + onBlur?.(e); + }; + + const borderWidth = inputFocused || error ? 2 : 1; + const margin = inputFocused || error ? 0 : 1; + return ( + + ); +}; + +export default TextInput; + +const styles = StyleSheet.create({ + textInput: { + borderRadius: 4, + borderStyle: 'solid', + fontSize: 16, + paddingHorizontal: 16, + paddingVertical: 10, + }, +}); diff --git a/src/components/index.ts b/src/components/index.ts index 565906bfbd..644d41c453 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,6 @@ export { default as IconButtonV2 } from './IconButtonV2/IconButtonV2'; +export { default as AnimatedIconButton } from './IconButtonV2/AnimatedIconButton'; +export { default as TextInput } from './TextInput'; export { default as SearchbarV2 } from './SearchbarV2/SearchbarV2'; export { default as LoadingScreenV2 } from './LoadingScreenV2/LoadingScreenV2'; export { default as ErrorScreenV2 } from './ErrorScreenV2/ErrorScreenV2'; diff --git a/src/hooks/common/useKeyboardHeight.ts b/src/hooks/common/useKeyboardHeight.ts new file mode 100644 index 0000000000..3af2afc9c4 --- /dev/null +++ b/src/hooks/common/useKeyboardHeight.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; +import { Keyboard, KeyboardEvent } from 'react-native'; + +export const useKeyboardHeight = () => { + const [keyboardHeight, setKeyboardHeight] = useState(0); + + useEffect(() => { + function onKeyboardDidShow(e: KeyboardEvent) { + setKeyboardHeight(e.endCoordinates.height); + } + + function onKeyboardDidHide() { + setKeyboardHeight(0); + } + + const showSubscription = Keyboard.addListener( + 'keyboardDidShow', + onKeyboardDidShow, + ); + const hideSubscription = Keyboard.addListener( + 'keyboardDidHide', + onKeyboardDidHide, + ); + return () => { + showSubscription.remove(); + hideSubscription.remove(); + }; + }, []); + + return keyboardHeight; +}; diff --git a/src/hooks/persisted/useSettings.ts b/src/hooks/persisted/useSettings.ts index 6835f906bd..7f320c2132 100644 --- a/src/hooks/persisted/useSettings.ts +++ b/src/hooks/persisted/useSettings.ts @@ -119,8 +119,22 @@ export interface ChapterReaderSettings { epubUseAppTheme: boolean; epubUseCustomCSS: boolean; epubUseCustomJS: boolean; + /** + * Custom code + */ + replaceText: Record; + removeText: string[]; + codeSnippetsCSS: CodeSnippet[]; + codeSnippetsJS: CodeSnippet[]; } +type CodeSnippet = { + name: string; + code: string; + lang: 'js' | 'css'; + active: boolean; +}; + const initialAppSettings: AppSettings = { /** * General settings @@ -209,6 +223,13 @@ export const initialChapterReaderSettings: ChapterReaderSettings = { epubUseAppTheme: false, epubUseCustomCSS: false, epubUseCustomJS: false, + /** + * Custom code + */ + replaceText: {}, + removeText: [], + codeSnippetsCSS: [], + codeSnippetsJS: [], }; export const useAppSettings = () => { diff --git a/src/navigators/MoreStack.tsx b/src/navigators/MoreStack.tsx index 2e60fc7466..0a4dde1c66 100644 --- a/src/navigators/MoreStack.tsx +++ b/src/navigators/MoreStack.tsx @@ -18,6 +18,7 @@ import RespositorySettings from '@screens/settings/SettingsRepositoryScreen/Sett // import LibrarySettings from '@screens/settings/SettingsLibraryScreen/SettingsLibraryScreen'; import StatsScreen from '@screens/StatsScreen/StatsScreen'; import { MoreStackParamList, SettingsStackParamList } from './types'; +import SettingsCustomCode from '@screens/settings/SettingsCustomCodeScreen'; const Stack = createNativeStackNavigator< MoreStackParamList & SettingsStackParamList @@ -34,6 +35,7 @@ const SettingsStack = () => ( + {/* */} diff --git a/src/navigators/types/index.ts b/src/navigators/types/index.ts index b0fe4cd52e..40b1a07cd0 100644 --- a/src/navigators/types/index.ts +++ b/src/navigators/types/index.ts @@ -82,6 +82,7 @@ export type SettingsStackParamList = { AdvancedSettings: undefined; LibrarySettings: undefined; RespositorySettings: { url?: string } | undefined; + CustomCode: undefined; }; export type NovelScreenProps = StackScreenProps< @@ -169,6 +170,10 @@ export type BackupSettingsScreenProps = StackScreenProps< SettingsStackParamList, 'BackupSettings' >; +export type CustomCodeSettingsScreenProps = StackScreenProps< + SettingsStackParamList, + 'CustomCode' +>; export type AdvancedSettingsScreenProps = StackScreenProps< SettingsStackParamList, 'AdvancedSettings' diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index 99befe1cc7..0afe991720 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -94,7 +94,7 @@ const WebViewReader: React.FC = ({ onPress }) => { useEffect(() => { setReaderSettings( getMMKVObject(CHAPTER_READER_SETTINGS) || - initialChapterReaderSettings, + initialChapterReaderSettings, ); }, [chapter.id]); @@ -111,6 +111,78 @@ const WebViewReader: React.FC = ({ onPress }) => { const ttsQueueRef = useRef([]); const ttsQueueIndexRef = useRef(0); + // Replace modal state + const [replaceModalVisible, setReplaceModalVisible] = useState(false); + const [selectedTextForReplace, setSelectedTextForReplace] = useState(''); + const [replacementText, setReplacementText] = useState(''); + + const handleTextAction = React.useCallback( + (action: string, text: string) => { + if (!text) return; + + const { setSettings } = settings; + if (action === 'remove') { + // Add to removeText array if not already present + const newRemoveText = [...settings.removeText]; + if (!newRemoveText.includes(text)) { + newRemoveText.push(text); + setSettings({ removeText: newRemoveText }); + } + } else if (action === 'replace-prompt') { + // Show modal for user to enter replacement text + setSelectedTextForReplace(text); + setReplacementText(''); + setReplaceModalVisible(true); + } + }, + [settings], + ); + + const handleReplaceSave = React.useCallback(() => { + if (!selectedTextForReplace) return false; + + const { setSettings } = settings; + const newReplaceText = { ...settings.replaceText }; + if (!(selectedTextForReplace in newReplaceText)) { + newReplaceText[selectedTextForReplace] = replacementText; + setSettings({ replaceText: newReplaceText }); + } + setReplaceModalVisible(false); + return true; + }, [selectedTextForReplace, replacementText, settings]); + + const handleReplaceCancel = React.useCallback(() => { + setReplaceModalVisible(false); + setSelectedTextForReplace(''); + setReplacementText(''); + }, []); + + const html = useMemo(() => { + let chText = chapterText; + settings.removeText.forEach(text => { + const m = text.match(/^\/(.*)\/([gmiyuvsd]*)$/); + if (m) { + const regex = new RegExp(m[1], m[2] ?? ''); + chText = chText.replace(regex, ''); + } else { + chText = chText.split(text).join(''); + } + }); + Object.entries(settings.replaceText).forEach(([text, replacement]) => { + const m = text.match(/^\/(.*)\/([gmiyuvsd]*)$/); + if (m) { + const regex = new RegExp(m[1], m[2] ?? ''); + chText = chText.replace(regex, replacement); + } else { + chText = chText.split(text).join(replacement); + } + }); + return chText; + }, [chapterText, settings.removeText, settings.replaceText]); + + if (chapterText === undefined) { + } + useEffect(() => { readerSettingsRef.current = readerSettings; }, [readerSettings]); @@ -307,6 +379,45 @@ const WebViewReader: React.FC = ({ onPress }) => { const isRTL = plugin?.lang === 'Arabic' || plugin?.lang === 'Hebrew'; const readerDir = isRTL ? 'rtl' : 'ltr'; + const customJS = useMemo(() => { + return readerSettings.codeSnippetsJS + .map(snippet => { + if (!snippet.active) return null; + return ` + try { + ${snippet.code} + } catch (error) { + alert(\`Error loading executing ${snippet.name}:\n\` + error); + } + `; + }) + .filter(Boolean) + .join('\n'); + }, [readerSettings.codeSnippetsJS]); + + const customCSS = useMemo(() => { + return readerSettings.codeSnippetsCSS + .map(snippet => { + if (!snippet.active) return null; + return snippet.code; + }) + .filter(Boolean) + .join('\n'); + }, [readerSettings.codeSnippetsCSS]); + + const preparedHTML = useMemo(() => { + let resultHtml = html; + readerSettings.removeText.forEach(text => { + resultHtml = resultHtml.replace(text, ''); + }); + Object.entries(readerSettings.replaceText).forEach( + ([text, replacement]) => { + resultHtml = resultHtml.replace(text, replacement); + }, + ); + return resultHtml; + }, [html, readerSettings.removeText, readerSettings.replaceText]); + return ( = ({ onPress }) => { | undefined; const queue = Array.isArray(payload?.queue) ? payload?.queue.filter( - (item): item is string => - typeof item === 'string' && item.trim().length > 0, - ) + (item): item is string => + typeof item === 'string' && item.trim().length > 0, + ) : []; ttsQueueRef.current = queue; if (typeof payload?.startIndex === 'number') { @@ -441,6 +552,11 @@ const WebViewReader: React.FC = ({ onPress }) => { updateTTSPlaybackState(isReading); } break; + case 'text-action': + if (event.action && event.text) { + handleTextAction(event.action as string, String(event.text)); + } + break; } }} source={{ @@ -448,7 +564,7 @@ const WebViewReader: React.FC = ({ onPress }) => { headers: plugin?.imageRequestInit?.headers, method: plugin?.imageRequestInit?.method, body: plugin?.imageRequestInit?.body, - html: ` + html: ` @@ -475,62 +591,68 @@ const WebViewReader: React.FC = ({ onPress }) => { --theme-onSecondary: ${theme.onSecondary}; --theme-surface: ${theme.surface}; --theme-surface-0-9: ${color(theme.surface) - .alpha(0.9) - .toString()}; + .alpha(0.9) + .toString()}; --theme-onSurface: ${theme.onSurface}; --theme-surfaceVariant: ${theme.surfaceVariant}; --theme-onSurfaceVariant: ${theme.onSurfaceVariant}; --theme-outline: ${theme.outline}; --theme-rippleColor: ${theme.rippleColor}; } - + @font-face { font-family: ${readerSettings.fontFamily}; - src: url("file:///android_asset/fonts/${readerSettings.fontFamily - }.ttf"); + src: url("file:///android_asset/fonts/${ + readerSettings.fontFamily + }.ttf"); } - + - + - -
+
${chapter.name}
- ${html} + ${preparedHTML}
@@ -540,8 +662,19 @@ 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); + `, }} diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx new file mode 100644 index 0000000000..dab8f53e72 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx @@ -0,0 +1,180 @@ +import { TextInput } from '@components'; +import { WINDOW_HEIGHT } from '@gorhom/bottom-sheet'; +import React from 'react'; +import { PixelRatio, StyleSheet } from 'react-native'; +import Animated, { + measure, + useAnimatedRef, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { useAnimatedKeyboard } from 'react-native-keyboard-controller'; +import { getRuntimeKind } from 'react-native-worklets'; +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; +}; + +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 }: CodeInputProps) => { + const theme = useTheme(); + const TextInputRef = useAnimatedRef(); + const { height: keyboardHeight } = useAnimatedKeyboard(); + const expanded = useSharedValue(0); + + const maxHeight = useAnimatedStyle(() => { + let m: { height: number; pageY: number } | null = null; + if (getRuntimeKind() !== 1) { + try { + m = measure(TextInputRef); + } catch {} + } + + if (!m || !keyboardHeight.value) { + return { maxHeight: WINDOW_HEIGHT / 2 }; + } + return { + maxHeight: Math.min( + Math.max(WINDOW_HEIGHT - keyboardHeight.value - m.pageY, 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..1b06e20a73 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/SelfHidingAppbar.tsx @@ -0,0 +1,43 @@ +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; +}; + +const SelfHidingAppBar = ({ hiddenState, ...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 : 150, { + duration: 250, + easing: Easing.out(Easing.cubic), + }), + overflow: 'hidden', + }; + }, []); // The dependency array is now EMPTY! + + return ( + + + + ); +}; + +export default SelfHidingAppBar; diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/SettingsWebView.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/SettingsWebView.tsx new file mode 100644 index 0000000000..6592305e1e --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/SettingsWebView.tsx @@ -0,0 +1,363 @@ +import React, { memo, useEffect, useMemo, useRef } from 'react'; +import { NativeEventEmitter, NativeModules, StatusBar } from 'react-native'; +import WebView from 'react-native-webview'; +import color from 'color'; +import { getString } from '@strings/translations'; +import { MMKVStorage } from '@utils/mmkv/mmkv'; +import { + CHAPTER_GENERAL_SETTINGS, + CHAPTER_READER_SETTINGS, + useChapterGeneralSettings, + useChapterReaderSettings, +} from '@hooks/persisted/useSettings'; + +import { getBatteryLevelSync } from 'react-native-device-info'; +import * as Speech from 'expo-speech'; +import { dummyHTML } from './dummies'; +import { useTheme } from '@hooks/persisted'; + +type WebViewPostEvent = { + type: string; + data?: { [key: string]: string | number }; +}; + +const onLogMessage = (payload: { nativeEvent: { data: string } }) => { + const dataPayload = JSON.parse(payload.nativeEvent.data); + if (dataPayload) { + if (dataPayload.type === 'console') { + /* eslint-disable no-console */ + console.info(`[Console] ${JSON.stringify(dataPayload.msg, null, 2)}`); + } + } +}; + +const { RNDeviceInfo } = NativeModules; +const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo); + +const assetsUriPrefix = __DEV__ + ? 'http://localhost:8081/assets' + : 'file:///android_asset'; + +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 SettingsWebView = () => { + const webViewRef = useRef(null); + const theme = useTheme(); + const settings = useChapterReaderSettings(); + const generalSettings = useChapterGeneralSettings(); + const batteryLevel = useMemo(() => getBatteryLevelSync(), []); + + useEffect(() => { + const mmkvListener = MMKVStorage.addOnValueChangedListener(key => { + switch (key) { + case CHAPTER_READER_SETTINGS: + webViewRef.current?.injectJavaScript( + `reader.settings.val = ${MMKVStorage.getString( + CHAPTER_READER_SETTINGS, + )}`, + ); + break; + case CHAPTER_GENERAL_SETTINGS: + webViewRef.current?.injectJavaScript( + `reader.generalSettings.val = ${MMKVStorage.getString( + CHAPTER_GENERAL_SETTINGS, + )}`, + ); + break; + } + }); + + const subscription = deviceInfoEmitter.addListener( + 'RNDeviceInfo_batteryLevelDidChange', + (level: number) => { + webViewRef.current?.injectJavaScript( + `reader.batteryLevel.val = ${level}`, + ); + }, + ); + return () => { + subscription.remove(); + mmkvListener.remove(); + }; + }, [webViewRef]); + + const customJS = useMemo(() => { + return settings.codeSnippetsJS + .map(snippet => { + if (!snippet.active) return null; + return ` + try { + ${snippet.code} + } catch (error) { + alert('Error loading executing ${snippet.name}:\n' + error); + } + `; + }) + .filter(Boolean) + .join('\n'); + }, [settings.codeSnippetsJS]); + + const customCSS = useMemo(() => { + return settings.codeSnippetsCSS + .map(snippet => { + if (!snippet.active) return null; + return snippet.code; + }) + .filter(Boolean) + .join('\n'); + }, [settings.codeSnippetsCSS]); + + const preparedDummyHTML = useMemo(() => { + let resultHtml = dummyHTML; + settings.removeText.forEach(text => { + resultHtml = resultHtml.replace(text, ''); + }); + Object.entries(settings.replaceText).forEach(([text, replacement]) => { + resultHtml = resultHtml.replace(text, replacement); + }); + return resultHtml; + }, [settings.removeText, settings.replaceText]); + + const webViewCSS = useMemo( + () => ` + + + + + + + `, + [ + settings.theme, + customCSS, + settings.fontFamily, + settings.lineHeight, + settings.padding, + settings.textAlign, + settings.textColor, + settings.textSize, + theme.onPrimary, + theme.onSecondary, + theme.onSurface, + theme.onSurfaceVariant, + theme.onTertiary, + theme.outline, + theme.primary, + theme.rippleColor, + theme.secondary, + theme.surface, + theme.surfaceVariant, + theme.tertiary, + ], + ); + + const webViewSource = useMemo( + () => ({ + html: ` + + + + ${webViewCSS} + + + +
${chapter.name}
+
+ ${preparedDummyHTML} +
+
+ + + + + + + + + + `, + }), + [ + webViewCSS, + generalSettings.showScrollPercentage, + generalSettings.showBatteryAndTime, + generalSettings.verticalSeekbar, + generalSettings.bionicReading, + generalSettings.pageReader, + batteryLevel, + preparedDummyHTML, + settings, + customJS, + ], + ); + + return ( + { + __DEV__ && onLogMessage(ev); + const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data); + switch (event.type) { + case 'hide': + break; + case 'next': + break; + case 'prev': + break; + case 'save': + break; + case 'speak': + if (event.data && typeof event.data === 'string') { + Speech.speak(event.data, { + onDone() { + webViewRef.current?.injectJavaScript('tts.next?.()'); + }, + voice: settings.tts?.voice?.identifier, + pitch: settings.tts?.pitch || 1, + rate: settings.tts?.rate || 1, + }); + } else { + webViewRef.current?.injectJavaScript('tts.next?.()'); + } + break; + case 'stop-speak': + Speech.stop(); + break; + } + }} + source={webViewSource} + /> + ); +}; + +export default memo(SettingsWebView); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/dummies.ts b/src/screens/settings/SettingsCustomCodeScreen/Components/dummies.ts new file mode 100644 index 0000000000..0ff72acd18 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/dummies.ts @@ -0,0 +1,88 @@ +export const dummyHTML = ` +

Lorem ipsum dolor sit amet consectetuer adipiscing +elit

+

Lorem ipsum dolor sit amet, consectetuer adipiscing +elit. Aenean commodo ligula eget dolor. Aenean massa +strong. Cum sociis natoque penatibus +et magnis dis parturient montes, nascetur ridiculus +mus. Donec quam felis, ultricies nec, pellentesque +eu, pretium quis, sem. Nulla consequat massa quis +enim. Donec pede justo, fringilla vel, aliquet nec, +vulputate eget, arcu. In enim justo, rhoncus ut, +imperdiet a, venenatis vitae, justo. Nullam dictum +felis eu pede link +mollis pretium. Integer tincidunt. Cras dapibus. +Vivamus elementum semper nisi. Aenean vulputate +eleifend tellus. Aenean leo ligula, porttitor eu, +consequat vitae, eleifend ac, enim. Aliquam lorem ante, +dapibus in, viverra quis, feugiat a, tellus. Phasellus +viverra nulla ut metus varius laoreet. Quisque rutrum. +Aenean imperdiet. Etiam ultricies nisi vel augue. +Curabitur ullamcorper ultricies nisi.

+

Lorem ipsum dolor sit amet consectetuer adipiscing +elit

+

Aenean commodo ligula eget dolor aenean massa

+

Lorem ipsum dolor sit amet, consectetuer adipiscing +elit. Aenean commodo ligula eget dolor. Aenean massa. +Cum sociis natoque penatibus et magnis dis parturient +montes, nascetur ridiculus mus. Donec quam felis, +ultricies nec, pellentesque eu, pretium quis, sem.

+ +

Aenean commodo ligula eget dolor aenean massa

+

Lorem ipsum dolor sit amet, consectetuer adipiscing +elit. Aenean commodo ligula eget dolor. Aenean massa. +Cum sociis natoque penatibus et magnis dis parturient +montes, nascetur ridiculus mus. Donec quam felis, +ultricies nec, pellentesque eu, pretium quis, sem.

+
    +
  • Lorem ipsum dolor sit amet consectetuer.
  • +
  • Aenean commodo ligula eget dolor.
  • +
  • Aenean massa cum sociis natoque penatibus.
  • +
+

Lorem ipsum dolor sit amet, consectetuer adipiscing +elit. Aenean commodo ligula eget dolor. Aenean massa. +Cum sociis natoque penatibus et magnis dis parturient +montes, nascetur ridiculus mus. Donec quam felis, +ultricies nec, pellentesque eu, pretium quis, sem.

+ +

Lorem ipsum dolor sit amet, consectetuer adipiscing +elit. Aenean commodo ligula eget dolor. Aenean massa. +Cum sociis natoque penatibus et magnis dis parturient +montes, nascetur ridiculus mus. Donec quam felis, +ultricies nec, pellentesque eu, pretium quis, sem.

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Entry Header 1Entry Header 2Entry Header 3Entry Header 4
Entry First Line 1Entry First Line 2Entry First Line 3Entry First Line 4
Entry Line 1Entry Line 2Entry Line 3Entry Line 4
Entry Last Line 1Entry Last Line 2Entry Last Line 3Entry Last Line 4
+

Lorem ipsum dolor sit amet, consectetuer adipiscing +elit. Aenean commodo ligula eget dolor. Aenean massa. +Cum sociis natoque penatibus et magnis dis parturient +montes, nascetur ridiculus mus. Donec quam felis, +ultricies nec, pellentesque eu, pretium quis, sem.

+ +`; + +export const dummyText = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean lobortis, diam sed malesuada bibendum, nulla libero scelerisque sapien, nec interdum nisl ipsum ac ipsum. In lacinia eros ut quam commodo, in finibus augue ultricies. Vestibulum ex purus, condimentum eget sem at, molestie semper mi. Mauris ac feugiat quam. Pellentesque sagittis bibendum nibh eu lacinia. Aenean rhoncus, velit sit amet mollis egestas, diam turpis ornare velit, a dictum elit velit in erat. Quisque luctus in sem a vulputate.\n\nPellentesque id tempus orci, non finibus tortor. Suspendisse in neque non eros eleifend hendrerit vitae a lacus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae;'; diff --git a/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx b/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx new file mode 100644 index 0000000000..695f1702f3 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx @@ -0,0 +1,280 @@ +import { AnimatedIconButton, List } from '@components'; +import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal'; +import { WINDOW_HEIGHT } from '@gorhom/bottom-sheet'; +import { useBoolean } from '@hooks/index'; +import { FlashList } from '@shopify/flash-list'; +import { LinearGradient } from 'expo-linear-gradient'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { TextInput as RNTextInput, StyleSheet } from 'react-native'; +import { TextInput } from 'react-native-paper'; +import Animated, { + FadeIn, + FadeOut, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { + LIST_ITEM_LINE_HEIGHT, + RemoveItem, + ReplaceItem, +} from '../Components/ListItems'; +import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; + +const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient); + +type ReplaceItemModalProps = { + showReplace?: boolean; + listExpanded: boolean; + toggleList: () => void; +}; + +const ReplaceItemModal = ({ + showReplace = false, + listExpanded = false, + toggleList, +}: ReplaceItemModalProps) => { + const theme = useTheme(); + const modal = useBoolean(false); + const { + setChapterReaderSettings: setSettings, + replaceText, + removeText, + } = useChapterReaderSettings(); + const replaceArray = useMemo(() => { + return Object.entries(replaceText); + }, [replaceText]); + + const arrayLength = showReplace ? replaceArray.length : removeText.length; + + const textRef = useRef(null); + const replaceTextRef = useRef(null); + + const [text, setText] = React.useState(''); + const [replacementText, setReplacementText] = React.useState(''); + const [editing, setEditing] = React.useState(); + const [error, setError] = React.useState<[string, string] | undefined>(); + + const listSize = useSharedValue( + Math.min(110, arrayLength * 48), + ); + const iconRotation = useSharedValue(0); + + const cancel = () => { + setError(undefined); + textRef.current?.clear(); + setText(''); + setEditing(undefined); + if (showReplace) { + replaceTextRef.current?.clear(); + setReplacementText(''); + } + }; + + const save = () => { + if (!text || (showReplace && !replacementText)) { + const e: [string, string] = ['', '']; + if (!text) { + e[0] = 'Enter a match'; + } + if (!replacementText) { + e[1] = 'Enter a replace'; + } + setError(e); + return false; + } + + if (showReplace) { + if (editing && editing !== text) delete replaceText[editing]; + replaceText[text] = replacementText; + setSettings({ replaceText: replaceText }); + } else { + if (editing) { + const i = removeText.findIndex(v => v === editing); + removeText[i] = text; + } else if (!removeText.includes(text)) { + removeText.push(text); + } else { + setError(['Item already exists', '']); + return false; + } + setSettings({ removeText: removeText }); + } + cancel(); + return true; + }; + + const removeItem = useCallback( + (identifier: string | number) => { + if (showReplace) { + delete replaceText[identifier]; + setSettings({ replaceText: replaceText }); + } else { + removeText.splice(identifier as number, 1); + setSettings({ removeText: removeText }); + } + }, + [removeText, replaceText, setSettings, showReplace], + ); + + const editItem = useCallback( + (item: string[]) => { + setEditing(item[0]); + setText(item[0]); + if (showReplace) { + setReplacementText(item[1]); + } + modal.setTrue(); + }, + [modal, showReplace], + ); + + const colorTheme = useMemo(() => { + return { colors: theme }; + }, [theme]); + + const calcListSize = useCallback( + (toggle: boolean = true) => { + if (toggle) { + toggleList(); + iconRotation.value = listExpanded ? 0 : 180; + } + if (listExpanded) { + listSize.value = Math.min( + WINDOW_HEIGHT * 0.6, + arrayLength * (LIST_ITEM_LINE_HEIGHT + 16), + ); + } else { + listSize.value = Math.min( + 110, + arrayLength * (LIST_ITEM_LINE_HEIGHT + 16), + ); + } + }, + [arrayLength, iconRotation, listExpanded, listSize, toggleList], + ); + useEffect(() => { + calcListSize(false); + }, [replaceArray, removeText, calcListSize]); + useEffect(() => { + iconRotation.value = !listExpanded ? 0 : 180; + }, [iconRotation, listExpanded]); + + const animatedListSize = useAnimatedStyle(() => ({ + height: withTiming(listSize.value, { duration: 250 }), + overflow: 'hidden', + position: 'relative', + })); + + return ( + <> + + + {arrayLength <= 3 || listExpanded ? null : ( + calcListSize()} + /> + )} + {showReplace ? ( + ( + + )} + /> + ) : ( + ( + + )} + /> + )} + + + { + modal.setFalse(); + setError(undefined); + }} + onSave={save} + onCancel={cancel} + title="Edit Replace" + > + + {!showReplace ? null : ( + + )} + + + ); +}; + +export default ReplaceItemModal; + +const styles = StyleSheet.create({ + textfield: { + marginBottom: 16, + }, + bottom: { + marginBottom: 24, + }, + marginHorizontal: { + marginHorizontal: 16, + }, + gradient: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + zIndex: 1, + }, +}); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx b/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx new file mode 100644 index 0000000000..4e179a1056 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx @@ -0,0 +1,235 @@ +import { Button, TextInput } from '@components'; +import { Row } from '@components/Common'; +import { ToggleButton } from '@components/Common/ToggleButton'; +import { WINDOW_HEIGHT } from '@gorhom/bottom-sheet'; +import { getString } from '@strings/translations'; +import React from 'react'; +import { StyleSheet } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; +import { Text } from 'react-native-paper'; +import Animated, { useAnimatedStyle } from 'react-native-reanimated'; +import CodeInput from '../Components/CodeInput'; +import { showToast } from '@utils/showToast'; +import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; +import { useAnimatedKeyboard } from 'react-native-keyboard-controller'; + +type CodeRouteProps = { + language?: 'css' | 'js'; + snippetIndex?: number; + jumpTo: (key: string) => void; + editingSnippet?: { + index: number; + isJS: boolean; + } | null; + onSnippetSaved?: () => void; +}; + +const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView); + +const CodeRoute = ({ + language: dLang, + snippetIndex, + jumpTo, + editingSnippet, + onSnippetSaved, +}: CodeRouteProps) => { + const theme = useTheme(); + const { + codeSnippetsJS, + codeSnippetsCSS, + setChapterReaderSettings: setSettings, + } = useChapterReaderSettings(); + const [error, setError] = React.useState({ title: false, code: false }); + + // Use editingSnippet if provided, otherwise fall back to old props + const isEditing = editingSnippet !== null && editingSnippet !== undefined; + const editIndex = isEditing ? editingSnippet.index : snippetIndex; + const editIsJS = isEditing ? editingSnippet.isJS : dLang === 'js'; + + const [language, setLanguage] = React.useState<'js' | 'css'>('js'); + + // Update language when editing state changes + React.useEffect(() => { + if (isEditing) { + setLanguage(editIsJS ? 'js' : 'css'); + } else { + setLanguage('js'); // Default to JS for new snippets + } + }, [isEditing, editIsJS]); + + const snippets = language === 'js' ? codeSnippetsJS : codeSnippetsCSS; + const snippet = + editIndex === undefined || editIndex === -1 ? null : snippets[editIndex]; + + const [title, setTitle] = React.useState(''); + const [code, setCode] = React.useState(''); + + // Update title, code, and reset errors when snippet changes + React.useEffect(() => { + setTitle(snippet?.name ?? ''); + setCode(snippet?.code ?? ''); + setError({ title: false, code: false }); + }, [snippet]); + + const { height: keyboardHeight } = useAnimatedKeyboard(); + + const ScrollViewRef = React.useRef(null); + + const maxHeightScrollView = useAnimatedStyle(() => { + return { + maxHeight: WINDOW_HEIGHT - keyboardHeight.value - 26, + }; + }); + + const colors = React.useMemo( + () => ({ + colors: theme, + }), + [theme], + ); + + const save = React.useCallback(() => { + setError({ title: false, code: false }); + if (!code.trim() || !title.trim()) { + setError({ title: !title.trim(), code: !code.trim() }); + return; + } + + // Editing existing snippet + if (isEditing && editIndex !== undefined && editIndex !== -1) { + snippets[editIndex].name = title; + snippets[editIndex].code = code; + setSettings({ + [language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets, + }); + showToast('Snippet updated successfully'); + onSnippetSaved?.(); + return; + } + + // Creating new snippet + snippets.push({ + name: title, + code, + active: true, + lang: language, + }); + setSettings({ + [language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets, + }); + showToast('Snippet saved successfully'); + jumpTo('first'); // Go back to settings tab + }, [ + language, + snippets, + title, + code, + setSettings, + jumpTo, + isEditing, + editIndex, + onSnippetSaved, + ]); + + return ( + + + + {'Select CSS or JS'} + + setLanguage('css')} + disabled={isEditing} + /> + setLanguage('js')} + disabled={isEditing} + /> + + + + +
- ${preparedHTML} + ${html}
@@ -661,6 +280,7 @@ const WebViewReader: React.FC = ({ onPress }) => { + `, - }} - /> + }} + /> + setReplaceModalVisible(false)} + onSave={handleReplaceSave} + onCancel={handleReplaceCancel} + title="Replace Text" + > + + + + ); }; +const styles = StyleSheet.create({ + textInput: { + marginBottom: 16, + }, +}); + export default memo(WebViewReader); From 409ce50a0793d002c7a724e51a915a87bb075b6d Mon Sep 17 00:00:00 2001 From: CD-Z Date: Fri, 8 May 2026 14:39:16 +0200 Subject: [PATCH 03/28] update pnpm-lock.yaml --- pnpm-lock.yaml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c996bdd3c7..0daa19453f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,9 @@ importers: react-native-gesture-handler: specifier: ^2.30.1 version: 2.30.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + react-native-keyboard-controller: + specifier: ^1.20.7 + version: 1.21.7(react-native-reanimated@4.3.0(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) react-native-lottie-splash-screen: specifier: ^1.1.2 version: 1.1.2(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) @@ -5283,8 +5286,8 @@ packages: react: '*' react-native: '*' - react-native-keyboard-controller@1.20.7: - resolution: {integrity: sha512-G8S5jz1FufPrcL1vPtReATx+jJhT/j+sTqxMIb30b1z7cYEfMlkIzOCyaHgf6IMB2KA9uBmnA5M6ve2A9Ou4kw==} + react-native-keyboard-controller@1.21.7: + resolution: {integrity: sha512-gs+8nI8HYnRdDt4NWbk1iVuS6kDLf2taJvp+h/TjM1FBdtnQmlYLJ6buNiUqSnkIH4OFEAxdNr3/GOOYdLfkUQ==} peerDependencies: react: '*' react-native: '*' @@ -6111,6 +6114,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-to-istanbul@9.3.0: @@ -12660,6 +12664,13 @@ snapshots: react: 19.2.4 react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4) + react-native-keyboard-controller@1.21.7(react-native-reanimated@4.3.0(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + react-native-reanimated: 4.3.0(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + react-native-linear-gradient@2.8.3(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 From d12d8e67e12fc6e5e10ea60a2cdeebfee6dc6e54 Mon Sep 17 00:00:00 2001 From: CD-Z Date: Fri, 8 May 2026 14:53:08 +0200 Subject: [PATCH 04/28] Update index.tsx --- .../settings/SettingsCustomCodeScreen/index.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/screens/settings/SettingsCustomCodeScreen/index.tsx b/src/screens/settings/SettingsCustomCodeScreen/index.tsx index dbb7af323c..574de68240 100644 --- a/src/screens/settings/SettingsCustomCodeScreen/index.tsx +++ b/src/screens/settings/SettingsCustomCodeScreen/index.tsx @@ -1,4 +1,4 @@ -import { SafeAreaView } from '@components'; +import { Appbar, SafeAreaView } from '@components'; import { CustomCodeSettingsScreenProps } from '@navigators/types'; import React from 'react'; import { StyleSheet, useWindowDimensions } from 'react-native'; @@ -11,8 +11,6 @@ import { import SettingsRoute from './Routes/SettingsRoute'; import Color from 'color'; import CodeRoute from './Routes/CodeRoute'; -import SelfHidingAppBar from './Components/SelfHidingAppbar'; -import { useSharedValue } from 'react-native-reanimated'; import { useTheme } from '@hooks/persisted'; import SettingsWebView from './Components/SettingsWebView'; @@ -32,9 +30,6 @@ const SettingsCustomCode = ({ navigation }: CustomCodeSettingsScreenProps) => { const [index, setIndex] = React.useState(0); const layout = useWindowDimensions(); - // 0 = visible, 1 = hidden. Using a number is flexible. - const appBarHiddenState = useSharedValue(0); - // State for editing snippets const [editingSnippet, setEditingSnippet] = React.useState<{ index: number; @@ -42,7 +37,6 @@ const SettingsCustomCode = ({ navigation }: CustomCodeSettingsScreenProps) => { } | null>(null); const handleTabChange = (newIndex: number) => { - appBarHiddenState.value = newIndex ? 1 : 0; setIndex(newIndex); // Clear editing state when manually switching tabs if (newIndex !== 1) { @@ -51,7 +45,6 @@ const SettingsCustomCode = ({ navigation }: CustomCodeSettingsScreenProps) => { }; const handleEditSnippet = (snippetIndex: number, isJS: boolean) => { - appBarHiddenState.value = 1; setEditingSnippet({ index: snippetIndex, isJS: snippetIndex === -1 ? true : isJS, // Default to JS for new snippets, use passed value for editing @@ -127,11 +120,10 @@ const SettingsCustomCode = ({ navigation }: CustomCodeSettingsScreenProps) => { ); return ( - navigation.goBack()} theme={theme} - hiddenState={appBarHiddenState} /> Date: Fri, 8 May 2026 16:07:19 +0200 Subject: [PATCH 05/28] Improve TextInput focus and keyboard behavior Add forceFocused prop to TextInput and CodeInput and use the gesture-handler TextInput. Replace measure/useAnimatedRef logic with a keyboard-height based maxHeight calc, add keyboard-avoiding wrapper and focusedField tracking with timeouts to better manage focus. Introduce a self-hiding app bar that reacts to keyboard on the Code tab. --- src/components/TextInput/index.tsx | 16 +- .../Components/CodeInput.tsx | 43 ++--- .../Routes/CodeRoute.tsx | 164 ++++++++++++------ .../SettingsCustomCodeScreen/index.tsx | 25 ++- 4 files changed, 169 insertions(+), 79 deletions(-) diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx index 07169006dc..1cfe158ad3 100644 --- a/src/components/TextInput/index.tsx +++ b/src/components/TextInput/index.tsx @@ -1,20 +1,19 @@ import { useTheme } from '@hooks/persisted'; import React, { useState } from 'react'; -import { - StyleSheet, - TextInput as RNTextInput, - TextInputProps as RNTextInputProps, -} from 'react-native'; +import { StyleSheet, TextInputProps as RNTextInputProps } from 'react-native'; +import { TextInput as RNTextInput } from 'react-native-gesture-handler'; interface TextInputProps extends RNTextInputProps { error?: boolean; value?: never; + forceFocused?: boolean; } const TextInput = ({ onBlur, onFocus, error, + forceFocused, style, ...props }: TextInputProps) => { @@ -31,8 +30,9 @@ const TextInput = ({ onBlur?.(e); }; - const borderWidth = inputFocused || error ? 2 : 1; - const margin = inputFocused || error ? 0 : 1; + const isFocused = forceFocused ?? inputFocused; + const borderWidth = isFocused || error ? 2 : 1; + const margin = isFocused || error ? 0 : 1; return ( void; error?: boolean; + forceFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; }; const START_JS_CODE = `const qs = (s) => document.querySelector(s); @@ -52,28 +52,23 @@ const END_JS_CODE = 'qs("#LNReader-chapter").innerHTML = html;'; const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); -const CodeInput = ({ language, code, setCode, error }: CodeInputProps) => { +const CodeInput = ({ + language, + code, + setCode, + error, + forceFocused, + onFocus, + onBlur, +}: CodeInputProps) => { const theme = useTheme(); - const TextInputRef = useAnimatedRef(); const { height: keyboardHeight } = useAnimatedKeyboard(); const expanded = useSharedValue(0); const maxHeight = useAnimatedStyle(() => { - let m: { height: number; pageY: number } | null = null; - if (getRuntimeKind() !== 1) { - try { - m = measure(TextInputRef); - } catch {} - } - - if (!m || !keyboardHeight.value) { - return { maxHeight: WINDOW_HEIGHT / 2 }; - } + const availableHeight = WINDOW_HEIGHT - keyboardHeight.value - 200; return { - maxHeight: Math.min( - Math.max(WINDOW_HEIGHT - keyboardHeight.value - m.pageY, 300), - WINDOW_HEIGHT / 2, - ), + maxHeight: Math.min(Math.max(availableHeight, 300), WINDOW_HEIGHT / 2), }; }); const maxHeightTop = useAnimatedStyle(() => { @@ -109,10 +104,18 @@ const CodeInput = ({ language, code, setCode, error }: CodeInputProps) => { {language === 'js' ? START_JS_CODE : START_CSS_CODE} { + onFocus?.(); + console.log('Focused code input'); + }} + onBlur={() => { + onBlur?.(); + console.log('Blurred code input'); + }} multiline autoCorrect={false} autoCapitalize={'none'} diff --git a/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx b/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx index 4e179a1056..83bf1ef150 100644 --- a/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx +++ b/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx @@ -4,7 +4,7 @@ import { ToggleButton } from '@components/Common/ToggleButton'; import { WINDOW_HEIGHT } from '@gorhom/bottom-sheet'; import { getString } from '@strings/translations'; import React from 'react'; -import { StyleSheet } from 'react-native'; +import { 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'; @@ -12,6 +12,7 @@ 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'; @@ -22,6 +23,7 @@ type CodeRouteProps = { isJS: boolean; } | null; onSnippetSaved?: () => void; + isActive: boolean; }; const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView); @@ -32,6 +34,7 @@ const CodeRoute = ({ jumpTo, editingSnippet, onSnippetSaved, + isActive, }: CodeRouteProps) => { const theme = useTheme(); const { @@ -63,6 +66,14 @@ const CodeRoute = ({ 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(() => { @@ -71,8 +82,30 @@ const CodeRoute = ({ setError({ title: false, code: false }); }, [snippet]); - const { height: keyboardHeight } = useAnimatedKeyboard(); + React.useEffect(() => { + keyboardHeightRef.current = keyboardHeightValue; + if (keyboardHeightValue > 0 && focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + focusTimeoutRef.current = null; + } + }, [keyboardHeightValue]); + + React.useEffect(() => { + return () => { + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } + }; + }, []); + React.useEffect(() => { + if (!isActive) { + Keyboard.dismiss(); + setFocusedField(null); + } + }, [isActive]); + + const { height: keyboardHeight } = useAnimatedKeyboard(); const ScrollViewRef = React.useRef(null); const maxHeightScrollView = useAnimatedStyle(() => { @@ -132,56 +165,87 @@ const CodeRoute = ({ ]); return ( - - - - {'Select CSS or JS'} - - setLanguage('css')} - disabled={isEditing} + + + + {'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} /> - setLanguage('js')} - disabled={isEditing} + { + setFocusedField('code'); + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } + focusTimeoutRef.current = setTimeout(() => { + if (keyboardHeightRef.current === 0) { + setFocusedField(null); + } + }, 250); + }} /> - - - - -