feat: Create Dedicated Custom Code Settings Page V2 (#1902)

* init

* text modification without webview rerender

* Fix SettingsReaderScreen

* remove unneeded

* solve ts issue

* add tests

* Add KeyboardProvider

* Update SimpleCodeEditor.tsx

* fix ts error

* add types package

* reworked SimpleCodeEditor

* Preserve highlightmode

* Update SimpleCodeEditor.tsx

* performance improvement

* simplified

* fix tsc

* fix alignment issues

* load current snippet into example view

* Merge branch 'master' into custom-code-clean

* fix type + lint

* Resolve can't read property id of undefined error

---------

Co-authored-by: Rajarshee Chatterjee <rajarshee.adm@gmail.com>
This commit is contained in:
Patrick Loser
2026-08-11 03:39:52 +02:00
committed by GitHub
parent 7f1f76408b
commit 64707409bb
47 changed files with 5418 additions and 898 deletions
+17 -11
View File
@@ -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 (
<Suspense fallback={null}>
<ThemeProvider>
<ThemeProvider>
<ThemedRootView>
<AppErrorBoundary>
<SafeAreaProvider>
<ThemedPaperProvider>
<BottomSheetModalProvider>
<StatusBar translucent={true} backgroundColor="transparent" />
<Main />
</BottomSheetModalProvider>
</ThemedPaperProvider>
</SafeAreaProvider>
</AppErrorBoundary>
<KeyboardProvider>
<AppErrorBoundary>
<SafeAreaProvider>
<ThemedPaperProvider>
<BottomSheetModalProvider>
<StatusBar
translucent={true}
backgroundColor="transparent"
/>
<Main />
</BottomSheetModalProvider>
</ThemedPaperProvider>
</SafeAreaProvider>
</AppErrorBoundary>
</KeyboardProvider>
</ThemedRootView>
</ThemeProvider>
</Suspense>
+262
View File
@@ -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;
};
+3
View File
@@ -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",
+206
View File
@@ -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: {}
+18 -1
View File
@@ -4,10 +4,27 @@ import { View, StyleSheet } from 'react-native';
const Row = ({
children,
style = {},
horizontalSpacing,
verticalSpacing,
}: {
children?: React.ReactNode;
style?: any;
}) => <View style={[styles.row, style]}>{children}</View>;
horizontalSpacing?: number | `${number}%`;
verticalSpacing?: number | `${number}%`;
}) => (
<View
style={[
styles.row,
style,
{
paddingHorizontal: horizontalSpacing,
paddingVertical: verticalSpacing,
},
]}
>
{children}
</View>
);
export { Row };
+8 -2
View File
@@ -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<ToggleButtonProps> = ({
@@ -32,15 +36,17 @@ export const ToggleButton: React.FC<ToggleButtonProps> = ({
theme,
color,
onPress,
disabled,
}) => (
<View style={styles.toggleButtonContainer}>
<Pressable
android_ripple={{ color: theme.rippleColor }}
style={[
styles.toggleButtonPressable,
getToggleButtonPressableStyle(selected, theme),
getToggleButtonPressableStyle(selected, theme, disabled),
]}
onPress={onPress}
disabled={disabled}
>
<MaterialCommunityIcons
name={icon}
@@ -0,0 +1,96 @@
import './mocks';
import { render, screen, fireEvent } from '@testing-library/react-native';
import { ToggleButton } from '../ToggleButton';
// Mock native icon module
jest.mock('@react-native-vector-icons/material-design-icons', () => {
const React = require('react');
const { View } = require('react-native');
const MockIcon = (props: Record<string, unknown>) =>
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(
<ToggleButton icon="cog" selected={false} theme={mockTheme} onPress={() => { }} />,
);
expect(screen.getByTestId('icon')).toBeTruthy();
});
it('selected state: icon color is theme.primary', () => {
render(
<ToggleButton icon="cog" selected={true} theme={mockTheme} onPress={() => { }} />,
);
const icon = screen.getByTestId('icon');
expect(icon.props.color).toBe(mockTheme.primary);
});
it('unselected state: icon color is theme.onSurface', () => {
render(
<ToggleButton icon="cog" selected={false} theme={mockTheme} onPress={() => { }} />,
);
const icon = screen.getByTestId('icon');
expect(icon.props.color).toBe(mockTheme.onSurface);
});
it('calls onPress on press', () => {
const onPress = jest.fn();
render(
<ToggleButton icon="cog" selected={false} theme={mockTheme} onPress={onPress} />,
);
fireEvent.press(screen.getByTestId('icon'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('disabled: press does not call onPress', () => {
const onPress = jest.fn();
render(
<ToggleButton icon="cog" selected={false} theme={mockTheme} onPress={onPress} disabled={true} />,
);
fireEvent.press(screen.getByTestId('icon'));
expect(onPress).not.toHaveBeenCalled();
});
});
+30
View File
@@ -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');
@@ -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<number>;
scale?: SharedValue<number>;
};
const AnimatedIconButton: React.FC<Props> = ({
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 (
<View style={[styles.container, style]}>
<Pressable
style={[styles.pressable, { padding }]}
onPress={onPress}
disabled={disabled}
android_ripple={
onPress
? { color: Color(theme.primary).alpha(0.12).string() }
: undefined
}
>
<AnimatedIcon
name={name}
size={size}
color={disabled ? theme.outline : color || theme.onSurface}
style={IconStyle}
/>
</Pressable>
</View>
);
};
export default React.memo(AnimatedIconButton);
const styles = StyleSheet.create({
container: {
borderRadius: 50,
overflow: 'hidden',
},
pressable: {
padding: 8,
},
});
+6 -1
View File
@@ -14,7 +14,9 @@ interface SwitchItemProps {
value: boolean;
label: string;
description?: string;
descriptionNumberOfLines?: number;
onPress: () => void;
onLongPress?: () => void;
theme: ThemeColors;
style?: StyleProp<ViewStyle>;
}
@@ -22,7 +24,9 @@ interface SwitchItemProps {
const SwitchItem: React.FC<SwitchItemProps> = ({
label,
description,
descriptionNumberOfLines,
onPress,
onLongPress,
theme,
value,
style,
@@ -34,11 +38,12 @@ const SwitchItem: React.FC<SwitchItemProps> = ({
android_ripple={{ color: theme.rippleColor }}
style={[styles.container, style]}
onPress={onPress}
onLongPress={onLongPress}
>
<View style={styles.labelContainer}>
<Text style={[{ color: theme.onSurface }, styles.label]}>{label}</Text>
{description ? (
<Text style={[styles.description, { color: theme.onSurfaceVariant }]}>
<Text numberOfLines={descriptionNumberOfLines} style={[styles.description, { color: theme.onSurfaceVariant }]}>
{description}
</Text>
) : null}
@@ -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<string, unknown>) => 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(<SwitchItem label="Test Label" value={false} onPress={() => { }} theme={mockTheme} />);
expect(screen.getByText('Test Label')).toBeTruthy();
});
it('renders description when provided', () => {
render(
<SwitchItem
label="Test"
description="A helpful description"
value={false}
onPress={() => { }}
theme={mockTheme}
/>,
);
expect(screen.getByText('A helpful description')).toBeTruthy();
});
it('does not render description when omitted', () => {
render(<SwitchItem label="Test" value={false} onPress={() => { }} theme={mockTheme} />);
expect(screen.queryByText('A helpful description')).toBeNull();
});
it('calls onPress on press', () => {
const onPress = jest.fn();
render(<SwitchItem label="Pressable" value={false} onPress={onPress} theme={mockTheme} />);
fireEvent.press(screen.getByText('Pressable'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('calls onLongPress on long press', () => {
const onLongPress = jest.fn();
render(
<SwitchItem
label="Long Press"
value={false}
onPress={() => { }}
onLongPress={onLongPress}
theme={mockTheme}
/>,
);
fireEvent(screen.getByText('Long Press'), 'onLongPress');
expect(onLongPress).toHaveBeenCalledTimes(1);
});
it('has correct accessibility role and label', () => {
render(<SwitchItem label="Accessible" value={false} onPress={() => { }} theme={mockTheme} />);
const element = screen.getByRole('switch', { name: 'Accessible' });
expect(element).toBeTruthy();
});
});
+30
View File
@@ -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');
@@ -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(<TextInput placeholder="Enter text" />);
expect(screen.getByPlaceholderText('Enter text')).toBeTruthy();
});
it('on focus: border color changes to theme.primary, border width 2', () => {
render(<TextInput placeholder="Test" />);
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(<TextInput placeholder="Test" />);
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(<TextInput placeholder="Test" error />);
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(<TextInput placeholder="Test" forceFocused />);
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(<TextInput placeholder="Test" style={{ fontSize: 20 }} />);
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(<TextInput placeholder="Test" onChangeText={onChangeText} />);
const input = screen.getByPlaceholderText('Test');
fireEvent.changeText(input, 'new text');
expect(onChangeText).toHaveBeenCalledWith('new text');
});
});
@@ -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');
+71
View File
@@ -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 (
<RNTextInput
placeholderTextColor={'grey'}
onFocus={_onFocus}
onBlur={_onBlur}
style={[
{
color: theme.onBackground,
backgroundColor: theme.background,
borderColor: error
? theme.error
: isFocused
? theme.primary
: theme.outline,
borderWidth: borderWidth,
margin: margin,
},
styles.textInput,
style,
]}
{...props}
/>
);
};
export default TextInput;
const styles = StyleSheet.create({
textInput: {
borderRadius: 4,
borderStyle: 'solid',
fontSize: 16,
paddingHorizontal: 16,
paddingVertical: 10,
},
});
+2
View File
@@ -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';
@@ -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);
});
});
+31
View File
@@ -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;
};
+63 -1
View File
@@ -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<string, string>;
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,
+28 -1
View File
@@ -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": {
+25
View File
@@ -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';
+4
View File
@@ -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}
/>
<Stack.Screen name="LibrarySettings" component={LibrarySettings} />
<Stack.Screen name="CustomCode" component={SettingsCustomCode} />
<Stack.Screen name="CodeSnippets" component={CodeSnippetsScreen} />
<Stack.Screen name="GenreTaxonomy" component={GenreTaxonomyScreen} />
</Stack.Navigator>
);
+11
View File
@@ -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<RootStackParamList, 'BottomNavigator'>
>;
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
@@ -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,
};
}
@@ -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<WebView<object> | 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<string, unknown>;
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,
};
}
+209 -131
View File
@@ -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<WebViewReaderProps> = ({
} = useChapterContext();
const theme = useTheme();
const initialReaderSettings = useMemo(
() =>
getMMKVObject<ChapterReaderSettings>(CHAPTER_READER_SETTINGS) ||
initialChapterReaderSettings,
// eslint-disable-next-line react-hooks/exhaustive-deps
() => ({
...initialChapterReaderSettings,
...getMMKVObject<ChapterReaderSettings>(CHAPTER_READER_SETTINGS),
}), // eslint-disable-next-line react-hooks/exhaustive-deps
[chapter.id],
);
const chapterGeneralSettings = useMemo(
() =>
getMMKVObject<ChapterGeneralSettings>(CHAPTER_GENERAL_SETTINGS) ||
initialChapterGeneralSettings,
() => ({
...initialChapterGeneralSettings,
...getMMKVObject<ChapterGeneralSettings>(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<WebViewReaderProps> = ({
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<ChapterReaderSettings>(CHAPTER_READER_SETTINGS) ||
@@ -339,9 +365,7 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
}
</style>
<link rel="stylesheet" href="${pluginCustomCSS}">
<style id="ln-custom-css">${
initialReaderSettings.customCSS
}</style>
<style id="ln-custom-css">${customCSS}</style>
</head>
<body class="${
chapterGeneralSettings.pageReader ? 'page-reader' : ''
@@ -354,7 +378,7 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
${chapterGeneralSettings.pageReader ? '' : 'display: none'}"
">${chapter.name}</div>
<div id="LNReader-chapter">
${html}
${processedHtml}
</div>
<div id="reader-ui"></div>
</body>
@@ -388,9 +412,15 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
<script src="${assetsUriPrefix}/js/core.js"></script>
<script src="${assetsUriPrefix}/js/search.js"></script>
<script src="${assetsUriPrefix}/js/index.js"></script>
<script src="${assetsUriPrefix}/js/textRemover.js"></script>
<script src="${pluginCustomJS}"></script>
<script id="ln-custom-js">
${initialReaderSettings.customJS}
function fn(){
let html = document.querySelector('#LNReader-chapter').innerHTML;
${customJS}
document.querySelector('#LNReader-chapter').innerHTML = html;
}
document.addEventListener('DOMContentLoaded', fn);
</script>
</html>
`,
@@ -399,7 +429,9 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
batteryLevel,
chapter,
chapterGeneralSettings,
html,
processedHtml,
customJS,
customCSS,
initialReaderSettings,
novel,
plugin,
@@ -410,6 +442,7 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
]);
return (
<>
<WebView
ref={webViewRef}
onTouchStart={onTouchStart}
@@ -432,20 +465,24 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
`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<WebViewReaderProps> = ({
}
})();
`);
}, 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}
/>
<Dialog.Root
visible={replaceModalVisible}
onDismiss={() => setReplaceModalVisible(false)}
>
<Dialog.Header>
<Dialog.Title>Replace Text</Dialog.Title>
</Dialog.Header>
<Dialog.Content>
<TextInput
label="Text to replace"
value={selectedTextForReplace}
editable={false}
mode="outlined"
style={styles.textInput}
theme={{ colors: { background: theme.surface } }}
/>
<TextInput
label="Replace with"
value={replacementText}
onChangeText={setReplacementText}
autoCorrect={false}
mode="outlined"
style={styles.textInput}
theme={{ colors: { background: theme.surface } }}
/>
</Dialog.Content>
<Dialog.Actions>
<Dialog.Action onPress={handleReplaceCancel}>Cancel</Dialog.Action>
<Dialog.Action onPress={handleReplaceSave}>Save</Dialog.Action>
</Dialog.Actions>
</Dialog.Root>
</>
);
};
const styles = StyleSheet.create({
textInput: { marginBottom: 16 },
});
export default memo(WebViewReader);
@@ -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<CodeSnippetsScreenProps> = ({
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<string>();
const editorRef = React.useRef<SnippetEditorHandle>(null);
const renderScene = ({
route: r,
}: SceneRendererProps & {
route: {
key: string;
title: string;
};
}) => {
switch (r.key) {
case 'code':
return (
<SnippetEditor
ref={editorRef}
snippetIndex={snippetIndex}
language={language}
/>
);
case 'example':
return (
<SettingsReaderWebView
customCSS={isJS === false ? exampleCode : undefined}
customJS={isJS === false ? undefined : exampleCode}
/>
);
default:
return null;
}
};
const renderTabBar = React.useCallback(
(props: SceneRendererProps & { navigationState: State }) => (
<TabBar
{...props}
indicatorStyle={[
styles.tabBarIndicator,
{ backgroundColor: theme.primary },
]}
style={[
{
backgroundColor: theme.surface,
borderBottomColor: Color(theme.isDark ? '#FFFFFF' : '#000000')
.alpha(0.12)
.string(),
},
styles.tabBar,
]}
tabStyle={styles.flex}
gap={8}
inactiveColor={theme.secondary}
activeColor={theme.primary}
android_ripple={{ color: theme.rippleColor, foreground: true }}
/>
),
[
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 (
<SafeAreaView excludeTop>
<Appbar
title=""
handleGoBack={() => navigation.goBack()}
theme={theme}
mode="small"
>
<IconButtonV2
name="file-import-outline"
size={24}
onPress={handleImport}
theme={theme}
/>
<IconButtonV2
name="content-save"
size={24}
onPress={() => editorRef.current?.save()}
theme={theme}
/>
</Appbar>
<TabView
collapsable={false}
lazy
navigationState={{ index, routes }}
renderScene={renderScene}
renderTabBar={renderTabBar}
onIndexChange={i => {
if (routes[i]?.key === 'example') {
setExampleCode(editorRef.current?.getCode());
}
setIndex(i);
}}
initialLayout={{ width: layout.width }}
/>
</SafeAreaView>
);
};
export default CodeSnippetsScreen;
const styles = StyleSheet.create({
tabBar: {
borderBottomWidth: 1,
elevation: 0,
marginBottom: -8,
},
tabBarIndicator: {
height: 3,
},
flex: {
flex: 1,
},
});
@@ -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<NodeJS.Timeout | null>(null);
const [error, setError] = React.useState<string | undefined>(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 (
<View style={[styles.container]}>
<Animated.View
style={[
styles.error,
{
backgroundColor: theme.errorContainer,
maxHeight: error ? 35 : 0,
padding: error ? 8 : 0,
transitionProperty: ['maxHeight', 'padding'],
transitionDuration: '150ms',
},
]}
>
<Text
numberOfLines={1}
style={[styles.errorText, { color: theme.onErrorContainer }]}
>
{error}
</Text>
</Animated.View>
<MemoizedHighlightedCode
style={[
codeFieldStyle,
styles.fontStyle,
styles.fakeTextInput,
styles.topField,
]}
isDark={theme.isDark}
mode={language}
lines={startLines}
/>
<SimpleCodeEditor
placeholder={getString('customCodeSettings.yourCodeHere')}
value={code}
mode={language}
highlightMode={highlightMode}
onChangeText={setAndAnalyzeCode}
onFocus={onFocus}
onBlur={onBlur}
placeholderTextColor={'grey'}
lines={lines}
startLine={startLines.length}
isDark={theme.isDark}
style={[codeFieldStyle, styles.fontStyle, styles.codeField]}
scrollSink={scrollSink}
/>
{language !== 'js' ? null : (
<MemoizedHighlightedCode
startLine={lines.length + startLines.length}
style={[styles.fakeTextInput, styles.bottomField, codeFieldStyle]}
mode={language}
isDark={theme.isDark}
value={END_JS_CODE}
/>
)}
</View>
);
};
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,
},
});
@@ -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 (
<Pressable
style={[styles.row, styles.itemRow]}
onPress={() => editItem(item)}
>
<Text numberOfLines={1} style={styles.textItem} theme={colorTheme}>
{item[0]}
</Text>
<Icon
name="arrow-right-bold"
size={LIST_ITEM_LINE_HEIGHT}
color={theme.onBackground}
/>
<View style={[styles.textItem, styles.row]}>
<Text
numberOfLines={1}
style={[styles.textItem, styles.textItemRight]}
theme={colorTheme}
>
{item[1]}
</Text>
<Icon
name="trash-can-outline"
size={LIST_ITEM_LINE_HEIGHT}
color={theme.onBackground}
onPress={e => {
e.stopPropagation();
removeItem(item[0]);
}}
/>
</View>
</Pressable>
);
};
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 (
<Pressable
style={[styles.row, styles.itemRow]}
onPress={() => editItem([item])}
>
<Text numberOfLines={1} style={styles.textItem} theme={colorTheme}>
{item}
</Text>
<Icon
name="trash-can-outline"
size={LIST_ITEM_LINE_HEIGHT}
color={theme.onBackground}
onPress={() => removeItem(index)}
/>
</Pressable>
);
};
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%',
},
});
@@ -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<WebView>(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(
() => `
<style>
:root {
--StatusBar-currentHeight: ${StatusBar.currentHeight};
--readerSettings-theme: ${settings.theme};
--readerSettings-padding: ${settings.padding}px;
--readerSettings-textSize: ${settings.textSize}px;
--readerSettings-textColor: ${settings.textColor};
--readerSettings-textAlign: ${settings.textAlign};
--readerSettings-lineHeight: ${settings.lineHeight};
--readerSettings-fontFamily: ${settings.fontFamily};
--theme-primary: ${theme.primary};
--theme-onPrimary: ${theme.onPrimary};
--theme-secondary: ${theme.secondary};
--theme-tertiary: ${theme.tertiary};
--theme-onTertiary: ${theme.onTertiary};
--theme-onSecondary: ${theme.onSecondary};
--theme-surface: ${theme.surface};
--theme-surface-0-9: ${color(theme.surface).alpha(0.9).toString()};
--theme-onSurface: ${theme.onSurface};
--theme-surfaceVariant: ${theme.surfaceVariant};
--theme-onSurfaceVariant: ${theme.onSurfaceVariant};
--theme-outline: ${theme.outline};
--theme-rippleColor: ${theme.rippleColor};
}
@font-face {
font-family: ${settings.fontFamily};
src: url("file:///android_asset/fonts/${settings.fontFamily}.ttf");
}
</style>
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
<link rel="stylesheet" href="${assetsUriPrefix}/css/pageReader.css">
<link rel="stylesheet" href="${assetsUriPrefix}/css/toolWrapper.css">
<link rel="stylesheet" href="${assetsUriPrefix}/css/tts.css">
<style>
${customCSS}
</style>
`,
[
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: `
<html >
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
${webViewCSS}
<script async>
var initSettings = {
showScrollPercentage: ${
generalSettings.showScrollPercentage
},
swipeGestures: false,
showBatteryAndTime: ${generalSettings.showBatteryAndTime},
verticalSeekbar: ${generalSettings.verticalSeekbar},
bionicReading: ${generalSettings.bionicReading},
}
var batteryLevel = ${batteryLevel};
var autoSaveInterval = 2222;
var { NOVEL, CHAPTER } = ${JSON.stringify({
NOVEL: novel,
CHAPTER: chapter,
})}
</script>
</head>
<body class="${generalSettings.pageReader ? 'page-reader' : ''}">
<div class="transition-chapter" style="transform: translateX(0%);
${
generalSettings.pageReader ? '' : 'display: none'
}"
>${chapter.name}</div>
<div id="LNReader-chapter">
${preparedDummyHTML}
</div>
<div id="reader-ui"></div>
</body>
<script>
var initialPageReaderConfig = ${JSON.stringify({
nextChapterScreenVisible: false,
})};
var initialReaderConfig = ${JSON.stringify({
readerSettings: settings,
chapterGeneralSettings: settings,
novel,
chapter,
nextChapter: undefined,
prevChapter: undefined,
batteryLevel,
autoSaveInterval: 2222,
DEBUG: __DEV__,
strings: {
finished: `${getString(
'readerScreen.finished',
)}: ${chapter.name.trim()}`,
nextChapter: getString(
'readerScreen.nextChapter',
{
name: undefined,
},
),
noNextChapter: getString(
'readerScreen.noNextChapter',
),
},
})}
</script>
<script src="${assetsUriPrefix}/js/icons.js"></script>
<script src="${assetsUriPrefix}/js/van.js"></script>
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
<script src="${assetsUriPrefix}/js/core.js"></script>
<script src="${assetsUriPrefix}/js/index.js"></script>
<script>
${customJS}
async function fn(){
let novelName = "${novel.name}";
let chapterName = "${chapter.name}";
let sourceId = "${novel.pluginId}";
let chapterId =${chapter.id};
let novelId =${chapter.novelId};
let html = document.getElementById("LNReader-chapter").innerHTML;
}
document.addEventListener("DOMContentLoaded", fn);
</script>
</html>
`,
}),
[
webViewCSS,
generalSettings.showScrollPercentage,
generalSettings.showBatteryAndTime,
generalSettings.verticalSeekbar,
generalSettings.bionicReading,
generalSettings.pageReader,
batteryLevel,
preparedDummyHTML,
settings,
customJS,
],
);
return (
<WebView
ref={webViewRef}
style={{ backgroundColor: settings.theme }}
allowFileAccess={true}
originWhitelist={['*']}
scalesPageToFit={true}
showsVerticalScrollIndicator={false}
javaScriptEnabled={true}
onMessage={(ev: { nativeEvent: { data: string } }) => {
__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);
@@ -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<string, HLStyleValue>;
type RNStylesheet = Record<string, TextStyle>;
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<ViewStyle>;
scrollSink?: ScrollSink;
};
interface LineModel {
id: string;
code: string;
}
interface HighlightedLineProps {
code: string;
isDark?: boolean;
mode: SupportedMode;
textStyle: TextStyle;
}
type PrismStylesheet = Record<string, React.CSSProperties>;
interface RendererProps {
rows: RendererNode[];
stylesheet: PrismStylesheet;
}
const stylesheetCache = new WeakMap<PrismStylesheet, RNStylesheet>();
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(
<Text
key={key}
allowFontScaling={false}
style={[
{
color: defaultColor,
includeFontPadding: false,
...getStylesForNode(node, rnStylesheet),
},
]}
>
{renderInlineNodes(
node.children,
rnStylesheet,
defaultColor,
`${key}_c`,
)}
</Text>,
);
}
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 (
<Text
allowFontScaling={false}
style={[textStyle, styles.codeLine, styles.withoutFontPadding]}
>
{code.length === 0 ? (
'\u200B'
) : (
<Light
language={LANG_MAP[mode]}
style={isDark ? materialDark : materialLight}
PreTag={Passthrough}
CodeTag={Passthrough}
renderer={lineHighlightRenderer}
>
{code}
</Light>
)}
</Text>
);
},
(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<TextStyle>) {
const flat = StyleSheet.flatten(style) ?? {};
return {
opacity: flat.opacity ?? 1,
};
}
function extractTextStyle(style: StyleProp<TextStyle>): 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<TextStyle>;
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<View>(null);
const firstRowRef = useRef<View>(null);
const lastScrollYRef = useRef<number | null>(null);
const layerContentYRef = useRef<number | null>(null);
const viewportHRef = useRef(viewportHeight);
const charWidthRef = useRef<number | null>(null);
const containerWidthRef = useRef<number | null>(null);
const heightsRef = useRef<Map<string, number>>(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 (
<View
ref={layerRef}
style={[styles.lineContainer, opacityStyle]}
onLayout={e => {
containerWidthRef.current = e.nativeEvent.layout.width;
measureLayerY();
}}
>
<Text
pointerEvents="none"
style={[textStyle, styles.withoutFontPadding, styles.charMeasure]}
onLayout={e => {
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}
</Text>
<View
style={scrollSink ? { paddingTop: cumSum(start) + corr } : undefined}
>
{resolvedLines.slice(start, end).map((line, i) => (
<View
key={line.id}
style={styles.row}
ref={scrollSink && i === 0 ? firstRowRef : undefined}
onLayout={
scrollSink
? e => {
const h = e.nativeEvent.layout.height;
if (heightsRef.current.get(line.id) !== h) {
heightsRef.current.set(line.id, h);
}
}
: undefined
}
>
<LineRenderer
line={line}
index={i + start}
isDark={isDark}
mode={mode}
startLine={startLine}
textStyle={textStyle}
hideCode={hideCode}
/>
</View>
))}
</View>
</View>
);
}
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 (
<>
<Text
allowFontScaling={false}
style={[textStyle, styles.lineNumber, styles.withoutFontPadding]}
>
{index + 1 + startLine}
</Text>
{hideCode ? null : (
<HighlightedLine
code={line.code}
isDark={isDark}
mode={mode}
textStyle={textStyle}
/>
)}
</>
);
},
(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<MemoizedHighlightedCodeProps, 'hideCode'>) {
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 (
<View style={[styles.container, containerStyle]}>
<View
pointerEvents="none"
style={[StyleSheet.absoluteFill, styles.highlightLayer]}
>
<MemoizedHighlightedCode
lines={lines}
value={value}
mode={mode}
style={style}
hideCode={hideHighlight}
isDark={isDark}
setLines={setLines}
startLine={startLine}
scrollSink={scrollSink}
/>
</View>
<TextInput
{...props}
multiline
allowFontScaling={false}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
scrollEnabled={scrollEnabled}
underlineColorAndroid="transparent"
value={value}
onChangeText={onChangeText}
cursorColor="#abb2bf"
selectionColor="#abb2bf"
style={[
style,
styles.input,
textStyle,
{
color: inputColor,
},
]}
/>
</View>
);
}
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,
},
});
@@ -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 (
<View style={styles.snippetRow}>
<SwitchItem
value={snippet.active}
label={snippet.name}
description={snippet.code.substring(0, 50)}
descriptionNumberOfLines={2}
onPress={() => toggle(index, isJs)}
onLongPress={() => rename(index, isJs, snippet.name)}
theme={theme}
style={styles.switchItem}
/>
<View style={styles.actionButtons}>
<IconButtonV2
name="pencil"
size={20}
onPress={() => edit(index, isJs)}
theme={theme}
/>
<IconButtonV2
name="delete"
size={20}
onPress={() => _delete(index, isJs)}
theme={theme}
/>
</View>
</View>
);
}
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,
},
});
@@ -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(
<MemoizedHighlightedCode
value={LONG_CODE}
mode="js"
isDark
style={{ color: '#ffffff' }}
scrollSink={scrollSink}
/>,
);
// 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(
<MemoizedHighlightedCode
value={'const x = 1;\nconst y = 2;'}
mode="js"
isDark
style={{ color: '#ffffff' }}
scrollSink={scrollSink}
/>,
);
// The next scroll pass corrects the window and the new content shows.
act(() => {
scrollSink.current?.(0);
});
expect(screen.getByText('const x = 1;')).toBeTruthy();
});
});
@@ -0,0 +1,88 @@
export const dummyHTML = `
<h1>Lorem ipsum dolor sit amet consectetuer adipiscing
elit</h1>
<p>Lorem ipsum dolor sit amet, consectetuer adipiscing
elit. Aenean commodo ligula eget dolor. Aenean massa
<strong>strong</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 <a class="external ext" href="#">link</a>
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.</p>
<h1>Lorem ipsum dolor sit amet consectetuer adipiscing
elit</h1>
<h2>Aenean commodo ligula eget dolor aenean massa</h2>
<p>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.</p>
<img src="data:image/webp;base64,UklGRrYZAABXRUJQVlA4TKkZAAAvv8AvEFWH4rZtHGn/sZNcL9+ImAB3pFsSRiZ5D0DgW3XUnc2bHD1uMl79/5ZNkrIHsScAGdtvVbtLtbu7u7vruLV377a7uzO0u2xvu7v3Hstb//t53ue+34pr641wtwdnXXNc0s6I3N3dydxlJMRtdSwkckknvHF4ca3rWi+ysQh3PQYPNySylQ6nM7yYjXAvPN90LyJ3uFOcbGVkfbfWd4ukU+wANsUldOlOcXd3u1dwtyYlI3KpAyByi3qvO8DdHfqaCHf3cC/fPYI9ASvcR16kZjLcO8dJCYkIh3CilRB3qMHdGj0Dlz4BjoDYhbZtW0pr6/7/f4QAcdsJcXf3E/cqoASotbivDhvJjdt4EN8dSf0i5ai2bbUJa8gsArIyy5gKXiqj1UB2mGx01QPDngA52f8/bvNLufTe1HvX8Sf5u+jG5D+GgLcUFnnMQR4FIvt/PSAGcY9xCIGnG+0tAEwlfARCQLL3ykMIMh2LVBD/MeEgIC4UBCQNggEEgnetYuGOIE1jCoUwqL8x5iAGIRAQguQGIxSi5xAEgRBkutUIRMNxI0mKlP7beIxNw8v7cdi2bSAHiavt/MOxtu2YM7Zt2/bENup0qpLKSZ8dZAF2lXTJAmyjdtKP55kAdnSb6/vvF0aJ/WL/zV/iefZL9f4i4w6KzUEXGWdtRRy3yEFxOOgi1tbW1u6clm+A9tLs…
<h2>Aenean commodo ligula eget dolor aenean massa</h2>
<p>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.</p>
<ul>
<li>Lorem ipsum dolor sit amet consectetuer.</li>
<li>Aenean commodo ligula eget dolor.</li>
<li>Aenean massa cum sociis natoque penatibus.</li>
</ul>
<p>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.</p>
<p>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.</p>
<table class="data">
<tr>
<th>Entry Header 1</th>
<th>Entry Header 2</th>
<th>Entry Header 3</th>
<th>Entry Header 4</th>
</tr>
<tr>
<td>Entry First Line 1</td>
<td>Entry First Line 2</td>
<td>Entry First Line 3</td>
<td>Entry First Line 4</td>
</tr>
<tr>
<td>Entry Line 1</td>
<td>Entry Line 2</td>
<td>Entry Line 3</td>
<td>Entry Line 4</td>
</tr>
<tr>
<td>Entry Last Line 1</td>
<td>Entry Last Line 2</td>
<td>Entry Last Line 3</td>
<td>Entry Last Line 4</td>
</tr>
</table>
<p>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.</p>
`;
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;';
@@ -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<RNTextInput>(null);
const replaceTextRef = useRef<RNTextInput>(null);
const [text, setText] = React.useState('');
const [replacementText, setReplacementText] = React.useState('');
const [editing, setEditing] = React.useState<string | undefined>();
const [error, setError] = React.useState<[string, string] | undefined>();
const listSize = useSharedValue<number | `${number}%`>(
Math.min(LIST_CLOSED_HEIGHT, arrayLength * LIST_ITEM_HEIGHT),
);
const iconRotation = useSharedValue<number>(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 (
<>
<List.Item
title={
showReplace
? getString('customCodeSettings.replace')
: getString('common.remove')
}
description={
showReplace
? getString('common.replaceText')
: getString('customCodeSettings.removeText')
}
theme={theme}
right="plus"
onPress={modal.setTrue}
/>
<Animated.View style={animatedListSize}>
{arrayLength <= 3 || listExpanded ? null : (
<AnimatedLinearGradient
entering={FadeIn.duration(150)}
exiting={FadeOut.duration(150)}
colors={['transparent', 'transparent', theme.background]}
style={styles.gradient}
onTouchEnd={() => calcListSize()}
/>
)}
{showReplace ? (
<LegendList
recycleItems
data={replaceArray}
renderItem={({ item }) => (
<ReplaceItem
item={item}
removeItem={removeItem}
editItem={editItem}
/>
)}
/>
) : (
<LegendList
recycleItems
data={removeText}
renderItem={({ item, index }) => (
<RemoveItem
item={item}
index={index}
removeItem={removeItem}
editItem={editItem}
/>
)}
/>
)}
</Animated.View>
{arrayLength > 3 ? (
<AnimatedIconButton
name="menu-down"
theme={theme}
onPress={calcListSize}
style={styles.marginHorizontal}
color={theme.primary}
rotation={iconRotation}
/>
) : null}
<Dialog.Root
visible={modal.value}
onDismiss={() => {
modal.setFalse();
setError(undefined);
}}
>
<Dialog.Header>
<Dialog.Title>
{getString('customCodeSettings.editReplace')}
</Dialog.Title>
</Dialog.Header>
<Dialog.Content>
<TextInput
ref={textRef}
label={getString('common.textToReplace')}
theme={colorTheme}
defaultValue={text}
onChangeText={setText}
autoCorrect={false}
mode="outlined"
style={styles.textfield}
error={error && !!error[0]}
/>
{!showReplace ? null : (
<TextInput
ref={replaceTextRef}
label={getString('common.replaceWith')}
theme={colorTheme}
defaultValue={replacementText}
onChangeText={setReplacementText}
autoCorrect={false}
mode="outlined"
style={[styles.textfield, styles.bottom]}
error={error && !!error[1]}
/>
)}
</Dialog.Content>
<Dialog.Actions>
<Dialog.Action
onPress={() => {
cancel();
modal.setFalse();
}}
>
Cancel
</Dialog.Action>
<Dialog.Action
onPress={() => {
if (save()) modal.setFalse();
}}
>
Save
</Dialog.Action>
</Dialog.Actions>
</Dialog.Root>
</>
);
};
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,
},
});
@@ -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<string>('');
const [code, setCode] = React.useState<string>('');
// 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<ScrollView>(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 (
<AnimatedScrollView
ref={scrollViewRef}
style={[styles.container, maxHeightScrollView]}
scrollEventThrottle={16}
onScroll={e => {
editorScrollSink.current?.(e.nativeEvent.contentOffset.y);
}}
>
<Row verticalSpacing={8}>
<Text theme={colors} style={styles.text}>
{'Select CSS or JS'}
</Text>
<ToggleButton
theme={theme}
icon="language-css3"
selected={language === 'css'}
onPress={() => setLanguage('css')}
disabled={isEditing}
/>
<ToggleButton
theme={theme}
icon="language-javascript"
selected={language === 'js'}
onPress={() => setLanguage('js')}
disabled={isEditing}
/>
</Row>
<TextInput
placeholder={'Snippet name'}
defaultValue={title}
onChangeText={setTitle}
style={styles.snippetName}
error={error.title}
/>
<CodeInput
language={language}
code={code}
setCode={setCode}
error={error.code}
scrollSink={editorScrollSink}
/>
<Row verticalSpacing={8}>
<Button
style={styles.button}
title={getString('readerSettings.openJSFile')}
mode="outlined"
/>
<Button
style={styles.button}
title={getString('common.save')}
mode="contained"
onPress={save}
/>
</Row>
</AnimatedScrollView>
);
};
export default React.memo(CodeRoute);
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 16,
},
text: {
flex: 1,
},
button: {
marginHorizontal: 8,
flexBasis: '40%',
flex: 1,
},
snippetName: {
marginTop: 8,
marginBottom: 16,
},
});
@@ -0,0 +1,191 @@
import React from 'react';
import { ScrollView, StyleSheet, View } from 'react-native';
import ReplaceItemModal from '../Modals/ReplaceItemModal';
import { List, SwitchItem, IconButtonV2 } from '@components';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
type SettingsRouteProps = {
onEditSnippet?: (index: number, isJS: boolean) => void;
};
const defaultExtended = [false, false, false, false];
const SettingsRoute: React.FC<SettingsRouteProps> = ({ onEditSnippet }) => {
const theme = useTheme();
const {
codeSnippetsJS,
codeSnippetsCSS,
setChapterReaderSettings: setSettings,
} = useChapterReaderSettings();
const [extended, setExtended] = React.useState(defaultExtended);
const toggleExtended = React.useCallback(
(index: number) => {
const newExtended = [...defaultExtended];
newExtended[index] = !extended[index];
setExtended(newExtended);
},
[extended],
);
const toggleSnippet = React.useCallback(
(index: number, isJS: boolean) => {
const snippets = isJS ? [...codeSnippetsJS] : [...codeSnippetsCSS];
snippets[index].active = !snippets[index].active;
setSettings({
[isJS ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets,
});
},
[codeSnippetsJS, codeSnippetsCSS, setSettings],
);
const deleteSnippet = React.useCallback(
(index: number, isJS: boolean) => {
const snippets = isJS ? [...codeSnippetsJS] : [...codeSnippetsCSS];
snippets.splice(index, 1);
setSettings({
[isJS ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets,
});
},
[codeSnippetsJS, codeSnippetsCSS, setSettings],
);
const editSnippet = React.useCallback(
(index: number, isJS: boolean) => {
onEditSnippet?.(index, isJS);
},
[onEditSnippet],
);
return (
<ScrollView style={styles.paddingBottom}>
<List.Section>
<List.SubHeader theme={theme}>{'Text manipulation'}</List.SubHeader>
<ReplaceItemModal
showReplace
toggleList={() => toggleExtended(0)}
listExpanded={extended[0]}
/>
<ReplaceItemModal
toggleList={() => toggleExtended(1)}
listExpanded={extended[1]}
/>
<List.Divider theme={theme} />
<List.SubHeader theme={theme}>{'Code Snippets'}</List.SubHeader>
<List.Item
title={'Create new snippet'}
description={'Add CSS or JavaScript code'}
theme={theme}
right="plus"
onPress={() => onEditSnippet?.(-1, true)} // -1 indicates new snippet
/>
{/* CSS Snippets */}
{codeSnippetsCSS.length > 0 && (
<>
<View style={styles.subSubHeader}>
<List.SubHeader theme={theme}>{'CSS Snippets'}</List.SubHeader>
</View>
{codeSnippetsCSS.map((snippet, index) => (
<View key={`css-${index}`} style={styles.snippetRow}>
<SwitchItem
value={snippet.active}
label={snippet.name}
description={
snippet.code.substring(0, 50) +
(snippet.code.length > 50 ? '...' : '')
}
onPress={() => toggleSnippet(index, false)}
theme={theme}
style={styles.switchItem}
/>
<View style={styles.actionButtons}>
<IconButtonV2
name="pencil"
size={20}
onPress={() => editSnippet(index, false)}
theme={theme}
/>
<IconButtonV2
name="delete"
size={20}
onPress={() => deleteSnippet(index, false)}
theme={theme}
/>
</View>
</View>
))}
</>
)}
{/* JS Snippets */}
{codeSnippetsJS.length > 0 && (
<>
<View style={styles.subSubHeader}>
<List.SubHeader theme={theme}>
{'JavaScript Snippets'}
</List.SubHeader>
</View>
{codeSnippetsJS.map((snippet, index) => (
<View key={`js-${index}`} style={styles.snippetRow}>
<SwitchItem
value={snippet.active}
label={snippet.name}
description={
snippet.code.substring(0, 50) +
(snippet.code.length > 50 ? '...' : '')
}
onPress={() => toggleSnippet(index, true)}
theme={theme}
style={styles.switchItem}
/>
<View style={styles.actionButtons}>
<IconButtonV2
name="pencil"
size={20}
onPress={() => editSnippet(index, true)}
theme={theme}
/>
<IconButtonV2
name="delete"
size={20}
onPress={() => deleteSnippet(index, true)}
theme={theme}
/>
</View>
</View>
))}
</>
)}
{codeSnippetsCSS.length === 0 && codeSnippetsJS.length === 0 && (
<List.Item title={'No code snippets'} theme={theme} />
)}
</List.Section>
</ScrollView>
);
};
export default SettingsRoute;
const styles = StyleSheet.create({
paddingBottom: { paddingBottom: 40 },
subSubHeader: {
fontSize: 14,
marginTop: 8,
marginBottom: 4,
},
snippetRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
},
switchItem: {
flex: 1,
paddingHorizontal: 0,
},
actionButtons: {
flexDirection: 'row',
gap: 8,
marginLeft: 8,
},
});
@@ -0,0 +1,208 @@
import { getString } from '@i18n/translations';
import React from 'react';
import { StyleSheet, View } from 'react-native';
import CodeInput from './Components/CodeInput';
import { showToast } from '@utils/showToast';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
import { TextInput as PaperTextInput } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { Dialog, IconButtonV2 } from '@components';
import type { HighlightMode } from './Components/SimpleCodeEditor';
import { useMMKVString } from 'react-native-mmkv';
export type SnippetEditorHandle = {
save: () => void;
setCode: (val: string) => void;
getCode: () => string;
};
type SnippetEditorProps = {
snippetIndex?: number;
language: 'css' | 'js';
};
const SnippetEditor = React.forwardRef<SnippetEditorHandle, SnippetEditorProps>(
({ snippetIndex, language }, ref) => {
const navigation = useNavigation();
const theme = useTheme();
const {
codeSnippetsJS,
codeSnippetsCSS,
setChapterReaderSettings: setSettings,
} = useChapterReaderSettings();
const isEditing = snippetIndex !== undefined && snippetIndex >= 0;
const snippets = language === 'js' ? codeSnippetsJS : codeSnippetsCSS;
const snippet = isEditing ? snippets[snippetIndex!] : null;
const [code, setCode] = React.useState<string>(snippet?.code ?? '');
const [error, setError] = React.useState({ code: false });
const [highlightMode = 'combined', setHighlightMode] = useMMKVString(
`snippetEditorHighlightMode`,
) as [
HighlightMode,
(value: HighlightMode | ((prev: HighlightMode) => HighlightMode)) => void,
];
const [snippetName, setSnippetName] = React.useState('');
const [showNameModal, setShowNameModal] = React.useState(false);
const editorScrollSink = React.useRef<((y: number) => void) | null>(null);
const save = React.useCallback(() => {
setError({ code: false });
if (!code.trim()) {
setError({ code: true });
return;
}
if (isEditing) {
const newSnippets = [...snippets];
newSnippets[snippetIndex!].code = code;
setSettings({
[language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']:
newSnippets,
});
showToast(getString('customCodeSettings.snippetUpdated'));
navigation.goBack();
} else {
setShowNameModal(true);
}
}, [
code,
snippets,
setSettings,
isEditing,
snippetIndex,
language,
navigation,
]);
const handleNameModalSave = React.useCallback(() => {
if (!snippetName.trim()) return false;
const newSnippets = [...snippets];
newSnippets.push({
name: snippetName.trim(),
code,
active: true,
lang: language,
});
setSettings({
[language === 'js' ? 'codeSnippetsJS' : 'codeSnippetsCSS']: newSnippets,
});
showToast(getString('customCodeSettings.snippetSaved'));
setShowNameModal(false);
navigation.goBack();
return true;
}, [snippetName, code, language, snippets, setSettings, navigation]);
const handleNameModalCancel = React.useCallback(() => {
setShowNameModal(false);
setSnippetName('');
}, []);
React.useImperativeHandle(
ref,
() => ({ save, setCode, getCode: () => code }),
[save, setCode, code],
);
return (
<>
<View style={styles.toolbar}>
<IconButtonV2
name="code-braces"
color={
highlightMode === 'off'
? theme.outline
: highlightMode === 'on'
? theme.primary
: theme.secondary
}
size={24}
theme={theme}
onPress={() =>
setHighlightMode((prev: HighlightMode) =>
prev === 'off'
? 'combined'
: prev === 'combined'
? 'on'
: 'off',
)
}
style={{ position: 'absolute', end: 8, top: 8, zIndex: 2 }}
/>
</View>
<KeyboardAwareScrollView
style={styles.scrollContainer}
bottomOffset={100}
nestedScrollEnabled
scrollEventThrottle={16}
onScroll={e => {
editorScrollSink.current?.(e.nativeEvent.contentOffset.y);
}}
contentContainerStyle={styles.flexGrow}
>
<CodeInput
language={language}
code={code}
setCode={setCode}
highlightMode={highlightMode}
error={error.code}
scrollSink={editorScrollSink}
/>
</KeyboardAwareScrollView>
<Dialog.Root visible={showNameModal} onDismiss={handleNameModalCancel}>
<Dialog.Header>
<Dialog.Title>{getString('common.name')}</Dialog.Title>
</Dialog.Header>
<Dialog.Content>
<PaperTextInput
label={getString('common.name')}
defaultValue={snippetName}
onChangeText={setSnippetName}
autoFocus
mode="outlined"
style={styles.mb16}
theme={{ colors: theme }}
/>
</Dialog.Content>
<Dialog.Actions>
<Dialog.Action onPress={handleNameModalCancel}>
Cancel
</Dialog.Action>
<Dialog.Action
onPress={() => {
if (handleNameModalSave()) setShowNameModal(false);
}}
>
Save
</Dialog.Action>
</Dialog.Actions>
</Dialog.Root>
</>
);
},
);
export default React.memo(SnippetEditor);
const styles = StyleSheet.create({
flexGrow: { flexGrow: 1 },
mb16: { marginBottom: 16 },
toolbar: {
flexDirection: 'row',
justifyContent: 'flex-end',
paddingHorizontal: 8,
paddingVertical: 4,
},
scrollContainer: {
paddingHorizontal: 2,
},
scrollContent: {},
button: {
marginHorizontal: 8,
flexBasis: '40%',
flex: 1,
},
});
@@ -0,0 +1,248 @@
import { Appbar, Dialog, List, SafeAreaView } from '@components';
import { CustomCodeSettingsScreenProps } from '@navigators/types';
import React from 'react';
import { Keyboard, ScrollView, StyleSheet, View } from 'react-native';
import { TextInput } from 'react-native-paper';
import ReplaceItemModal from './Modals/ReplaceItemModal';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
import { getString } from '@i18n/translations';
import Snippet from './Components/Snippet';
const SettingsCustomCode = ({ navigation }: CustomCodeSettingsScreenProps) => {
const theme = useTheme();
const {
codeSnippetsJS,
codeSnippetsCSS,
setChapterReaderSettings: setSettings,
} = useChapterReaderSettings();
const [renameSnippet, setRenameSnippet] = React.useState<{
index: number;
isJS: boolean;
name: string;
} | null>(null);
const [extended, setExtended] = React.useState([false, false, false, false]);
const toggleExtended = React.useCallback(
(index: number) => {
const newExtended = [false, false, false, false];
newExtended[index] = !extended[index];
setExtended(newExtended);
},
[extended],
);
const toggleSnippet = React.useCallback(
(index: number, isJS: boolean) => {
const snippets = isJS ? [...codeSnippetsJS] : [...codeSnippetsCSS];
snippets[index].active = !snippets[index].active;
setSettings({
[isJS ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets,
});
},
[codeSnippetsJS, codeSnippetsCSS, setSettings],
);
const deleteSnippet = React.useCallback(
(index: number, isJS: boolean) => {
const snippets = isJS ? [...codeSnippetsJS] : [...codeSnippetsCSS];
snippets.splice(index, 1);
setSettings({
[isJS ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets,
});
},
[codeSnippetsJS, codeSnippetsCSS, setSettings],
);
const handleEditSnippet = (snippetIndex: number, isJS: boolean) => {
navigation.navigate('CodeSnippets', {
snippetIndex,
isJS,
});
};
const handleRenameSave = React.useCallback(() => {
if (!renameSnippet || !renameSnippet.name.trim()) return false;
const snippets = renameSnippet.isJS
? [...codeSnippetsJS]
: [...codeSnippetsCSS];
snippets[renameSnippet.index].name = renameSnippet.name.trim();
setSettings({
[renameSnippet.isJS ? 'codeSnippetsJS' : 'codeSnippetsCSS']: snippets,
});
setRenameSnippet(null);
return true;
}, [renameSnippet, codeSnippetsJS, codeSnippetsCSS, setSettings]);
const handleRenameCancel = React.useCallback(() => {
setRenameSnippet(null);
}, []);
return (
<SafeAreaView excludeTop>
<Appbar
title={getString('common.custom_code')}
handleGoBack={() => {
Keyboard.dismiss();
navigation.goBack();
}}
theme={theme}
/>
<ScrollView style={styles.paddingBottom}>
<List.Section>
<List.SubHeader theme={theme}>
{getString('customCodeSettings.textManipulation')}
</List.SubHeader>
<ReplaceItemModal
showReplace
toggleList={() => toggleExtended(0)}
listExpanded={extended[0]}
/>
<ReplaceItemModal
toggleList={() => toggleExtended(1)}
listExpanded={extended[1]}
/>
<List.Divider theme={theme} />
<List.SubHeader theme={theme}>
{getString('customCodeSettings.codeSnippets')}
</List.SubHeader>
{/* CSS Snippets */}
<View style={styles.subSubHeader}>
<List.SubHeader theme={theme}>
{getString('customCodeSettings.cssSnippets')}
</List.SubHeader>
</View>
{codeSnippetsCSS.length > 0 &&
codeSnippetsCSS.map((snippet, index) => (
<Snippet
key={`css-${index}`}
toggle={toggleSnippet}
rename={(_index, isJS, name) =>
setRenameSnippet({
index,
isJS,
name,
})
}
edit={handleEditSnippet}
delete={deleteSnippet}
index={index}
snippet={snippet}
/>
))}
<List.Item
title={getString('customCodeSettings.createCSSSnippet')}
description={getString('customCodeSettings.addCssCode')}
theme={theme}
right="plus"
onPress={() => handleEditSnippet(-1, false)}
/>
{codeSnippetsCSS.length === 0 && (
<List.Item
title={getString('customCodeSettings.noCodeSnippets')}
theme={theme}
/>
)}
{/* JS Snippets */}
<View style={styles.subSubHeader}>
<List.SubHeader theme={theme}>
{getString('customCodeSettings.javascriptSnippets')}
</List.SubHeader>
</View>
{codeSnippetsJS.length > 0 &&
codeSnippetsJS.map((snippet, index) => (
<Snippet
key={`js-${index}`}
toggle={toggleSnippet}
rename={(_index, isJS, name) =>
setRenameSnippet({
index,
isJS,
name,
})
}
edit={handleEditSnippet}
delete={deleteSnippet}
index={index}
snippet={snippet}
/>
))}
<List.Item
title={getString('customCodeSettings.createJSSnippet')}
description={getString('customCodeSettings.addJavascriptCode')}
theme={theme}
right="plus"
onPress={() => handleEditSnippet(-1, true)}
/>
{codeSnippetsJS.length === 0 && (
<List.Item
title={getString('customCodeSettings.noCodeSnippets')}
theme={theme}
/>
)}
</List.Section>
</ScrollView>
<Dialog.Root
visible={renameSnippet !== null}
onDismiss={handleRenameCancel}
>
<Dialog.Header>
<Dialog.Title>
{getString('customCodeSettings.renameSnippet')}
</Dialog.Title>
</Dialog.Header>
<Dialog.Content>
<TextInput
label={getString('common.name')}
defaultValue={renameSnippet?.name ?? ''}
onChangeText={text => {
if (renameSnippet) {
setRenameSnippet({ ...renameSnippet, name: text });
}
}}
autoFocus
mode="outlined"
style={styles.mb16}
theme={{ colors: theme }}
/>
</Dialog.Content>
<Dialog.Actions>
<Dialog.Action onPress={handleRenameCancel}>Cancel</Dialog.Action>
<Dialog.Action
onPress={() => {
if (handleRenameSave()) setRenameSnippet(null);
}}
>
Save
</Dialog.Action>
</Dialog.Actions>
</Dialog.Root>
</SafeAreaView>
);
};
export default SettingsCustomCode;
const styles = StyleSheet.create({
mb16: { marginBottom: 16 },
paddingBottom: { paddingBottom: 40 },
subSubHeader: {
fontSize: 14,
marginTop: 8,
marginBottom: 4,
},
snippetRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
},
switchItem: {
flex: 1,
paddingHorizontal: 0,
},
actionButtons: {
flexDirection: 'row',
gap: 8,
marginLeft: 8,
},
});
@@ -1,8 +1,7 @@
import { View, StatusBar, StyleSheet, useWindowDimensions } from 'react-native';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { View, StyleSheet, useWindowDimensions } from 'react-native';
import { useCallback, useRef, useState } from 'react';
import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
import { useNavigation } from '@react-navigation/native';
import WebView from 'react-native-webview';
import { FAB } from 'react-native-paper';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import {
@@ -10,32 +9,21 @@ import {
type TabBarProps,
type TabDescriptor,
} from 'react-native-tab-view';
import { dummyHTML } from './utils';
import { Appbar, SafeAreaView, TopTabBar } from '@components/index';
import BottomSheet from '@components/BottomSheet/BottomSheet';
import {
useChapterGeneralSettings,
useChapterReaderSettings,
useTheme,
} from '@hooks/persisted';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
import { getString } from '@i18n/translations';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import color from 'color';
import { useBatteryLevel } from 'react-native-device-info';
import SettingsReaderWebView from './components/SettingsReaderWebView';
import DisplayTab from './tabs/DisplayTab';
import ThemeTab from './tabs/ThemeTab';
import NavigationTab from './tabs/NavigationTab';
import AccessibilityTab from './tabs/AccessibilityTab';
import AdvancedTab from './tabs/AdvancedTab';
import { useTtsSession } from '@screens/reader/hooks/useTtsSession';
import type { TtsSettings } from '@modules/nitro-tts';
type ReaderSettingsRoute = {
key: 'display' | 'theme' | 'navigation' | 'accessibility' | 'advanced';
key: 'display' | 'theme' | 'navigation' | 'accessibility';
title: string;
icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'];
};
@@ -43,17 +31,8 @@ type ReaderSettingsRoute = {
const routes: ReaderSettingsRoute[] = [
{ key: 'display', title: 'Display', icon: 'format-size' },
{ key: 'theme', title: 'Theme', icon: 'palette-outline' },
{
key: 'navigation',
title: 'Navigation',
icon: 'gesture-swipe-horizontal',
},
{
key: 'accessibility',
title: 'Accessibility',
icon: 'account-voice',
},
{ key: 'advanced', title: 'Advanced', icon: 'code-braces' },
{ key: 'navigation', title: 'Navigation', icon: 'gesture-swipe-horizontal' },
{ key: 'accessibility', title: 'Accessibility', icon: 'account-voice' },
];
const tabOptions: TabDescriptor<ReaderSettingsRoute> = {
@@ -71,143 +50,18 @@ export type TextAlignments =
| 'justify'
| undefined;
type WebViewPostEvent = {
type: string;
data?: unknown;
};
const toNativeTtsSettings = (
settings: ReturnType<typeof useChapterReaderSettings>['tts'],
): TtsSettings => ({
engineName: settings?.engine?.name,
voiceIdentifier: settings?.voice?.identifier,
rate: settings?.rate ?? 1,
pitch: settings?.pitch ?? 1,
});
const SettingsReaderScreen = () => {
const theme = useTheme();
const navigation = useNavigation();
const webViewRef = useRef<WebView<object>>(null);
const bottomSheetRef = useRef<BottomSheetModalMethods>(null);
const { bottom, right } = useSafeAreaInsets();
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const [tabIndex, setTabIndex] = useState(0);
const novel = {
'artist': null,
'author': 'LNReader-kun',
'cover':
'file:///storage/emulated/0/Android/data/com.rajarsheechatterjee.LNReader/files/Novels/lightnovelcave/16/cover.png?1717862123181',
'genres': 'Action,Hero',
'id': 16,
'inLibrary': 1,
'isLocal': 0,
'name': 'Preview Man (LN)',
'path': 'novel/preview-man-16091321',
'pluginId': 'lightnovelcave',
'status': 'Ongoing',
'summary':
'To preview or not preview. A question that bothered humanity for a long time, until one day… Preview Man appeared.Show More',
'totalPages': 8,
};
const chapter = {
'bookmark': 0,
'chapterNumber': 1,
'id': 3722,
'isDownloaded': 1,
'name': 'Chapter 1 - The rise of Preview Man',
'novelId': 16,
'page': '2',
'path': 'novel/preview-man/chapter-1',
'position': 0,
'progress': 3,
'readTime': '2100-01-01 00:00:00',
'releaseTime': 'January 1, 2100',
'unread': 1,
'updatedTime': null,
};
const [hidden, setHidden] = useState(true);
const batteryLevel = useBatteryLevel();
const readerSettings = useChapterReaderSettings();
const chapterGeneralSettings = useChapterGeneralSettings();
const {
command: runTtsCommand,
loadAndPlay,
progress: ttsProgress,
seekTo: seekTts,
state: ttsState,
updateSettings: updateTtsSettings,
} = useTtsSession();
const BOTTOM_SHEET_HEIGHT = screenHeight * 0.7;
const assetsUriPrefix = useMemo(
() => (__DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'),
[],
);
const webViewCSS = `
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
<style>
:root {
--StatusBar-currentHeight: ${StatusBar.currentHeight};
--readerSettings-theme: ${readerSettings.theme};
--readerSettings-padding: ${readerSettings.padding}px;
--readerSettings-textSize: ${readerSettings.textSize}px;
--readerSettings-textColor: ${readerSettings.textColor};
--readerSettings-textAlign: ${readerSettings.textAlign};
--readerSettings-lineHeight: ${readerSettings.lineHeight};
--readerSettings-fontFamily: ${readerSettings.fontFamily};
--theme-primary: ${theme.primary};
--theme-onPrimary: ${theme.onPrimary};
--theme-secondary: ${theme.secondary};
--theme-tertiary: ${theme.tertiary};
--theme-onTertiary: ${theme.onTertiary};
--theme-onSecondary: ${theme.onSecondary};
--theme-surface: ${theme.surface};
--theme-surface-0-9: ${color(theme.surface).alpha(0.9).toString()};
--theme-onSurface: ${theme.onSurface};
--theme-surfaceVariant: ${theme.surfaceVariant};
--theme-onSurfaceVariant: ${theme.onSurfaceVariant};
--theme-outline: ${theme.outline};
--theme-rippleColor: ${theme.rippleColor};
}
@font-face {
font-family: ${readerSettings.fontFamily};
src: url("file:///android_asset/fonts/${
readerSettings.fontFamily
}.ttf");
}
</style>
<style>${readerSettings.customCSS}</style>
`;
const readerBackgroundColor = readerSettings.theme;
useEffect(() => {
updateTtsSettings(toNativeTtsSettings(readerSettings.tts));
}, [readerSettings.tts, updateTtsSettings]);
useEffect(() => {
webViewRef.current?.injectJavaScript(`
window.tts?.setPlaybackState?.(${JSON.stringify(ttsState)});
true;
`);
if (ttsState === 'completed') {
webViewRef.current?.injectJavaScript('window.tts?.complete?.(); true;');
}
}, [ttsState]);
useEffect(() => {
if (ttsProgress.total > 0) {
webViewRef.current?.injectJavaScript(`
window.tts?.setActiveIndex?.(${ttsProgress.index});
true;
`);
}
}, [ttsProgress]);
const openBottomSheet = () => {
bottomSheetRef.current?.present();
};
@@ -223,8 +77,6 @@ const SettingsReaderScreen = () => {
return <NavigationTab />;
case 'accessibility':
return <AccessibilityTab />;
case 'advanced':
return <AdvancedTab />;
default:
return <DisplayTab />;
}
@@ -273,131 +125,7 @@ const SettingsReaderScreen = () => {
{/* Large Preview Area */}
<View style={styles.previewContainer}>
<WebView<object>
ref={webViewRef}
originWhitelist={['*']}
allowFileAccess={true}
scalesPageToFit={true}
showsVerticalScrollIndicator={false}
javaScriptEnabled={true}
style={[styles.webView, { backgroundColor: readerBackgroundColor }]}
nestedScrollEnabled={true}
onMessage={(ev: { nativeEvent: { data: string } }) => {
const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data);
switch (event.type) {
case 'hide':
if (hidden) {
webViewRef.current?.injectJavaScript(
'reader.hidden.val = true',
);
} else {
webViewRef.current?.injectJavaScript(
'reader.hidden.val = false',
);
}
setHidden(!hidden);
break;
case '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,
chapterName: chapter.name,
coverUri: novel.cover,
},
toNativeTtsSettings(readerSettings.tts),
);
break;
}
case 'tts-command': {
if (!event.data || typeof event.data !== 'object') {
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;
}
}
}}
source={{
html: `
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
${webViewCSS}
</head>
<body class="${
chapterGeneralSettings.pageReader ? 'page-reader' : ''
}">
<div id="LNReader-chapter">
${dummyHTML}
</div>
<div id="reader-ui"></div>
</body>
<script>
var initialReaderConfig = ${JSON.stringify({
readerSettings,
chapterGeneralSettings,
novel,
chapter,
nextChapter: chapter,
batteryLevel,
autoSaveInterval: 2222,
DEBUG: __DEV__,
strings: {
finished: `${getString(
'readerScreen.finished',
)}: ${chapter.name.trim()}`,
nextChapter: getString('readerScreen.nextChapter', {
name: chapter.name,
}),
noNextChapter: getString('readerScreen.noNextChapter'),
},
})}
</script>
<script src="${assetsUriPrefix}/js/icons.js"></script>
<script src="${assetsUriPrefix}/js/van.js"></script>
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
<script src="${assetsUriPrefix}/js/core.js"></script>
<script src="${assetsUriPrefix}/js/index.js"></script>
<script>
${readerSettings.customJS}
</script>
</html>
`,
}}
/>
<SettingsReaderWebView />
</View>
{/* Floating Action Button to Open Bottom Sheet */}
@@ -454,9 +182,6 @@ const styles = StyleSheet.create({
previewContainer: {
flex: 1,
},
webView: {
flex: 1,
},
tabBar: {
borderBottomWidth: 1,
elevation: 0,
@@ -0,0 +1,291 @@
import { StatusBar, StyleSheet } from 'react-native';
import { useEffect, useMemo, useRef, useState } from 'react';
import WebView from 'react-native-webview';
import {
useChapterGeneralSettings,
useChapterReaderSettings,
useTheme,
} from '@hooks/persisted';
import { getString } from '@i18n/translations';
import type { ChapterReaderSettings } from '@hooks/persisted/useSettings';
import color from 'color';
import { useBatteryLevel } from 'react-native-device-info';
import { useTtsSession } from '@screens/reader/hooks/useTtsSession';
import type { TtsSettings } from '@modules/nitro-tts';
import { dummyHTML } from '@screens/settings/SettingsCustomCodeScreen/Components/dummies';
type WebViewPostEvent = {
type: string;
data?: unknown;
msg?: string;
};
type SettingsReaderWebViewProps = {
customCSS?: string;
customJS?: string;
};
const toNativeTtsSettings = (
settings: ChapterReaderSettings['tts'],
): TtsSettings => ({
engineName: settings?.engine?.name,
voiceIdentifier: settings?.voice?.identifier,
rate: settings?.rate ?? 1,
pitch: settings?.pitch ?? 1,
});
const SettingsReaderWebView = ({
customCSS,
customJS,
}: SettingsReaderWebViewProps) => {
const theme = useTheme();
const webViewRef = useRef<WebView<object>>(null);
const novel = {
'artist': null,
'author': 'LNReader-kun',
'cover':
'file:///storage/emulated/0/Android/data/com.rajarsheechatterjee.LNReader/files/Novels/lightnovelcave/16/cover.png?1717862123181',
'genres': 'Action,Hero',
'id': 16,
'inLibrary': 1,
'isLocal': 0,
'name': 'Preview Man (LN)',
'path': 'novel/preview-man-16091321',
'pluginId': 'lightnovelcave',
'status': 'Ongoing',
'summary':
'To preview or not preview. A question that bothered humanity for a long time, until one day… Preview Man appeared.Show More',
'totalPages': 8,
};
const chapter = {
'bookmark': 0,
'chapterNumber': 1,
'id': 3722,
'isDownloaded': 1,
'name': 'Chapter 1 - The rise of Preview Man',
'novelId': 16,
'page': '2',
'path': 'novel/preview-man/chapter-1',
'position': 0,
'progress': 3,
'readTime': '2100-01-01 00:00:00',
'releaseTime': 'January 1, 2100',
'unread': 1,
'updatedTime': null,
};
const [hidden, setHidden] = useState(true);
const batteryLevel = useBatteryLevel();
const readerSettings = useChapterReaderSettings();
const chapterGeneralSettings = useChapterGeneralSettings();
const {
command: runTtsCommand,
loadAndPlay,
progress: ttsProgress,
seekTo: seekTts,
state: ttsState,
updateSettings: updateTtsSettings,
} = useTtsSession();
const assetsUriPrefix = useMemo(
() => (__DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'),
[],
);
const webViewCSS = `
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
<style>
:root {
--StatusBar-currentHeight: ${StatusBar.currentHeight};
--readerSettings-theme: ${readerSettings.theme};
--readerSettings-padding: ${readerSettings.padding}px;
--readerSettings-textSize: ${readerSettings.textSize}px;
--readerSettings-textColor: ${readerSettings.textColor};
--readerSettings-textAlign: ${readerSettings.textAlign};
--readerSettings-lineHeight: ${readerSettings.lineHeight};
--readerSettings-fontFamily: ${readerSettings.fontFamily};
--theme-primary: ${theme.primary};
--theme-onPrimary: ${theme.onPrimary};
--theme-secondary: ${theme.secondary};
--theme-tertiary: ${theme.tertiary};
--theme-onTertiary: ${theme.onTertiary};
--theme-onSecondary: ${theme.onSecondary};
--theme-surface: ${theme.surface};
--theme-surface-0-9: ${color(theme.surface).alpha(0.9).toString()};
--theme-onSurface: ${theme.onSurface};
--theme-surfaceVariant: ${theme.surfaceVariant};
--theme-onSurfaceVariant: ${theme.onSurfaceVariant};
--theme-outline: ${theme.outline};
--theme-rippleColor: ${theme.rippleColor};
}
@font-face {
font-family: ${readerSettings.fontFamily};
src: url("file:///android_asset/fonts/${
readerSettings.fontFamily
}.ttf");
}
</style>
<style>${customCSS ?? readerSettings.customCSS}</style>
`;
const readerBackgroundColor = readerSettings.theme;
useEffect(() => {
updateTtsSettings(toNativeTtsSettings(readerSettings.tts));
}, [readerSettings.tts, updateTtsSettings]);
useEffect(() => {
webViewRef.current?.injectJavaScript(`
window.tts?.setPlaybackState?.(${JSON.stringify(ttsState)});
true;
`);
if (ttsState === 'completed') {
webViewRef.current?.injectJavaScript('window.tts?.complete?.(); true;');
}
}, [ttsState]);
useEffect(() => {
if (ttsProgress.total > 0) {
webViewRef.current?.injectJavaScript(`
window.tts?.setActiveIndex?.(${ttsProgress.index});
true;
`);
}
}, [ttsProgress]);
return (
<WebView
ref={webViewRef}
originWhitelist={['*']}
allowFileAccess={true}
scalesPageToFit={true}
showsVerticalScrollIndicator={false}
javaScriptEnabled={true}
style={[styles.webView, { backgroundColor: readerBackgroundColor }]}
nestedScrollEnabled={true}
onMessage={(ev: { nativeEvent: { data: string } }) => {
const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data);
switch (event.type) {
case 'hide':
if (hidden) {
webViewRef.current?.injectJavaScript('reader.hidden.val = true');
} else {
webViewRef.current?.injectJavaScript('reader.hidden.val = false');
}
setHidden(!hidden);
break;
case '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,
chapterName: chapter.name,
coverUri: novel.cover,
},
toNativeTtsSettings(readerSettings.tts),
);
break;
}
case 'tts-command': {
if (!event.data || typeof event.data !== 'object') {
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 'console':
/* eslint-disable no-console */
console.info(`[Console] ${JSON.stringify(event.msg, null, 2)}`);
}
}}
source={{
html: `
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
${webViewCSS}
</head>
<body class="${
chapterGeneralSettings.pageReader ? 'page-reader' : ''
}">
<div id="LNReader-chapter">
${dummyHTML}
</div>
<div id="reader-ui"></div>
</body>
<script>
var initialReaderConfig = ${JSON.stringify({
readerSettings,
chapterGeneralSettings,
novel,
chapter,
nextChapter: chapter,
batteryLevel,
autoSaveInterval: 2222,
DEBUG: __DEV__,
strings: {
finished: `${getString(
'readerScreen.finished',
)}: ${chapter.name.trim()}`,
nextChapter: getString('readerScreen.nextChapter', {
name: chapter.name,
}),
noNextChapter: getString('readerScreen.noNextChapter'),
},
})}
</script>
<script src="${assetsUriPrefix}/js/icons.js"></script>
<script src="${assetsUriPrefix}/js/van.js"></script>
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
<script src="${assetsUriPrefix}/js/core.js"></script>
<script src="${assetsUriPrefix}/js/index.js"></script>
<script>
${customJS ?? readerSettings.customJS}
</script>
</html>
`,
}}
/>
);
};
export default SettingsReaderWebView;
const styles = StyleSheet.create({
webView: {
flex: 1,
},
});
@@ -1,413 +0,0 @@
import React, { useState } from 'react';
import {
View,
StyleSheet,
Text,
Pressable,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { TextInput } from 'react-native-paper';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import * as DocumentPicker from 'expo-document-picker';
import NativeFile from '@modules/native-file';
import { useTheme, useChapterReaderSettings } from '@hooks/persisted';
import { getString } from '@i18n/translations';
import { ThemeColors } from '@theme/types';
import { Button, ConfirmationDialog } from '@components/index';
import { showToast } from '@utils/showToast';
import { useBoolean } from '@hooks';
type CodeTab = 'css' | 'js';
const AdvancedTab: React.FC = () => {
const theme = useTheme();
const styles = createStyles(theme);
const { customCSS, customJS, setChapterReaderSettings } =
useChapterReaderSettings();
const [activeCodeTab, setActiveCodeTab] = useState<CodeTab>('css');
const [cssValue, setCssValue] = useState(customCSS || '');
const [jsValue, setJsValue] = useState(customJS || '');
const clearCSSModal = useBoolean();
const clearJSModal = useBoolean();
const customCSSPlaceholder = `/* Custom CSS for your reader */
body {
margin: 16px;
line-height: 1.8;
}
h1, h2, h3 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: bold;
}
p {
text-indent: 1em;
margin-bottom: 1em;
}
/* Target specific sources */
#sourceId-example {
font-family: serif;
}`;
const customJSPlaceholder = `// Custom JavaScript for your reader
// Available variables:
// - html, novelName, chapterName
// - sourceId, chapterId, novelId
// Example: Remove elements
document.querySelectorAll('.ads').forEach(el => el.remove());
// Example: Modify content
const title = document.querySelector('h1');
if (title) {
title.style.color = '#FF6B6B';
}`;
const handleSave = () => {
if (activeCodeTab === 'css') {
setChapterReaderSettings({ customCSS: cssValue });
} else {
setChapterReaderSettings({ customJS: jsValue });
}
showToast(getString('common.saved'));
};
const handleReset = () => {
if (activeCodeTab === 'css') {
clearCSSModal.setTrue();
} else {
clearJSModal.setTrue();
}
};
const confirmResetCSS = () => {
setCssValue('');
setChapterReaderSettings({ customCSS: '' });
clearCSSModal.setFalse();
};
const confirmResetJS = () => {
setJsValue('');
setChapterReaderSettings({ customJS: '' });
clearJSModal.setFalse();
};
const handleImport = async () => {
try {
const mimeType =
activeCodeTab === 'css' ? 'text/css' : 'application/javascript';
const file = await DocumentPicker.getDocumentAsync({
copyToCacheDirectory: false,
type: mimeType,
});
if (file.assets) {
const tempPath =
NativeFile.ExternalCachesDirectoryPath +
'/imported_custom.' +
activeCodeTab;
await NativeFile.copyFile(file.assets[0].uri, tempPath);
const content = await NativeFile.readFile(tempPath);
await NativeFile.unlink(tempPath);
if (activeCodeTab === 'css') {
setCssValue(content.trim());
setChapterReaderSettings({ customCSS: content.trim() });
} else {
setJsValue(content.trim());
setChapterReaderSettings({ customJS: content.trim() });
}
showToast(getString('common.imported'));
}
} catch (error: any) {
showToast(error.message);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={100}
>
<BottomSheetScrollView
style={styles.scrollContainer}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.contentContainer}
>
{/* Tab Selector */}
<View style={styles.tabContainer}>
<Pressable
accessibilityLabel="CSS"
accessibilityRole="tab"
accessibilityState={{ selected: activeCodeTab === 'css' }}
style={styles.tab}
onPress={() => setActiveCodeTab('css')}
android_ripple={{ color: theme.rippleColor }}
>
<MaterialCommunityIcons
name="language-css3"
size={20}
color={
activeCodeTab === 'css' ? theme.primary : theme.onSurfaceVariant
}
style={styles.tabIcon}
/>
<Text
style={[
styles.tabLabel,
{
color:
activeCodeTab === 'css'
? theme.primary
: theme.onSurfaceVariant,
},
activeCodeTab === 'css' && styles.activeTabLabel,
]}
>
CSS
</Text>
{activeCodeTab === 'css' ? (
<View
style={[
styles.tabIndicator,
{ backgroundColor: theme.primary },
]}
/>
) : null}
</Pressable>
<Pressable
accessibilityLabel="JavaScript"
accessibilityRole="tab"
accessibilityState={{ selected: activeCodeTab === 'js' }}
style={styles.tab}
onPress={() => setActiveCodeTab('js')}
android_ripple={{ color: theme.rippleColor }}
>
<MaterialCommunityIcons
name="language-javascript"
size={20}
color={
activeCodeTab === 'js' ? theme.primary : theme.onSurfaceVariant
}
style={styles.tabIcon}
/>
<Text
style={[
styles.tabLabel,
{
color:
activeCodeTab === 'js'
? theme.primary
: theme.onSurfaceVariant,
},
activeCodeTab === 'js' && styles.activeTabLabel,
]}
>
JS
</Text>
{activeCodeTab === 'js' ? (
<View
style={[
styles.tabIndicator,
{ backgroundColor: theme.primary },
]}
/>
) : null}
</Pressable>
</View>
{/* Code Editor */}
<View style={styles.editorContainer}>
<TextInput
mode="flat"
value={activeCodeTab === 'css' ? cssValue : jsValue}
onChangeText={text =>
activeCodeTab === 'css' ? setCssValue(text) : setJsValue(text)
}
placeholder={
activeCodeTab === 'css'
? customCSSPlaceholder
: customJSPlaceholder
}
multiline
numberOfLines={12}
autoCorrect={false}
autoCapitalize="none"
spellCheck={false}
style={[styles.codeEditor, { backgroundColor: theme.surface2 }]}
activeUnderlineColor={theme.primary}
contentStyle={styles.codeEditorContent}
textColor={theme.onSurface}
placeholderTextColor={theme.onSurfaceVariant}
/>
</View>
{/* Hint */}
<View
style={[styles.hint, { backgroundColor: theme.secondaryContainer }]}
>
<MaterialCommunityIcons
name="lightbulb-outline"
size={18}
color={theme.onSecondaryContainer}
style={styles.hintIcon}
/>
<Text
style={[styles.hintText, { color: theme.onSecondaryContainer }]}
>
{activeCodeTab === 'css'
? getString('readerSettings.cssHint')
: getString('readerSettings.jsHint')}
</Text>
</View>
{/* Action Buttons */}
<View style={styles.actionButtons}>
<Button
title={
activeCodeTab === 'css'
? getString('readerSettings.openCSSFile')
: getString('readerSettings.openJSFile')
}
onPress={handleImport}
mode="outlined"
style={styles.button}
/>
<Button
title={getString('common.reset')}
onPress={handleReset}
mode="outlined"
style={styles.button}
disabled={activeCodeTab === 'css' ? !cssValue : !jsValue}
/>
<Button
title={getString('common.save')}
onPress={handleSave}
mode="contained"
style={styles.button}
/>
</View>
<View style={styles.bottomSpacing} />
</BottomSheetScrollView>
<ConfirmationDialog
title={getString('readerSettings.clearCustomCSS')}
confirmLabel={getString('common.clear')}
visible={clearCSSModal.value}
onConfirm={confirmResetCSS}
onDismiss={clearCSSModal.setFalse}
/>
<ConfirmationDialog
title={getString('readerSettings.clearCustomJS')}
confirmLabel={getString('common.clear')}
visible={clearJSModal.value}
onConfirm={confirmResetJS}
onDismiss={clearJSModal.setFalse}
/>
</KeyboardAvoidingView>
);
};
export default AdvancedTab;
const createStyles = (theme: ThemeColors) =>
StyleSheet.create({
container: {
flex: 1,
},
scrollContainer: {
flex: 1,
},
contentContainer: {
paddingBottom: 24,
},
tabContainer: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: theme.outlineVariant,
},
tab: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
minHeight: 48,
},
tabIndicator: {
position: 'absolute',
bottom: 0,
width: '60%',
height: 3,
borderTopLeftRadius: 3,
borderTopRightRadius: 3,
},
tabIcon: {
marginEnd: 8,
},
tabLabel: {
fontSize: 14,
letterSpacing: 0.5,
fontWeight: '400',
},
activeTabLabel: {
fontWeight: '500',
},
editorContainer: {
marginHorizontal: 16,
marginTop: 16,
minHeight: 300,
},
codeEditor: {
minHeight: 300,
maxHeight: 400,
},
codeEditorContent: {
fontFamily: 'monospace',
fontSize: 13,
lineHeight: 20,
paddingTop: 12,
paddingBottom: 12,
},
hint: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 12,
borderRadius: 8,
marginHorizontal: 16,
marginTop: 16,
gap: 8,
},
hintIcon: {
marginTop: 2,
},
hintText: {
flex: 1,
fontSize: 12,
lineHeight: 18,
},
actionButtons: {
flexDirection: 'row',
justifyContent: 'space-between',
marginHorizontal: 16,
marginTop: 16,
gap: 8,
},
button: {
flex: 1,
},
bottomSpacing: {
height: 24,
},
});
File diff suppressed because one or more lines are too long
+6
View File
@@ -67,6 +67,12 @@ const SettingsScreen = ({ navigation }: SettingsScreenProps) => {
}
theme={theme}
/>
<List.Item
title="Custom Code"
icon="code-braces"
onPress={() => navigation.navigate('CustomCode')}
theme={theme}
/>
<List.Item
title={getString('tracking')}
icon="sync"
+152
View File
@@ -0,0 +1,152 @@
import { composeCSS, composeJS, safeApplyRegex, applyTextModifications, CodeSnippet } from '../customCode';
describe('composeCSS', () => {
it('returns empty string for empty array', () => {
expect(composeCSS([])).toBe('');
});
it('returns empty string when all snippets are inactive', () => {
const snippets: CodeSnippet[] = [
{ name: 'a', code: 'body { color: red; }', lang: 'css', active: false },
{ name: 'b', code: 'h1 { font-size: 2em; }', lang: 'css', active: false },
];
expect(composeCSS(snippets)).toBe('');
});
it('concatenates only active snippet code, joined by newline', () => {
const snippets: CodeSnippet[] = [
{ name: 'a', code: 'body { color: red; }', lang: 'css', active: true },
{ name: 'b', code: 'h1 { font-size: 2em; }', lang: 'css', active: false },
{ name: 'c', code: 'p { margin: 0; }', lang: 'css', active: true },
];
expect(composeCSS(snippets)).toBe('body { color: red; }\np { margin: 0; }');
});
it('preserves order', () => {
const snippets: CodeSnippet[] = [
{ name: 'a', code: 'first', lang: 'css', active: true },
{ name: 'b', code: 'second', lang: 'css', active: true },
{ name: 'c', code: 'third', lang: 'css', active: true },
];
expect(composeCSS(snippets)).toBe('first\nsecond\nthird');
});
});
describe('composeJS', () => {
it('returns empty string for empty array', () => {
expect(composeJS([])).toBe('');
});
it('returns empty string when all snippets are inactive', () => {
const snippets: CodeSnippet[] = [
{ name: 'a', code: 'console.log(1)', lang: 'js', active: false },
{ name: 'b', code: 'console.log(2)', lang: 'js', active: false },
];
expect(composeJS(snippets)).toBe('');
});
it('each active snippet wrapped in try/catch with JSON-stringified name', () => {
const snippets: CodeSnippet[] = [
{ name: 'test-snippet', code: 'doSomething()', lang: 'js', active: true },
];
const result = composeJS(snippets);
expect(result).toContain('try {');
expect(result).toContain('doSomething()');
expect(result).toContain('} catch (error) {');
expect(result).toContain(`${JSON.stringify('test-snippet')}`);
});
it('multiple active snippets produce concatenated try/catch blocks', () => {
const snippets: CodeSnippet[] = [
{ name: 'a', code: 'fnA()', lang: 'js', active: true },
{ name: 'b', code: 'fnB()', lang: 'js', active: true },
];
const result = composeJS(snippets);
expect(result).toContain('fnA()');
expect(result).toContain('fnB()');
const matches = result.match(/try\s*\{/g);
expect(matches).toHaveLength(2);
});
});
describe('safeApplyRegex', () => {
it('applies valid regex with replacement and returns modified text', () => {
const match = ['/foo/g', 'foo', 'g'] as unknown as RegExpMatchArray;
const result = safeApplyRegex(match, 'foo bar foo', 'baz');
expect(result).toBe('baz bar baz');
});
it('applies valid regex without replacement (default empty string) — removes matches', () => {
const match = ['/\\d+/g', '\\d+', 'g'] as unknown as RegExpMatchArray;
const result = safeApplyRegex(match, 'abc 123 def 456');
expect(result).toBe('abc def ');
});
it('returns original text when flags are invalid — does not throw', () => {
const match = ['/foo/x', 'foo', 'x'] as unknown as RegExpMatchArray;
const result = safeApplyRegex(match, 'foo bar', 'baz');
expect(result).toBe('foo bar');
});
it('returns original text when regex pattern itself is invalid', () => {
const match = ['/(unmatched(/g', '(unmatched(', 'g'] as unknown as RegExpMatchArray;
const result = safeApplyRegex(match, 'test', 'replacement');
expect(result).toBe('test');
});
it('handles all valid flags: g, m, i, y, u, v, s, d', () => {
const text = 'Line1\nline2\nLINE3\nline4';
const match = ['/^line.*/gim', '^line.*', 'gim'] as unknown as RegExpMatchArray;
const result = safeApplyRegex(match, text, 'MATCH');
expect(result).toBe('MATCH\nMATCH\nMATCH\nMATCH');
});
it('uses RegExp match array format ["/pattern/flags", "pattern", "flags"]', () => {
const text = 'hello world hello';
const match = ['/hello/g', 'hello', 'g'] as unknown as RegExpMatchArray;
const result = safeApplyRegex(match, text, 'hi');
expect(result).toBe('hi world hi');
});
});
describe('applyTextModifications', () => {
it('removes literal text strings via removeText array (split/join)', () => {
const result = applyTextModifications('hello world foo', ['world '], {});
expect(result).toBe('hello foo');
});
it('removes text matching regex patterns from removeText (entries starting/ending with /)', () => {
const result = applyTextModifications('abc123def456ghi', ['/\\d+/g'], {});
expect(result).toBe('abcdefghi');
});
it('replaces literal text strings via replaceText record', () => {
const result = applyTextModifications('hello world', [], { hello: 'hi' });
expect(result).toBe('hi world');
});
it('replaces text matching regex patterns from replaceText keys', () => {
const result = applyTextModifications('foo123bar456baz', [], { '/\\d+/g': '#' });
expect(result).toBe('foo#bar#baz');
});
it('apply order: removeText first, then replaceText', () => {
const result = applyTextModifications('x hello x world x', ['x '], { world: 'earth' });
expect(result).toBe('hello earth x');
});
it('empty text key in replaceText is skipped', () => {
const result = applyTextModifications('hello world', [], { '': 'x', hello: 'hi' });
expect(result).toBe('hi world');
});
it('returns html unchanged when both arrays are empty', () => {
const result = applyTextModifications('original text', [], {});
expect(result).toBe('original text');
});
it('regex in removeText and literal in replaceText coexist correctly', () => {
const result = applyTextModifications('abc123def', ['/\\d+/g'], { def: 'xyz' });
expect(result).toBe('abcxyz');
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* Shared utilities for custom code snippets and text manipulation.
*
* These were duplicated across multiple files — centralize here.
*/
/** A single code snippet entry */
export type CodeSnippet = {
name: string;
code: string;
lang: 'js' | 'css';
active: boolean;
};
/**
* Build a concatenated CSS string from active snippets.
*/
export const composeCSS = (snippets: CodeSnippet[]): string =>
snippets
.filter(s => s.active)
.map(s => s.code)
.join('\n');
/**
* Build a concatenated JS string from active snippets,
* each wrapped in try/catch so one failure doesn't stop the rest.
*/
export const composeJS = (snippets: CodeSnippet[]): string =>
snippets
.filter(s => s.active)
.map(
s => `
try {
${s.code}
} catch (error) {
alert(\`Error executing ${JSON.stringify(s.name)}:\n\` + error);
}
`,
)
.join('\n');
/**
* Safely apply a RegExp match to a text string.
*
* The match is expected to have been produced by /^\/(.*)\/([gmiyuvsd]*)$/.
* Returns the original text when the pattern is invalid or the match fails.
*/
export const safeApplyRegex = (
match: RegExpMatchArray,
text: string,
replacement: string = '',
): string => {
const validFlags = new Set(['g', 'm', 'i', 'y', 'u', 'v', 's', 'd']);
const flags = match[2] ?? '';
const hasInvalidFlags = [...flags].some(f => !validFlags.has(f));
if (hasInvalidFlags) {
/* eslint-disable-next-line no-console */
console.warn('Invalid regex flags:', match[0]);
return text;
}
try {
const r = new RegExp(match[1], flags);
return text.replace(r, replacement);
} catch {
/* eslint-disable-next-line no-console */
console.warn('Invalid regex pattern:', match[0]);
}
return text;
};
/**
* Test whether `input` looks like a `/pattern/flags` regex string.
*/
const isRegexString = (input: string): RegExpMatchArray | null =>
input.match(/^\/(.*)\/([gmiyuvsd]*)$/);
/**
* Apply all remove-text and replace-text entries to `html`.
*
* Each entry in `removeText` or a key in `replaceText` can be:
* - a literal string (simple split/join)
* - a `/pattern/flags` regex string
*/
export const applyTextModifications = (
html: string,
removeText: string[],
replaceText: Record<string, string>,
): string => {
let result = html;
for (const text of removeText) {
const m = isRegexString(text);
if (m) {
result = safeApplyRegex(m, result);
} else {
result = result.split(text).join('');
}
}
for (const [text, replacement] of Object.entries(replaceText)) {
if (!text) continue;
const m = isRegexString(text);
if (m) {
result = safeApplyRegex(m, result, replacement);
} else {
result = result.split(text).join(replacement);
}
}
return result;
};
+22 -6
View File
@@ -1,17 +1,33 @@
const mockNavigate = jest.fn();
const mockSetOptions = jest.fn();
jest.mock('react-native-worklets', () =>
require('react-native-worklets/src/mock'),
);
// Patch react-native-worklets mock so reanimated setup doesn't crash.
// Reanimated v4.5.2 imports createShareable, UIRuntimeId, getUIRuntimeHolder,
// getUISchedulerHolder, toggleSlowAnimationsOnUIRuntime from worklets,
// but the shipped mock (src/mock.ts) doesn't export them.
jest.mock('react-native-worklets', () => {
const mockWorklets = jest.requireActual('react-native-worklets/src/mock');
return {
__esModule: true,
...mockWorklets,
getUIRuntimeHolder: () => ({}),
getUISchedulerHolder: () => ({}),
createShareable: (_hostRuntimeId, initial) => initial,
UIRuntimeId: 2,
toggleSlowAnimationsOnUIRuntime: (_enabled) => { },
};
});
// Include this line for mocking react-native-gesture-handler
require('react-native-gesture-handler/jestSetup');
// Include this section for mocking react-native-reanimated
const { setUpTests } = require('react-native-reanimated');
setUpTests();
try {
const { setUpTests } = require('react-native-reanimated');
setUpTests();
} catch (e) {
// swallow — reanimated setup may fail in some environments
}
jest.mock('@react-navigation/native', () => {
return {