diff --git a/App.tsx b/App.tsx index df4f9a1b..25ab60a5 100644 --- a/App.tsx +++ b/App.tsx @@ -22,6 +22,7 @@ import { useInitializeAppServices } from '@hooks/common/useInitializeAppServices import { opSqliteAdapter } from './src/rozenite/opSqliteAdapter'; import { useRozeniteSqlitePlugin } from '@rozenite/sqlite-plugin'; import { ThemeProvider, useTheme } from '@hooks/persisted/useTheme'; +import { KeyboardProvider } from 'react-native-keyboard-controller'; enableFreeze(true); const sqliteAdapters = __DEV__ && opSqliteAdapter ? [opSqliteAdapter] : []; @@ -91,18 +92,23 @@ const App = () => { return ( - + - - - - - - - - - - + + + + + + + + + + + + diff --git a/assets/reader/js/textRemover.js b/assets/reader/js/textRemover.js new file mode 100644 index 00000000..75fd1c33 --- /dev/null +++ b/assets/reader/js/textRemover.js @@ -0,0 +1,262 @@ +// 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: 0; + border-radius: 4px; + font-size: 12px; + `, + onclick: e => { + if (reader.hidden.val) { + e.stopPropagation(); + } + removeSelectedText(); + }, + }, + 'Remove', + ), + button( + { + style: ` + background: var(--theme-secondary); + color: var(--theme-onSecondary); + padding: 6px 12px; + margin: 2px; + border: 0; + 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 / 5) * 4) { + // Selection is in top 4/5, 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 1/5, 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(); + }); +})(); + +/** + * Directly remove text from the chapter DOM without reloading the WebView. + * Also updates reader.rawHTML so the text-options deriver (bionic reading, + * paragraph spacing) doesn't re-introduce removed text on its next run. + */ +window.textRemover.performRemove = function (text) { + const el = document.querySelector('#LNReader-chapter'); + if (!el) return; + const m = text.match(/^\/(.*)\/([gmiyuvsd]*)$/); + let result; + if (m) { + try { + result = el.innerHTML.replace(new RegExp(m[1], m[2]), ''); + } catch (_e) { + return; + } + } else { + result = el.innerHTML.split(text).join(''); + } + el.innerHTML = result; + reader.rawHTML = result; +}; + +/** + * Directly replace text in the chapter DOM without reloading the WebView. + * Also updates reader.rawHTML so the text-options deriver stays in sync. + */ +window.textRemover.performReplace = function (from, to) { + const el = document.querySelector('#LNReader-chapter'); + if (!el) return; + const m = from.match(/^\/(.*)\/([gmiyuvsd]*)$/); + let result; + if (m) { + try { + result = el.innerHTML.replace(new RegExp(m[1], m[2]), to); + } catch (_e) { + return; + } + } else { + result = el.innerHTML.split(from).join(to); + } + el.innerHTML = result; + reader.rawHTML = result; +}; diff --git a/package.json b/package.json index 364d8458..88f48fd1 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "react-native-edge-to-edge": "^1.8.1", "react-native-error-boundary": "^3.1.0", "react-native-gesture-handler": "^2.32.0", + "react-native-keyboard-controller": "^1.22.2", "react-native-mmkv": "^4.3.2", "react-native-pager-view": "^8.0.2", "react-native-paper": "^5.15.3", @@ -92,6 +93,7 @@ "react-native-url-polyfill": "^3.0.0", "react-native-webview": "^13.16.1", "react-native-worklets": "^0.10.0", + "react-syntax-highlighter": "^16.1.1", "sanitize-html": "^2.17.6", "urlencode": "^2.0.0", "zustand": "^5.0.14" @@ -111,6 +113,7 @@ "@types/jest": "^29.5.14", "@types/lodash-es": "^4.17.12", "@types/react": "~19.2.17", + "@types/react-syntax-highlighter": "^15.5.13", "@types/sanitize-html": "^2.16.1", "@typescript-eslint/eslint-plugin": "^8.64.0", "@typescript-eslint/parser": "^8.64.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c8eb106..221a72d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: react-native-gesture-handler: specifier: ^2.32.0 version: 2.32.0(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3) + react-native-keyboard-controller: + specifier: ^1.22.2 + version: 1.22.2(430c6ce90af62d66e63ca89ee6a684f0) react-native-mmkv: specifier: ^4.3.2 version: 4.3.2(react-native-nitro-modules@0.36.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3) @@ -179,6 +182,9 @@ importers: react-native-worklets: specifier: ^0.10.0 version: 0.10.2(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0) + react-syntax-highlighter: + specifier: ^16.1.1 + version: 16.1.1(react@19.2.3) sanitize-html: specifier: ^2.17.6 version: 2.17.6 @@ -231,6 +237,9 @@ importers: '@types/react': specifier: ~19.2.17 version: 19.2.17 + '@types/react-syntax-highlighter': + specifier: ^15.5.13 + version: 15.5.13 '@types/sanitize-html': specifier: ^2.16.1 version: 2.16.1 @@ -1850,6 +1859,9 @@ packages: '@types/hammerjs@2.0.46': resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -1880,6 +1892,12 @@ packages: '@types/node@26.1.1': resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + '@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-test-renderer@19.1.0': resolution: {integrity: sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==} @@ -1895,6 +1913,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==} @@ -2518,6 +2542,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==} @@ -2638,6 +2671,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -2781,6 +2817,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'} @@ -3528,6 +3567,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + 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'} @@ -3615,6 +3657,10 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -3765,6 +3811,12 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} 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@250829098.0.14: resolution: {integrity: sha512-5meXwsZxgiqFaJjNzwjzI9IyUkuGGBisu+z9BvQWmGVpjH6nz11hgqkyxe4dl8UAdyIV4lTbz91+Dlnjz0VxqA==} @@ -3806,6 +3858,12 @@ packages: hermes-parser@0.37.0: resolution: {integrity: sha512-8PuWcyaF6VHQHL2dbU+QEIkbtHYQrTX/6+ep4VcBLJ3f/ehevowr5Zj8LQfYXQQGFG2O+df7hIMtt4PkkhfK3Q==} + 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==} @@ -3924,6 +3982,12 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + 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'} @@ -3965,6 +4029,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'} @@ -4002,6 +4069,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-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -4504,6 +4574,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: 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==} @@ -4892,6 +4965,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'} @@ -5019,6 +5095,10 @@ packages: resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} 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} @@ -5037,6 +5117,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + protobufjs@8.7.1: resolution: {integrity: sha512-agdGHrXNTv0IrYscJPDou/PlEJk1c/hBZ9o/B5NH2i/nSPtPqacNxzgwf1CebXxFMjMrZH5sqv9uQuw96aGt/A==} engines: {node: '>=12.0.0'} @@ -5151,6 +5234,13 @@ packages: react: '*' react-native: '*' + react-native-keyboard-controller@1.22.2: + resolution: {integrity: sha512-O5NfkDCzNFJPkcyCNKdaRV9ouEeUNml/x5PMpKt3m5zkER0HM/qPCgg6ajpFieZSOUhZRrdUWd+966Abs2oERw==} + peerDependencies: + react: '*' + react-native: '*' + react-native-reanimated: '>=3.0.0' + react-native-linear-gradient@2.8.3: resolution: {integrity: sha512-KflAXZcEg54PXkLyflaSZQ3PJp4uC4whM7nT/Uot9m0e/qxFV3p6uor1983D1YOBJbJN7rrWdqIjq0T42jOJyA==} peerDependencies: @@ -5258,6 +5348,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.3: resolution: {integrity: sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw==} peerDependencies: @@ -5279,6 +5375,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'} @@ -5549,6 +5648,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'} @@ -8185,6 +8287,10 @@ snapshots: '@types/hammerjs@2.0.46': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -8220,6 +8326,12 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/prismjs@1.26.6': {} + + '@types/react-syntax-highlighter@15.5.13': + dependencies: + '@types/react': 19.2.17 + '@types/react-test-renderer@19.1.0': dependencies: '@types/react': 19.2.17 @@ -8236,6 +8348,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': @@ -8945,6 +9061,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 @@ -9081,6 +9203,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + commander@12.1.0: {} commander@2.20.3: {} @@ -9230,6 +9354,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: @@ -10104,6 +10232,10 @@ snapshots: fast-levenshtein@2.0.6: {} + fault@1.0.4: + dependencies: + format: 0.2.2 + fb-dotslash@0.5.8: {} fb-watchman@2.0.2: @@ -10209,6 +10341,8 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + format@0.2.2: {} + forwarded@0.2.0: {} freeport-async@2.0.0: {} @@ -10347,6 +10481,18 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + 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@250829098.0.14: {} hermes-eslint@0.37.0(eslint@9.39.5(jiti@2.7.0)(supports-color@9.4.0)): @@ -10386,6 +10532,10 @@ snapshots: dependencies: hermes-estree: 0.37.0 + highlight.js@10.7.3: {} + + highlightjs-vue@1.0.0: {} + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -10518,6 +10668,13 @@ snapshots: ipaddr.js@1.9.1: {} + 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.9 @@ -10566,6 +10723,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-decimal@2.0.1: {} + is-docker@2.2.1: {} is-document.all@1.0.0: @@ -10596,6 +10755,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -11321,6 +11482,11 @@ snapshots: dependencies: js-tokens: 4.0.0 + lowlight@1.20.0: + dependencies: + fault: 1.0.4 + highlight.js: 10.7.3 + lru-cache@10.4.3: {} lru-cache@11.5.2: {} @@ -11802,6 +11968,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.7 @@ -11920,6 +12096,8 @@ snapshots: react-is-18: react-is@18.3.1 react-is-19: react-is@19.2.7 + prismjs@1.30.0: {} + proc-log@4.2.0: {} progress@2.0.3: {} @@ -11939,6 +12117,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + property-information@7.2.0: {} + protobufjs@8.7.1: dependencies: long: 5.3.2 @@ -12068,6 +12248,13 @@ snapshots: react: 19.2.3 react-native: 0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0) + react-native-keyboard-controller@1.22.2(430c6ce90af62d66e63ca89ee6a684f0): + dependencies: + react: 19.2.3 + react-native: 0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3) + react-native-reanimated: 4.5.2(react-native-worklets@0.10.2(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3) + react-native-linear-gradient@2.8.3(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3): dependencies: react: 19.2.3 @@ -12220,6 +12407,16 @@ snapshots: react-refresh@0.14.2: {} + react-syntax-highlighter@16.1.1(react@19.2.3): + dependencies: + '@babel/runtime': 7.29.7 + highlight.js: 10.7.3 + highlightjs-vue: 1.0.0 + lowlight: 1.20.0 + prismjs: 1.30.0 + react: 19.2.3 + refractor: 5.0.0 + react-test-renderer@19.2.3(react@19.2.3): dependencies: react: 19.2.3 @@ -12250,6 +12447,13 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + refractor@5.0.0: + dependencies: + '@types/hast': 3.0.5 + '@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 @@ -12578,6 +12782,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 a2b45d2e..8e02960d 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 1b4a6b7a..ab32c427 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', @@ -24,6 +27,7 @@ interface ToggleButtonProps { theme: ThemeColors; color?: string; onPress: () => void; + disabled?: boolean; } export const ToggleButton: React.FC = ({ @@ -32,15 +36,17 @@ export const ToggleButton: React.FC = ({ theme, color, onPress, + disabled, }) => ( { + const React = require('react'); + const { View } = require('react-native'); + const MockIcon = (props: Record) => + React.createElement(View, { ...props, testID: 'icon' }); + MockIcon.displayName = 'MaterialCommunityIcons'; + return { __esModule: true, default: MockIcon }; +}); + +const mockTheme = { + id: 0, + name: 'test', + isDark: false, + primary: '#6200ee', + onPrimary: '#fff', + primaryContainer: '#e8def8', + onPrimaryContainer: '#21005d', + secondary: '#625b71', + onSecondary: '#fff', + secondaryContainer: '#e8def8', + onSecondaryContainer: '#1d192b', + tertiary: '#7d5260', + onTertiary: '#fff', + tertiaryContainer: '#ffd8e4', + onTertiaryContainer: '#31111d', + error: '#f00', + onError: '#fff', + errorContainer: '#f9dedc', + onErrorContainer: '#410e0b', + background: '#fff', + onBackground: '#000', + surface: '#f5f5f5', + onSurface: '#000', + surfaceVariant: '#e8e8e8', + onSurfaceVariant: '#666', + outline: '#ccc', + outlineVariant: '#cac4d0', + shadow: '#000', + scrim: '#000', + inverseSurface: '#313033', + inverseOnSurface: '#f4eff4', + inversePrimary: '#d0bcff', + surfaceDisabled: 'rgba(28, 27, 31, 0.12)', + onSurfaceDisabled: 'rgba(28, 27, 31, 0.38)', + backdrop: 'rgba(0, 0, 0, 0.4)', + rippleColor: 'rgba(0,0,0,0.1)', +}; + +describe('ToggleButton', () => { + it('renders icon via MaterialCommunityIcons', () => { + render( + { }} />, + ); + expect(screen.getByTestId('icon')).toBeTruthy(); + }); + + it('selected state: icon color is theme.primary', () => { + render( + { }} />, + ); + const icon = screen.getByTestId('icon'); + expect(icon.props.color).toBe(mockTheme.primary); + }); + + it('unselected state: icon color is theme.onSurface', () => { + render( + { }} />, + ); + const icon = screen.getByTestId('icon'); + expect(icon.props.color).toBe(mockTheme.onSurface); + }); + + it('calls onPress on press', () => { + const onPress = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByTestId('icon')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('disabled: press does not call onPress', () => { + const onPress = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('icon')); + expect(onPress).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Common/__tests__/mocks.ts b/src/components/Common/__tests__/mocks.ts new file mode 100644 index 00000000..251235c2 --- /dev/null +++ b/src/components/Common/__tests__/mocks.ts @@ -0,0 +1,30 @@ +jest.mock('@utils/showToast', () => ({ + showToast: jest.fn(), +})); + +jest.mock('@i18n/translations', () => ({ + getString: jest.fn(key => key), +})); + +jest.mock('@utils/parseChapterNumber', () => ({ + parseChapterNumber: jest.fn(() => 1), +})); + +jest.mock('@utils/Storages', () => ({ + NOVEL_STORAGE: '/mock/storage', +})); + +jest.mock('@hooks/persisted/usePlugins'); +jest.mock('@hooks/persisted/useTracker'); +jest.mock('@hooks/persisted/useDownload'); +jest.mock('@hooks/persisted/useUserAgent'); +jest.mock('@hooks/persisted/useSettings'); +jest.mock('@hooks/persisted/useNovelSettings'); +jest.mock('@hooks/persisted/useHistory'); +jest.mock('@hooks/persisted/useImport'); +jest.mock('@hooks/persisted/useSelfHost'); +jest.mock('@hooks/persisted/useTheme'); +jest.mock('@hooks/persisted/useTrackedNovel'); +jest.mock('@hooks/persisted/useUpdates'); +jest.mock('@services/plugin/fetch'); +jest.mock('@components/Context/LibraryContext'); diff --git a/src/components/IconButtonV2/AnimatedIconButton.tsx b/src/components/IconButtonV2/AnimatedIconButton.tsx new file mode 100644 index 00000000..e7522e2b --- /dev/null +++ b/src/components/IconButtonV2/AnimatedIconButton.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { Pressable, StyleSheet, View, ViewStyle } from 'react-native'; +import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; +import Color from 'color'; + +import { ThemeColors } from '../../theme/types'; +import { MaterialDesignIconName } from '@type/icon'; +import Animated, { + SharedValue, + useAnimatedStyle, + withTiming, +} from 'react-native-reanimated'; + +const AnimatedIcon = Animated.createAnimatedComponent(MaterialCommunityIcons); + +type Props = { + name: MaterialDesignIconName; + color?: string; + size?: number; + disabled?: boolean; + padding?: number; + onPress?: () => 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/Switch/SwitchItem.tsx b/src/components/Switch/SwitchItem.tsx index 9dfb60f7..91151deb 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, @@ -34,11 +38,12 @@ 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/Switch/__tests__/SwitchItem.test.tsx b/src/components/Switch/__tests__/SwitchItem.test.tsx new file mode 100644 index 00000000..54b2c946 --- /dev/null +++ b/src/components/Switch/__tests__/SwitchItem.test.tsx @@ -0,0 +1,122 @@ +import './mocks'; +import { render, screen, fireEvent } from '@testing-library/react-native'; +import SwitchItem from '../SwitchItem'; + +// Mock reanimated — setUpTests in global setup may have failed. +jest.mock('react-native-reanimated', () => { + const { View } = require('react-native'); + return { + useSharedValue: (init: unknown) => ({ value: init }), + useAnimatedStyle: (fn: () => Record) => fn(), + useDerivedValue: (fn: () => unknown) => fn(), + withTiming: (val: unknown) => val, + withSpring: (val: unknown) => val, + interpolateColor: () => 'transparent', + createAnimatedComponent: (c: unknown) => c, + default: { View }, + __esModule: true, + }; +}); + +const mockUseTheme = jest.fn(); + +jest.mock('@hooks/persisted', () => ({ + useTheme: () => mockUseTheme(), +})); + +const mockTheme = { + id: 0, + name: 'test', + isDark: false, + primary: '#6200ee', + onPrimary: '#fff', + primaryContainer: '#e8def8', + onPrimaryContainer: '#21005d', + secondary: '#625b71', + onSecondary: '#fff', + secondaryContainer: '#e8def8', + onSecondaryContainer: '#1d192b', + tertiary: '#7d5260', + onTertiary: '#fff', + tertiaryContainer: '#ffd8e4', + onTertiaryContainer: '#31111d', + error: '#f00', + onError: '#fff', + errorContainer: '#f9dedc', + onErrorContainer: '#410e0b', + background: '#fff', + onBackground: '#000', + surface: '#f5f5f5', + onSurface: '#000', + surfaceVariant: '#e8e8e8', + onSurfaceVariant: '#666', + outline: '#ccc', + outlineVariant: '#cac4d0', + shadow: '#000', + scrim: '#000', + inverseSurface: '#313033', + inverseOnSurface: '#f4eff4', + inversePrimary: '#d0bcff', + surfaceDisabled: 'rgba(28, 27, 31, 0.12)', + onSurfaceDisabled: 'rgba(28, 27, 31, 0.38)', + backdrop: 'rgba(0, 0, 0, 0.4)', + rippleColor: 'rgba(0,0,0,0.1)', +}; + +describe('SwitchItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseTheme.mockReturnValue(mockTheme); + }); + + it('renders label text', () => { + render( { }} theme={mockTheme} />); + expect(screen.getByText('Test Label')).toBeTruthy(); + }); + + it('renders description when provided', () => { + render( + { }} + theme={mockTheme} + />, + ); + expect(screen.getByText('A helpful description')).toBeTruthy(); + }); + + it('does not render description when omitted', () => { + render( { }} theme={mockTheme} />); + expect(screen.queryByText('A helpful description')).toBeNull(); + }); + + it('calls onPress on press', () => { + const onPress = jest.fn(); + render(); + fireEvent.press(screen.getByText('Pressable')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('calls onLongPress on long press', () => { + const onLongPress = jest.fn(); + render( + { }} + onLongPress={onLongPress} + theme={mockTheme} + />, + ); + fireEvent(screen.getByText('Long Press'), 'onLongPress'); + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + it('has correct accessibility role and label', () => { + render( { }} theme={mockTheme} />); + const element = screen.getByRole('switch', { name: 'Accessible' }); + expect(element).toBeTruthy(); + }); +}); diff --git a/src/components/Switch/__tests__/mocks.ts b/src/components/Switch/__tests__/mocks.ts new file mode 100644 index 00000000..251235c2 --- /dev/null +++ b/src/components/Switch/__tests__/mocks.ts @@ -0,0 +1,30 @@ +jest.mock('@utils/showToast', () => ({ + showToast: jest.fn(), +})); + +jest.mock('@i18n/translations', () => ({ + getString: jest.fn(key => key), +})); + +jest.mock('@utils/parseChapterNumber', () => ({ + parseChapterNumber: jest.fn(() => 1), +})); + +jest.mock('@utils/Storages', () => ({ + NOVEL_STORAGE: '/mock/storage', +})); + +jest.mock('@hooks/persisted/usePlugins'); +jest.mock('@hooks/persisted/useTracker'); +jest.mock('@hooks/persisted/useDownload'); +jest.mock('@hooks/persisted/useUserAgent'); +jest.mock('@hooks/persisted/useSettings'); +jest.mock('@hooks/persisted/useNovelSettings'); +jest.mock('@hooks/persisted/useHistory'); +jest.mock('@hooks/persisted/useImport'); +jest.mock('@hooks/persisted/useSelfHost'); +jest.mock('@hooks/persisted/useTheme'); +jest.mock('@hooks/persisted/useTrackedNovel'); +jest.mock('@hooks/persisted/useUpdates'); +jest.mock('@services/plugin/fetch'); +jest.mock('@components/Context/LibraryContext'); diff --git a/src/components/TextInput/__tests__/TextInput.test.tsx b/src/components/TextInput/__tests__/TextInput.test.tsx new file mode 100644 index 00000000..8b3312be --- /dev/null +++ b/src/components/TextInput/__tests__/TextInput.test.tsx @@ -0,0 +1,132 @@ +jest.mock('react-native-gesture-handler', () => { + const RN = require('react-native'); + const View = RN.View || require('react-native').View; + return { + TextInput: RN.TextInput, + GestureHandlerRootView: View, + Swipeable: View, + Pressable: RN.Pressable || View, + State: { BEGAN: 0, ACTIVE: 1, END: 2 }, + PanGestureHandler: View, + NativeViewGestureHandler: View, + TapGestureHandler: View, + LongPressGestureHandler: View, + PinchGestureHandler: View, + RotationGestureHandler: View, + FlingGestureHandler: View, + Directions: { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8 }, + gestureHandlerRootHOC: (c: unknown) => c, + createNativeWrapper: (c: unknown) => c, + // Default export used by some imports + default: { + TextInput: RN.TextInput, + GestureHandlerRootView: View, + }, + }; +}); + +import './mocks'; +import { render, screen, fireEvent } from '@testing-library/react-native'; +import TextInput from '../index'; + +const mockUseTheme = jest.fn(); + +jest.mock('@hooks/persisted', () => ({ + useTheme: () => mockUseTheme(), +})); + +const mockTheme = { + primary: '#6200ee', + onSurface: '#000', + onSurfaceVariant: '#666', + surfaceVariant: '#e8e8e8', + rippleColor: 'rgba(0,0,0,0.1)', + outline: '#ccc', + error: '#f00', + background: '#fff', + surface: '#f5f5f5', + onPrimary: '#fff', + onBackground: '#000', +}; + +describe('TextInput', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseTheme.mockReturnValue(mockTheme); + }); + + it('renders with placeholder', () => { + render(); + expect(screen.getByPlaceholderText('Enter text')).toBeTruthy(); + }); + + it('on focus: border color changes to theme.primary, border width 2', () => { + render(); + const input = screen.getByPlaceholderText('Test'); + + fireEvent(input, 'focus'); + + // The style array contains the dynamic style object as first element + const styleArray = input.props.style; + expect(styleArray[0].borderColor).toBe(mockTheme.primary); + expect(styleArray[0].borderWidth).toBe(2); + }); + + it('on blur: border color reverts to theme.outline, border width 1', () => { + render(); + const input = screen.getByPlaceholderText('Test'); + + fireEvent(input, 'focus'); + fireEvent(input, 'blur'); + + const styleArray = input.props.style; + expect(styleArray[0].borderColor).toBe(mockTheme.outline); + expect(styleArray[0].borderWidth).toBe(1); + }); + + it('error state: border color is theme.error, border width 2 regardless of focus', () => { + render(); + const input = screen.getByPlaceholderText('Test'); + + const styleArray = input.props.style; + expect(styleArray[0].borderColor).toBe(mockTheme.error); + expect(styleArray[0].borderWidth).toBe(2); + + // Blur should not change error styling + fireEvent(input, 'blur'); + expect(styleArray[0].borderColor).toBe(mockTheme.error); + expect(styleArray[0].borderWidth).toBe(2); + }); + + it('forceFocused prop: shows focused styling even without actual focus', () => { + render(); + const input = screen.getByPlaceholderText('Test'); + + const styleArray = input.props.style; + expect(styleArray[0].borderColor).toBe(mockTheme.primary); + expect(styleArray[0].borderWidth).toBe(2); + + // Blur should not change forceFocused styling + fireEvent(input, 'blur'); + expect(styleArray[0].borderColor).toBe(mockTheme.primary); + }); + + it('merges custom style prop with default styles', () => { + render(); + const input = screen.getByPlaceholderText('Test'); + + const styleArray = input.props.style; + // Default textInput style (index 1) should have inherited styles + // Custom style is last (index 2) + expect(styleArray[2]).toEqual({ fontSize: 20 }); + }); + + it('passes through other TextInput props (onChangeText)', () => { + const onChangeText = jest.fn(); + render(); + const input = screen.getByPlaceholderText('Test'); + + fireEvent.changeText(input, 'new text'); + expect(onChangeText).toHaveBeenCalledWith('new text'); + }); +}); diff --git a/src/components/TextInput/__tests__/mocks.ts b/src/components/TextInput/__tests__/mocks.ts new file mode 100644 index 00000000..251235c2 --- /dev/null +++ b/src/components/TextInput/__tests__/mocks.ts @@ -0,0 +1,30 @@ +jest.mock('@utils/showToast', () => ({ + showToast: jest.fn(), +})); + +jest.mock('@i18n/translations', () => ({ + getString: jest.fn(key => key), +})); + +jest.mock('@utils/parseChapterNumber', () => ({ + parseChapterNumber: jest.fn(() => 1), +})); + +jest.mock('@utils/Storages', () => ({ + NOVEL_STORAGE: '/mock/storage', +})); + +jest.mock('@hooks/persisted/usePlugins'); +jest.mock('@hooks/persisted/useTracker'); +jest.mock('@hooks/persisted/useDownload'); +jest.mock('@hooks/persisted/useUserAgent'); +jest.mock('@hooks/persisted/useSettings'); +jest.mock('@hooks/persisted/useNovelSettings'); +jest.mock('@hooks/persisted/useHistory'); +jest.mock('@hooks/persisted/useImport'); +jest.mock('@hooks/persisted/useSelfHost'); +jest.mock('@hooks/persisted/useTheme'); +jest.mock('@hooks/persisted/useTrackedNovel'); +jest.mock('@hooks/persisted/useUpdates'); +jest.mock('@services/plugin/fetch'); +jest.mock('@components/Context/LibraryContext'); diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx new file mode 100644 index 00000000..2e039381 --- /dev/null +++ b/src/components/TextInput/index.tsx @@ -0,0 +1,71 @@ +import { useTheme } from '@hooks/persisted'; +import { 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 9e167ea3..2f374a89 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -33,5 +33,7 @@ export type { export { default as BottomTabBar } from './BottomTabBar'; export { default as TopTabBar } from './TopTabBar/TopTabBar'; export { default as Menu } from './Menu'; +export { default as AnimatedIconButton } from './IconButtonV2/AnimatedIconButton'; +export { default as TextInput } from './TextInput'; export { default as Slider } from './Slider/Slider'; export type { SliderProps, SliderSize } from './Slider/Slider'; diff --git a/src/hooks/common/__tests__/useKeyboardHeight.test.ts b/src/hooks/common/__tests__/useKeyboardHeight.test.ts new file mode 100644 index 00000000..258549d5 --- /dev/null +++ b/src/hooks/common/__tests__/useKeyboardHeight.test.ts @@ -0,0 +1,72 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { Keyboard } from 'react-native'; +import { useKeyboardHeight } from '../useKeyboardHeight'; + +describe('useKeyboardHeight', () => { + let showCallback: Function; + let hideCallback: Function; + let mockAddListener: jest.Mock; + const mockShowRemove = jest.fn(); + const mockHideRemove = jest.fn(); + + beforeAll(() => { + mockAddListener = jest.fn((event: string, cb: Function) => { + if (event === 'keyboardDidShow') showCallback = cb; + if (event === 'keyboardDidHide') hideCallback = cb; + return event === 'keyboardDidShow' + ? { remove: mockShowRemove } + : { remove: mockHideRemove }; + }); + jest.spyOn(Keyboard, 'addListener').mockImplementation(mockAddListener); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + showCallback = () => { }; + hideCallback = () => { }; + mockShowRemove.mockClear(); + mockHideRemove.mockClear(); + mockAddListener.mockClear(); + }); + + it('returns 0 initially', () => { + const { result } = renderHook(() => useKeyboardHeight()); + expect(result.current).toBe(0); + }); + + it('after keyboardDidShow with endCoordinates.height = 350, returns 350', () => { + const { result } = renderHook(() => useKeyboardHeight()); + + act(() => { + showCallback({ endCoordinates: { height: 350 } }); + }); + + expect(result.current).toBe(350); + }); + + it('after keyboardDidHide, returns 0', () => { + const { result } = renderHook(() => useKeyboardHeight()); + + act(() => { + showCallback({ endCoordinates: { height: 350 } }); + }); + expect(result.current).toBe(350); + + act(() => { + hideCallback(); + }); + expect(result.current).toBe(0); + }); + + it('cleanup: on unmount, calls remove() on both listeners', () => { + const { unmount } = renderHook(() => useKeyboardHeight()); + + unmount(); + + expect(mockShowRemove).toHaveBeenCalledTimes(1); + expect(mockHideRemove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/common/useKeyboardHeight.ts b/src/hooks/common/useKeyboardHeight.ts new file mode 100644 index 00000000..3af2afc9 --- /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 16ad2398..9c518162 100644 --- a/src/hooks/persisted/useSettings.ts +++ b/src/hooks/persisted/useSettings.ts @@ -6,7 +6,7 @@ import { } from '@screens/library/constants/constants'; import { TtsEngine, TtsVoice } from '@modules/nitro-tts'; import { useMMKVObject } from 'react-native-mmkv'; -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { getMMKVObject } from '@utils/mmkv/mmkv'; import type { DateFormat } from '@utils/dateFormat'; import type { @@ -223,8 +223,22 @@ export interface ChapterReaderSettings { epubUseCustomCSS: boolean; epubUseCustomJS: boolean; epubIncludeChapterNumber: 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 @@ -326,6 +340,13 @@ export const initialChapterReaderSettings: ChapterReaderSettings = { epubUseCustomCSS: false, epubUseCustomJS: false, epubIncludeChapterNumber: false, + /** + * Custom code + */ + replaceText: {}, + removeText: [], + codeSnippetsCSS: [], + codeSnippetsJS: [], }; export const useAppSettings = () => { @@ -400,6 +421,7 @@ export const useChapterReaderSettings = () => { // object on every render invalidates everything derived from them. const chapterReaderSettings = useMemo( () => ({ + ...initialChapterReaderSettings, ...storedSettings, tts: { ...initialChapterReaderSettings.tts, @@ -435,6 +457,46 @@ export const useChapterReaderSettings = () => { ), }); + // Migrate old customCSS/customJS to new CodeSnippet arrays + useEffect(() => { + const css = storedSettings.customCSS?.trim(); + const js = storedSettings.customJS?.trim(); + if (css || js) { + const migratedCSS: CodeSnippet[] = css + ? [ + { + name: 'Legacy CSS', + code: css, + lang: 'css' as const, + active: storedSettings.epubUseCustomCSS, + }, + ] + : []; + const migratedJS: CodeSnippet[] = js + ? [ + { + name: 'Legacy JS', + code: js, + lang: 'js' as const, + active: storedSettings.epubUseCustomJS, + }, + ] + : []; + setChapterReaderSettings({ + customCSS: '', + customJS: '', + codeSnippetsCSS: [ + ...migratedCSS, + ...(storedSettings.codeSnippetsCSS ?? []), + ], + codeSnippetsJS: [ + ...migratedJS, + ...(storedSettings.codeSnippetsJS ?? []), + ], + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return { ...chapterReaderSettings, setChapterReaderSettings, diff --git a/src/i18n/languages/en/strings.json b/src/i18n/languages/en/strings.json index 5a7a3b76..dafdc47d 100644 --- a/src/i18n/languages/en/strings.json +++ b/src/i18n/languages/en/strings.json @@ -340,7 +340,12 @@ "submit": "Submit", "loading": "Loading ...", "moreOptions": "More options", - "warning": "Warning" + "warning": "Warning", + "code": "Code", + "custom_code": "Custom Code", + "textToReplace": "Text to replace", + "replaceWith": "Replace with", + "replaceText": "Replace text" }, "webview": { "refresh": "Refresh", @@ -780,6 +785,28 @@ "other": "%{count} days" } }, +"customCodeSettings": { + "textManipulation": "Text Manipulation", + "codeSnippets": "Code Snippets", + "cssSnippets": "CSS Snippets", + "javascriptSnippets": "JavaScript Snippets", + "createCSSSnippet": "Create CSS Snippet", + "addCssCode": "Add custom CSS code that applies to all chapters", + "createJSSnippet": "Create JS Snippet", + "addJavascriptCode": "Add custom JavaScript code that applies to all chapters", + "noCodeSnippets": "No code snippets", + "renameSnippet": "Rename Snippet", + "yourCodeHere": "Your code here", + "editReplace": "Edit Replace/Remove", + "enterAMatch": "Enter a match pattern", + "enterAReplace": "Enter a replace value", + "itemAlreadyExists": "Item already exists", + "replace": "Replace", + "removeText": "Remove Text", + "snippetSaved": "Snippet saved", + "snippetUpdated": "Snippet updated", + "imported": "Imported" + }, "genreStats": { "title": "Genre statistics", "totalNovels": { diff --git a/src/i18n/types/index.ts b/src/i18n/types/index.ts index 3b872d5b..9be74da3 100644 --- a/src/i18n/types/index.ts +++ b/src/i18n/types/index.ts @@ -298,6 +298,11 @@ export interface StringMap { 'common.loading': 'string'; 'common.moreOptions': 'string'; 'common.warning': 'string'; + 'common.code': 'string'; + 'common.custom_code': 'string'; + 'common.textToReplace': 'string'; + 'common.replaceWith': 'string'; + 'common.replaceText': 'string'; 'webview.refresh': 'string'; 'webview.share': 'string'; 'webview.openInWebView': 'string'; @@ -652,6 +657,26 @@ export interface StringMap { 'time.hours.other': 'string'; 'time.days.one': 'string'; 'time.days.other': 'string'; +'customCodeSettings.textManipulation': 'string'; + 'customCodeSettings.codeSnippets': 'string'; + 'customCodeSettings.cssSnippets': 'string'; + 'customCodeSettings.javascriptSnippets': 'string'; + 'customCodeSettings.createCSSSnippet': 'string'; + 'customCodeSettings.addCssCode': 'string'; + 'customCodeSettings.createJSSnippet': 'string'; + 'customCodeSettings.addJavascriptCode': 'string'; + 'customCodeSettings.noCodeSnippets': 'string'; + 'customCodeSettings.renameSnippet': 'string'; + 'customCodeSettings.yourCodeHere': 'string'; + 'customCodeSettings.editReplace': 'string'; + 'customCodeSettings.enterAMatch': 'string'; + 'customCodeSettings.enterAReplace': 'string'; + 'customCodeSettings.itemAlreadyExists': 'string'; + 'customCodeSettings.replace': 'string'; + 'customCodeSettings.removeText': 'string'; + 'customCodeSettings.snippetSaved': 'string'; + 'customCodeSettings.snippetUpdated': 'string'; + 'customCodeSettings.imported': 'string'; 'genreStats.title': 'string'; 'genreStats.totalNovels.one': 'string'; 'genreStats.totalNovels.other': 'string'; diff --git a/src/navigators/MoreStack.tsx b/src/navigators/MoreStack.tsx index bf895d29..10258e46 100644 --- a/src/navigators/MoreStack.tsx +++ b/src/navigators/MoreStack.tsx @@ -12,6 +12,8 @@ import TaskQueue from '../screens/more/TaskQueueScreen'; import Downloads from '../screens/more/DownloadsScreen'; import AppearanceSettings from '../screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen'; import CategoriesScreen from '@screens/Categories/CategoriesScreen'; +import SettingsCustomCode from '@screens/settings/SettingsCustomCodeScreen'; +import CodeSnippetsScreen from '@screens/settings/SettingsCustomCodeScreen/CodeSnippetsScreen'; import RespositorySettings from '@screens/settings/SettingsRepositoryScreen/SettingsRepositoryScreen'; import LibrarySettings from '@screens/settings/SettingsLibraryScreen/SettingsLibraryScreen'; import StatsScreen from '@screens/StatsScreen/StatsScreen'; @@ -46,6 +48,8 @@ const SettingsStack = () => { component={RespositorySettings} /> + + ); diff --git a/src/navigators/types/index.ts b/src/navigators/types/index.ts index 3940e0aa..986f1085 100644 --- a/src/navigators/types/index.ts +++ b/src/navigators/types/index.ts @@ -83,6 +83,8 @@ export type SettingsStackParamList = { AdvancedSettings: undefined; LibrarySettings: undefined; RespositorySettings: { url?: string } | undefined; + CustomCode: undefined; + CodeSnippets: { snippetIndex: number; isJS: boolean } | undefined; GenreTaxonomy: undefined; }; @@ -196,6 +198,15 @@ export type RespositorySettingsScreenProps = CompositeScreenProps< NativeStackScreenProps >; +export type CustomCodeSettingsScreenProps = NativeStackScreenProps< + SettingsStackParamList, + 'CustomCode' +>; +export type CodeSnippetsScreenProps = NativeStackScreenProps< + SettingsStackParamList, + 'CodeSnippets' +>; + declare global { namespace ReactNavigation { // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/src/screens/reader/components/Hooks/useCustomCode.ts b/src/screens/reader/components/Hooks/useCustomCode.ts new file mode 100644 index 00000000..ba0778e5 --- /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/useTextModifications.ts b/src/screens/reader/components/Hooks/useTextModifications.ts new file mode 100644 index 00000000..ed79cff1 --- /dev/null +++ b/src/screens/reader/components/Hooks/useTextModifications.ts @@ -0,0 +1,113 @@ +import { useChapterReaderSettings } from '@hooks/persisted/useSettings'; +import { applyTextModifications } from '@utils/customCode'; +import React, { useMemo, useState } from 'react'; +import type WebView from 'react-native-webview'; +import { WebViewPostEvent } from '../WebViewReader'; +export default function useTextModifications( + chapterText: string, + webViewRef: React.RefObject | null>, +) { + // Replace modal state + const [replaceModalVisible, setReplaceModalVisible] = useState(false); + const [selectedTextForReplace, setSelectedTextForReplace] = useState(''); + const [replacementText, setReplacementText] = useState(''); + + const { setChapterReaderSettings, ...readerSettings } = + useChapterReaderSettings(); + + // html is computed once per chapter at load time, using the current saved + // settings. Subsequent dynamic remove/replace actions inject JS directly + // into the WebView DOM instead of rebuilding the HTML source (which would + // reload the WebView and lose the reading position). + const html = useMemo( + () => + applyTextModifications( + chapterText, + readerSettings.removeText, + readerSettings.replaceText, + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [chapterText], + ); + + const injectJS = React.useCallback( + (script: string) => { + webViewRef.current?.injectJavaScript(script); + }, + [webViewRef], + ); + 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 }); + } + // Directly remove text in the WebView DOM to avoid full re-render + injectJS( + `window.textRemover?.performRemove?.(${JSON.stringify(text)}); true;`, + ); + } else if (action === 'replace') { + // Show modal for user to enter replacement text + setSelectedTextForReplace(text); + setReplacementText(''); + setReplaceModalVisible(true); + } + }, + [readerSettings.removeText, setChapterReaderSettings, injectJS], + ); + + const handleReplaceSave = React.useCallback(() => { + if (!selectedTextForReplace) return false; + + const newReplaceText = { ...readerSettings.replaceText }; + if (!(selectedTextForReplace in newReplaceText)) { + newReplaceText[selectedTextForReplace] = replacementText; + setChapterReaderSettings({ replaceText: newReplaceText }); + } + // Directly replace text in the WebView DOM to avoid full re-render + injectJS( + `window.textRemover?.performReplace?.(${JSON.stringify(selectedTextForReplace)}, ${JSON.stringify(replacementText)}); true;`, + ); + setReplaceModalVisible(false); + return true; + }, [ + selectedTextForReplace, + readerSettings.replaceText, + replacementText, + setChapterReaderSettings, + injectJS, + ]); + + const handleReplaceCancel = React.useCallback(() => { + setReplaceModalVisible(false); + setSelectedTextForReplace(''); + setReplacementText(''); + }, []); + + function eventTextAction(event: WebViewPostEvent) { + if (event.data) { + const data = event.data as Record; + const action = Object.keys(data)[0]; + const text = 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 a8076a59..349eaa30 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -1,5 +1,10 @@ import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { NativeEventEmitter, NativeModules, StatusBar } from 'react-native'; +import { + NativeEventEmitter, + NativeModules, + StatusBar, + StyleSheet, +} from 'react-native'; import WebView from 'react-native-webview'; import * as Linking from 'expo-linking'; import color from 'color'; @@ -24,12 +29,18 @@ import { ReaderSearchResult } from '../types'; import { useTtsSession } from '../hooks/useTtsSession'; import type { TtsSettings } from '@modules/nitro-tts'; import { ChapterInfo } from '@database/types'; +import { Dialog } from '@components/Dialog'; +import { TextInput } from 'react-native-paper'; +import useCustomCode from './Hooks/useCustomCode'; +import useTextModifications from './Hooks/useTextModifications'; import { isPluginIssueReportUrl } from '../utils/sanitizeChapterText'; -type WebViewPostEvent = { +export type WebViewPostEvent = { type: string; data?: unknown; autoStartTTS?: boolean; + index?: number; + total?: number; }; type WebViewReaderProps = { @@ -130,17 +141,18 @@ const WebViewReader: React.FC = ({ } = useChapterContext(); const theme = useTheme(); const initialReaderSettings = useMemo( - () => - getMMKVObject(CHAPTER_READER_SETTINGS) || - initialChapterReaderSettings, - // eslint-disable-next-line react-hooks/exhaustive-deps + () => ({ + ...initialChapterReaderSettings, + ...getMMKVObject(CHAPTER_READER_SETTINGS), + }), // eslint-disable-next-line react-hooks/exhaustive-deps [chapter.id], ); const chapterGeneralSettings = useMemo( - () => - getMMKVObject(CHAPTER_GENERAL_SETTINGS) || - initialChapterGeneralSettings, + () => ({ + ...initialChapterGeneralSettings, + ...getMMKVObject(CHAPTER_GENERAL_SETTINGS), + }), // needed to preserve settings during chapter change // eslint-disable-next-line react-hooks/exhaustive-deps [chapter.id], @@ -163,6 +175,20 @@ const WebViewReader: React.FC = ({ updateSettings: updateTtsSettings, } = useTtsSession(); + const { customJS, customCSS } = useCustomCode(initialReaderSettings); + + const { + html: processedHtml, + replaceModalVisible, + setReplaceModalVisible, + selectedTextForReplace, + replacementText, + setReplacementText, + handleReplaceSave, + handleReplaceCancel, + eventTextAction, + } = useTextModifications(html, webViewRef); + const [readerSettings, setReaderSettings] = useState( () => getMMKVObject(CHAPTER_READER_SETTINGS) || @@ -339,9 +365,7 @@ const WebViewReader: React.FC = ({ } - + ${chapter.name} - ${html} + ${processedHtml} @@ -388,9 +412,15 @@ const WebViewReader: React.FC = ({ +