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 = ({ + `, @@ -399,7 +429,9 @@ const WebViewReader: React.FC = ({ batteryLevel, chapter, chapterGeneralSettings, - html, + processedHtml, + customJS, + customCSS, initialReaderSettings, novel, plugin, @@ -410,6 +442,7 @@ const WebViewReader: React.FC = ({ ]); return ( + <> = ({ `if (window.reader && window.reader.batteryLevel) { window.reader.batteryLevel.val = ${lastKnownBatteryLevel}; }`, - ); - webViewRef.current?.injectJavaScript(adjacentChapterScriptRef.current); - - const searchText = searchTextRef.current.trim(); - if (searchText) { - webViewRef.current?.injectJavaScript( - `window.readerSearch?.search(${JSON.stringify(searchText)}); true;`, ); - } + webViewRef.current?.injectJavaScript( + adjacentChapterScriptRef.current, + ); - if (autoStartTTSRef.current) { - autoStartTTSRef.current = false; - setTimeout(() => { - webViewRef.current?.injectJavaScript(` + const searchText = searchTextRef.current.trim(); + if (searchText) { + webViewRef.current?.injectJavaScript( + `window.readerSearch?.search(${JSON.stringify( + searchText, + )}); true;`, + ); + } + + if (autoStartTTSRef.current) { + autoStartTTSRef.current = false; + setTimeout(() => { + webViewRef.current?.injectJavaScript(` (function() { if (window.tts && reader.generalSettings.val.TTSEnable) { setTimeout(() => { @@ -454,117 +491,158 @@ const WebViewReader: React.FC = ({ } })(); `); - }, 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, - ) - : []; - const startIndex = - typeof payload?.startIndex === 'number' ? payload.startIndex : 0; - void loadAndPlay( - queue, - startIndex, - { - novelName: novel?.name || 'Unknown', - chapterName: chapter.name, - coverUri: novel?.cover || undefined, - }, - toNativeTtsSettings(readerSettingsRef.current.tts), - ); - break; + }, 300); } - case 'tts-command': { - if (!event.data || typeof event.data !== 'object') { + }} + 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, + ) + : []; + const startIndex = + typeof payload?.startIndex === 'number' + ? payload.startIndex + : 0; + void loadAndPlay( + queue, + startIndex, + { + novelName: novel?.name || 'Unknown', + chapterName: chapter.name, + coverUri: novel?.cover || undefined, + }, + toNativeTtsSettings(readerSettingsRef.current.tts), + ); break; } - const data = event.data as { - command?: unknown; - index?: unknown; - }; - switch (data.command) { - case 'next': - case 'pause': - case 'play': - case 'previous': - case 'replay': - case 'stop': - runTtsCommand(data.command); - break; - case 'seekTo': - if (typeof data.index === 'number') { - seekTts(data.index); - } - break; - } - break; - } - 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 'search-result': - if (event.data && typeof event.data === 'object') { - const data = event.data as { - query?: unknown; - current?: unknown; - total?: unknown; - renderedTotal?: unknown; - isTruncated?: unknown; - }; - const query = typeof data.query === 'string' ? data.query : ''; - if (query !== searchTextRef.current.trim()) { + case 'tts-command': { + if (!event.data || typeof event.data !== 'object') { break; } - const total = typeof data.total === 'number' ? data.total : 0; - onSearchResult({ - query, - current: typeof data.current === 'number' ? data.current : 0, - total, - renderedTotal: - typeof data.renderedTotal === 'number' - ? data.renderedTotal - : total, - isTruncated: data.isTruncated === true, - }); + const data = event.data as { + command?: unknown; + index?: unknown; + }; + switch (data.command) { + case 'next': + case 'pause': + case 'play': + case 'previous': + case 'replay': + case 'stop': + runTtsCommand(data.command); + break; + case 'seekTo': + if (typeof data.index === 'number') { + seekTts(data.index); + } + break; + } + break; } - break; - case 'interaction': - onUserInteraction(); - break; - } - }} - source={source} - /> + 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 'text-action': + eventTextAction(event); + break; + case 'search-result': + if (event.data && typeof event.data === 'object') { + const data = event.data as { + query?: unknown; + current?: unknown; + total?: unknown; + renderedTotal?: unknown; + isTruncated?: unknown; + }; + const query = typeof data.query === 'string' ? data.query : ''; + if (query !== searchTextRef.current.trim()) { + break; + } + const total = typeof data.total === 'number' ? data.total : 0; + onSearchResult({ + query, + current: typeof data.current === 'number' ? data.current : 0, + total, + renderedTotal: + typeof data.renderedTotal === 'number' + ? data.renderedTotal + : total, + isTruncated: data.isTruncated === true, + }); + } + break; + case 'interaction': + onUserInteraction(); + break; + } + }} + source={source} + /> + setReplaceModalVisible(false)} + > + + Replace Text + + + + + + + Cancel + Save + + + ); }; +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 00000000..947c46f5 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/CodeSnippetsScreen.tsx @@ -0,0 +1,186 @@ +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 '@i18n/translations'; +import SnippetEditor, { SnippetEditorHandle } from './SnippetEditor'; +import SettingsReaderWebView from '../SettingsReaderScreen/components/SettingsReaderWebView'; +import { CodeSnippetsScreenProps } from '@navigators/types'; +import NativeFile from '@modules/native-file'; +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 [exampleCode, setExampleCode] = React.useState(); + 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.ExternalCachesDirectoryPath + + '/imported_custom.' + + language; + await NativeFile.copyFile(file.assets[0].uri, tempPath); + const content = await NativeFile.readFile(tempPath); + await 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} + /> + + { + if (routes[i]?.key === 'example') { + setExampleCode(editorRef.current?.getCode()); + } + setIndex(i); + }} + initialLayout={{ width: layout.width }} + /> + + ); +}; + +export default CodeSnippetsScreen; + +const styles = StyleSheet.create({ + tabBar: { + borderBottomWidth: 1, + elevation: 0, + marginBottom: -8, + }, + 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 00000000..7df2a919 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/CodeInput.tsx @@ -0,0 +1,220 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useTheme } from '@hooks/persisted'; +import { getString } from '@i18n/translations'; +import { + SimpleCodeEditor, + MemoizedHighlightedCode, + HighlightMode, + ScrollSink, + useStableLineModels, + FONT_SIZE, + LINE_HEIGHT, +} from './SimpleCodeEditor'; +import Animated from 'react-native-reanimated'; + +const MIN_LINES = 16; + +type CodeInputProps = { + language: 'css' | 'js'; + code: string; + setCode: (code: string) => void; + highlightMode?: HighlightMode; + error?: boolean; + onFocus?: () => void; + onBlur?: () => void; + scrollSink?: ScrollSink; +}; + +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, + scrollSink, +}: 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 { + new Function(val); + setError(undefined); + } catch (e: unknown) { + setError( + (e as Error).message.replace( + /^(\d+)/, + (_, i) => Number(i) + startLines.length + '', + ), + ); + } + } + + return ( + + + + {error} + + + + + + {language !== 'js' ? null : ( + + )} + + ); +}; +export default CodeInput; + +const styles = StyleSheet.create({ + error: { + width: '100%', + padding: 8, + marginBottom: 8, + backgroundColor: 'red', + }, + errorText: { + textAlign: 'center', + }, + container: { + flex: 1, + 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 00000000..62bf6766 --- /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/SettingsWebView.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/SettingsWebView.tsx new file mode 100644 index 00000000..825d2133 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/SettingsWebView.tsx @@ -0,0 +1,363 @@ +import { memo, useEffect, useMemo, useRef } from 'react'; +import { NativeEventEmitter, NativeModules, StatusBar } from 'react-native'; +import WebView from 'react-native-webview'; +import color from 'color'; +import { getString } from '@i18n/translations'; +import { MMKVStorage } from '@utils/mmkv/mmkv'; +import { + CHAPTER_GENERAL_SETTINGS, + CHAPTER_READER_SETTINGS, + useChapterGeneralSettings, + useChapterReaderSettings, +} from '@hooks/persisted/useSettings'; + +import { getBatteryLevelSync } from 'react-native-device-info'; +import * as Speech from 'expo-speech'; +import { dummyHTML } from './dummies'; +import { useTheme } from '@hooks/persisted'; + +type WebViewPostEvent = { + type: string; + data?: { [key: string]: string | number }; +}; + +const onLogMessage = (payload: { nativeEvent: { data: string } }) => { + const dataPayload = JSON.parse(payload.nativeEvent.data); + if (dataPayload) { + if (dataPayload.type === 'console') { + /* eslint-disable no-console */ + console.info(`[Console] ${JSON.stringify(dataPayload.msg, null, 2)}`); + } + } +}; + +const { RNDeviceInfo } = NativeModules; +const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo); + +const assetsUriPrefix = __DEV__ + ? 'http://localhost:8081/assets' + : 'file:///android_asset'; + +const novel = { + 'artist': null, + 'author': 'LNReader-kun', + 'cover': + 'file:///storage/emulated/0/Android/data/com.rajarsheechatterjee.LNReader/files/Novels/lightnovelcave/16/cover.png?1717862123181', + 'genres': 'Action,Hero', + 'id': 16, + 'inLibrary': 1, + 'isLocal': 0, + 'name': 'Preview Man (LN)', + 'path': 'novel/preview-man-16091321', + 'pluginId': 'lightnovelcave', + 'status': 'Ongoing', + 'summary': + 'To preview or not preview. A question that bothered humanity for a long time, until one day… Preview Man appeared.Show More', + 'totalPages': 8, +}; +const chapter = { + 'bookmark': 0, + 'chapterNumber': 1, + 'id': 3722, + 'isDownloaded': 1, + 'name': 'Chapter 1 - The rise of Preview Man', + 'novelId': 16, + 'page': '2', + 'path': 'novel/preview-man/chapter-1', + 'position': 0, + 'progress': 3, + 'readTime': '2100-01-01 00:00:00', + 'releaseTime': 'January 1, 2100', + 'unread': 1, + 'updatedTime': null, +}; + +const SettingsWebView = () => { + const webViewRef = useRef(null) as any; + const theme = useTheme(); + const settings = useChapterReaderSettings(); + const generalSettings = useChapterGeneralSettings(); + const batteryLevel = useMemo(() => getBatteryLevelSync(), []); + + useEffect(() => { + const mmkvListener = MMKVStorage.addOnValueChangedListener(key => { + switch (key) { + case CHAPTER_READER_SETTINGS: + webViewRef.current?.injectJavaScript( + `reader.settings.val = ${MMKVStorage.getString( + CHAPTER_READER_SETTINGS, + )}`, + ); + break; + case CHAPTER_GENERAL_SETTINGS: + webViewRef.current?.injectJavaScript( + `reader.generalSettings.val = ${MMKVStorage.getString( + CHAPTER_GENERAL_SETTINGS, + )}`, + ); + break; + } + }); + + const subscription = deviceInfoEmitter.addListener( + 'RNDeviceInfo_batteryLevelDidChange', + (level: number) => { + webViewRef.current?.injectJavaScript( + `reader.batteryLevel.val = ${level}`, + ); + }, + ); + return () => { + subscription.remove(); + mmkvListener.remove(); + }; + }, [webViewRef]); + + const customJS = useMemo(() => { + return settings.codeSnippetsJS + .map(snippet => { + if (!snippet.active) return null; + return ` + try { + ${snippet.code} + } catch (error) { + alert('Error loading executing ${snippet.name}:\n' + error); + } + `; + }) + .filter(Boolean) + .join('\n'); + }, [settings.codeSnippetsJS]); + + const customCSS = useMemo(() => { + return settings.codeSnippetsCSS + .map(snippet => { + if (!snippet.active) return null; + return snippet.code; + }) + .filter(Boolean) + .join('\n'); + }, [settings.codeSnippetsCSS]); + + const preparedDummyHTML = useMemo(() => { + let resultHtml = dummyHTML; + settings.removeText.forEach(text => { + resultHtml = resultHtml.replace(text, ''); + }); + Object.entries(settings.replaceText).forEach(([text, replacement]) => { + resultHtml = resultHtml.replace(text, replacement); + }); + return resultHtml; + }, [settings.removeText, settings.replaceText]); + + const webViewCSS = useMemo( + () => ` + + + + + + + `, + [ + settings.theme, + customCSS, + settings.fontFamily, + settings.lineHeight, + settings.padding, + settings.textAlign, + settings.textColor, + settings.textSize, + theme.onPrimary, + theme.onSecondary, + theme.onSurface, + theme.onSurfaceVariant, + theme.onTertiary, + theme.outline, + theme.primary, + theme.rippleColor, + theme.secondary, + theme.surface, + theme.surfaceVariant, + theme.tertiary, + ], + ); + + const webViewSource = useMemo( + () => ({ + html: ` + + + + ${webViewCSS} + + + +
${chapter.name}
+
+ ${preparedDummyHTML} +
+
+ + + + + + + + + + `, + }), + [ + webViewCSS, + generalSettings.showScrollPercentage, + generalSettings.showBatteryAndTime, + generalSettings.verticalSeekbar, + generalSettings.bionicReading, + generalSettings.pageReader, + batteryLevel, + preparedDummyHTML, + settings, + customJS, + ], + ); + + return ( + { + __DEV__ && onLogMessage(ev); + const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data); + switch (event.type) { + case 'hide': + break; + case 'next': + break; + case 'prev': + break; + case 'save': + break; + case 'speak': + if (event.data && typeof event.data === 'string') { + Speech.speak(event.data, { + onDone() { + webViewRef.current?.injectJavaScript('tts.next?.()'); + }, + voice: settings.tts?.voice?.identifier, + pitch: settings.tts?.pitch || 1, + rate: settings.tts?.rate || 1, + }); + } else { + webViewRef.current?.injectJavaScript('tts.next?.()'); + } + break; + case 'stop-speak': + Speech.stop(); + break; + } + }} + source={webViewSource} + /> + ); +}; + +export default memo(SettingsWebView); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/SimpleCodeEditor.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/SimpleCodeEditor.tsx new file mode 100644 index 00000000..7b620f0b --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/SimpleCodeEditor.tsx @@ -0,0 +1,787 @@ +import React, { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + ColorValue, + StyleProp, + StyleSheet, + Text, + TextInput, + TextInputProps, + TextStyle, + View, + ViewStyle, + useWindowDimensions, +} from 'react-native'; +import Color from 'color'; +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'; + +export const FONT_SIZE = 14; +export const LINE_HEIGHT = Math.ceil(FONT_SIZE * 1.2); + +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'; + +/** The owning ScrollView registers a callback here that receives contentOffset.y. */ +export type ScrollSink = React.MutableRefObject<((y: number) => void) | null>; + +type SimpleCodeEditorProps = Omit< + TextInputProps, + 'value' | 'defaultValue' | 'children' | 'onChangeText' +> & { + highlightMode?: HighlightMode; + onChangeText?: (text: string) => void; + containerStyle?: StyleProp; + scrollSink?: ScrollSink; +}; + +interface LineModel { + id: string; + code: string; +} + +interface HighlightedLineProps { + code: string; + isDark?: boolean; + mode: SupportedMode; + textStyle: TextStyle; +} + +type PrismStylesheet = Record; + +interface RendererProps { + rows: RendererNode[]; + stylesheet: PrismStylesheet; +} + +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 'textDecoration': + case 'textDecorationLine': + rn.textDecorationLine = value as TextStyle['textDecorationLine']; + break; + + default: + break; + } + } + + return rn; +} + +function getRNStylesheet(stylesheet: PrismStylesheet): 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['code[class*="language-"]']?.color ?? + rnStylesheet['pre[class*="language-"]']?.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 (keyof TextStyle)[]; + const bKeys = Object.keys(b) as (keyof TextStyle)[]; + + if (aKeys.length !== bKeys.length) { + return false; + } + + return aKeys.every(key => a[key] === b[key]); +} + +const HighlightedLine = memo( + function _HighlightedLine({ + code, + mode, + isDark, + textStyle, + }: HighlightedLineProps) { + return ( + + {code.length === 0 ? ( + '\u200B' + ) : ( + + {code} + + )} + + ); + }, + (prev, next) => + prev.code === next.code && + prev.mode === next.mode && + prev.isDark === next.isDark && + shallowEqualTextStyle(prev.textStyle, next.textStyle), +); + +export function useStableLineModels(value: string): LineModel[] { + const previousRef = useRef<{ + lines: string[]; + models: LineModel[]; + } | null>(null); + + const nextIdRef = useRef(0); + + return useMemo(() => { + const newLines = value + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .split('\n'); + 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) { + const flat = StyleSheet.flatten(style) ?? {}; + + return { + opacity: flat.opacity ?? 1, + }; +} + +function extractTextStyle(style: StyleProp): TextStyle { + const flat = StyleSheet.flatten(style) ?? {}; + + return { + color: flat.color ?? '#abb2bf', + fontFamily: flat.fontFamily ?? 'monospace', + fontSize: typeof flat.fontSize === 'number' ? flat.fontSize : FONT_SIZE, + fontStyle: flat.fontStyle, + fontWeight: flat.fontWeight, + letterSpacing: flat.letterSpacing ?? 0, + lineHeight: + typeof flat.lineHeight === 'number' ? flat.lineHeight : LINE_HEIGHT, + }; +} + +export type MemoizedHighlightedCodeProps = { + lines?: LineModel[]; + value?: string; + mode: SupportedMode; + style?: StyleProp; + hideCode?: boolean; + isDark?: boolean; + setLines?: (num: number) => void; + startLine?: number; + scrollSink?: ScrollSink; +}; + +const TOP_OVERS = 6; +const BOTTOM_OVERS = 8; +const CHAR_MEASURE_STENCIL = 'WWWWWWWWWWWWWWWWWWWW'; +const GUTTER_WIDTH = 32; + +export function MemoizedHighlightedCode({ + lines, + value, + mode, + style, + hideCode = false, + setLines, + isDark = false, + startLine = 0, + scrollSink, +}: MemoizedHighlightedCodeProps) { + // Never call a hook conditionally. Generating this for externally supplied + // lines is cheap and keeps the hook order valid. + const generatedLines = useStableLineModels(value ?? ''); + const resolvedLines = lines ?? generatedLines; + + const opacityStyle = useMemo(() => extractOpacityStyle(style), [style]); + const textStyle = useMemo(() => extractTextStyle(style), [style]); + + const { height: viewportHeight } = useWindowDimensions(); + + const layerRef = useRef(null); + const firstRowRef = useRef(null); + const lastScrollYRef = useRef(null); + const layerContentYRef = useRef(null); + const viewportHRef = useRef(viewportHeight); + const charWidthRef = useRef(null); + const containerWidthRef = useRef(null); + const heightsRef = useRef>(new Map()); + const linesRef = useRef(resolvedLines); + + linesRef.current = resolvedLines; + viewportHRef.current = viewportHeight; + + // Without a sink (static previews) the whole list renders, so the window + // state is inert. With a sink, seed a conservative superset window until + // the layer position is measured and the exact window is computed. + const [window, setWindow] = useState<{ start: number; end: number }>(() => + scrollSink + ? { + start: 0, + end: Math.min( + resolvedLines.length, + Math.ceil(viewportHeight / LINE_HEIGHT) + BOTTOM_OVERS, + ), + } + : { start: 0, end: 0 }, + ); + const [corr, setCorr] = useState(0); + + const estimateHeight = useCallback((line: LineModel): number => { + const charWidth = charWidthRef.current; + const containerWidth = containerWidthRef.current; + if (charWidth == null || containerWidth == null) { + return LINE_HEIGHT; + } + const charsPerLine = Math.max( + 1, + Math.floor((containerWidth - GUTTER_WIDTH) / charWidth), + ); + return ( + Math.max(1, Math.ceil(line.code.length / charsPerLine)) * LINE_HEIGHT + ); + }, []); + + const cumSum = useCallback( + (count: number): number => { + const rows = linesRef.current; + let sum = 0; + // The window is updated by scroll events, so after a large edit that + // shrinks the content it can point past the end of the current rows + // until the next scroll/layout pass. Never read past the array. + const limit = Math.min(count, rows.length); + for (let i = 0; i < limit; i += 1) { + sum += heightsRef.current.get(rows[i].id) ?? estimateHeight(rows[i]); + } + return sum; + }, + [estimateHeight], + ); + + const handleScroll = useCallback( + (y: number) => { + lastScrollYRef.current = y; + const layerY = layerContentYRef.current; + if (layerY == null) return; + // layerContentYRef is the layer's window y measured at scroll offset 0 + // (measureInWindow y + scroll y is invariant), so the viewport top + // relative to the layer grows with the scroll offset. + const windowY = y - layerY; + const viewportH = viewportHRef.current; + const rows = linesRef.current; + const count = rows.length; + let cum = 0; + let W = -1; + let E = -1; + for (let i = 0; i < count; i += 1) { + const h = heightsRef.current.get(rows[i].id) ?? estimateHeight(rows[i]); + if (W < 0 && cum + h > windowY) W = i; // first line whose bottom is below the viewport top + if (W >= 0 && cum >= windowY + viewportH) { + E = i; // first line whose top is below the viewport bottom + break; + } + cum += h; + } + if (W < 0) W = count; + if (E < 0) E = count; + const start = Math.max(0, W - TOP_OVERS); + const end = Math.min(count, E + BOTTOM_OVERS); + setWindow(prev => + prev.start === start && prev.end === end ? prev : { start, end }, + ); + }, + [estimateHeight], + ); + + useEffect(() => { + if (!scrollSink) return; + scrollSink.current = handleScroll; + return () => { + if (scrollSink.current === handleScroll) { + scrollSink.current = null; + } + }; + }, [scrollSink, handleScroll]); + + const measureLayerY = useCallback(() => { + const layer = layerRef.current; + if (!layer) return; + layer.measureInWindow((_, y) => { + layerContentYRef.current = y + (lastScrollYRef.current ?? 0); + handleScroll(lastScrollYRef.current ?? 0); + }); + }, [handleScroll]); + + // Self-correction: the window's padding must equal the TextInput's true + // cumulative text height; estimates + native insets leave a residual that + // this measures directly off the first rendered row and folds into corr. + useEffect(() => { + if (!scrollSink) return; + const first = firstRowRef.current; + const layer = layerRef.current; + if (!first || !layer) return; + layer.measureInWindow((_, ly) => { + first.measureInWindow((_, fy) => { + const residual = fy - ly - cumSum(window.start); + setCorr(prev => (Math.abs(prev - residual) < 0.5 ? prev : residual)); + }); + }); + }, [scrollSink, window.start, lines, cumSum]); + + useEffect(() => { + setLines?.(resolvedLines.length); + }, [resolvedLines.length, setLines]); + + // Without a sink the whole list renders (static previews): the window is + // {0, 0}, so start/end expand to the full list and the row extras are off. + const start = scrollSink ? window.start : 0; + const end = scrollSink ? window.end : resolvedLines.length; + + return ( + { + containerWidthRef.current = e.nativeEvent.layout.width; + measureLayerY(); + }} + > + { + charWidthRef.current = + e.nativeEvent.layout.width / CHAR_MEASURE_STENCIL.length; + // The first window computation runs before this measured width is + // available (single-row estimates); re-derive it now that wrapping + // can be estimated correctly. + measureLayerY(); + }} + > + {CHAR_MEASURE_STENCIL} + + + {resolvedLines.slice(start, end).map((line, i) => ( + { + const h = e.nativeEvent.layout.height; + if (heightsRef.current.get(line.id) !== h) { + heightsRef.current.set(line.id, h); + } + } + : undefined + } + > + + + ))} + + + ); +} + +const LineRenderer = memo( + ({ + line, + index, + mode, + startLine, + textStyle, + isDark, + hideCode, + }: { + line: LineModel; + index: number; + startLine: number; + mode: SupportedMode; + textStyle: TextStyle; + isDark: boolean; + hideCode: boolean; + }) => { + return ( + <> + + {index + 1 + startLine} + + {hideCode ? null : ( + + )} + + ); + }, + (prev, next) => + prev.line.code === next.line.code && + prev.index === next.index && + prev.startLine === next.startLine && + prev.mode === next.mode && + prev.isDark === next.isDark && + prev.hideCode === next.hideCode && + shallowEqualTextStyle(prev.textStyle, next.textStyle), +); + +export function SimpleCodeEditor({ + highlightMode = 'combined', + onChangeText, + containerStyle, + scrollEnabled = true, + lines, + value, + mode, + style, + isDark, + setLines, + startLine, + scrollSink, + ...props +}: SimpleCodeEditorProps & Omit) { + const hideHighlight = highlightMode === 'off'; + + const textStyle = useMemo(() => extractTextStyle(style), [style]); + + const inputColor = + highlightMode === 'off' + ? textStyle.color + : highlightMode === 'combined' + ? Color(textStyle.color).alpha(0.4).string() + : 'rgba(0, 0, 0, 0.1)'; + + return ( + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'relative', + overflow: 'hidden', + }, + highlightLayer: { + zIndex: 0, + }, + lineContainer: { + position: 'relative', + width: '100%', + }, + row: { + width: '100%', + flexDirection: 'row', + alignItems: 'flex-start', + }, + lineNumber: { + width: GUTTER_WIDTH, + flexShrink: 0, + margin: 0, + padding: 0, + paddingRight: 4, + textAlign: 'right', + }, + codeLine: { + flex: 1, + minWidth: 0, + margin: 0, + padding: 0, + }, + input: { + zIndex: 1, + width: '100%', + margin: 0, + borderWidth: 0, + padding: 0, + paddingLeft: GUTTER_WIDTH, + backgroundColor: 'transparent', + textAlignVertical: 'top', + includeFontPadding: false, + }, + charMeasure: { + position: 'absolute', + opacity: 0, + }, + withoutFontPadding: { + includeFontPadding: false, + }, +}); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/Snippet.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/Snippet.tsx new file mode 100644 index 00000000..054716a4 --- /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/Components/__tests__/SimpleCodeEditor.test.tsx b/src/screens/settings/SettingsCustomCodeScreen/Components/__tests__/SimpleCodeEditor.test.tsx new file mode 100644 index 00000000..3a6369b4 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/__tests__/SimpleCodeEditor.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { View } from 'react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import { MemoizedHighlightedCode, ScrollSink } from '../SimpleCodeEditor'; + +// The real Prism bundles are ESM under dist/esm and the renderer output is +// irrelevant for the windowing behavior under test. +jest.mock('react-syntax-highlighter', () => { + const MockLight = Object.assign( + ({ children }: { children?: React.ReactNode }) => children, + { registerLanguage: jest.fn() }, + ); + return { PrismLight: MockLight }; +}); +jest.mock('react-syntax-highlighter/dist/esm/languages/prism/css', () => ({})); +jest.mock( + 'react-syntax-highlighter/dist/esm/languages/prism/javascript', + () => ({}), +); +jest.mock( + 'react-syntax-highlighter/dist/esm/styles/prism/material-dark', + () => ({}), +); +jest.mock( + 'react-syntax-highlighter/dist/esm/styles/prism/material-light', + () => ({}), +); + +const CHAR_MEASURE_STENCIL = 'WWWWWWWWWWWWWWWWWWWW'; + +const LONG_CODE = Array.from( + { length: 1000 }, + (_, i) => `const line${i} = ${i};`, +).join('\n'); + +describe('MemoizedHighlightedCode', () => { + // The RN jest mock never invokes the measure callback, but the component + // needs the layer position to compute its render window. + beforeEach(() => { + const proto = View.prototype as unknown as { + measureInWindow: (callback: (...args: number[]) => void) => void; + }; + jest + .spyOn(proto, 'measureInWindow') + .mockImplementation(function ( + this: unknown, + callback: (...args: number[]) => void, + ) { + callback(0, 0, 0, 0); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('recovers when a large edit shrinks content below the scroll window', () => { + const scrollSink: ScrollSink = { current: null }; + + const { rerender } = render( + , + ); + + // Let the layer measure its position, then scroll deep into the + // document so the render window starts near the end of the content. + fireEvent(screen.getByText(CHAR_MEASURE_STENCIL), 'layout', { + nativeEvent: { layout: { x: 0, y: 0, width: 120, height: 17 } }, + }); + act(() => { + scrollSink.current?.(1_000_000); + }); + + // Replace nearly all the content while the window still points at the + // old end (no scroll event in between). Previously the padding + // computation read past the end of the line array and crashed with + // "Cannot read property 'id' of undefined". + rerender( + , + ); + + // The next scroll pass corrects the window and the new content shows. + act(() => { + scrollSink.current?.(0); + }); + + expect(screen.getByText('const x = 1;')).toBeTruthy(); + }); +}); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Components/dummies.ts b/src/screens/settings/SettingsCustomCodeScreen/Components/dummies.ts new file mode 100644 index 00000000..56f591ef --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Components/dummies.ts @@ -0,0 +1,88 @@ +export const dummyHTML = ` +

Lorem ipsum dolor sit amet consectetuer adipiscing +elit

+

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

+

Lorem ipsum dolor sit amet consectetuer adipiscing +elit

+

Aenean commodo ligula eget dolor aenean massa

+

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

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

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

+ +`; + +export const dummyText = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean lobortis, diam sed malesuada bibendum, nulla libero scelerisque sapien, nec interdum nisl ipsum ac ipsum. In lacinia eros ut quam commodo, in finibus augue ultricies. Vestibulum ex purus, condimentum eget sem at, molestie semper mi. Mauris ac feugiat quam. Pellentesque sagittis bibendum nibh eu lacinia. Aenean rhoncus, velit sit amet mollis egestas, diam turpis ornare velit, a dictum elit velit in erat. Quisque luctus in sem a vulputate.\n\nPellentesque id tempus orci, non finibus tortor. Suspendisse in neque non eros eleifend hendrerit vitae a lacus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae;'; diff --git a/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx b/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx new file mode 100644 index 00000000..d576a9d8 --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Modals/ReplaceItemModal.tsx @@ -0,0 +1,325 @@ +import { AnimatedIconButton, Dialog, List } from '@components'; +import { getString } from '@i18n/translations'; +import { useBoolean } from '@hooks/index'; +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'; +import { LegendList } from '@legendapp/list/react-native'; + +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); + }} + > + + + {getString('customCodeSettings.editReplace')} + + + + + {!showReplace ? null : ( + + )} + + + { + cancel(); + modal.setFalse(); + }} + > + Cancel + + { + if (save()) modal.setFalse(); + }} + > + Save + + + + + ); +}; + +export default ReplaceItemModal; + +const styles = StyleSheet.create({ + textfield: { + marginBottom: 16, + }, + bottom: { + marginBottom: 24, + }, + marginHorizontal: { + marginHorizontal: 16, + }, + gradient: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + zIndex: 1, + }, +}); diff --git a/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx b/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx new file mode 100644 index 00000000..8fdfe2fa --- /dev/null +++ b/src/screens/settings/SettingsCustomCodeScreen/Routes/CodeRoute.tsx @@ -0,0 +1,215 @@ +import { Button, TextInput } from '@components'; +import { Row } from '@components/Common'; +import { ToggleButton } from '@components/Common/ToggleButton'; +import { Dimensions, StyleSheet } from 'react-native'; +import { getString } from '@i18n/translations'; +import React from 'react'; +import { ScrollView } from 'react-native-gesture-handler'; +import { Text } from 'react-native-paper'; +import Animated, { useAnimatedStyle } from 'react-native-reanimated'; +import CodeInput from '../Components/CodeInput'; +import { showToast } from '@utils/showToast'; +import { useChapterReaderSettings, useTheme } from '@hooks/persisted'; +import { useKeyboardHeight } from '@hooks/common/useKeyboardHeight'; + +type CodeRouteProps = { + language?: 'css' | 'js'; + snippetIndex?: number; + jumpTo: (key: string) => void; + editingSnippet?: { + index: number; + isJS: boolean; + } | null; + onSnippetSaved?: () => void; +}; + +const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView); + +const CodeRoute = ({ + language: dLang, + snippetIndex, + jumpTo, + editingSnippet, + onSnippetSaved, +}: CodeRouteProps) => { + const theme = useTheme(); + const { + codeSnippetsJS, + codeSnippetsCSS, + setChapterReaderSettings: setSettings, + } = useChapterReaderSettings(); + const [error, setError] = React.useState({ title: false, code: false }); + + // Use editingSnippet if provided, otherwise fall back to old props + const isEditing = editingSnippet !== null && editingSnippet !== undefined; + const editIndex = isEditing ? editingSnippet.index : snippetIndex; + const editIsJS = isEditing ? editingSnippet.isJS : dLang === 'js'; + + const [language, setLanguage] = React.useState<'js' | 'css'>('js'); + + // Update language when editing state changes + React.useEffect(() => { + if (isEditing) { + setLanguage(editIsJS ? 'js' : 'css'); + } else { + setLanguage('js'); // Default to JS for new snippets + } + }, [isEditing, editIsJS]); + + const snippets = language === 'js' ? codeSnippetsJS : codeSnippetsCSS; + const snippet = + editIndex === undefined || editIndex === -1 ? null : snippets[editIndex]; + + const [title, setTitle] = React.useState(''); + const [code, setCode] = React.useState(''); + + // Update title, code, and reset errors when snippet changes + React.useEffect(() => { + setTitle(snippet?.name ?? ''); + setCode(snippet?.code ?? ''); + setError({ title: false, code: false }); + }, [snippet]); + + const keyboardHeight = useKeyboardHeight(); + + const scrollViewRef = React.useRef(null); + const editorScrollSink = React.useRef<((y: number) => void) | null>(null); + + const maxHeightScrollView = useAnimatedStyle(() => { + return { + maxHeight: Dimensions.get('window').height - keyboardHeight - 26, + }; + }); + + const colors = React.useMemo( + () => ({ + colors: theme, + }), + [theme], + ); + + const save = React.useCallback(() => { + setError({ title: false, code: false }); + if (!code.trim() || !title.trim()) { + setError({ title: !title.trim(), code: !code.trim() }); + return; + } + + // Editing existing snippet + if (isEditing && editIndex !== undefined && editIndex !== -1) { + snippets[editIndex].name = title; + snippets[editIndex].code = code; + setSettings({ + [language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets, + }); + showToast('Snippet updated successfully'); + onSnippetSaved?.(); + return; + } + + // Creating new snippet + snippets.push({ + name: title, + code, + active: true, + lang: language, + }); + setSettings({ + [language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets, + }); + showToast('Snippet saved successfully'); + jumpTo('first'); // Go back to settings tab + }, [ + language, + snippets, + title, + code, + setSettings, + jumpTo, + isEditing, + editIndex, + onSnippetSaved, + ]); + + return ( + { + editorScrollSink.current?.(e.nativeEvent.contentOffset.y); + }} + > + + + {'Select CSS or JS'} + + setLanguage('css')} + disabled={isEditing} + /> + setLanguage('js')} + disabled={isEditing} + /> + + + + + + + +