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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
Generated
+206
@@ -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: {}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
+100
@@ -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
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Vendored
+22
-6
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user