diff --git a/App.tsx b/App.tsx index 9800c978cb..e81d7dba32 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(); @@ -48,18 +48,23 @@ const App = () => { return ( - - - - - - -
- - - - - + + + + + + + +
+ + + + + + ); 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..f5533c8325 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", @@ -142,6 +143,7 @@ "@types/jest": "^29.5.14", "@types/lodash-es": "^4.17.12", "@types/react": "~19.2.14", + "@types/react-syntax-highlighter": "^15.5.13", "@types/sanitize-html": "^2.16.1", "@typescript-eslint/eslint-plugin": "^8.58.0", "@typescript-eslint/parser": "^8.58.0", @@ -163,6 +165,7 @@ "jest-expo": "^55.0.11", "lint-staged": "^12.5.0", "prettier": "2.8.8", + "react-syntax-highlighter": "^16.1.1", "react-test-renderer": "19.2.4", "rock": "^0.12.12", "typescript": "~5.9.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55203247ce..4c65dde0ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,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) @@ -287,6 +290,9 @@ importers: '@types/react': specifier: ~19.2.14 version: 19.2.14 + '@types/react-syntax-highlighter': + specifier: ^15.5.13 + version: 15.5.13 '@types/sanitize-html': specifier: ^2.16.1 version: 2.16.1 @@ -350,6 +356,9 @@ importers: prettier: specifier: 2.8.8 version: 2.8.8 + react-syntax-highlighter: + specifier: ^16.1.1 + version: 16.1.1(react@19.2.4) react-test-renderer: specifier: 19.2.4 version: 19.2.4(react@19.2.4) @@ -2031,6 +2040,9 @@ packages: '@types/hammerjs@2.0.46': resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -2058,6 +2070,12 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/prismjs@1.26.6': + resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} + + '@types/react-syntax-highlighter@15.5.13': + resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -2073,6 +2091,12 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -2549,6 +2573,15 @@ packages: resolution: {integrity: sha512-cbGOjAptfM2LVmWhwRFHEKTPkLwNddVmuqYZQt895yXwAsWsXObCG+YN4DGQ/JBtT4GP1a1lPPdio2z413LmTg==} engines: {node: '>=12.20'} + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} @@ -2672,6 +2705,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + command-exists@1.2.9: resolution: {integrity: sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==} @@ -2808,6 +2844,9 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decode-uri-component@0.2.2: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} @@ -3514,6 +3553,9 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fault@1.0.4: + resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==} + fb-dotslash@0.5.8: resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} engines: {node: '>=20'} @@ -3593,6 +3635,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -3738,6 +3784,12 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hermes-compiler@0.14.1: resolution: {integrity: sha512-+RPPQlayoZ9n6/KXKt5SFILWXCGJ/LV5d24L5smXrvTDrPS4L6dSctPczXauuvzFP3QEJbD1YO7Z3Ra4a+4IhA==} @@ -3768,6 +3820,12 @@ packages: hermes-parser@0.33.3: resolution: {integrity: sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==} + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + + highlightjs-vue@1.0.0: + resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -3882,6 +3940,12 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -3920,6 +3984,9 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} @@ -3962,6 +4029,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -4544,6 +4614,9 @@ packages: react-native-windows: optional: true + lowlight@1.20.0: + resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -5007,6 +5080,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -5137,6 +5213,10 @@ packages: resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + proc-log@4.2.0: resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -5159,6 +5239,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + protobufjs@8.0.0: resolution: {integrity: sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==} engines: {node: '>=12.0.0'} @@ -5289,6 +5372,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: @@ -5421,6 +5511,12 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-syntax-highlighter@16.1.1: + resolution: {integrity: sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==} + engines: {node: '>= 16.20.2'} + peerDependencies: + react: '>= 0.14.0' + react-test-renderer@19.2.0: resolution: {integrity: sha512-zLCFMHFE9vy/w3AxO0zNxy6aAupnCuLSVOJYDe/Tp+ayGI1f2PLQsFVPANSD42gdSbmYx5oN+1VWDhcXtq7hAQ==} peerDependencies: @@ -5451,6 +5547,9 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + refractor@5.0.0: + resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==} + regenerate-unicode-properties@10.2.2: resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} engines: {node: '>=4'} @@ -5735,6 +5834,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split-on-first@1.1.0: resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} engines: {node: '>=6'} @@ -8716,6 +8818,10 @@ snapshots: '@types/hammerjs@2.0.46': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -8756,6 +8862,12 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/prismjs@1.26.6': {} + + '@types/react-syntax-highlighter@15.5.13': + dependencies: + '@types/react': 19.2.14 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -8772,6 +8884,10 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -9375,6 +9491,12 @@ snapshots: char-regex@2.0.2: {} + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + cheerio-select@2.1.0: dependencies: boolbase: 1.0.0 @@ -9516,6 +9638,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + command-exists@1.2.9: optional: true @@ -9659,6 +9783,10 @@ snapshots: decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + decode-uri-component@0.2.2: {} decompress-response@6.0.0: @@ -10406,6 +10534,10 @@ snapshots: dependencies: reusify: 1.1.0 + fault@1.0.4: + dependencies: + format: 0.2.2 + fb-dotslash@0.5.8: {} fb-watchman@2.0.2: @@ -10490,6 +10622,8 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + format@0.2.2: {} + fresh@0.5.2: {} fs-constants@1.0.0: {} @@ -10635,6 +10769,18 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + hermes-compiler@0.14.1: {} hermes-eslint@0.33.3: @@ -10667,6 +10813,10 @@ snapshots: dependencies: hermes-estree: 0.33.3 + highlight.js@10.7.3: {} + + highlightjs-vue@1.0.0: {} + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -10800,6 +10950,13 @@ snapshots: dependencies: loose-envify: 1.4.0 + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -10844,6 +11001,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-decimal@2.0.1: {} + is-docker@2.2.1: {} is-docker@3.0.0: {} @@ -10875,6 +11034,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -11699,6 +11860,11 @@ snapshots: 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-safe-modules: 1.0.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)) + lowlight@1.20.0: + dependencies: + fault: 1.0.4 + highlight.js: 10.7.3 + lru-cache@10.4.3: {} lru-cache@11.2.7: {} @@ -12376,6 +12542,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.0 @@ -12497,6 +12673,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + prismjs@1.30.0: {} + proc-log@4.2.0: {} process@0.11.10: {} @@ -12518,6 +12696,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + property-information@7.2.0: {} + protobufjs@8.0.0: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -12661,6 +12841,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 @@ -12839,6 +13026,16 @@ snapshots: react-refresh@0.14.2: {} + react-syntax-highlighter@16.1.1(react@19.2.4): + dependencies: + '@babel/runtime': 7.29.2 + highlight.js: 10.7.3 + highlightjs-vue: 1.0.0 + lowlight: 1.20.0 + prismjs: 1.30.0 + react: 19.2.4 + refractor: 5.0.0 + react-test-renderer@19.2.0(react@19.2.4): dependencies: react: 19.2.4 @@ -12883,6 +13080,13 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + refractor@5.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/prismjs': 1.26.6 + hastscript: 9.0.1 + parse-entities: 4.0.2 + regenerate-unicode-properties@10.2.2: dependencies: regenerate: 1.4.2 @@ -13209,6 +13413,8 @@ snapshots: source-map@0.6.1: {} + space-separated-tokens@2.0.2: {} + split-on-first@1.1.0: {} sprintf-js@1.0.3: {} 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..9c4a9bbf46 --- /dev/null +++ b/src/components/Modal/KeyboardAvoidingModal.tsx @@ -0,0 +1,169 @@ +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 BORDER_RADIUS = 28; + +const getModalTitleColor = (theme: ThemeColors) => ({ + color: theme.onSurface, +}); + +export type DefaultModalProps = { + title: string; + onSave: () => 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; + const animatedContainerStyle = useAnimatedStyle(() => { + const kb = keyboard.height.value; + + const availableHeight = + default_availableHeight - Math.max(insets.bottom, kb); + return { + maxHeight: withClamp( + { min: 200 }, + withTiming(availableHeight, { duration: 0 }), + ), + marginBottom: kb, + }; + }, [insets.bottom, default_availableHeight]); + + return ( + + + + + {title} + + + + + {children} + + + + + {onReset ? ( + + ) : null} + + + + + + + + + + ); +}; + +export default KeyboardAvoidingModal; + +const styles = StyleSheet.create({ + modalWrapper: { + justifyContent: 'center', + paddingHorizontal: MODAL_MARGIN, + }, + modalContainer: { + borderRadius: BORDER_RADIUS, + shadowColor: 'transparent', + }, + modalTitle: { + fontSize: 24, + lineHeight: 24, + padding: 24, + }, + body: { + flexShrink: 1, + minHeight: 0, + }, + content: { + paddingBottom: 16, + paddingHorizontal: 24, + }, + buttonRow: { + alignItems: 'center', + flexDirection: 'row', + marginBottom: -8, + marginHorizontal: -8, + padding: 24, + paddingTop: 8, + }, + flex: { + flex: 1, + }, +}); diff --git a/src/components/Switch/SwitchItem.tsx b/src/components/Switch/SwitchItem.tsx index d619387fb5..e4ec83d4cf 100644 --- a/src/components/Switch/SwitchItem.tsx +++ b/src/components/Switch/SwitchItem.tsx @@ -14,7 +14,9 @@ interface SwitchItemProps { value: boolean; label: string; description?: string; + descriptionNumberOfLines?: number; onPress: () => void; + onLongPress?: () => void; theme: ThemeColors; style?: StyleProp; } @@ -22,7 +24,9 @@ interface SwitchItemProps { const SwitchItem: React.FC = ({ label, description, + descriptionNumberOfLines, onPress, + onLongPress, theme, value, style, @@ -31,20 +35,20 @@ const SwitchItem: React.FC = ({ android_ripple={{ color: theme.rippleColor }} style={[styles.container, style]} onPress={onPress} + onLongPress={onLongPress} > {label} {description ? ( - + {description} ) : null} - + ); 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 97d3becc66..1c4afb7303 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'; import { getMMKVObject } from '@utils/mmkv/mmkv'; @@ -132,8 +133,6 @@ export interface ChapterReaderSettings { padding: number; fontFamily: string; lineHeight: number; - customCSS: string; - customJS: string; customThemes: ReaderTheme[]; tts?: { voice?: Voice; @@ -146,8 +145,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 @@ -223,8 +236,6 @@ export const initialChapterReaderSettings: ChapterReaderSettings = { padding: 16, fontFamily: '', lineHeight: 1.5, - customCSS: '', - customJS: '', customThemes: [], tts: { rate: 1, @@ -236,6 +247,13 @@ export const initialChapterReaderSettings: ChapterReaderSettings = { epubUseAppTheme: false, epubUseCustomCSS: false, epubUseCustomJS: false, + /** + * Custom code + */ + replaceText: {}, + removeText: [], + codeSnippetsCSS: [], + codeSnippetsJS: [], }; export const useAppSettings = () => { @@ -301,9 +319,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 = { @@ -341,7 +401,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..5060ad1c75 100644 --- a/src/navigators/MoreStack.tsx +++ b/src/navigators/MoreStack.tsx @@ -18,6 +18,8 @@ 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'; +import CodeSnippetsScreen from '@screens/settings/SettingsCustomCodeScreen/CodeSnippetsScreen'; const Stack = createNativeStackNavigator< MoreStackParamList & SettingsStackParamList @@ -34,6 +36,8 @@ const SettingsStack = () => ( + + {/* */} diff --git a/src/navigators/types/index.ts b/src/navigators/types/index.ts index b0fe4cd52e..9f39f3437c 100644 --- a/src/navigators/types/index.ts +++ b/src/navigators/types/index.ts @@ -82,6 +82,8 @@ export type SettingsStackParamList = { AdvancedSettings: undefined; LibrarySettings: undefined; RespositorySettings: { url?: string } | undefined; + CustomCode: undefined; + CodeSnippets: { snippetIndex?: number; isJS?: boolean } | undefined; }; export type NovelScreenProps = StackScreenProps< @@ -169,6 +171,14 @@ export type BackupSettingsScreenProps = StackScreenProps< SettingsStackParamList, 'BackupSettings' >; +export type CustomCodeSettingsScreenProps = StackScreenProps< + SettingsStackParamList, + 'CustomCode' +>; +export type CodeSnippetsScreenProps = StackScreenProps< + SettingsStackParamList, + 'CodeSnippets' +>; export type AdvancedSettingsScreenProps = StackScreenProps< SettingsStackParamList, 'AdvancedSettings' diff --git a/src/screens/novel/components/EditInfoModal.tsx b/src/screens/novel/components/EditInfoModal.tsx index 3980b76660..02bee2f810 100644 --- a/src/screens/novel/components/EditInfoModal.tsx +++ b/src/screens/novel/components/EditInfoModal.tsx @@ -18,6 +18,7 @@ import { ThemeColors } from '@theme/types'; import { NovelInfo } from '@database/types'; import { NovelStatus } from '@plugins/types'; import { translateNovelStatus } from '@utils/translateEnum'; +import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal'; interface EditInfoModalProps { theme: ThemeColors; @@ -71,10 +72,22 @@ const EditInfoModal = ({ return ( - - - {getString('novelScreen.edit.info')} - + { + setNovel(novelInfo); + updateNovelInfo(novelInfo); + hideModal(); + return true; + }} + onCancel={hideModal} + onReset={() => { + setNovelInfo(initialNovelInfo); + updateNovelInfo(initialNovelInfo); + }} + > {getString('novelScreen.edit.status')} @@ -197,28 +210,7 @@ const EditInfoModal = ({ showsHorizontalScrollIndicator={false} /> ) : null} - - - - - - - + ); }; diff --git a/src/screens/novel/components/ExportNovelAsEpubButton.tsx b/src/screens/novel/components/ExportNovelAsEpubButton.tsx index 5bd2831468..4c32cbfe0a 100644 --- a/src/screens/novel/components/ExportNovelAsEpubButton.tsx +++ b/src/screens/novel/components/ExportNovelAsEpubButton.tsx @@ -15,6 +15,8 @@ import { getNovelDownloadedChapters } from '@database/queries/ChapterQueries'; import ExportEpubModal from './ExportEpubModal'; import { MaterialDesignIconName } from '@type/icon'; +import { composeCSS, composeJS } from '@utils/customCode'; + interface ExportNovelAsEpubButtonProps { novel?: NovelInfo; @@ -84,8 +86,10 @@ const ExportNovelAsEpubButton: React.FC = ({ }` : ''; + const customCSS = composeCSS(readerSettings.codeSnippetsCSS); + const customStyles = epubUseCustomCSS - ? readerSettings.customCSS + ? customCSS .replace(RegExp(`#sourceId-${novel.pluginId}\\s*\\{`, 'g'), 'body {') .replace(RegExp(`#sourceId-${novel.pluginId}[^.#A-Z]*`, 'gi'), '') : ''; @@ -93,6 +97,8 @@ const ExportNovelAsEpubButton: React.FC = ({ return appThemeStyles + customStyles; }, [novel, epubUseAppTheme, epubUseCustomCSS, readerSettings, theme.primary]); + const customJS = composeJS(readerSettings.codeSnippetsJS); + const epubJavaScript = useMemo(() => { if (!novel) { return ''; @@ -105,10 +111,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..ba0778e559 --- /dev/null +++ b/src/screens/reader/components/Hooks/useCustomCode.ts @@ -0,0 +1,25 @@ +import { ChapterReaderSettings } from '@hooks/persisted/useSettings'; +import { useMemo } from 'react'; +import { composeCSS, composeJS } from '@utils/customCode'; + +export default function useCustomCode( + readerSettings: Pick< + ChapterReaderSettings, + 'codeSnippetsJS' | 'codeSnippetsCSS' + >, +) { + const customJS = useMemo( + () => composeJS(readerSettings.codeSnippetsJS), + [readerSettings.codeSnippetsJS], + ); + + const customCSS = useMemo( + () => composeCSS(readerSettings.codeSnippetsCSS), + [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..006c1c7fa0 --- /dev/null +++ b/src/screens/reader/components/Hooks/useTextModifications.ts @@ -0,0 +1,84 @@ +import { useChapterReaderSettings } from '@hooks/persisted/useSettings'; +import { applyTextModifications } from '@utils/customCode'; +import React, { useMemo, useState } from 'react'; +import { WebViewPostEvent } from '../WebViewReader'; + +export default function useTextModifications(chapterText: string) { + // Replace modal state + const [replaceModalVisible, setReplaceModalVisible] = useState(false); + const [selectedTextForReplace, setSelectedTextForReplace] = useState(''); + const [replacementText, setReplacementText] = useState(''); + + const { setChapterReaderSettings, ...readerSettings } = + useChapterReaderSettings(); + + const html = useMemo( + () => applyTextModifications(chapterText, readerSettings.removeText, readerSettings.replaceText), + [chapterText, readerSettings.removeText, readerSettings.replaceText], + ); + + const handleTextAction = React.useCallback( + (action: string, text: string) => { + if (!text) return; + + if (action === 'remove') { + // Add to removeText array if not already present + const newRemoveText = [...readerSettings.removeText]; + if (!newRemoveText.includes(text)) { + newRemoveText.push(text); + setChapterReaderSettings({ removeText: newRemoveText }); + } + } else if (action === 'replace') { + // Show modal for user to enter replacement text + setSelectedTextForReplace(text); + setReplacementText(''); + setReplaceModalVisible(true); + } + }, + [readerSettings.removeText, setChapterReaderSettings], + ); + + const handleReplaceSave = React.useCallback(() => { + if (!selectedTextForReplace) return false; + + const newReplaceText = { ...readerSettings.replaceText }; + if (!(selectedTextForReplace in newReplaceText)) { + newReplaceText[selectedTextForReplace] = replacementText; + setChapterReaderSettings({ replaceText: newReplaceText }); + } + setReplaceModalVisible(false); + return true; + }, [ + selectedTextForReplace, + readerSettings.replaceText, + replacementText, + setChapterReaderSettings, + ]); + + const handleReplaceCancel = React.useCallback(() => { + setReplaceModalVisible(false); + setSelectedTextForReplace(''); + setReplacementText(''); + }, []); + + function eventTextAction(event: WebViewPostEvent) { + if (event.data) { + const action = Object.keys(event.data)[0]; + const text = event.data[action]; + handleTextAction(action as string, String(text)); + } + } + + return { + html, + replaceModalVisible, + setReplaceModalVisible, + selectedTextForReplace, + setSelectedTextForReplace, + replacementText, + setReplacementText, + handleReplaceSave, + handleReplaceCancel, + eventTextAction, + }; +} diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index 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/CodeSnippetsScreen.tsx b/src/screens/settings/SettingsCustomCodeScreen/CodeSnippetsScreen.tsx new file mode 100644 index 0000000000..e0f51cc64a --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/CodeSnippetsScreen.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { + NavigationState, + SceneRendererProps, + TabBar, + TabView, +} from 'react-native-tab-view'; +import { StyleSheet, useWindowDimensions } from 'react-native'; +import Color from 'color'; + +import { Appbar, IconButtonV2, SafeAreaView } from '@components'; +import { useTheme } from '@hooks/persisted'; +import { showToast } from '@utils/showToast'; +import { getString } from '@strings/translations'; +import SnippetEditor, { SnippetEditorHandle } from './SnippetEditor'; +import SettingsReaderWebView from '../SettingsReaderScreen/components/SettingsReaderWebView'; +import { CodeSnippetsScreenProps } from '@navigators/types'; +import NativeFile from '@specs/NativeFile'; +import * as DocumentPicker from 'expo-document-picker'; + +type State = NavigationState<{ + key: string; + title: string; +}>; + +const routes = [ + { key: 'code', title: getString('common.code') }, + { key: 'example', title: getString('common.example') }, +]; + +const CodeSnippetsScreen: React.FC = ({ + navigation, + route, +}) => { + const snippetIndex = route?.params?.snippetIndex; + const isJS = route?.params?.isJS; + const language = isJS === false ? 'css' : 'js'; + const theme = useTheme(); + const layout = useWindowDimensions(); + + const [index, setIndex] = React.useState(0); + const editorRef = React.useRef(null); + + const renderScene = ({ + route: r, + }: SceneRendererProps & { + route: { + key: string; + title: string; + }; + }) => { + switch (r.key) { + case 'code': + return ( + + ); + case 'example': + return ; + default: + return null; + } + }; + + const renderTabBar = React.useCallback( + (props: SceneRendererProps & { navigationState: State }) => ( + + ), + [ + theme.isDark, + theme.primary, + theme.rippleColor, + theme.secondary, + theme.surface, + ], + ); + + const handleImport = async () => { + try { + const mimeType = + language === 'css' ? 'text/css' : 'application/javascript'; + const file = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: false, + type: mimeType, + }); + + if (file.assets) { + const tempPath = + NativeFile.getConstants().ExternalCachesDirectoryPath + + '/imported_custom.' + + language; + NativeFile.copyFile(file.assets[0].uri, tempPath); + const content = NativeFile.readFile(tempPath); + NativeFile.unlink(tempPath); + + editorRef.current?.setCode(content.trim()); + showToast(getString('customCodeSettings.imported')); + } + } catch (error: any) { + showToast(error.message); + } + }; + + return ( + + navigation.goBack()} + theme={theme} + mode="small" + > + + editorRef.current?.save()} + theme={theme} + /> + + + + ); +}; + +export default CodeSnippetsScreen; + +const styles = StyleSheet.create({ + tabBar: { + borderBottomWidth: 1, + elevation: 0, + }, + tabBarIndicator: { + height: 3, + }, + flex: { + flex: 1, + }, +}); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx new file mode 100644 index 0000000000..7104125ec4 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { PixelRatio, StyleSheet, Text, View } from 'react-native'; +import { useTheme } from '@hooks/persisted'; +import { getString } from '@strings/translations'; +import { + SimpleCodeEditor, + MemoizedHighlightedCode, + HighlightMode, + useStableLineModels, +} from './SimpleCodeEditor'; +import { Portal } from 'react-native-paper'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; + +const FONT_SIZE = 14; +const LINE_HEIGHT = Math.ceil(FONT_SIZE * PixelRatio.getFontScale() * 1.2); +const MIN_LINES = 16; + +const MD3_DEFAULT_APPBAR_HEIGHT = 64; + +type CodeInputProps = { + language: 'css' | 'js'; + code: string; + setCode: (code: string) => void; + highlightMode?: HighlightMode; + error?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +const START_JS_CODE = `const qs = (s) => document.querySelector(s); +let html = qs("#LNReader-chapter").innerHTML;`; +const START_CSS_CODE = `:root { + --StatusBar-currentHeight: number px; + --readerSettings-theme: color; + --readerSettings-padding: number px; + --readerSettings-textSize: number px; + --readerSettings-textColor: color; + --readerSettings-textAlign: alignment; + --readerSettings-lineHeight: number; + --readerSettings-fontFamily: font; + --theme-primary: color; + --theme-onPrimary: color; + --theme-secondary: color; + --theme-tertiary: color; + --theme-onTertiary: color; + --theme-onSecondary: color; + --theme-surface: color; + --theme-surface-0-9: color; + --theme-onSurface: color; + --theme-surfaceVariant: color; + --theme-onSurfaceVariant: color; + --theme-outline: color; + --theme-rippleColor: color; +}`; +const END_JS_CODE = 'qs("#LNReader-chapter").innerHTML = html;'; + +const CodeInput = ({ + language, + code, + setCode, + highlightMode, + onFocus, + onBlur, +}: CodeInputProps) => { + const theme = useTheme(); + + const codeFieldStyle = React.useMemo( + () => ({ + color: theme.onBackground, + backgroundColor: theme.background, + }), + [theme], + ); + + const startValue = language === 'js' ? START_JS_CODE : START_CSS_CODE; + + const lines = useStableLineModels(code); + const startLines = useStableLineModels(startValue); + const debounce = React.useRef(null); + const [error, setError] = React.useState(undefined); + + function setAndAnalyzeCode(val: string) { + if (language === 'js') { + debounce.current && clearTimeout(debounce.current); + debounce.current = setTimeout(() => analyzeCode(val), 500); + } + setCode(val); + } + function analyzeCode(val: string) { + try { + // eslint-disable-next-line no-new-func, no-new + new Function(val); + setError(undefined); + } catch (e: unknown) { + setError( + (e as Error).message.replace( + /^(\d+)/, + (_, i) => Number(i) + startLines.length + '', + ), + ); + } + } + + return ( + + + {!error ? null : ( + + + {error} + + + )} + + + + {language !== 'js' ? null : ( + + )} + + ); +}; +export default CodeInput; + +const styles = StyleSheet.create({ + error: { + width: '100%', + position: 'absolute', + top: MD3_DEFAULT_APPBAR_HEIGHT * 1.3, + padding: 8, + transform: [{ translateY: 20 }], + zIndex: 9999, + backgroundColor: 'red', + }, + errorText: { + textAlign: 'center', + }, + container: { + flex: 1, + marginVertical: 8, + paddingBottom: 8, + }, + rowContainer: { + paddingVertical: 8, + alignItems: 'flex-start', + }, + codeContainer: { + flex: 1, + }, + lines: { + paddingRight: 4, + paddingTop: 0, + textAlign: 'right', + minWidth: 32, + }, + fontStyle: { + fontSize: FONT_SIZE, + lineHeight: LINE_HEIGHT, + fontFamily: 'monospace', + margin: 0, + marginBottom: 0, + marginTop: 0, + padding: 0, + paddingBottom: 0, + paddingTop: 0, + }, + fakeTextInput: { + opacity: 0.6, + }, + topField: { + flex: 1, + }, + codeField: { + verticalAlign: 'top', + paddingTop: 0, + flex: 1, + minHeight: LINE_HEIGHT * MIN_LINES, + }, + bottomField: { + flex: 1, + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + }, +}); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/ListItems.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/ListItems.tsx new file mode 100644 index 0000000000..62bf676692 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/ListItems.tsx @@ -0,0 +1,123 @@ +import { useTheme } from '@hooks/persisted'; +import { useMemo } from 'react'; +import { PixelRatio, Pressable, StyleSheet, View } from 'react-native'; +import Icon from '@react-native-vector-icons/material-design-icons'; +import { Text } from 'react-native-paper'; + +const fontScale = PixelRatio.getFontScale(); +const fontSize = 14; +export const LIST_ITEM_LINE_HEIGHT = Math.ceil(fontSize * fontScale * 1.2); +export const LIST_ITEM_HEIGHT = LIST_ITEM_LINE_HEIGHT + 16; + +export const ReplaceItem = ({ + item, + removeItem, + editItem, +}: { + item: [string, string]; + removeItem: (identifier: string | number) => void; + editItem: (item: string[]) => void; +}) => { + const theme = useTheme(); + const colorTheme = useMemo(() => { + return { colors: theme }; + }, [theme]); + return ( + editItem(item)} + > + + {item[0]} + + + + + {item[1]} + + { + e.stopPropagation(); + removeItem(item[0]); + }} + /> + + + ); +}; + +export const RemoveItem = ({ + item, + index, + removeItem, + editItem, +}: { + item: string; + index: number; + removeItem: (identifier: string | number) => void; + editItem: (item: string[]) => void; +}) => { + const theme = useTheme(); + const colorTheme = useMemo(() => { + return { colors: theme }; + }, [theme]); + return ( + editItem([item])} + > + + {item} + + removeItem(index)} + /> + + ); +}; + +const styles = StyleSheet.create({ + textfield: { + marginBottom: 16, + }, + row: { + flexDirection: 'row', + gap: 8, + alignItems: 'center', + }, + itemRow: { + justifyContent: 'space-between', + marginHorizontal: 24, + marginVertical: 8, + height: LIST_ITEM_LINE_HEIGHT, + }, + textItem: { + flexGrow: 1, + flexBasis: '40%', + overflow: 'hidden', + fontSize, + lineHeight: LIST_ITEM_LINE_HEIGHT, + }, + textItemRight: { + textAlign: 'right', + }, + spaceItem: { + flexShrink: 1, + textAlign: 'center', + flexBasis: '10%', + }, +}); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/SimpleCodeEditor.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/SimpleCodeEditor.tsx new file mode 100644 index 0000000000..070d2800ed --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/SimpleCodeEditor.tsx @@ -0,0 +1,591 @@ +import { Row } from '@components/Common'; +import React, { memo, useCallback, useMemo, useRef } from 'react'; +import { + AnimatableNumericValue, + Animated, + ColorValue, + PixelRatio, + Platform, + StyleProp, + StyleSheet, + Text, + TextInput, + TextInputProps, + TextStyle, + View, + ViewStyle, +} from 'react-native'; +import { PrismLight as Light } from 'react-syntax-highlighter'; +import css from 'react-syntax-highlighter/dist/esm/languages/prism/css'; +import js from 'react-syntax-highlighter/dist/esm/languages/prism/javascript'; +import materialDark from 'react-syntax-highlighter/dist/esm/styles/prism/material-dark'; +import materialLight from 'react-syntax-highlighter/dist/esm/styles/prism/material-light'; + +Light.registerLanguage('javascript', js); +Light.registerLanguage('css', css); + +const LANG_MAP = { + js: 'javascript', + css: 'css', +} as const; + +type SupportedMode = keyof typeof LANG_MAP; +type HLStyleValue = string | number; +type HLStyle = Record; +type RNStylesheet = Record; + +interface RendererNode { + type?: 'element' | 'text'; + value?: string | number; + properties?: { + className?: string[]; + [key: string]: unknown; + }; + children?: RendererNode[]; +} + +export type HighlightMode = 'off' | 'on' | 'combined'; + +type SimpleCodeEditorProps = Omit< + TextInputProps, + 'value' | 'defaultValue' | 'children' | 'onChangeText' +> & { + highlightMode?: HighlightMode; + onChangeText?: (text: string) => void; + containerStyle?: StyleProp; +}; + +interface LineModel { + id: string; + code: string; +} + +interface HighlightedLineProps { + code: string; + isDark?: boolean; + mode: SupportedMode; + textStyle: TextStyle; + lineHeight: number; + hide: boolean; +} + +const stylesheetCache = new WeakMap(); + +function Passthrough({ + children, +}: { + children?: React.ReactNode; + [_key: string]: unknown; +}) { + return <>{children}; +} + +function cssToTextStyle(cssStyle: HLStyle): TextStyle { + const rn: TextStyle = {}; + + for (const [key, value] of Object.entries(cssStyle)) { + switch (key) { + case 'background': + case 'backgroundColor': + rn.backgroundColor = String(value); + break; + + case 'color': + rn.color = String(value); + break; + + case 'fontStyle': + //rn.fontStyle = value as TextStyle['fontStyle']; + break; + + case 'fontWeight': + rn.fontWeight = String(value) as TextStyle['fontWeight']; + break; + + case 'textDecoration': + case 'textDecorationLine': + rn.textDecorationLine = value as TextStyle['textDecorationLine']; + break; + + default: + break; + } + } + + return rn; +} +type StyleSheet = { + [key: string]: React.CSSProperties; +}; +function getRNStylesheet(stylesheet: StyleSheet): RNStylesheet { + const cached = stylesheetCache.get(stylesheet); + + if (cached) { + return cached; + } + + const rn: RNStylesheet = {}; + + for (const [key, value] of Object.entries(stylesheet)) { + rn[key] = cssToTextStyle(value as HLStyle); + } + + stylesheetCache.set(stylesheet, rn); + + return rn; +} + +function getStylesForNode( + node: RendererNode, + rnStylesheet: RNStylesheet, +): TextStyle { + const result: TextStyle = {}; + + for (const className of node.properties?.className ?? []) { + const classStyle = rnStylesheet[className]; + + if (classStyle) { + Object.assign(result, classStyle); + } + } + + return result; +} + +function stripLineBreaks(value: string | number): string { + return String(value).replace(/\r?\n/g, ''); +} + +function renderInlineNodes( + nodes: RendererNode[], + rnStylesheet: RNStylesheet, + defaultColor: ColorValue, + keyPrefix = 'n', +): React.ReactNode[] { + const result: React.ReactNode[] = []; + + nodes.forEach((node, index) => { + const key = `${keyPrefix}_${index}`; + + if (node.children?.length) { + result.push( + + {renderInlineNodes( + node.children, + rnStylesheet, + defaultColor, + `${key}_c`, + )} + , + ); + } + + if (node.value != null) { + result.push(stripLineBreaks(node.value)); + } + }); + + return result; +} + +function lineHighlightRenderer(raw: rendererProps): React.ReactNode { + const { rows, stylesheet } = raw; + const rnStylesheet = getRNStylesheet(stylesheet); + const defaultColor = rnStylesheet.hljs?.color ?? '#abb2bf'; + const result: React.ReactNode[] = []; + + rows.forEach((row, rowIndex) => { + if (row.children?.length) { + result.push( + ...renderInlineNodes( + row.children, + rnStylesheet, + defaultColor, + `r_${rowIndex}`, + ), + ); + } else if (row.value != null) { + result.push(stripLineBreaks(row.value)); + } + }); + + return result; +} + +function shallowEqualTextStyle(a: TextStyle, b: TextStyle): boolean { + const aKeys = Object.keys(a) as Array; + const bKeys = Object.keys(b) as Array; + + if (aKeys.length !== bKeys.length) { + return false; + } + + return aKeys.every(key => a[key] === b[key]); +} + +const HighlightedLine = memo( + function HighlightedLine({ + code, + mode, + isDark = true, + textStyle, + lineHeight, + hide, + }: HighlightedLineProps) { + const style = { + minHeight: lineHeight, + flex: 1, + opacity: hide ? 0 : 1, + lineHeight, + }; + return ( + + {code.length === 0 ? ( + '\u200B' + ) : ( + + {code} + + )} + + ); + }, + (prev, next) => { + return ( + prev.code === next.code && + prev.mode === next.mode && + prev.lineHeight === next.lineHeight && + shallowEqualTextStyle(prev.textStyle, next.textStyle) + ); + }, +); + +function splitLines(value: string): string[] { + return value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); +} + +export function useStableLineModels(value: string): LineModel[] { + const previousRef = useRef<{ + lines: string[]; + models: LineModel[]; + } | null>(null); + + const nextIdRef = useRef(0); + + return useMemo(() => { + const newLines = splitLines(value); + const previous = previousRef.current; + + if (!previous) { + const models = newLines.map(line => ({ + id: `line_${nextIdRef.current++}`, + code: line, + })); + + previousRef.current = { + lines: newLines, + models, + }; + + return models; + } + + const oldLines = previous.lines; + const oldModels = previous.models; + + let prefix = 0; + + while ( + prefix < oldLines.length && + prefix < newLines.length && + oldLines[prefix] === newLines[prefix] + ) { + prefix += 1; + } + + let oldSuffix = oldLines.length - 1; + let newSuffix = newLines.length - 1; + + while ( + oldSuffix >= prefix && + newSuffix >= prefix && + oldLines[oldSuffix] === newLines[newSuffix] + ) { + oldSuffix -= 1; + newSuffix -= 1; + } + + const models: LineModel[] = []; + + for (let i = 0; i < prefix; i += 1) { + models.push(oldModels[i]); + } + + for (let i = prefix; i <= newSuffix; i += 1) { + models.push({ + id: `line_${nextIdRef.current++}`, + code: newLines[i], + }); + } + + const suffixCount = oldLines.length - 1 - oldSuffix; + + for (let i = suffixCount; i > 0; i -= 1) { + const oldIndex = oldLines.length - i; + models.push(oldModels[oldIndex]); + } + + previousRef.current = { + lines: newLines, + models, + }; + + return models; + }, [value]); +} + +function extractOpacityStyle(style: StyleProp): { + opacity: AnimatableNumericValue; +} { + const flat = StyleSheet.flatten(style) ?? {}; + + return { + opacity: flat.opacity ?? 1, + }; +} + +function extractTextStyle(style: StyleProp): TextStyle { + const flat = StyleSheet.flatten(style) ?? {}; + + return { + color: '#abb2bf', + fontFamily: flat.fontFamily, + fontSize: flat.fontSize, + fontStyle: flat.fontStyle, + fontWeight: flat.fontWeight, + letterSpacing: flat.letterSpacing, + lineHeight: flat.lineHeight, + }; +} + +function extractContentPadding(style: StyleProp): ViewStyle { + const flat = StyleSheet.flatten(style) ?? {}; + + return { + padding: flat.padding, + paddingBottom: flat.paddingBottom, + paddingEnd: flat.paddingEnd, + paddingHorizontal: flat.paddingHorizontal, + paddingLeft: flat.paddingLeft, + paddingRight: flat.paddingRight, + paddingStart: flat.paddingStart, + paddingTop: flat.paddingTop, + paddingVertical: flat.paddingVertical, + }; +} + +function getLineHeight(style: StyleProp): number { + const flat = StyleSheet.flatten(style) ?? {}; + + if (typeof flat.lineHeight === 'number') { + return flat.lineHeight; + } + + if (typeof flat.fontSize === 'number') { + return Math.ceil(flat.fontSize * 1.4); + } + + return 20; +} + +type _MemoizedHighlightedCode = (Lines extends false + ? { value: string; lines?: never } + : { lines: LineModel[]; value?: never }) & { + mode: SupportedMode; + style?: StyleProp; + hide?: boolean; + isDark?: boolean; + setLines?: (num: number) => void; + startLine?: number; +}; +export type MemoizedHighlightedCode = + | _MemoizedHighlightedCode + | _MemoizedHighlightedCode; + +export function MemoizedHighlightedCode({ + lines, + value, + mode, + style, + hide, + setLines, + isDark = false, + startLine = 0, +}: MemoizedHighlightedCode) { + const opacityStyle = useMemo(() => extractOpacityStyle(style), [style]); + const textStyle = useMemo(() => extractTextStyle(style), [style]); + const contentPadding = useMemo(() => extractContentPadding(style), [style]); + const lineHeight = useMemo(() => getLineHeight(style), [style]); + if (!lines) { + // Value and lines prop can't be switched + // eslint-disable-next-line react-hooks/rules-of-hooks + lines = useStableLineModels(value!); + setLines?.(lines.length); + } + return ( + + {lines.map((line, i) => ( + + + {i + 1 + startLine} + + + + + ))} + + ); +} + +export function SimpleCodeEditor({ + highlightMode = 'combined', + onChangeText, + containerStyle, + scrollEnabled = true, + lines, + value, + mode, + style, + isDark, + setLines, + startLine, + ...props +}: SimpleCodeEditorProps & Omit) { + const hideHighlight = highlightMode === 'off'; + const showInput = highlightMode !== 'on'; + const scrollY = useRef(new Animated.Value(0)).current; + + const highlightedCodeProps = { + lines, + value, + mode, + style, + hide: hideHighlight, + isDark, + setLines, + startLine, + }; + + const negativeScrollY = useMemo(() => { + return Animated.multiply(scrollY, -1); + }, [scrollY]); + + const handleChangeText = useCallback( + (text: string) => { + onChangeText?.(text); + }, + [onChangeText], + ); + + const color = { + color: showInput ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.01)', + }; + + return ( + + + {/*@ts-expect-error*/} + + + + + ); +} + +const FONT_SIZE = 14; +const LINE_HEIGHT = Math.ceil(FONT_SIZE * PixelRatio.getFontScale() * 1.2); +const styles = StyleSheet.create({ + container: { + position: 'relative', + overflow: 'hidden', + }, + highlightLayer: { + zIndex: 0, + }, + input: { + zIndex: 1, + backgroundColor: 'transparent', + width: 'auto', + textAlignVertical: 'top', + marginLeft: 32, + }, + inputVisible: { + color: '#abb2bf', + }, + androidInput: { + includeFontPadding: false, + }, + lines: { + textAlign: 'right', + width: 32, + fontSize: FONT_SIZE, + lineHeight: LINE_HEIGHT, + height: '100%', + fontFamily: 'monospace', + margin: 0, + marginBottom: 0, + marginTop: 0, + padding: 0, + paddingRight: 4, + paddingBottom: 0, + paddingTop: 0, + }, + lineContainer: { width: '100%', position: 'relative' }, +}); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/Snippet.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/Snippet.tsx new file mode 100644 index 0000000000..054716a447 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/Snippet.tsx @@ -0,0 +1,70 @@ +import { IconButtonV2, SwitchItem } from '@components'; +import { useTheme } from '@hooks/persisted'; +import { CodeSnippet } from '@utils/customCode'; +import { memo } from 'react'; +import { StyleSheet, View } from 'react-native'; + +function Snippet({ + delete: _delete, + edit, + rename, + snippet, + index, + toggle, +}: { + delete: (index: number, isJS: boolean) => void; + edit: (index: number, isJS: boolean) => void; + rename: (index: number, isJS: boolean, name: string) => void; + toggle: (index: number, isJS: boolean) => void; + index: number; + snippet: CodeSnippet; +}) { + const theme = useTheme(); + const isJs = snippet.lang === 'js'; + return ( + + toggle(index, isJs)} + onLongPress={() => rename(index, isJs, snippet.name)} + theme={theme} + style={styles.switchItem} + /> + + edit(index, isJs)} + theme={theme} + /> + _delete(index, isJs)} + theme={theme} + /> + + + ); +} +export default memo(Snippet); + +const styles = StyleSheet.create({ + snippetRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + }, + switchItem: { + flex: 1, + paddingHorizontal: 0, + }, + actionButtons: { + flexDirection: 'row', + gap: 8, + marginLeft: 8, + }, +}); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx b/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx new file mode 100644 index 0000000000..5d54e87983 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx @@ -0,0 +1,303 @@ +import { AnimatedIconButton, List } from '@components'; +import { getString } from '@strings/translations'; +import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal'; +import { useBoolean } from '@hooks/index'; +import { FlashList } from '@shopify/flash-list'; +import { LinearGradient } from 'expo-linear-gradient'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { + TextInput as RNTextInput, + StyleSheet, + useWindowDimensions, +} from 'react-native'; +import { TextInput } from 'react-native-paper'; +import Animated, { + FadeIn, + FadeOut, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { + LIST_ITEM_HEIGHT, + RemoveItem, + ReplaceItem, +} from '../Components/ListItems'; +import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; + +const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient); +const LIST_CLOSED_HEIGHT = LIST_ITEM_HEIGHT * 3; + +type ReplaceItemModalProps = { + showReplace?: boolean; + listExpanded: boolean; + toggleList: () => void; +}; + +const ReplaceItemModal = ({ + showReplace = false, + listExpanded = false, + toggleList, +}: ReplaceItemModalProps) => { + const theme = useTheme(); + const { height: windowHeight } = useWindowDimensions(); + const modal = useBoolean(false); + const { + setChapterReaderSettings: setSettings, + replaceText, + removeText, + } = useChapterReaderSettings(); + const replaceArray = useMemo(() => { + return Object.entries(replaceText); + }, [replaceText]); + + const arrayLength = showReplace ? replaceArray.length : removeText.length; + + const textRef = useRef(null); + const replaceTextRef = useRef(null); + + const [text, setText] = React.useState(''); + const [replacementText, setReplacementText] = React.useState(''); + const [editing, setEditing] = React.useState(); + const [error, setError] = React.useState<[string, string] | undefined>(); + const listSize = useSharedValue( + Math.min(LIST_CLOSED_HEIGHT, arrayLength * LIST_ITEM_HEIGHT), + ); + const iconRotation = useSharedValue(0); + + const cancel = () => { + setError(undefined); + textRef.current?.clear(); + setText(''); + setEditing(undefined); + if (showReplace) { + replaceTextRef.current?.clear(); + setReplacementText(''); + } + }; + + const save = () => { + if (!text || (showReplace && !replacementText)) { + const e: [string, string] = ['', '']; + if (!text) { + e[0] = getString('customCodeSettings.enterAMatch'); + } + if (!replacementText) { + e[1] = getString('customCodeSettings.enterAReplace'); + } + setError(e); + return false; + } + + if (showReplace) { + if (editing && editing !== text) delete replaceText[editing]; + replaceText[text] = replacementText; + setSettings({ replaceText: replaceText }); + } else { + if (editing) { + const i = removeText.findIndex(v => v === editing); + removeText[i] = text; + } else if (!removeText.includes(text)) { + removeText.push(text); + } else { + setError([getString('customCodeSettings.itemAlreadyExists'), '']); + return false; + } + setSettings({ removeText: removeText }); + } + cancel(); + modal.setFalse(); + return true; + }; + + const removeItem = useCallback( + (identifier: string | number) => { + if (showReplace) { + delete replaceText[identifier]; + setSettings({ replaceText: replaceText }); + } else { + removeText.splice(identifier as number, 1); + setSettings({ removeText: removeText }); + } + }, + [removeText, replaceText, setSettings, showReplace], + ); + + const editItem = useCallback( + (item: string[]) => { + setEditing(item[0]); + setText(item[0]); + if (showReplace) { + setReplacementText(item[1]); + } + modal.setTrue(); + }, + [modal, showReplace], + ); + + const colorTheme = useMemo(() => { + return { colors: theme }; + }, [theme]); + + const calcListSize = useCallback( + (toggle: boolean = true) => { + if (toggle) { + toggleList(); + iconRotation.value = listExpanded ? 0 : 180; + } + if (listExpanded) { + listSize.value = Math.min( + windowHeight * 0.6, + arrayLength * LIST_ITEM_HEIGHT, + ); + } else { + listSize.value = Math.min( + LIST_CLOSED_HEIGHT, + arrayLength * LIST_ITEM_HEIGHT, + ); + } + }, + [ + arrayLength, + iconRotation, + listExpanded, + listSize, + toggleList, + windowHeight, + ], + ); + useEffect(() => { + calcListSize(false); + }, [replaceArray, removeText, calcListSize]); + useEffect(() => { + iconRotation.value = !listExpanded ? 0 : 180; + }, [iconRotation, listExpanded]); + + const animatedListSize = useAnimatedStyle(() => ({ + height: withTiming(listSize.value, { duration: 250 }), + overflow: 'hidden', + position: 'relative', + })); + + return ( + <> + + + {arrayLength <= 3 || listExpanded ? null : ( + calcListSize()} + /> + )} + {showReplace ? ( + ( + + )} + /> + ) : ( + ( + + )} + /> + )} + + {arrayLength > 3 ? ( + + ) : null} + { + modal.setFalse(); + setError(undefined); + }} + onSave={save} + onCancel={cancel} + title={getString('customCodeSettings.editReplace')} + > + + {!showReplace ? null : ( + + )} + + + ); +}; + +export default ReplaceItemModal; + +const styles = StyleSheet.create({ + textfield: { + marginBottom: 16, + }, + bottom: { + marginBottom: 24, + }, + marginHorizontal: { + marginHorizontal: 16, + }, + gradient: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + zIndex: 1, + }, +}); diff --git a/src/screens/settings/SettingsCustomCodeScreen/SnippetEditor.tsx b/src/screens/settings/SettingsCustomCodeScreen/SnippetEditor.tsx new file mode 100644 index 0000000000..881d68c38c --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/SnippetEditor.tsx @@ -0,0 +1,180 @@ +import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal'; +import { getString } from '@strings/translations'; +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import CodeInput from './Components/CodeInput'; +import { showToast } from '@utils/showToast'; +import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; +import { TextInput as PaperTextInput } from 'react-native-paper'; + +import { useNavigation } from '@react-navigation/native'; +import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; +import { IconButtonV2 } from '@components'; +import type { HighlightMode } from './Components/SimpleCodeEditor'; +export type SnippetEditorHandle = { + save: () => void; + setCode: (val: string) => void; +}; + +type SnippetEditorProps = { + snippetIndex?: number; + language: 'css' | 'js'; +}; + +const SnippetEditor = React.forwardRef( + ({ snippetIndex, language }, ref) => { + const navigation = useNavigation(); + const theme = useTheme(); + const { + codeSnippetsJS, + codeSnippetsCSS, + setChapterReaderSettings: setSettings, + } = useChapterReaderSettings(); + + const isEditing = snippetIndex !== undefined && snippetIndex >= 0; + const snippets = language === 'js' ? codeSnippetsJS : codeSnippetsCSS; + const snippet = isEditing ? snippets[snippetIndex!] : null; + + const [code, setCode] = React.useState(snippet?.code ?? ''); + const [error, setError] = React.useState({ code: false }); + const [highlightMode, setHighlightMode] = + React.useState('combined'); + const [snippetName, setSnippetName] = React.useState(''); + + const [showNameModal, setShowNameModal] = React.useState(false); + + const save = React.useCallback(() => { + setError({ code: false }); + if (!code.trim()) { + setError({ code: true }); + return; + } + if (isEditing) { + const newSnippets = [...snippets]; + newSnippets[snippetIndex!].code = code; + setSettings({ + [language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']: + newSnippets, + }); + showToast(getString('customCodeSettings.snippetUpdated')); + navigation.goBack(); + } else { + setShowNameModal(true); + } + }, [ + code, + snippets, + setSettings, + isEditing, + snippetIndex, + language, + navigation, + ]); + const handleNameModalSave = React.useCallback(() => { + if (!snippetName.trim()) return false; + const newSnippets = [...snippets]; + newSnippets.push({ + name: snippetName.trim(), + code, + active: true, + lang: language, + }); + setSettings({ + [language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']: newSnippets, + }); + showToast(getString('customCodeSettings.snippetSaved')); + setShowNameModal(false); + navigation.goBack(); + return true; + }, [snippetName, code, language, snippets, setSettings, navigation]); + + const handleNameModalCancel = React.useCallback(() => { + setShowNameModal(false); + setSnippetName(''); + }, []); + + React.useImperativeHandle(ref, () => ({ save, setCode }), [save, setCode]); + + return ( + <> + + + setHighlightMode(prev => + prev === 'off' + ? 'combined' + : prev === 'combined' + ? 'on' + : 'off', + ) + } + style={{ position: 'absolute', end: 8, top: 8, zIndex: 2 }} + /> + + + + + + + + + ); + }, +); + +export default React.memo(SnippetEditor); + +const styles = StyleSheet.create({ + flexGrow: { flexGrow: 1 }, + mb16: { marginBottom: 16 }, + toolbar: { + flexDirection: 'row', + justifyContent: 'flex-end', + paddingHorizontal: 8, + paddingVertical: 4, + }, + scrollContainer: { + paddingHorizontal: 2, + }, + scrollContent: {}, + button: { + marginHorizontal: 8, + flexBasis: '40%', + flex: 1, + }, +}); diff --git a/src/screens/settings/SettingsCustomCodeScreen/index.tsx b/src/screens/settings/SettingsCustomCodeScreen/index.tsx new file mode 100644 index 0000000000..826a7b60be --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/index.tsx @@ -0,0 +1,235 @@ +import { Appbar, List, SafeAreaView } from '@components'; +import { CustomCodeSettingsScreenProps } from '@navigators/types'; +import React from 'react'; +import { Keyboard, ScrollView, StyleSheet, View } from 'react-native'; +import { TextInput } from 'react-native-paper'; +import KeyboardAvoidingModal from '@components/Modal/KeyboardAvoidingModal'; +import ReplaceItemModal from './Modals/ReplaceItemModal'; +import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; +import { getString } from '@strings/translations'; +import Snippet from './Components/Snippet'; + +const SettingsCustomCode = ({ navigation }: CustomCodeSettingsScreenProps) => { + const theme = useTheme(); + const { + codeSnippetsJS, + codeSnippetsCSS, + setChapterReaderSettings: setSettings, + } = useChapterReaderSettings(); + const [renameSnippet, setRenameSnippet] = React.useState<{ + index: number; + isJS: boolean; + name: string; + } | null>(null); + const [extended, setExtended] = React.useState([false, false, false, false]); + + const toggleExtended = React.useCallback( + (index: number) => { + const newExtended = [false, false, false, false]; + newExtended[index] = !extended[index]; + setExtended(newExtended); + }, + [extended], + ); + + const toggleSnippet = React.useCallback( + (index: number, isJS: boolean) => { + const snippets = isJS ? [...codeSnippetsJS] : [...codeSnippetsCSS]; + snippets[index].active = !snippets[index].active; + setSettings({ + [isJS ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets, + }); + }, + [codeSnippetsJS, codeSnippetsCSS, setSettings], + ); + + const deleteSnippet = React.useCallback( + (index: number, isJS: boolean) => { + const snippets = isJS ? [...codeSnippetsJS] : [...codeSnippetsCSS]; + snippets.splice(index, 1); + setSettings({ + [isJS ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets, + }); + }, + [codeSnippetsJS, codeSnippetsCSS, setSettings], + ); + + const handleEditSnippet = (snippetIndex: number, isJS: boolean) => { + navigation.navigate('CodeSnippets', { + snippetIndex, + isJS, + }); + }; + + const handleRenameSave = React.useCallback(() => { + if (!renameSnippet || !renameSnippet.name.trim()) return false; + const snippets = renameSnippet.isJS + ? [...codeSnippetsJS] + : [...codeSnippetsCSS]; + snippets[renameSnippet.index].name = renameSnippet.name.trim(); + setSettings({ + [renameSnippet.isJS ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets, + }); + setRenameSnippet(null); + return true; + }, [renameSnippet, codeSnippetsJS, codeSnippetsCSS, setSettings]); + + const handleRenameCancel = React.useCallback(() => { + setRenameSnippet(null); + }, []); + + return ( + + { + Keyboard.dismiss(); + navigation.goBack(); + }} + theme={theme} + /> + + + + {getString('customCodeSettings.textManipulation')} + + toggleExtended(0)} + listExpanded={extended[0]} + /> + toggleExtended(1)} + listExpanded={extended[1]} + /> + + + {getString('customCodeSettings.codeSnippets')} + + {/* CSS Snippets */} + + + {getString('customCodeSettings.cssSnippets')} + + + {codeSnippetsCSS.length > 0 && + codeSnippetsCSS.map((snippet, index) => ( + + setRenameSnippet({ + index, + isJS, + name, + }) + } + edit={handleEditSnippet} + delete={deleteSnippet} + index={index} + snippet={snippet} + /> + ))} + handleEditSnippet(-1, false)} + /> + {codeSnippetsCSS.length === 0 && ( + + )} + + {/* JS Snippets */} + + + {getString('customCodeSettings.javascriptSnippets')} + + + {codeSnippetsJS.length > 0 && + codeSnippetsJS.map((snippet, index) => ( + + setRenameSnippet({ + index, + isJS, + name, + }) + } + edit={handleEditSnippet} + delete={deleteSnippet} + index={index} + snippet={snippet} + /> + ))} + handleEditSnippet(-1, true)} + /> + {codeSnippetsJS.length === 0 && ( + + )} + + + + { + if (renameSnippet) { + setRenameSnippet({ ...renameSnippet, name: text }); + } + }} + autoFocus + mode="outlined" + style={styles.mb16} + theme={{ colors: theme }} + /> + + + ); +}; + +export default SettingsCustomCode; + +const styles = StyleSheet.create({ + mb16: { marginBottom: 16 }, + paddingBottom: { paddingBottom: 40 }, + subSubHeader: { + fontSize: 14, + marginTop: 8, + marginBottom: 4, + }, + snippetRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + }, + switchItem: { + flex: 1, + paddingHorizontal: 0, + }, + actionButtons: { + flexDirection: 'row', + gap: 8, + marginLeft: 8, + }, +}); diff --git a/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx b/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx index 6cad220489..be68fd2846 100644 --- a/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx +++ b/src/screens/settings/SettingsReaderScreen/SettingsReaderScreen.tsx @@ -1,24 +1,16 @@ -import { View, StatusBar, StyleSheet, useWindowDimensions } from 'react-native'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { View, StyleSheet, useWindowDimensions } from 'react-native'; +import React, { useEffect, useRef, useState } from 'react'; import { BottomSheetModal } from '@gorhom/bottom-sheet'; import { useNavigation } from '@react-navigation/native'; -import WebView from 'react-native-webview'; import { FAB } from 'react-native-paper'; -import { dummyHTML } from './utils'; import { Appbar, SafeAreaView } from '@components/index'; import BottomSheet from '@components/BottomSheet/BottomSheet'; -import { - useChapterGeneralSettings, - useChapterReaderSettings, - useTheme, -} from '@hooks/persisted'; +import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; import { getString } from '@strings/translations'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import color from 'color'; -import { useBatteryLevel } from 'react-native-device-info'; import * as Speech from 'expo-speech'; import TabBar, { Tab } from './components/TabBar'; @@ -26,7 +18,7 @@ import DisplayTab from './tabs/DisplayTab'; import ThemeTab from './tabs/ThemeTab'; import NavigationTab from './tabs/NavigationTab'; import AccessibilityTab from './tabs/AccessibilityTab'; -import AdvancedTab from './tabs/AdvancedTab'; +import SettingsReaderWebView from './components/SettingsReaderWebView'; export type TextAlignments = | 'left' @@ -36,15 +28,9 @@ export type TextAlignments = | 'justify' | undefined; -type WebViewPostEvent = { - type: string; - data?: { [key: string]: string | number }; -}; - const SettingsReaderScreen = () => { const theme = useTheme(); const navigation = useNavigation(); - const webViewRef = useRef(null); const bottomSheetRef = useRef(null); const { bottom, right } = useSafeAreaInsets(); const { height: screenHeight } = useWindowDimensions(); @@ -55,88 +41,10 @@ const SettingsReaderScreen = () => { { id: 'theme', label: 'Theme', icon: 'palette-outline' }, { id: 'navigation', label: 'Navigation', icon: 'gesture-swipe-horizontal' }, { id: 'accessibility', label: 'Accessibility', icon: 'account-voice' }, - { id: 'advanced', label: 'Advanced', icon: 'code-braces' }, ]; - const novel = { - 'artist': null, - 'author': 'LNReader-kun', - 'cover': - 'file:///storage/emulated/0/Android/data/com.rajarsheechatterjee.LNReader/files/Novels/lightnovelcave/16/cover.png?1717862123181', - 'genres': 'Action,Hero', - 'id': 16, - 'inLibrary': 1, - 'isLocal': 0, - 'name': 'Preview Man (LN)', - 'path': 'novel/preview-man-16091321', - 'pluginId': 'lightnovelcave', - 'status': 'Ongoing', - 'summary': - 'To preview or not preview. A question that bothered humanity for a long time, until one day… Preview Man appeared.Show More', - 'totalPages': 8, - }; - const chapter = { - 'bookmark': 0, - 'chapterNumber': 1, - 'id': 3722, - 'isDownloaded': 1, - 'name': 'Chapter 1 - The rise of Preview Man', - 'novelId': 16, - 'page': '2', - 'path': 'novel/preview-man/chapter-1', - 'position': 0, - 'progress': 3, - 'readTime': '2100-01-01 00:00:00', - 'releaseTime': 'January 1, 2100', - 'unread': 1, - 'updatedTime': null, - }; - const [hidden, setHidden] = useState(true); - const batteryLevel = useBatteryLevel(); const readerSettings = useChapterReaderSettings(); - const chapterGeneralSettings = useChapterGeneralSettings(); - const BOTTOM_SHEET_HEIGHT = screenHeight * 0.7; - const assetsUriPrefix = useMemo( - () => (__DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'), - [], - ); - const webViewCSS = ` - - - - - `; const readerBackgroundColor = readerSettings.theme; @@ -160,8 +68,6 @@ const SettingsReaderScreen = () => { return ; case 'accessibility': return ; - case 'advanced': - return ; default: return ; } @@ -181,96 +87,7 @@ const SettingsReaderScreen = () => { {/* Large Preview Area */} - { - const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data); - switch (event.type) { - case 'hide': - if (hidden) { - webViewRef.current?.injectJavaScript( - 'reader.hidden.val = true', - ); - } else { - webViewRef.current?.injectJavaScript( - 'reader.hidden.val = false', - ); - } - setHidden(!hidden); - break; - case 'speak': - if (event.data && typeof event.data === 'string') { - Speech.speak(event.data, { - onDone() { - webViewRef.current?.injectJavaScript('tts.next?.()'); - }, - voice: readerSettings.tts?.voice?.identifier, - pitch: readerSettings.tts?.pitch || 1, - rate: readerSettings.tts?.rate || 1, - }); - } else { - webViewRef.current?.injectJavaScript('tts.next?.()'); - } - break; - case 'stop-speak': - Speech.stop(); - break; - } - }} - source={{ - html: ` - - - - ${webViewCSS} - - -
- ${dummyHTML} -
-
- - - - - - - - - - `, - }} - /> +
{/* Floating Action Button to Open Bottom Sheet */} @@ -355,7 +172,4 @@ const styles = StyleSheet.create({ previewContainer: { flex: 1, }, - webView: { - flex: 1, - }, }); diff --git a/src/screens/settings/SettingsReaderScreen/components/SettingsReaderWebView.tsx b/src/screens/settings/SettingsReaderScreen/components/SettingsReaderWebView.tsx new file mode 100644 index 0000000000..f7a098cd62 --- /dev/null +++ b/src/screens/settings/SettingsReaderScreen/components/SettingsReaderWebView.tsx @@ -0,0 +1,261 @@ +import { StatusBar, StyleSheet } from 'react-native'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import WebView from 'react-native-webview'; +import { dummyHTML } from './dummy'; + +import { + useChapterGeneralSettings, + useChapterReaderSettings, + useTheme, +} from '@hooks/persisted'; +import { getString } from '@strings/translations'; + +import color from 'color'; +import { useBatteryLevel } from 'react-native-device-info'; +import * as Speech from 'expo-speech'; +import { + composeCSS, + composeJS, + applyTextModifications, +} from '@utils/customCode'; + +type WebViewPostEvent = { + type: string; + data?: { [key: string]: string | number }; + msg?: string; +}; + +const SettingsReaderWebView = ({ onPress }: { onPress?: () => void } = {}) => { + const theme = useTheme(); + const webViewRef = useRef(null); + + const novel = { + 'artist': null, + 'author': 'LNReader-kun', + 'cover': + 'file:///storage/emulated/0/Android/data/com.rajarsheechatterjee.LNReader/files/Novels/lightnovelcave/16/cover.png?1717862123181', + 'genres': 'Action,Hero', + 'id': 16, + 'inLibrary': 1, + 'isLocal': 0, + 'name': 'Preview Man (LN)', + 'path': 'novel/preview-man-16091321', + 'pluginId': 'lightnovelcave', + 'status': 'Ongoing', + 'summary': + 'To preview or not preview. A question that bothered humanity for a long time, until one day… Preview Man appeared.Show More', + 'totalPages': 8, + }; + const chapter = { + 'bookmark': 0, + 'chapterNumber': 1, + 'id': 3722, + 'isDownloaded': 1, + 'name': 'Chapter 1 - The rise of Preview Man', + 'novelId': 16, + 'page': '2', + 'path': 'novel/preview-man/chapter-1', + 'position': 0, + 'progress': 3, + 'readTime': '2100-01-01 00:00:00', + 'releaseTime': 'January 1, 2100', + 'unread': 1, + 'updatedTime': null, + }; + const [hidden, setHidden] = useState(true); + const batteryLevel = useBatteryLevel(); + const readerSettings = useChapterReaderSettings(); + const chapterGeneralSettings = useChapterGeneralSettings(); + + const customJS = useMemo( + () => composeJS(readerSettings.codeSnippetsJS), + [readerSettings.codeSnippetsJS], + ); + + const customCSS = useMemo( + () => composeCSS(readerSettings.codeSnippetsCSS), + [readerSettings.codeSnippetsCSS], + ); + + const assetsUriPrefix = useMemo( + () => (__DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'), + [], + ); + const webViewCSS = ` + + + + + + + + `; + + const readerBackgroundColor = readerSettings.theme; + + useEffect(() => { + return () => { + Speech.stop(); + }; + }, []); + + const preparedDummyHTML = useMemo( + () => + applyTextModifications( + dummyHTML, + readerSettings.removeText, + readerSettings.replaceText, + ), + [readerSettings.removeText, readerSettings.replaceText], + ); + + return ( + { + const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data); + switch (event.type) { + case 'hide': + onPress?.(); + if (hidden) { + webViewRef.current?.injectJavaScript('reader.hidden.val = true'); + } else { + webViewRef.current?.injectJavaScript('reader.hidden.val = false'); + } + setHidden(!hidden); + break; + case 'speak': + if (event.data && typeof event.data === 'string') { + Speech.speak(event.data, { + onDone() { + webViewRef.current?.injectJavaScript('tts.next?.()'); + }, + voice: readerSettings.tts?.voice?.identifier, + pitch: readerSettings.tts?.pitch || 1, + rate: readerSettings.tts?.rate || 1, + }); + } else { + webViewRef.current?.injectJavaScript('tts.next?.()'); + } + break; + case 'stop-speak': + Speech.stop(); + break; + case 'console': + /* eslint-disable no-console */ + console.info(`[Console] ${JSON.stringify(event.msg, null, 2)}`); + } + }} + source={{ + html: ` + + + + ${webViewCSS} + + +
+ ${preparedDummyHTML} +
+
+ + + + + + + + + + + + `, + }} + /> + ); +}; + +export default SettingsReaderWebView; + +const styles = StyleSheet.create({ + webView: { + flex: 1, + }, +}); diff --git a/src/screens/settings/SettingsReaderScreen/utils.ts b/src/screens/settings/SettingsReaderScreen/components/dummy.ts similarity index 100% rename from src/screens/settings/SettingsReaderScreen/utils.ts rename to src/screens/settings/SettingsReaderScreen/components/dummy.ts diff --git a/src/screens/settings/SettingsReaderScreen/tabs/AdvancedTab.tsx b/src/screens/settings/SettingsReaderScreen/tabs/AdvancedTab.tsx deleted file mode 100644 index 5f416bf76c..0000000000 --- a/src/screens/settings/SettingsReaderScreen/tabs/AdvancedTab.tsx +++ /dev/null @@ -1,392 +0,0 @@ -import React, { useState } from 'react'; -import { - View, - StyleSheet, - Text, - Pressable, - KeyboardAvoidingView, - Platform, -} from 'react-native'; -import { BottomSheetScrollView } from '@gorhom/bottom-sheet'; -import { TextInput, Portal } from 'react-native-paper'; -import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; -import * as DocumentPicker from 'expo-document-picker'; -import NativeFile from '@specs/NativeFile'; -import { useTheme, useChapterReaderSettings } from '@hooks/persisted'; -import { getString } from '@strings/translations'; -import { ThemeColors } from '@theme/types'; -import { Button, ConfirmationDialog } from '@components/index'; -import { showToast } from '@utils/showToast'; -import { useBoolean } from '@hooks'; - -type CodeTab = 'css' | 'js'; - -const AdvancedTab: React.FC = () => { - const theme = useTheme(); - const styles = createStyles(theme); - const { customCSS, customJS, setChapterReaderSettings } = - useChapterReaderSettings(); - - const [activeCodeTab, setActiveCodeTab] = useState('css'); - const [cssValue, setCssValue] = useState(customCSS || ''); - const [jsValue, setJsValue] = useState(customJS || ''); - - const clearCSSModal = useBoolean(); - const clearJSModal = useBoolean(); - - const customCSSPlaceholder = `/* Custom CSS for your reader */ - -body { - margin: 16px; - line-height: 1.8; -} - -h1, h2, h3 { - margin-top: 1.5em; - margin-bottom: 0.5em; - font-weight: bold; -} - -p { - text-indent: 1em; - margin-bottom: 1em; -} - -/* Target specific sources */ -#sourceId-example { - font-family: serif; -}`; - - const customJSPlaceholder = `// Custom JavaScript for your reader -// Available variables: -// - html, novelName, chapterName -// - sourceId, chapterId, novelId - -// Example: Remove elements -document.querySelectorAll('.ads').forEach(el => el.remove()); - -// Example: Modify content -const title = document.querySelector('h1'); -if (title) { - title.style.color = '#FF6B6B'; -}`; - - const handleSave = () => { - if (activeCodeTab === 'css') { - setChapterReaderSettings({ customCSS: cssValue }); - } else { - setChapterReaderSettings({ customJS: jsValue }); - } - showToast('Saved'); - }; - - const handleReset = () => { - if (activeCodeTab === 'css') { - clearCSSModal.setTrue(); - } else { - clearJSModal.setTrue(); - } - }; - - const confirmResetCSS = () => { - setCssValue(''); - setChapterReaderSettings({ customCSS: '' }); - clearCSSModal.setFalse(); - }; - - const confirmResetJS = () => { - setJsValue(''); - setChapterReaderSettings({ customJS: '' }); - clearJSModal.setFalse(); - }; - - const handleImport = async () => { - try { - const mimeType = - activeCodeTab === 'css' ? 'text/css' : 'application/javascript'; - const file = await DocumentPicker.getDocumentAsync({ - copyToCacheDirectory: false, - type: mimeType, - }); - - if (file.assets) { - const tempPath = - NativeFile.getConstants().ExternalCachesDirectoryPath + - '/imported_custom.' + - activeCodeTab; - NativeFile.copyFile(file.assets[0].uri, tempPath); - const content = NativeFile.readFile(tempPath); - NativeFile.unlink(tempPath); - - if (activeCodeTab === 'css') { - setCssValue(content.trim()); - setChapterReaderSettings({ customCSS: content.trim() }); - } else { - setJsValue(content.trim()); - setChapterReaderSettings({ customJS: content.trim() }); - } - showToast('Imported'); - } - } catch (error: any) { - showToast(error.message); - } - }; - - return ( - - - {/* Tab Selector */} - - setActiveCodeTab('css')} - android_ripple={{ color: theme.rippleColor }} - > - - - CSS - - - - setActiveCodeTab('js')} - android_ripple={{ color: theme.rippleColor }} - > - - - JS - - - - - {/* Code Editor */} - - - activeCodeTab === 'css' ? setCssValue(text) : setJsValue(text) - } - placeholder={ - activeCodeTab === 'css' - ? customCSSPlaceholder - : customJSPlaceholder - } - multiline - numberOfLines={12} - autoCorrect={false} - autoCapitalize="none" - spellCheck={false} - style={[styles.codeEditor, { backgroundColor: theme.surface2 }]} - activeUnderlineColor={theme.primary} - contentStyle={styles.codeEditorContent} - textColor={theme.onSurface} - placeholderTextColor={theme.onSurfaceVariant} - /> - - - {/* Hint */} - - - - {activeCodeTab === 'css' - ? getString('readerSettings.cssHint') - : getString('readerSettings.jsHint')} - - - - {/* Action Buttons */} - -