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/android/app/src/main/assets/css/toolWrapper.css b/android/app/src/main/assets/css/toolWrapper.css index 580a8ece70..7097ff5d82 100644 --- a/android/app/src/main/assets/css/toolWrapper.css +++ b/android/app/src/main/assets/css/toolWrapper.css @@ -12,7 +12,7 @@ right: unset; left: 50%; transform: translateX(-50%); - bottom: 120px; + bottom: calc(68px + var(--bottom-inset)); display: flex; flex-direction: row-reverse; align-items: center; @@ -20,7 +20,7 @@ @media only screen and (min-width: 500px) { #ToolWrapper.horizontal { - bottom: 80px; + bottom: calc(28px + var(--bottom-inset)); } } @@ -32,7 +32,7 @@ #ToolWrapper.hidden.horizontal { opacity: 0; - bottom: 80px; + bottom: calc(28px + var(--bottom-inset)); right: unset; } diff --git a/android/app/src/main/assets/js/textRemover.js b/android/app/src/main/assets/js/textRemover.js new file mode 100644 index 0000000000..b4ae6ba017 --- /dev/null +++ b/android/app/src/main/assets/js/textRemover.js @@ -0,0 +1,195 @@ +// Text selection functionality +window.textRemover = new (function () { + let selectionUI = null; + let isUIActive = false; + + function createSelectionUI() { + if (selectionUI) return selectionUI; + + const { div, button } = van.tags; + selectionUI = div( + { + id: 'text-selection-ui', + style: + 'position: fixed; background: color-mix(in srgb, var(--theme-surface), transparent 10%); border-radius: 8px; padding: 8px; z-index: 100000; opacity: 0; box-shadow: 0 4px 12px rgba(0,0,0,0.25); transition: opacity 150ms', + }, + button( + { + style: + 'background: var(--theme-secondary); color: var(--theme-onSecondary); padding: 6px 12px; margin: 2px; border-radius: 4px; font-size: 12px;', + onclick: e => { + if (reader.hidden.val) { + e.stopPropagation(); + } + removeSelectedText(); + }, + }, + 'Remove', + ), + button( + { + style: + 'background: var(--theme-primary); color: var(--theme-onPrimary); padding: 6px 12px; margin: 2px; border-radius: 4px; font-size: 12px;', + onclick: e => { + if (reader.hidden.val) { + e.stopPropagation(); + } + replaceSelectedText(); + }, + }, + 'Replace', + ), + ); + + document.body.appendChild(selectionUI); + return selectionUI; + } + + function showSelectionUI() { + const ui = createSelectionUI(); + + // Get selection bounds + const selection = window.getSelection(); + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + // Get UI element heights from CSS variables (with fallbacks) + const statusBarHeight = + parseInt( + getComputedStyle(document.documentElement).getPropertyValue( + '--StatusBar-currentHeight', + ), + 10, + ) || 24; + const navigationBarHeight = + parseInt( + getComputedStyle(document.documentElement).getPropertyValue( + '--bottom-inset', + ), + 10, + ) || 24; + const readerPadding = + parseInt( + getComputedStyle(document.documentElement).getPropertyValue( + '--readerSettings-padding', + ), + 10, + ) || 16; + const uiHeight = 50; // Approximate height of our UI + + // Calculate available space + const viewportHeight = window.innerHeight; + const selectionCenterY = rect.top + rect.height / 2; + const topSafeArea = statusBarHeight + readerPadding + 10; + const bottomSafeArea = readerPadding + uiHeight + navigationBarHeight; + + // Position UI based on selection location + let topPosition; + if (selectionCenterY < viewportHeight / 2) { + // Selection is in top half, position UI at bottom + //TODO: make this dynamic + const avoidScrollbar = reader.generalSettings.val.verticalSeekbar + ? 0 + : 42; + const avoidUI = !reader.hidden.val ? 46 + avoidScrollbar : 0; + topPosition = viewportHeight - bottomSafeArea - avoidUI - 4; + ui.style.top = topPosition + 'px'; + ui.style.bottom = 'auto'; + } else { + // Selection is in bottom half, position UI at top (accounting for status bar) + topPosition = Math.max(topSafeArea, statusBarHeight + 20); + const avoidUI = !reader.hidden.val ? 34 : 0; + ui.style.top = topPosition + avoidUI + 'px'; + ui.style.bottom = 'auto'; + } + + // Center horizontally + ui.style.left = '50%'; + ui.style.transform = 'translateX(-50%)'; + } else { + // Fallback: position at top if no selection rect available + ui.style.top = '20px'; + ui.style.left = '50%'; + ui.style.transform = 'translateX(-50%)'; + ui.style.bottom = 'auto'; + } + + ui.style.opacity = '1'; + isUIActive = true; + } + + function hideSelectionUI() { + if (selectionUI) { + selectionUI.style.opacity = '0'; + } + isUIActive = false; + } + + function getSelectedText() { + const selection = window.getSelection(); + if (selection.rangeCount > 0) { + return selection.toString().trim(); + } + return ''; + } + + function removeSelectedText() { + const selectedText = getSelectedText(); + if (selectedText) { + reader.post({ + type: 'text-action', + data: { remove: selectedText }, + }); + } + hideSelectionUI(); + window.getSelection().removeAllRanges(); + } + + function replaceSelectedText() { + const selectedText = getSelectedText(); + if (selectedText) { + // For replace, we need user input, so send a different message + reader.post({ + type: 'text-action', + data: { replace: selectedText }, + }); + } + hideSelectionUI(); + window.getSelection().removeAllRanges(); + } + + // Handle text selection + document.addEventListener('selectionchange', function () { + const selectedText = getSelectedText(); + if (selectedText) { + showSelectionUI(); + } else if (!isUIActive) { + hideSelectionUI(); + } + }); + + // Hide UI when clicking/tapping elsewhere + document.addEventListener('touchstart', function (e) { + if (isUIActive && selectionUI && !selectionUI.contains(e.target)) { + const selectedText = getSelectedText(); + if (!selectedText) { + hideSelectionUI(); + } + } + }); + + document.addEventListener('click', function (e) { + if (isUIActive && selectionUI && !selectionUI.contains(e.target)) { + const selectedText = getSelectedText(); + if (!selectedText) { + hideSelectionUI(); + } + } + }); + + // Hide UI on scroll + window.addEventListener('scroll', function () { + hideSelectionUI(); + }); +})(); diff --git a/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..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,6 +5286,13 @@ packages: react: '*' react-native: '*' + react-native-keyboard-controller@1.21.7: + resolution: {integrity: sha512-gs+8nI8HYnRdDt4NWbk1iVuS6kDLf2taJvp+h/TjM1FBdtnQmlYLJ6buNiUqSnkIH4OFEAxdNr3/GOOYdLfkUQ==} + peerDependencies: + react: '*' + react-native: '*' + react-native-reanimated: '>=3.0.0' + react-native-linear-gradient@2.8.3: resolution: {integrity: sha512-KflAXZcEg54PXkLyflaSZQ3PJp4uC4whM7nT/Uot9m0e/qxFV3p6uor1983D1YOBJbJN7rrWdqIjq0T42jOJyA==} peerDependencies: @@ -6104,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: @@ -12653,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 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..ab37a1f390 100644 --- a/src/components/Common/ToggleButton.tsx +++ b/src/components/Common/ToggleButton.tsx @@ -1,16 +1,19 @@ import React from 'react'; -import { Pressable, View, StyleSheet } from 'react-native'; +import { View, StyleSheet } from 'react-native'; import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; import { ThemeColors } from '../../theme/types'; import Color from 'color'; import { MaterialDesignIconName } from '@type/icon'; +import { Pressable } from 'react-native-gesture-handler'; // --- Dynamic style helpers --- const getToggleButtonPressableStyle = ( selected: boolean, theme: ThemeColors, + disabled?: boolean, ) => ({ + opacity: disabled ? 0.6 : 1, backgroundColor: selected ? Color(theme.primary).alpha(0.12).string() : 'transparent', @@ -28,6 +31,7 @@ interface ToggleButtonProps { theme: ThemeColors; color?: string; onPress: () => void; + disabled?: boolean; } export const ToggleButton: React.FC = ({ @@ -36,15 +40,17 @@ export const ToggleButton: React.FC = ({ theme, color, onPress, + 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..d8cf32ecab --- /dev/null +++ b/src/components/Modal/KeyboardAvoidingModal.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { + Keyboard, + ScrollView, + StyleSheet, + Text, + View, + useWindowDimensions, +} from 'react-native'; +import { Modal, ModalProps, overlay, Portal } from 'react-native-paper'; +import Animated, { + FadeIn, + FadeOut, + useAnimatedStyle, + withClamp, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import Button from '@components/Button/Button'; +import { useTheme } from '@hooks/persisted'; +import { getString } from '@strings/translations'; +import { ThemeColors } from '@theme/types'; +import { useAnimatedKeyboard } from 'react-native-keyboard-controller'; + +const MODAL_MARGIN = 24; + +const getModalTitleColor = (theme: ThemeColors) => ({ + color: theme.onSurface, +}); + +export type DefaultModalProps = { + title: string; + onSave: () => void | boolean; + onDismiss: () => void; + onCancel?: () => void; + onReset?: () => void; +} & Omit; + +const KeyboardAvoidingModal: React.FC = ({ + visible, + onDismiss: _onDismiss, + onSave, + onCancel, + onReset, + title, + children, + ...props +}) => { + const theme = useTheme(); + const insets = useSafeAreaInsets(); + const { height: windowHeight } = useWindowDimensions(); + const keyboard = useAnimatedKeyboard(); + + const onDismiss = () => { + Keyboard.dismiss(); + _onDismiss?.(); + }; + + const dismiss = (cb?: () => void | boolean) => { + if (cb?.() === false) return; + onDismiss(); + }; + + const default_availableHeight = windowHeight - insets.top - MODAL_MARGIN * 2; + const animatedContainerStyle = useAnimatedStyle(() => { + const kb = keyboard.height.value; + + const availableHeight = + default_availableHeight - Math.max(insets.bottom, kb); + + return { + maxHeight: withClamp({ min: 200 }, withTiming(availableHeight)), + transform: [ + { + translateY: -(kb * 0.5), + }, + ], + }; + }, [insets.bottom, default_availableHeight]); + + return ( + + + + + {title} + + + + + {children} + + + + + {onReset ? ( + + ) : null} + + + + + + + + + + ); +}; + +export default KeyboardAvoidingModal; + +const styles = StyleSheet.create({ + modalWrapper: { + justifyContent: 'center', + paddingHorizontal: MODAL_MARGIN, + }, + modalContainer: { + borderRadius: 28, + padding: 24, + shadowColor: 'transparent', + }, + modalTitle: { + fontSize: 24, + lineHeight: 24, + marginBottom: 24, + }, + body: { + flexShrink: 1, + minHeight: 0, + }, + content: { + paddingBottom: 16, + }, + buttonRow: { + alignItems: 'center', + flexDirection: 'row', + marginTop: 8, + marginBottom: -8, + marginHorizontal: -8, + }, + flex: { + flex: 1, + }, +}); diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx new file mode 100644 index 0000000000..1cfe158ad3 --- /dev/null +++ b/src/components/TextInput/index.tsx @@ -0,0 +1,71 @@ +import { useTheme } from '@hooks/persisted'; +import React, { useState } from 'react'; +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) => { + 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 isFocused = forceFocused ?? inputFocused; + const borderWidth = isFocused || error ? 2 : 1; + const margin = isFocused || 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..fc67347608 100644 --- a/src/hooks/persisted/useSettings.ts +++ b/src/hooks/persisted/useSettings.ts @@ -5,6 +5,7 @@ import { LibrarySortOrder, } from '@screens/library/constants/constants'; import { Voice } from 'expo-speech'; +import { useEffect, useMemo } from 'react'; import { useMMKVObject } from 'react-native-mmkv'; export const APP_SETTINGS = 'APP_SETTINGS'; @@ -105,8 +106,6 @@ export interface ChapterReaderSettings { padding: number; fontFamily: string; lineHeight: number; - customCSS: string; - customJS: string; customThemes: ReaderTheme[]; tts?: { voice?: Voice; @@ -119,8 +118,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 @@ -196,8 +209,6 @@ export const initialChapterReaderSettings: ChapterReaderSettings = { padding: 16, fontFamily: '', lineHeight: 1.5, - customCSS: '', - customJS: '', customThemes: [], tts: { rate: 1, @@ -209,6 +220,13 @@ export const initialChapterReaderSettings: ChapterReaderSettings = { epubUseAppTheme: false, epubUseCustomCSS: false, epubUseCustomJS: false, + /** + * Custom code + */ + replaceText: {}, + removeText: [], + codeSnippetsCSS: [], + codeSnippetsJS: [], }; export const useAppSettings = () => { @@ -274,9 +292,51 @@ export const useChapterGeneralSettings = () => { }; }; +type MigrationChapterReaderSettings = ChapterReaderSettings & { + customJS?: string; + customCSS?: string; +}; export const useChapterReaderSettings = () => { - const [storedSettings = initialChapterReaderSettings, setSettings] = - useMMKVObject(CHAPTER_READER_SETTINGS); + const [_storedSettings, setSettings] = + useMMKVObject(CHAPTER_READER_SETTINGS); + + const storedSettings: MigrationChapterReaderSettings = useMemo( + () => ({ + ...initialChapterReaderSettings, + ..._storedSettings, + }), + [_storedSettings], + ); + + // Migrate old js and css to new + useEffect(() => { + if (storedSettings.customJS) { + storedSettings.codeSnippetsJS.push({ + active: true, + code: storedSettings.customJS, + lang: 'js', + name: 'Custom JS', + }); + setSettings({ + ...storedSettings, + customJS: undefined, + codeSnippetsJS: storedSettings.codeSnippetsJS, + }); + } + if (storedSettings.customCSS) { + storedSettings.codeSnippetsCSS.push({ + active: true, + code: storedSettings.customCSS, + lang: 'css', + name: 'Custom CSS', + }); + setSettings({ + ...storedSettings, + customCSS: undefined, + codeSnippetsCSS: storedSettings.codeSnippetsCSS, + }); + } + }, [setSettings, storedSettings]); // Ensure TTS settings have proper defaults (migration for existing users) const chapterReaderSettings = { @@ -314,7 +374,7 @@ export const useChapterReaderSettings = () => { }); return { - ...chapterReaderSettings, + ...(chapterReaderSettings as ChapterReaderSettings), setChapterReaderSettings, saveCustomReaderTheme, deleteCustomReaderTheme, 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/novel/components/ExportNovelAsEpubButton.tsx b/src/screens/novel/components/ExportNovelAsEpubButton.tsx index 5bd2831468..c73104d415 100644 --- a/src/screens/novel/components/ExportNovelAsEpubButton.tsx +++ b/src/screens/novel/components/ExportNovelAsEpubButton.tsx @@ -84,8 +84,16 @@ const ExportNovelAsEpubButton: React.FC = ({ }` : ''; + const customCSS = readerSettings.codeSnippetsCSS + .map(snippet => { + if (!snippet.active) return null; + return snippet.code; + }) + .filter(Boolean) + .join('\n'); + const customStyles = epubUseCustomCSS - ? readerSettings.customCSS + ? customCSS .replace(RegExp(`#sourceId-${novel.pluginId}\\s*\\{`, 'g'), 'body {') .replace(RegExp(`#sourceId-${novel.pluginId}[^.#A-Z]*`, 'gi'), '') : ''; @@ -93,6 +101,20 @@ const ExportNovelAsEpubButton: React.FC = ({ return appThemeStyles + customStyles; }, [novel, epubUseAppTheme, epubUseCustomCSS, readerSettings, theme.primary]); + const customJS = 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'); + const epubJavaScript = useMemo(() => { if (!novel) { return ''; @@ -105,10 +127,10 @@ const ExportNovelAsEpubButton: React.FC = ({ let chapterId = ""; let novelId = ${novel.id}; let html = document.querySelector("chapter").innerHTML; - - ${readerSettings.customJS} + + ${customJS} `; - }, [novel, readerSettings]); + }, [customJS, novel]); const exportNovelAsEpub = async ( destinationUri: string, diff --git a/src/screens/reader/ReaderScreen.tsx b/src/screens/reader/ReaderScreen.tsx index 30de4f444c..937e96096e 100644 --- a/src/screens/reader/ReaderScreen.tsx +++ b/src/screens/reader/ReaderScreen.tsx @@ -63,13 +63,18 @@ export const ChapterContent = ({ navigation, openDrawer, }: ChapterContentProps) => { - const { left, right } = useSafeAreaInsets(); + const { left, right, bottom } = useSafeAreaInsets(); const { novel, chapter } = useChapterContext(); const readerSheetRef = useRef(null); const theme = useTheme(); const { pageReader = false, keepScreenOn } = useChapterGeneralSettings(); - const [bookmarked, setBookmarked] = useState(chapter.bookmark ?? false); - + const [bookmarked, setBookmarked] = useState( + chapter.bookmark ?? false, + ); + const nonZeroBottom = useRef(bottom); + if (nonZeroBottom.current !== bottom && bottom !== 0) { + nonZeroBottom.current = bottom; + } useEffect(() => { setBookmarked(chapter.bookmark ?? false); }, [chapter]); @@ -121,14 +126,15 @@ export const ChapterContent = ({ ); } return ( - + {keepScreenOn ? : null} {loading ? ( ) : ( - + )} {!hidden ? ( diff --git a/src/screens/reader/components/Hooks/useCustomCode.ts b/src/screens/reader/components/Hooks/useCustomCode.ts new file mode 100644 index 0000000000..5e05a4c5a9 --- /dev/null +++ b/src/screens/reader/components/Hooks/useCustomCode.ts @@ -0,0 +1,42 @@ +import { ChapterReaderSettings } from '@hooks/persisted/useSettings'; +import { useMemo } from 'react'; + +export default function useCustomCode( + readerSettings: Pick< + ChapterReaderSettings, + 'codeSnippetsJS' | 'codeSnippetsCSS' + >, +) { + const customJS = useMemo(() => { + return readerSettings.codeSnippetsJS + .map(snippet => { + if (!snippet.active) return null; + return ` + try { + ${snippet.code} + } catch (error) { + alert(\`Error loading executing ${JSON.stringify( + snippet.name, + )}:\n\` + error); + } + `; + }) + .filter(Boolean) + .join('\n'); + }, [readerSettings.codeSnippetsJS]); + + const customCSS = useMemo(() => { + return readerSettings.codeSnippetsCSS + .map(snippet => { + if (!snippet.active) return null; + return snippet.code; + }) + .filter(Boolean) + .join('\n'); + }, [readerSettings.codeSnippetsCSS]); + + return { + customJS, + customCSS, + }; +} diff --git a/src/screens/reader/components/Hooks/useTTS.ts b/src/screens/reader/components/Hooks/useTTS.ts new file mode 100644 index 0000000000..5e11fd64c2 --- /dev/null +++ b/src/screens/reader/components/Hooks/useTTS.ts @@ -0,0 +1,303 @@ +import React, { useEffect, useRef } from 'react'; +import { AppState, NativeEventEmitter, NativeModules } from 'react-native'; +import WebView from 'react-native-webview'; + +import { MMKVStorage } from '@utils/mmkv/mmkv'; +import { + CHAPTER_GENERAL_SETTINGS, + CHAPTER_READER_SETTINGS, + useChapterReaderSettings, +} from '@hooks/persisted/useSettings'; +import * as Speech from 'expo-speech'; +import { + updateTTSNotification, + dismissTTSNotification, + ttsMediaEmitter, + showTTSNotification, + updateTTSProgress, + updateTTSPlaybackState, +} from '@utils/ttsNotification'; +import { ChapterInfo, NovelInfo } from '@database/types'; +import { WebViewPostEvent } from '../WebViewReader'; + +const { RNDeviceInfo } = NativeModules; +const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo); + +export default function useTTS( + webViewRef: React.RefObject, + novel: NovelInfo, + chapter: ChapterInfo, +) { + const { setChapterReaderSettings, ...readerSettings } = + useChapterReaderSettings(); + const isTTSReadingRef = useRef(false); + const autoStartTTSRef = useRef(false); + const ttsQueueRef = useRef([]); + const ttsQueueIndexRef = useRef(0); + const appStateRef = useRef(AppState.currentState); + + useEffect(() => { + const playListener = ttsMediaEmitter.addListener('TTSPlay', () => { + webViewRef.current?.injectJavaScript(` + if (window.tts && !tts.reading) { tts.resume(); } + `); + }); + const pauseListener = ttsMediaEmitter.addListener('TTSPause', () => { + webViewRef.current?.injectJavaScript(` + if (window.tts && tts.reading) { tts.pause(); } + `); + }); + const stopListener = ttsMediaEmitter.addListener('TTSStop', () => { + webViewRef.current?.injectJavaScript(` + if (window.tts) { tts.stop(); } + `); + }); + const rewindListener = ttsMediaEmitter.addListener('TTSRewind', () => { + webViewRef.current?.injectJavaScript(` + if (window.tts && tts.started) { tts.rewind(); } + `); + }); + const prevListener = ttsMediaEmitter.addListener('TTSPrev', () => { + webViewRef.current?.injectJavaScript(` + if (window.tts && window.reader && window.reader.prevChapter) { + window.reader.post({ type: 'prev', autoStartTTS: true }); + } + `); + }); + const nextListener = ttsMediaEmitter.addListener('TTSNext', () => { + webViewRef.current?.injectJavaScript(` + if (window.tts && window.reader && window.reader.nextChapter) { + window.reader.post({ type: 'next', autoStartTTS: true }); + } + `); + }); + const seekToListener = ttsMediaEmitter.addListener( + 'TTSSeekTo', + (event: { position: number }) => { + const position = event.position; + webViewRef.current?.injectJavaScript(` + if (window.tts && tts.started) { tts.seekTo(${position}); } + `); + }, + ); + return () => { + playListener.remove(); + pauseListener.remove(); + stopListener.remove(); + rewindListener.remove(); + prevListener.remove(); + nextListener.remove(); + seekToListener.remove(); + }; + }, [webViewRef]); + + useEffect(() => { + if (isTTSReadingRef.current) { + updateTTSNotification({ + novelName: novel?.name || 'Unknown', + chapterName: chapter.name, + coverUri: novel?.cover || '', + isPlaying: isTTSReadingRef.current, + }); + } + return () => { + dismissTTSNotification(); + }; + }, [novel?.name, novel?.cover, chapter.name]); + + useEffect(() => { + const mmkvListener = MMKVStorage.addOnValueChangedListener(key => { + switch (key) { + case CHAPTER_READER_SETTINGS: + // Stop any currently playing speech + Speech.stop(); + + // Update WebView settings + webViewRef.current?.injectJavaScript( + ` + reader.readerSettings.val = ${MMKVStorage.getString( + CHAPTER_READER_SETTINGS, + )}; + // Auto-restart TTS if currently reading + if (window.tts && tts.reading) { + const currentElement = tts.currentElement; + const wasReading = tts.reading; + tts.stop(); + if (wasReading) { + setTimeout(() => { + tts.start(currentElement); + }, 100); + } + } + `, + ); + break; + case CHAPTER_GENERAL_SETTINGS: + webViewRef.current?.injectJavaScript( + `reader.generalSettings.val = ${MMKVStorage.getString( + CHAPTER_GENERAL_SETTINGS, + )}`, + ); + break; + } + }); + + const subscription = deviceInfoEmitter.addListener( + 'RNDeviceInfo_batteryLevelDidChange', + (level: number) => { + webViewRef.current?.injectJavaScript( + `reader.batteryLevel.val = ${level}`, + ); + }, + ); + return () => { + subscription.remove(); + mmkvListener.remove(); + }; + }, [setChapterReaderSettings, webViewRef]); + + useEffect(() => { + const subscription = AppState.addEventListener('change', nextState => { + appStateRef.current = nextState; + if (nextState === 'active' && isTTSReadingRef.current) { + const index = ttsQueueIndexRef.current; + webViewRef.current?.injectJavaScript(` + if (window.tts && window.tts.allReadableElements) { + const idx = ${index}; + if (idx < tts.allReadableElements.length) { + tts.elementsRead = idx; + tts.currentElement = tts.allReadableElements[idx]; + tts.prevElement = null; + tts.started = true; + tts.reading = true; + tts.scrollToElement(tts.currentElement); + tts.currentElement.classList.add('highlight'); + } + } + `); + } + }); + + return () => subscription.remove(); + }, [webViewRef]); + + const speakText = (text: string) => { + Speech.speak(text, { + onDone() { + const isBackground = + appStateRef.current === 'background' || + appStateRef.current === 'inactive'; + + if ( + isBackground && + ttsQueueRef.current.length > 0 && + ttsQueueIndexRef.current + 1 < ttsQueueRef.current.length + ) { + const nextIndex = ttsQueueIndexRef.current + 1; + const nextText = ttsQueueRef.current[nextIndex]; + if (nextText) { + ttsQueueIndexRef.current = nextIndex; + speakText(nextText); + return; + } + } + + if (isBackground) { + isTTSReadingRef.current = false; + dismissTTSNotification(); + webViewRef.current?.injectJavaScript('tts.stop?.()'); + return; + } + + webViewRef.current?.injectJavaScript('tts.next?.()'); + }, + voice: readerSettings.tts?.voice?.identifier, + pitch: readerSettings.tts?.pitch || 1, + rate: readerSettings.tts?.rate || 1, + }); + }; + + function eventTTSQueue(event: WebViewPostEvent) { + const payload = event.data as + | { queue?: unknown; startIndex?: unknown } + | undefined; + const queue = Array.isArray(payload?.queue) + ? payload?.queue.filter( + (item): item is string => + typeof item === 'string' && item.trim().length > 0, + ) + : []; + ttsQueueRef.current = queue; + if (typeof payload?.startIndex === 'number') { + ttsQueueIndexRef.current = payload.startIndex; + } else { + ttsQueueIndexRef.current = 0; + } + } + + function eventTTSSpeak(event: WebViewPostEvent) { + if (event.data && typeof event.data === 'string') { + if (typeof event.index === 'number') { + ttsQueueIndexRef.current = event.index; + } + if (!isTTSReadingRef.current) { + isTTSReadingRef.current = true; + showTTSNotification({ + novelName: novel?.name || 'Unknown', + chapterName: chapter.name, + coverUri: novel?.cover || '', + isPlaying: true, + }); + } else { + updateTTSNotification({ + novelName: novel?.name || 'Unknown', + chapterName: chapter.name, + coverUri: novel?.cover || '', + isPlaying: true, + }); + } + if ( + typeof event.index === 'number' && + typeof event.total === 'number' && + event.total > 0 + ) { + updateTTSProgress(event.index, event.total); + } + speakText(event.data); + } else { + webViewRef.current?.injectJavaScript('tts.next?.()'); + } + } + function eventTTSPauseSpeak() { + Speech.stop(); + } + function eventTTSStopSpeak() { + Speech.stop(); + if (!autoStartTTSRef.current) { + isTTSReadingRef.current = false; + ttsQueueRef.current = []; + ttsQueueIndexRef.current = 0; + dismissTTSNotification(); + } + } + function eventTTSState(event: WebViewPostEvent) { + if (event.data && typeof event.data === 'object') { + const data = event.data as { isReading?: boolean }; + const isReading = data.isReading === true; + isTTSReadingRef.current = isReading; + updateTTSPlaybackState(isReading); + } + } + + return { + isTTSReadingRef, + autoStartTTSRef, + appStateRef, + speakText, + eventTTSQueue, + eventTTSSpeak, + eventTTSPauseSpeak, + eventTTSStopSpeak, + eventTTSState, + }; +} diff --git a/src/screens/reader/components/Hooks/useTextModifications.ts b/src/screens/reader/components/Hooks/useTextModifications.ts new file mode 100644 index 0000000000..465ff3cfb4 --- /dev/null +++ b/src/screens/reader/components/Hooks/useTextModifications.ts @@ -0,0 +1,128 @@ +/* eslint-disable no-console */ +import { useChapterReaderSettings } from '@hooks/persisted/useSettings'; +import React, { useCallback, useMemo, useState } from 'react'; +import { WebViewPostEvent } from '../WebViewReader'; + +export default function useTextModifications(chapterText: string) { + // Replace modal state + const [replaceModalVisible, setReplaceModalVisible] = useState(false); + const [selectedTextForReplace, setSelectedTextForReplace] = useState(''); + const [replacementText, setReplacementText] = useState(''); + + const { setChapterReaderSettings, ...readerSettings } = + useChapterReaderSettings(); + + const saveRegex = useCallback( + (regex: RegExpMatchArray, text: string, replacement: string = '') => { + const validFlags = new Set(['g', 'm', 'i', 'y', 'u', 'v', 's', 'd']); + const flags = regex[2] ?? ''; + const hasInvalidFlags = [...flags].some(f => !validFlags.has(f)); + if (hasInvalidFlags) { + console.warn('Invalid regex flags in removeText:', text); + return text; + } + try { + const r = new RegExp(regex[1], flags); + return text.replace(r, replacement); + } catch { + console.warn('Invalid regex pattern in removeText:', text); + } + return text; + }, + [], + ); + + const html = useMemo(() => { + let chText = chapterText; + readerSettings.removeText.forEach(text => { + // test if text is regex + const m = text.match(/^\/(.*)\/([gmiyuvsd]*)$/); + if (m) { + chText = saveRegex(m, chText); + } else { + chText = chText.split(text).join(''); + } + }); + Object.entries(readerSettings.replaceText).forEach( + ([text, replacement]) => { + const m = text.match(/^\/(.*)\/([gmiyuvsd]*)$/); + if (m) { + chText = saveRegex(m, chText, replacement); + } else { + chText = chText.split(text).join(replacement); + } + }, + ); + return chText; + }, [ + chapterText, + readerSettings.removeText, + readerSettings.replaceText, + saveRegex, + ]); + + const handleTextAction = React.useCallback( + (action: string, text: string) => { + if (!text) return; + + if (action === 'remove') { + // Add to removeText array if not already present + const newRemoveText = [...readerSettings.removeText]; + if (!newRemoveText.includes(text)) { + newRemoveText.push(text); + setChapterReaderSettings({ removeText: newRemoveText }); + } + } else if (action === 'replace') { + // Show modal for user to enter replacement text + setSelectedTextForReplace(text); + setReplacementText(''); + setReplaceModalVisible(true); + } + }, + [readerSettings.removeText, setChapterReaderSettings], + ); + + const handleReplaceSave = React.useCallback(() => { + if (!selectedTextForReplace) return false; + + const newReplaceText = { ...readerSettings.replaceText }; + if (!(selectedTextForReplace in newReplaceText)) { + newReplaceText[selectedTextForReplace] = replacementText; + setChapterReaderSettings({ replaceText: newReplaceText }); + } + setReplaceModalVisible(false); + return true; + }, [ + selectedTextForReplace, + readerSettings.replaceText, + replacementText, + setChapterReaderSettings, + ]); + + const handleReplaceCancel = React.useCallback(() => { + setReplaceModalVisible(false); + setSelectedTextForReplace(''); + setReplacementText(''); + }, []); + + function eventTextAction(event: WebViewPostEvent) { + if (event.data) { + const action = Object.keys(event.data)[0]; + const text = event.data[action]; + handleTextAction(action as string, String(text)); + } + } + + return { + html, + replaceModalVisible, + setReplaceModalVisible, + selectedTextForReplace, + setSelectedTextForReplace, + replacementText, + setReplacementText, + handleReplaceSave, + handleReplaceCancel, + eventTextAction, + }; +} diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index 99befe1cc7..ded477ae80 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -1,10 +1,5 @@ -import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { - AppState, - NativeEventEmitter, - NativeModules, - StatusBar, -} from 'react-native'; +import React, { memo, useMemo, useRef } from 'react'; +import { StatusBar, StyleSheet } from 'react-native'; import WebView from 'react-native-webview'; import color from 'color'; @@ -12,29 +7,20 @@ import { useTheme } from '@hooks/persisted'; import { getString } from '@strings/translations'; import { getPlugin } from '@plugins/pluginManager'; -import { MMKVStorage, getMMKVObject } from '@utils/mmkv/mmkv'; import { - CHAPTER_GENERAL_SETTINGS, - CHAPTER_READER_SETTINGS, - ChapterGeneralSettings, - ChapterReaderSettings, - initialChapterGeneralSettings, - initialChapterReaderSettings, + useChapterGeneralSettings, + useChapterReaderSettings, } from '@hooks/persisted/useSettings'; import { getBatteryLevelSync } from 'react-native-device-info'; -import * as Speech from 'expo-speech'; import { PLUGIN_STORAGE } from '@utils/Storages'; import { useChapterContext } from '../ChapterContext'; -import { - showTTSNotification, - updateTTSNotification, - updateTTSPlaybackState, - updateTTSProgress, - dismissTTSNotification, - ttsMediaEmitter, -} from '@utils/ttsNotification'; +import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal'; +import { TextInput } from 'react-native-paper'; +import useTTS from './Hooks/useTTS'; +import useCustomCode from './Hooks/useCustomCode'; +import useTextModifications from './Hooks/useTextModifications'; -type WebViewPostEvent = { +export type WebViewPostEvent = { type: string; data?: { [key: string]: unknown }; autoStartTTS?: boolean; @@ -44,6 +30,7 @@ type WebViewPostEvent = { type WebViewReaderProps = { onPress(): void; + bottomInset: number; }; const onLogMessage = (payload: { nativeEvent: { data: string } }) => { @@ -56,18 +43,18 @@ const onLogMessage = (payload: { nativeEvent: { data: string } }) => { } }; -const { RNDeviceInfo } = NativeModules; -const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo); - const assetsUriPrefix = __DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'; -const WebViewReader: React.FC = ({ onPress }) => { +const WebViewReader: React.FC = ({ + onPress, + bottomInset, +}) => { const { novel, chapter, - chapterText: html, + chapterText, navigateChapter, saveProgress, nextChapter, @@ -76,27 +63,30 @@ const WebViewReader: React.FC = ({ onPress }) => { } = useChapterContext(); const theme = useTheme(); // Use state for settings so they update when MMKV changes - const [readerSettings, setReaderSettings] = useState( - () => - getMMKVObject(CHAPTER_READER_SETTINGS) || - initialChapterReaderSettings, - ); - const chapterGeneralSettings = useMemo( - () => - getMMKVObject(CHAPTER_GENERAL_SETTINGS) || - initialChapterGeneralSettings, - // needed to preserve settings during chapter change - // eslint-disable-next-line react-hooks/exhaustive-deps - [chapter.id], - ); + const readerSettings = useChapterReaderSettings(); + const chapterGeneralSettings = useChapterGeneralSettings(); - // Update readerSettings when chapter changes - useEffect(() => { - setReaderSettings( - getMMKVObject(CHAPTER_READER_SETTINGS) || - initialChapterReaderSettings, - ); - }, [chapter.id]); + const { + autoStartTTSRef, + eventTTSQueue, + eventTTSSpeak, + eventTTSPauseSpeak, + eventTTSStopSpeak, + eventTTSState, + } = useTTS(webViewRef, novel, chapter); + const { + html, + replaceModalVisible, + setReplaceModalVisible, + selectedTextForReplace, + setSelectedTextForReplace, + replacementText, + setReplacementText, + handleReplaceSave, + handleReplaceCancel, + eventTextAction, + } = useTextModifications(chapterText); + const { customJS, customCSS } = useCustomCode(readerSettings); // Update battery level when chapter changes to ensure fresh value on navigation const batteryLevel = useMemo(() => getBatteryLevelSync(), []); @@ -104,232 +94,34 @@ const WebViewReader: React.FC = ({ onPress }) => { const pluginCustomJS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.js`; const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`; const nextChapterScreenVisible = useRef(false); - const autoStartTTSRef = useRef(false); - const isTTSReadingRef = useRef(false); - const readerSettingsRef = useRef(readerSettings); - const appStateRef = useRef(AppState.currentState); - const ttsQueueRef = useRef([]); - const ttsQueueIndexRef = useRef(0); - - useEffect(() => { - readerSettingsRef.current = readerSettings; - }, [readerSettings]); - - useEffect(() => { - const playListener = ttsMediaEmitter.addListener('TTSPlay', () => { - webViewRef.current?.injectJavaScript(` - if (window.tts && !tts.reading) { tts.resume(); } - `); - }); - const pauseListener = ttsMediaEmitter.addListener('TTSPause', () => { - webViewRef.current?.injectJavaScript(` - if (window.tts && tts.reading) { tts.pause(); } - `); - }); - const stopListener = ttsMediaEmitter.addListener('TTSStop', () => { - webViewRef.current?.injectJavaScript(` - if (window.tts) { tts.stop(); } - `); - }); - const rewindListener = ttsMediaEmitter.addListener('TTSRewind', () => { - webViewRef.current?.injectJavaScript(` - if (window.tts && tts.started) { tts.rewind(); } - `); - }); - const prevListener = ttsMediaEmitter.addListener('TTSPrev', () => { - webViewRef.current?.injectJavaScript(` - if (window.tts && window.reader && window.reader.prevChapter) { - window.reader.post({ type: 'prev', autoStartTTS: true }); - } - `); - }); - const nextListener = ttsMediaEmitter.addListener('TTSNext', () => { - webViewRef.current?.injectJavaScript(` - if (window.tts && window.reader && window.reader.nextChapter) { - window.reader.post({ type: 'next', autoStartTTS: true }); - } - `); - }); - const seekToListener = ttsMediaEmitter.addListener( - 'TTSSeekTo', - (event: { position: number }) => { - const position = event.position; - webViewRef.current?.injectJavaScript(` - if (window.tts && tts.started) { tts.seekTo(${position}); } - `); - }, - ); - return () => { - playListener.remove(); - pauseListener.remove(); - stopListener.remove(); - rewindListener.remove(); - prevListener.remove(); - nextListener.remove(); - seekToListener.remove(); - }; - }, [webViewRef]); - - useEffect(() => { - if (isTTSReadingRef.current) { - updateTTSNotification({ - novelName: novel?.name || 'Unknown', - chapterName: chapter.name, - coverUri: novel?.cover || '', - isPlaying: isTTSReadingRef.current, - }); - } - }, [novel?.name, novel?.cover, chapter.name]); - - useEffect(() => { - return () => { - dismissTTSNotification(); - }; - }, []); - useEffect(() => { - const mmkvListener = MMKVStorage.addOnValueChangedListener(key => { - switch (key) { - case CHAPTER_READER_SETTINGS: - // Update local state with new settings - const newSettings = - getMMKVObject(CHAPTER_READER_SETTINGS) || - initialChapterReaderSettings; - setReaderSettings(newSettings); - - // Stop any currently playing speech - Speech.stop(); - - // Update WebView settings - webViewRef.current?.injectJavaScript( - ` - reader.readerSettings.val = ${MMKVStorage.getString( - CHAPTER_READER_SETTINGS, - )}; - // Auto-restart TTS if currently reading - if (window.tts && tts.reading) { - const currentElement = tts.currentElement; - const wasReading = tts.reading; - tts.stop(); - if (wasReading) { - setTimeout(() => { - tts.start(currentElement); - }, 100); - } - } - `, - ); - break; - case CHAPTER_GENERAL_SETTINGS: - webViewRef.current?.injectJavaScript( - `reader.generalSettings.val = ${MMKVStorage.getString( - CHAPTER_GENERAL_SETTINGS, - )}`, - ); - break; - } - }); - - const subscription = deviceInfoEmitter.addListener( - 'RNDeviceInfo_batteryLevelDidChange', - (level: number) => { - webViewRef.current?.injectJavaScript( - `reader.batteryLevel.val = ${level}`, - ); - }, - ); - return () => { - subscription.remove(); - mmkvListener.remove(); - }; - }, [webViewRef]); - - useEffect(() => { - const subscription = AppState.addEventListener('change', nextState => { - appStateRef.current = nextState; - if (nextState === 'active' && isTTSReadingRef.current) { - const index = ttsQueueIndexRef.current; - webViewRef.current?.injectJavaScript(` - if (window.tts && window.tts.allReadableElements) { - const idx = ${index}; - if (idx < tts.allReadableElements.length) { - tts.elementsRead = idx; - tts.currentElement = tts.allReadableElements[idx]; - tts.prevElement = null; - tts.started = true; - tts.reading = true; - tts.scrollToElement(tts.currentElement); - tts.currentElement.classList.add('highlight'); - } - } - `); - } - }); - - return () => subscription.remove(); - }, [webViewRef]); - - const speakText = (text: string) => { - Speech.speak(text, { - onDone() { - const isBackground = - appStateRef.current === 'background' || - appStateRef.current === 'inactive'; - - if ( - isBackground && - ttsQueueRef.current.length > 0 && - ttsQueueIndexRef.current + 1 < ttsQueueRef.current.length - ) { - const nextIndex = ttsQueueIndexRef.current + 1; - const nextText = ttsQueueRef.current[nextIndex]; - if (nextText) { - ttsQueueIndexRef.current = nextIndex; - speakText(nextText); - return; - } - } - - if (isBackground) { - isTTSReadingRef.current = false; - dismissTTSNotification(); - webViewRef.current?.injectJavaScript('tts.stop?.()'); - return; - } - - webViewRef.current?.injectJavaScript('tts.next?.()'); - }, - voice: readerSettingsRef.current.tts?.voice?.identifier, - pitch: readerSettingsRef.current.tts?.pitch || 1, - rate: readerSettingsRef.current.tts?.rate || 1, - }); - }; const isRTL = plugin?.lang === 'Arabic' || plugin?.lang === 'Hebrew'; const readerDir = isRTL ? 'rtl' : 'ltr'; return ( - { - // Update battery level when WebView finishes loading - const currentBatteryLevel = getBatteryLevelSync(); - webViewRef.current?.injectJavaScript( - `if (window.reader && window.reader.batteryLevel) { + <> + { + // Update battery level when WebView finishes loading + const currentBatteryLevel = getBatteryLevelSync(); + webViewRef.current?.injectJavaScript( + `if (window.reader && window.reader.batteryLevel) { window.reader.batteryLevel.val = ${currentBatteryLevel}; }`, - ); + ); - if (autoStartTTSRef.current) { - autoStartTTSRef.current = false; - setTimeout(() => { - webViewRef.current?.injectJavaScript(` + if (autoStartTTSRef.current) { + autoStartTTSRef.current = false; + setTimeout(() => { + webViewRef.current?.injectJavaScript(` (function() { if (window.tts && reader.generalSettings.val.TTSEnable) { setTimeout(() => { @@ -342,113 +134,61 @@ const WebViewReader: React.FC = ({ onPress }) => { } })(); `); - }, 300); - } - }} - onMessage={(ev: { nativeEvent: { data: string } }) => { - __DEV__ && onLogMessage(ev); - const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data); - switch (event.type) { - case 'tts-queue': { - const payload = event.data as - | { queue?: unknown; startIndex?: unknown } - | undefined; - const queue = Array.isArray(payload?.queue) - ? payload?.queue.filter( - (item): item is string => - typeof item === 'string' && item.trim().length > 0, - ) - : []; - ttsQueueRef.current = queue; - if (typeof payload?.startIndex === 'number') { - ttsQueueIndexRef.current = payload.startIndex; - } else { - ttsQueueIndexRef.current = 0; - } - break; + }, 300); } - case 'hide': - onPress(); - break; - case 'next': - nextChapterScreenVisible.current = true; - if (event.autoStartTTS) { - autoStartTTSRef.current = true; - } - navigateChapter('NEXT'); - break; - case 'prev': - if (event.autoStartTTS) { - autoStartTTSRef.current = true; - } - navigateChapter('PREV'); - break; - case 'save': - if (event.data && typeof event.data === 'number') { - saveProgress(event.data); - } - break; - case 'speak': - if (event.data && typeof event.data === 'string') { - if (typeof event.index === 'number') { - ttsQueueIndexRef.current = event.index; + }} + onMessage={(ev: { nativeEvent: { data: string } }) => { + __DEV__ && onLogMessage(ev); + const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data); + switch (event.type) { + case 'hide': + onPress(); + break; + case 'next': + nextChapterScreenVisible.current = true; + if (event.autoStartTTS) { + autoStartTTSRef.current = true; } - if (!isTTSReadingRef.current) { - isTTSReadingRef.current = true; - showTTSNotification({ - novelName: novel?.name || 'Unknown', - chapterName: chapter.name, - coverUri: novel?.cover || '', - isPlaying: true, - }); - } else { - updateTTSNotification({ - novelName: novel?.name || 'Unknown', - chapterName: chapter.name, - coverUri: novel?.cover || '', - isPlaying: true, - }); + navigateChapter('NEXT'); + break; + case 'prev': + if (event.autoStartTTS) { + autoStartTTSRef.current = true; } - if ( - typeof event.index === 'number' && - typeof event.total === 'number' && - event.total > 0 - ) { - updateTTSProgress(event.index, event.total); + navigateChapter('PREV'); + break; + case 'save': + if (event.data && typeof event.data === 'number') { + saveProgress(event.data); } - speakText(event.data); - } else { - webViewRef.current?.injectJavaScript('tts.next?.()'); - } - break; - case 'pause-speak': - Speech.stop(); - break; - case 'stop-speak': - Speech.stop(); - if (!autoStartTTSRef.current) { - isTTSReadingRef.current = false; - ttsQueueRef.current = []; - ttsQueueIndexRef.current = 0; - dismissTTSNotification(); + break; + case 'tts-queue': { + eventTTSQueue(event); + break; } - break; - case 'tts-state': - if (event.data && typeof event.data === 'object') { - const data = event.data as { isReading?: boolean }; - const isReading = data.isReading === true; - isTTSReadingRef.current = isReading; - updateTTSPlaybackState(isReading); - } - break; - } - }} - source={{ - baseUrl: !chapter.isDownloaded ? plugin?.site : undefined, - headers: plugin?.imageRequestInit?.headers, - method: plugin?.imageRequestInit?.method, - body: plugin?.imageRequestInit?.body, - html: ` + case 'speak': + eventTTSSpeak(event); + break; + case 'pause-speak': + eventTTSPauseSpeak(); + break; + case 'stop-speak': + eventTTSStopSpeak(); + break; + case 'tts-state': + eventTTSState(event); + break; + case 'text-action': + eventTextAction(event); + break; + } + }} + source={{ + baseUrl: !chapter.isDownloaded ? plugin?.site : undefined, + headers: plugin?.imageRequestInit?.headers, + method: plugin?.imageRequestInit?.method, + body: plugin?.imageRequestInit?.body, + html: ` @@ -460,6 +200,7 @@ const WebViewReader: React.FC = ({ onPress }) => { - + - + - -
+
${chapter.name}
- ${html} + ${html}
@@ -539,14 +286,60 @@ const WebViewReader: React.FC = ({ onPress }) => { + + function fn(){ + let novelName = "${novel.name}"; + let chapterName = "${chapter.name}"; + let sourceId = "${novel.pluginId}"; + let chapterId =${chapter.id}; + let novelId =${chapter.novelId}; + const qs = (s) => document.querySelector(s); + let html = qs("#LNReader-chapter").innerHTML; + ${customJS} + qs("#LNReader-chapter").innerHTML = html; + } + document.addEventListener("DOMContentLoaded", fn); + `, - }} - /> + }} + /> + setReplaceModalVisible(false)} + onSave={handleReplaceSave} + onCancel={handleReplaceCancel} + title={getString('common.replaceText')} + > + + + + ); }; +const styles = StyleSheet.create({ + textInput: { + marginBottom: 16, + }, +}); + export default memo(WebViewReader); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx new file mode 100644 index 0000000000..4cb41e50a2 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx @@ -0,0 +1,177 @@ +import { TextInput } from '@components'; +import { WINDOW_HEIGHT } from '@gorhom/bottom-sheet'; +import React from 'react'; +import { PixelRatio, StyleSheet } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { useAnimatedKeyboard } from 'react-native-keyboard-controller'; +import { useTheme } from '@hooks/persisted'; + +const FONT_SIZE = 14; +const LINE_HEIGHT = FONT_SIZE * PixelRatio.getFontScale() * 1.2; + +type CodeInputProps = { + language: 'css' | 'js'; + code: string; + setCode: (code: string) => void; + error?: boolean; + forceFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +const START_JS_CODE = `const qs = (s) => document.querySelector(s); +let html = qs("#LNReader-chapter").innerHTML;`; +const START_CSS_CODE = `:root { + --StatusBar-currentHeight: number px; + --readerSettings-theme: color; + --readerSettings-padding: number px; + --readerSettings-textSize: number px; + --readerSettings-textColor: color; + --readerSettings-textAlign: alignment; + --readerSettings-lineHeight: number; + --readerSettings-fontFamily: font; + --theme-primary: color; + --theme-onPrimary: color; + --theme-secondary: color; + --theme-tertiary: color; + --theme-onTertiary: color; + --theme-onSecondary: color; + --theme-surface: color; + --theme-surface-0-9: color; + --theme-onSurface: color; + --theme-surfaceVariant: color; + --theme-onSurfaceVariant: color; + --theme-outline: color; + --theme-rippleColor: color; +}`; +const END_JS_CODE = 'qs("#LNReader-chapter").innerHTML = html;'; + +const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); + +const CodeInput = ({ + language, + code, + setCode, + error, + forceFocused, + onFocus, + onBlur, +}: CodeInputProps) => { + const theme = useTheme(); + const { height: keyboardHeight } = useAnimatedKeyboard(); + const expanded = useSharedValue(0); + + const maxHeight = useAnimatedStyle(() => { + const availableHeight = WINDOW_HEIGHT - keyboardHeight.value - 200; + return { + maxHeight: Math.min(Math.max(availableHeight, 300), WINDOW_HEIGHT / 2), + }; + }); + const maxHeightTop = useAnimatedStyle(() => { + if (expanded.value !== 1) { + return { maxHeight: withTiming(2 * LINE_HEIGHT + 18, { duration: 250 }) }; + } + return { maxHeight: withTiming(WINDOW_HEIGHT / 2, { duration: 250 }) }; + }, [expanded]); + const maxHeightBottom = useAnimatedStyle(() => { + if (expanded.value !== 2) { + return { maxHeight: withTiming(2 * LINE_HEIGHT + 18, { duration: 250 }) }; + } + return { maxHeight: withTiming(WINDOW_HEIGHT / 2, { duration: 250 }) }; + }); + + const codeColor = React.useMemo( + () => ({ borderColor: theme.outline, color: theme.onSurfaceDisabled }), + [theme], + ); + + return ( + <> + { + if (expanded.value === 1) { + expanded.value = 0; + } else { + expanded.value = 1; + } + }} + > + {language === 'js' ? START_JS_CODE : START_CSS_CODE} + + { + expanded.value = 0; + }} + error={error} + /> + { + if (expanded.value === 2) { + expanded.value = 0; + } else { + expanded.value = 2; + } + }} + > + {language === 'js' ? END_JS_CODE : ''} + + + ); +}; + +export default CodeInput; + +const styles = StyleSheet.create({ + fakeTextInput: { + borderRadius: 4, + borderWidth: 1, + borderStyle: 'solid', + paddingHorizontal: 16, + paddingVertical: 8, + marginHorizontal: 1, + marginVertical: 2, + fontSize: FONT_SIZE, + lineHeight: LINE_HEIGHT, + }, + topField: { + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + borderBottomWidth: 0, + flexGrow: 1, + }, + codeField: { + verticalAlign: 'top', + flexGrow: 1, + borderRadius: 0, + borderTopWidth: 0, + borderBottomWidth: 0, + minHeight: LINE_HEIGHT * 8, + }, + bottomField: { + flexGrow: 1, + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + borderTopWidth: 0, + }, +}); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/ListItems.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/ListItems.tsx new file mode 100644 index 0000000000..c779094c6b --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/ListItems.tsx @@ -0,0 +1,123 @@ +import { useTheme } from '@hooks/persisted'; +import { useMemo } from 'react'; +import { PixelRatio, Pressable, StyleSheet, View } from 'react-native'; +import Icon from '@react-native-vector-icons/material-design-icons'; +import { Text } from 'react-native-paper'; + +const fontScale = PixelRatio.getFontScale(); +const fontSize = 14; +export const LIST_ITEM_LINE_HEIGHT = fontSize * fontScale * 1.2; + +export const ReplaceItem = ({ + item, + removeItem, + editItem, +}: { + item: [string, string]; + removeItem: (identifier: string | number) => void; + editItem: (item: string[]) => void; +}) => { + const theme = useTheme(); + const colorTheme = useMemo(() => { + return { colors: theme }; + }, [theme]); + return ( + editItem(item)} + > + + {item[0]} + + + + + {item[1]} + + { + e.stopPropagation(); + removeItem(item[0]); + }} + /> + + + ); +}; + +export const RemoveItem = ({ + item, + index, + removeItem, + editItem, +}: { + item: string; + index: number; + removeItem: (identifier: string | number) => void; + editItem: (item: string[]) => void; +}) => { + const theme = useTheme(); + const colorTheme = useMemo(() => { + return { colors: theme }; + }, [theme]); + return ( + editItem([item])} + > + + {item} + + removeItem(index)} + /> + + ); +}; + +const styles = StyleSheet.create({ + textfield: { + marginBottom: 16, + }, + row: { + flexDirection: 'row', + gap: 8, + alignItems: 'center', + verticalAlign: 'middle', + }, + itemRow: { + justifyContent: 'space-between', + marginHorizontal: 24, + marginVertical: 8, + height: LIST_ITEM_LINE_HEIGHT, + }, + textItem: { + flexGrow: 1, + flexBasis: '40%', + overflow: 'hidden', + fontSize, + lineHeight: LIST_ITEM_LINE_HEIGHT, + }, + textItemRight: { + textAlign: 'right', + }, + spaceItem: { + flexShrink: 1, + textAlign: 'center', + flexBasis: '10%', + }, +}); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/SelfHidingAppbar.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/SelfHidingAppbar.tsx new file mode 100644 index 0000000000..155e8a36c8 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/SelfHidingAppbar.tsx @@ -0,0 +1,50 @@ +import { Appbar } from '@components'; +import { ThemeColors } from '@theme/types'; +import React from 'react'; +import Animated, { + Easing, + SharedValue, + useAnimatedStyle, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +type SelfHidingAppBarProps = { + title: string; + handleGoBack?: () => void; + theme: ThemeColors; + mode?: 'small' | 'medium' | 'large' | 'center-aligned'; + children?: React.ReactNode; + hiddenState: SharedValue; + animationDuration?: SharedValue; +}; + +const APP_BAR_HEIGHT = 150; + +const SelfHidingAppBar = ({ + hiddenState, + animationDuration, + ...props +}: SelfHidingAppBarProps) => { + const { top } = useSafeAreaInsets(); + + const hideAppBar = useAnimatedStyle(() => { + // The animation now depends on the shared value's .value + const isHidden = hiddenState.value === 1; + return { + height: withTiming(isHidden ? top : APP_BAR_HEIGHT, { + duration: animationDuration?.value ?? 250, + easing: Easing.out(Easing.cubic), + }), + overflow: 'hidden', + }; + }); + + return ( + + + + ); +}; + +export default SelfHidingAppBar; diff --git a/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx b/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx new file mode 100644 index 0000000000..695f1702f3 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx @@ -0,0 +1,280 @@ +import { AnimatedIconButton, List } from '@components'; +import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal'; +import { WINDOW_HEIGHT } from '@gorhom/bottom-sheet'; +import { useBoolean } from '@hooks/index'; +import { FlashList } from '@shopify/flash-list'; +import { LinearGradient } from 'expo-linear-gradient'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { TextInput as RNTextInput, StyleSheet } from 'react-native'; +import { TextInput } from 'react-native-paper'; +import Animated, { + FadeIn, + FadeOut, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { + LIST_ITEM_LINE_HEIGHT, + RemoveItem, + ReplaceItem, +} from '../Components/ListItems'; +import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; + +const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient); + +type ReplaceItemModalProps = { + showReplace?: boolean; + listExpanded: boolean; + toggleList: () => void; +}; + +const ReplaceItemModal = ({ + showReplace = false, + listExpanded = false, + toggleList, +}: ReplaceItemModalProps) => { + const theme = useTheme(); + const modal = useBoolean(false); + const { + setChapterReaderSettings: setSettings, + replaceText, + removeText, + } = useChapterReaderSettings(); + const replaceArray = useMemo(() => { + return Object.entries(replaceText); + }, [replaceText]); + + const arrayLength = showReplace ? replaceArray.length : removeText.length; + + const textRef = useRef(null); + const replaceTextRef = useRef(null); + + const [text, setText] = React.useState(''); + const [replacementText, setReplacementText] = React.useState(''); + const [editing, setEditing] = React.useState(); + const [error, setError] = React.useState<[string, string] | undefined>(); + + const listSize = useSharedValue( + Math.min(110, arrayLength * 48), + ); + const iconRotation = useSharedValue(0); + + const cancel = () => { + setError(undefined); + textRef.current?.clear(); + setText(''); + setEditing(undefined); + if (showReplace) { + replaceTextRef.current?.clear(); + setReplacementText(''); + } + }; + + const save = () => { + if (!text || (showReplace && !replacementText)) { + const e: [string, string] = ['', '']; + if (!text) { + e[0] = 'Enter a match'; + } + if (!replacementText) { + e[1] = 'Enter a replace'; + } + setError(e); + return false; + } + + if (showReplace) { + if (editing && editing !== text) delete replaceText[editing]; + replaceText[text] = replacementText; + setSettings({ replaceText: replaceText }); + } else { + if (editing) { + const i = removeText.findIndex(v => v === editing); + removeText[i] = text; + } else if (!removeText.includes(text)) { + removeText.push(text); + } else { + setError(['Item already exists', '']); + return false; + } + setSettings({ removeText: removeText }); + } + cancel(); + return true; + }; + + const removeItem = useCallback( + (identifier: string | number) => { + if (showReplace) { + delete replaceText[identifier]; + setSettings({ replaceText: replaceText }); + } else { + removeText.splice(identifier as number, 1); + setSettings({ removeText: removeText }); + } + }, + [removeText, replaceText, setSettings, showReplace], + ); + + const editItem = useCallback( + (item: string[]) => { + setEditing(item[0]); + setText(item[0]); + if (showReplace) { + setReplacementText(item[1]); + } + modal.setTrue(); + }, + [modal, showReplace], + ); + + const colorTheme = useMemo(() => { + return { colors: theme }; + }, [theme]); + + const calcListSize = useCallback( + (toggle: boolean = true) => { + if (toggle) { + toggleList(); + iconRotation.value = listExpanded ? 0 : 180; + } + if (listExpanded) { + listSize.value = Math.min( + WINDOW_HEIGHT * 0.6, + arrayLength * (LIST_ITEM_LINE_HEIGHT + 16), + ); + } else { + listSize.value = Math.min( + 110, + arrayLength * (LIST_ITEM_LINE_HEIGHT + 16), + ); + } + }, + [arrayLength, iconRotation, listExpanded, listSize, toggleList], + ); + useEffect(() => { + calcListSize(false); + }, [replaceArray, removeText, calcListSize]); + useEffect(() => { + iconRotation.value = !listExpanded ? 0 : 180; + }, [iconRotation, listExpanded]); + + const animatedListSize = useAnimatedStyle(() => ({ + height: withTiming(listSize.value, { duration: 250 }), + overflow: 'hidden', + position: 'relative', + })); + + return ( + <> + + + {arrayLength <= 3 || listExpanded ? null : ( + calcListSize()} + /> + )} + {showReplace ? ( + ( + + )} + /> + ) : ( + ( + + )} + /> + )} + + + { + modal.setFalse(); + setError(undefined); + }} + onSave={save} + onCancel={cancel} + title="Edit Replace" + > + + {!showReplace ? null : ( + + )} + + + ); +}; + +export default ReplaceItemModal; + +const styles = StyleSheet.create({ + textfield: { + marginBottom: 16, + }, + bottom: { + marginBottom: 24, + }, + marginHorizontal: { + marginHorizontal: 16, + }, + gradient: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + zIndex: 1, + }, +}); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx b/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx new file mode 100644 index 0000000000..8e05835076 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx @@ -0,0 +1,305 @@ +import { Button, TextInput } from '@components'; +import { Row } from '@components/Common'; +import { ToggleButton } from '@components/Common/ToggleButton'; +import { WINDOW_HEIGHT } from '@gorhom/bottom-sheet'; +import { getString } from '@strings/translations'; +import React from 'react'; +import { + Keyboard, + KeyboardAvoidingView, + Platform, + StyleSheet, +} from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; +import { Text } from 'react-native-paper'; +import Animated, { useAnimatedStyle } from 'react-native-reanimated'; +import CodeInput from '../Components/CodeInput'; +import { showToast } from '@utils/showToast'; +import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; +import { useAnimatedKeyboard } from 'react-native-keyboard-controller'; +import { useKeyboardHeight } from '@hooks/common/useKeyboardHeight'; + +type CodeRouteProps = { + language?: 'css' | 'js'; + snippetIndex?: number; + jumpTo: (key: string) => void; + editingSnippet?: { + index: number; + isJS: boolean; + } | null; + onSnippetSaved?: () => void; + isActive: boolean; +}; + +const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView); + +const CodeRoute = ({ + language: dLang, + snippetIndex, + jumpTo, + editingSnippet, + onSnippetSaved, + isActive, +}: CodeRouteProps) => { + const theme = useTheme(); + const { + codeSnippetsJS, + codeSnippetsCSS, + setChapterReaderSettings: setSettings, + } = useChapterReaderSettings(); + const [error, setError] = React.useState({ title: false, code: false }); + + // Use editingSnippet if provided, otherwise fall back to old props + const isEditing = editingSnippet !== null && editingSnippet !== undefined; + const editIndex = isEditing ? editingSnippet.index : snippetIndex; + const editIsJS = isEditing ? editingSnippet.isJS : dLang === 'js'; + + const [language, setLanguage] = React.useState<'js' | 'css'>('js'); + + // Update language when editing state changes + React.useEffect(() => { + if (isEditing) { + setLanguage(editIsJS ? 'js' : 'css'); + } else { + setLanguage('js'); // Default to JS for new snippets + } + }, [isEditing, editIsJS]); + + const snippets = language === 'js' ? codeSnippetsJS : codeSnippetsCSS; + const snippet = + editIndex === undefined || editIndex === -1 ? null : snippets[editIndex]; + + const [title, setTitle] = React.useState(''); + const [code, setCode] = React.useState(''); + const [focusedField, setFocusedField] = React.useState< + 'title' | 'code' | null + >(null); + const keyboardHeightValue = useKeyboardHeight(); + const keyboardHeightRef = React.useRef(0); + const focusTimeoutRef = React.useRef | null>( + null, + ); + + // Update title, code, and reset errors when snippet changes + React.useEffect(() => { + setTitle(snippet?.name ?? ''); + setCode(snippet?.code ?? ''); + setError({ title: false, code: false }); + }, [snippet]); + + React.useEffect(() => { + keyboardHeightRef.current = keyboardHeightValue; + if (keyboardHeightValue > 0 && focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + focusTimeoutRef.current = null; + } + return () => { + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } + }; + }, [keyboardHeightValue]); + + React.useEffect(() => { + if (!isActive) { + Keyboard.dismiss(); + setFocusedField(null); + } + }, [isActive]); + + const { height: keyboardHeight } = useAnimatedKeyboard(); + const ScrollViewRef = React.useRef(null); + + const maxHeightScrollView = useAnimatedStyle(() => { + return { + maxHeight: WINDOW_HEIGHT - keyboardHeight.value, + }; + }); + + const colors = React.useMemo( + () => ({ + colors: theme, + }), + [theme], + ); + + const save = React.useCallback(() => { + setError({ title: false, code: false }); + if (!code.trim() || !title.trim()) { + setError({ title: !title.trim(), code: !code.trim() }); + return; + } + const newSnippets = [...snippets]; + + // Editing existing snippet + if (isEditing && editIndex !== undefined && editIndex !== -1) { + newSnippets[editIndex].name = title; + newSnippets[editIndex].code = code; + setSettings({ + [language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']: newSnippets, + }); + showToast('Snippet updated successfully'); + onSnippetSaved?.(); + return; + } + + // Creating new snippet + newSnippets.push({ + name: title, + code, + active: true, + lang: language, + }); + setSettings({ + [language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']: newSnippets, + }); + showToast('Snippet saved successfully'); + jumpTo('first'); // Go back to settings tab + }, [ + language, + snippets, + title, + code, + setSettings, + jumpTo, + isEditing, + editIndex, + onSnippetSaved, + ]); + return ( + + + + + {'Select CSS or JS'} + + setLanguage('css')} + disabled={isEditing} + /> + setLanguage('js')} + disabled={isEditing} + /> + + { + setFocusedField('title'); + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } + focusTimeoutRef.current = setTimeout(() => { + if (keyboardHeightRef.current === 0) { + setFocusedField(null); + } + }, 250); + }} + style={styles.snippetName} + error={error.title} + /> + { + setFocusedField('code'); + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } + focusTimeoutRef.current = setTimeout(() => { + if (keyboardHeightRef.current === 0) { + setFocusedField(null); + } + }, 250); + }} + /> + +