1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-10-31 00:07:48 +02:00

Mobile: Support building for web (#10650)

This commit is contained in:
Henry Heino
2024-08-02 06:51:49 -07:00
committed by GitHub
parent 88271bf1a7
commit f69dffcf23
157 changed files with 6251 additions and 1325 deletions

View File

@@ -53,6 +53,7 @@ packages/app-desktop/services/electron-context-menu.js
packages/app-desktop/vendor/lib/
packages/app-mobile/android
packages/app-mobile/**/*.bundle.js
packages/app-mobile/web/public/pluginAssets/**/*
packages/app-mobile/ios
packages/app-mobile/lib/rnInjectedJs/
packages/app-mobile/locales
@@ -529,15 +530,17 @@ packages/app-mobile/commands/openItem.js
packages/app-mobile/commands/openNote.js
packages/app-mobile/commands/scrollToHash.js
packages/app-mobile/commands/util/goToNote.js
packages/app-mobile/components/ActionButton.js
packages/app-mobile/commands/util/showResource.js
packages/app-mobile/components/BackButtonDialogBox.js
packages/app-mobile/components/BetaChip.js
packages/app-mobile/components/CameraView.js
packages/app-mobile/components/DialogManager.js
packages/app-mobile/components/DismissibleDialog.js
packages/app-mobile/components/Dropdown.test.js
packages/app-mobile/components/Dropdown.js
packages/app-mobile/components/ExtendedWebView/index.jest.js
packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
@@ -601,15 +604,19 @@ packages/app-mobile/components/ProfileSwitcher/useProfileConfig.js
packages/app-mobile/components/ScreenHeader/WarningBanner.test.js
packages/app-mobile/components/ScreenHeader/WarningBanner.js
packages/app-mobile/components/ScreenHeader/WarningBox.js
packages/app-mobile/components/ScreenHeader/WebBetaButton.js
packages/app-mobile/components/ScreenHeader/index.js
packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/TextInput.js
packages/app-mobile/components/accessibility/AccessibleModalMenu.js
packages/app-mobile/components/accessibility/AccessibleView.js
packages/app-mobile/components/app-nav.js
packages/app-mobile/components/base-screen.js
packages/app-mobile/components/biometrics/BiometricPopup.js
packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/buttons/FloatingActionButton.js
packages/app-mobile/components/buttons/TextButton.js
packages/app-mobile/components/buttons/index.js
packages/app-mobile/components/getResponsiveValue.test.js
@@ -623,7 +630,6 @@ packages/app-mobile/components/plugins/backgroundPage/pluginRunnerBackgroundPage
packages/app-mobile/components/plugins/backgroundPage/startStopPlugin.js
packages/app-mobile/components/plugins/backgroundPage/utils/getFormData.test.js
packages/app-mobile/components/plugins/backgroundPage/utils/getFormData.js
packages/app-mobile/components/plugins/backgroundPage/utils/makeSandboxedIframe.js
packages/app-mobile/components/plugins/backgroundPage/utils/reportUnhandledErrors.js
packages/app-mobile/components/plugins/backgroundPage/utils/wrapConsoleLog.js
packages/app-mobile/components/plugins/dialogs/PluginDialogManager.js
@@ -701,9 +707,11 @@ packages/app-mobile/components/screens/status.js
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js
packages/app-mobile/gulpfile.js
packages/app-mobile/index.web.js
packages/app-mobile/root.js
packages/app-mobile/services/AlarmServiceDriver.android.js
packages/app-mobile/services/AlarmServiceDriver.ios.js
packages/app-mobile/services/AlarmServiceDriver.web.js
packages/app-mobile/services/e2ee/RSA.react-native.js
packages/app-mobile/services/plugins/PlatformImplementation.js
packages/app-mobile/services/profiles/index.js
@@ -714,6 +722,7 @@ packages/app-mobile/tools/buildInjectedJs/BundledFile.js
packages/app-mobile/tools/buildInjectedJs/constants.js
packages/app-mobile/tools/buildInjectedJs/copyJs.js
packages/app-mobile/tools/buildInjectedJs/gulpTasks.js
packages/app-mobile/tools/copyAssets.js
packages/app-mobile/utils/ShareExtension.js
packages/app-mobile/utils/ShareUtils.test.js
packages/app-mobile/utils/ShareUtils.js
@@ -722,9 +731,13 @@ packages/app-mobile/utils/appDefaultState.js
packages/app-mobile/utils/autodetectTheme.js
packages/app-mobile/utils/checkPermissions.js
packages/app-mobile/utils/createRootStyle.js
packages/app-mobile/utils/database-driver-react-native.js
packages/app-mobile/utils/database-driver-react-native.web.js
packages/app-mobile/utils/debounce.js
packages/app-mobile/utils/fs-driver/constants.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.worker.js
packages/app-mobile/utils/fs-driver/runOnDeviceTests.js
packages/app-mobile/utils/fs-driver/tarCreate.js
packages/app-mobile/utils/fs-driver/tarExtract.test.js
@@ -733,22 +746,29 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
packages/app-mobile/utils/getPackageInfo.js
packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/image/fileToImage.web.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js
packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/injectedJs.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
packages/app-mobile/utils/lockToSingleInstance.js
packages/app-mobile/utils/makeShowMessageBox.js
packages/app-mobile/utils/pickDocument.js
packages/app-mobile/utils/polyfills/bufferPolyfill.js
packages/app-mobile/utils/polyfills/index.js
packages/app-mobile/utils/setupNotifications.js
packages/app-mobile/utils/shareFile.js
packages/app-mobile/utils/shareHandler.js
packages/app-mobile/utils/shim-init-react.js
packages/app-mobile/utils/showMessageBox.js
packages/app-mobile/utils/shim-init-react/index.js
packages/app-mobile/utils/shim-init-react/index.web.js
packages/app-mobile/utils/shim-init-react/injectedJs.js
packages/app-mobile/utils/shim-init-react/shimInitShared.js
packages/app-mobile/utils/testing/createMockReduxStore.js
packages/app-mobile/utils/testing/getWebViewDomById.js
packages/app-mobile/utils/types.js
packages/app-mobile/web/serviceWorker.js
packages/default-plugins/build.js
packages/default-plugins/buildDefaultPlugins.js
packages/default-plugins/commands/buildAll.js
@@ -1282,13 +1302,17 @@ packages/lib/urlUtils.js
packages/lib/utils/ActionLogger.test.js
packages/lib/utils/ActionLogger.js
packages/lib/utils/credentialFiles.js
packages/lib/utils/dom/makeSandboxedIframe.js
packages/lib/utils/focusHandler.js
packages/lib/utils/frontMatter.js
packages/lib/utils/ipc/RemoteMessenger.test.js
packages/lib/utils/ipc/RemoteMessenger.js
packages/lib/utils/ipc/TestMessenger.js
packages/lib/utils/ipc/WindowMessenger.js
packages/lib/utils/ipc/WorkerMessenger.js
packages/lib/utils/ipc/WorkerToWindowMessenger.js
packages/lib/utils/ipc/types.js
packages/lib/utils/ipc/utils/isTransferableObject.js
packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.test.js
packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.js
packages/lib/utils/ipc/utils/separateCallbacksFromSerializable.test.js

View File

@@ -15,6 +15,18 @@ module.exports = {
'globals': {
'Atomics': 'readonly',
'SharedArrayBuffer': 'readonly',
'BufferEncoding': 'readonly',
'AsyncIterable': 'readonly',
'FileSystemFileHandle': 'readonly',
'FileSystemDirectoryHandle': 'readonly',
'ReadableStreamDefaultReader': 'readonly',
'FileSystemCreateWritableOptions': 'readonly',
'FileSystemHandle': 'readonly',
// ServiceWorker
'ExtendableEvent': 'readonly',
'WindowClient': 'readonly',
'FetchEvent': 'readonly',
// Jest variables
'test': 'readonly',

31
.gitignore vendored
View File

@@ -508,15 +508,17 @@ packages/app-mobile/commands/openItem.js
packages/app-mobile/commands/openNote.js
packages/app-mobile/commands/scrollToHash.js
packages/app-mobile/commands/util/goToNote.js
packages/app-mobile/components/ActionButton.js
packages/app-mobile/commands/util/showResource.js
packages/app-mobile/components/BackButtonDialogBox.js
packages/app-mobile/components/BetaChip.js
packages/app-mobile/components/CameraView.js
packages/app-mobile/components/DialogManager.js
packages/app-mobile/components/DismissibleDialog.js
packages/app-mobile/components/Dropdown.test.js
packages/app-mobile/components/Dropdown.js
packages/app-mobile/components/ExtendedWebView/index.jest.js
packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
@@ -580,15 +582,19 @@ packages/app-mobile/components/ProfileSwitcher/useProfileConfig.js
packages/app-mobile/components/ScreenHeader/WarningBanner.test.js
packages/app-mobile/components/ScreenHeader/WarningBanner.js
packages/app-mobile/components/ScreenHeader/WarningBox.js
packages/app-mobile/components/ScreenHeader/WebBetaButton.js
packages/app-mobile/components/ScreenHeader/index.js
packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/TextInput.js
packages/app-mobile/components/accessibility/AccessibleModalMenu.js
packages/app-mobile/components/accessibility/AccessibleView.js
packages/app-mobile/components/app-nav.js
packages/app-mobile/components/base-screen.js
packages/app-mobile/components/biometrics/BiometricPopup.js
packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/buttons/FloatingActionButton.js
packages/app-mobile/components/buttons/TextButton.js
packages/app-mobile/components/buttons/index.js
packages/app-mobile/components/getResponsiveValue.test.js
@@ -602,7 +608,6 @@ packages/app-mobile/components/plugins/backgroundPage/pluginRunnerBackgroundPage
packages/app-mobile/components/plugins/backgroundPage/startStopPlugin.js
packages/app-mobile/components/plugins/backgroundPage/utils/getFormData.test.js
packages/app-mobile/components/plugins/backgroundPage/utils/getFormData.js
packages/app-mobile/components/plugins/backgroundPage/utils/makeSandboxedIframe.js
packages/app-mobile/components/plugins/backgroundPage/utils/reportUnhandledErrors.js
packages/app-mobile/components/plugins/backgroundPage/utils/wrapConsoleLog.js
packages/app-mobile/components/plugins/dialogs/PluginDialogManager.js
@@ -680,9 +685,11 @@ packages/app-mobile/components/screens/status.js
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js
packages/app-mobile/gulpfile.js
packages/app-mobile/index.web.js
packages/app-mobile/root.js
packages/app-mobile/services/AlarmServiceDriver.android.js
packages/app-mobile/services/AlarmServiceDriver.ios.js
packages/app-mobile/services/AlarmServiceDriver.web.js
packages/app-mobile/services/e2ee/RSA.react-native.js
packages/app-mobile/services/plugins/PlatformImplementation.js
packages/app-mobile/services/profiles/index.js
@@ -693,6 +700,7 @@ packages/app-mobile/tools/buildInjectedJs/BundledFile.js
packages/app-mobile/tools/buildInjectedJs/constants.js
packages/app-mobile/tools/buildInjectedJs/copyJs.js
packages/app-mobile/tools/buildInjectedJs/gulpTasks.js
packages/app-mobile/tools/copyAssets.js
packages/app-mobile/utils/ShareExtension.js
packages/app-mobile/utils/ShareUtils.test.js
packages/app-mobile/utils/ShareUtils.js
@@ -701,9 +709,13 @@ packages/app-mobile/utils/appDefaultState.js
packages/app-mobile/utils/autodetectTheme.js
packages/app-mobile/utils/checkPermissions.js
packages/app-mobile/utils/createRootStyle.js
packages/app-mobile/utils/database-driver-react-native.js
packages/app-mobile/utils/database-driver-react-native.web.js
packages/app-mobile/utils/debounce.js
packages/app-mobile/utils/fs-driver/constants.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.worker.js
packages/app-mobile/utils/fs-driver/runOnDeviceTests.js
packages/app-mobile/utils/fs-driver/tarCreate.js
packages/app-mobile/utils/fs-driver/tarExtract.test.js
@@ -712,22 +724,29 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
packages/app-mobile/utils/getPackageInfo.js
packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/image/fileToImage.web.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js
packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/injectedJs.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
packages/app-mobile/utils/lockToSingleInstance.js
packages/app-mobile/utils/makeShowMessageBox.js
packages/app-mobile/utils/pickDocument.js
packages/app-mobile/utils/polyfills/bufferPolyfill.js
packages/app-mobile/utils/polyfills/index.js
packages/app-mobile/utils/setupNotifications.js
packages/app-mobile/utils/shareFile.js
packages/app-mobile/utils/shareHandler.js
packages/app-mobile/utils/shim-init-react.js
packages/app-mobile/utils/showMessageBox.js
packages/app-mobile/utils/shim-init-react/index.js
packages/app-mobile/utils/shim-init-react/index.web.js
packages/app-mobile/utils/shim-init-react/injectedJs.js
packages/app-mobile/utils/shim-init-react/shimInitShared.js
packages/app-mobile/utils/testing/createMockReduxStore.js
packages/app-mobile/utils/testing/getWebViewDomById.js
packages/app-mobile/utils/types.js
packages/app-mobile/web/serviceWorker.js
packages/default-plugins/build.js
packages/default-plugins/buildDefaultPlugins.js
packages/default-plugins/commands/buildAll.js
@@ -1261,13 +1280,17 @@ packages/lib/urlUtils.js
packages/lib/utils/ActionLogger.test.js
packages/lib/utils/ActionLogger.js
packages/lib/utils/credentialFiles.js
packages/lib/utils/dom/makeSandboxedIframe.js
packages/lib/utils/focusHandler.js
packages/lib/utils/frontMatter.js
packages/lib/utils/ipc/RemoteMessenger.test.js
packages/lib/utils/ipc/RemoteMessenger.js
packages/lib/utils/ipc/TestMessenger.js
packages/lib/utils/ipc/WindowMessenger.js
packages/lib/utils/ipc/WorkerMessenger.js
packages/lib/utils/ipc/WorkerToWindowMessenger.js
packages/lib/utils/ipc/types.js
packages/lib/utils/ipc/utils/isTransferableObject.js
packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.test.js
packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.js
packages/lib/utils/ipc/utils/separateCallbacksFromSerializable.test.js

View File

@@ -70,6 +70,7 @@ components/**/*.bundle.js
components/**/*.bundle.js.LICENSE.txt
components/**/*.bundle.js.md5
components/**/*.bundle.min.js
web/public/pluginAssets/*
utils/fs-driver-android.js
android/app/build-*

View File

@@ -3,12 +3,14 @@ const { dirname } = require('@joplin/lib/path-utils');
import Setting from '@joplin/lib/models/Setting';
const pluginAssets = require('./pluginAssets/index');
import KvStore from '@joplin/lib/services/KvStore';
import Logger from '@joplin/utils/Logger';
import FsDriverWeb from './utils/fs-driver/fs-driver-rn.web';
const logger = Logger.create('PluginAssetsLoader');
export default class PluginAssetsLoader {
private static instance_: PluginAssetsLoader = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private logger_: any = null;
public static instance() {
if (PluginAssetsLoader.instance_) return PluginAssetsLoader.instance_;
@@ -16,39 +18,58 @@ export default class PluginAssetsLoader {
return PluginAssetsLoader.instance_;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public setLogger(logger: any) {
this.logger_ = logger;
private destDir_() {
return `${Setting.value('resourceDir')}/pluginAssets`;
}
public logger() {
return this.logger_;
}
private async importAssetsMobile_() {
const destDir = this.destDir_();
public async importAssets() {
const destDir = `${Setting.value('resourceDir')}/pluginAssets`;
await shim.fsDriver().mkdir(destDir);
const hash = pluginAssets.hash;
if (hash === await KvStore.instance().value('PluginAssetsLoader.lastHash')) {
this.logger().info(`PluginAssetsLoader: Assets are up to date. Hash: ${hash}`);
return;
}
this.logger().info(`PluginAssetsLoader: Importing assets to ${destDir}`);
try {
for (const name in pluginAssets.files) {
const dataBase64 = pluginAssets.files[name].data;
const destPath = `${destDir}/${name}`;
await shim.fsDriver().mkdir(dirname(destPath));
await shim.fsDriver().unlink(destPath);
this.logger().info(`PluginAssetsLoader: Copying: ${name} => ${destPath}`);
logger.info(`PluginAssetsLoader: Copying: ${name} => ${destPath}`);
await shim.fsDriver().writeFile(destPath, dataBase64);
}
}
private async importAssetsWeb_() {
const destDir = this.destDir_();
const fsDriver = shim.fsDriver() as FsDriverWeb;
await Promise.all(pluginAssets.files.map(async (name: string) => {
const destPath = `${destDir}/${name}`;
const response = await fetch(`pluginAssets/${name}`);
await shim.fsDriver().mkdir(dirname(destPath));
await shim.fsDriver().unlink(destPath);
await fsDriver.writeFile(destPath, await response.arrayBuffer(), 'Buffer');
}));
}
public async importAssets() {
const destDir = this.destDir_();
await shim.fsDriver().mkdir(destDir);
const hash = pluginAssets.hash;
if (hash === await KvStore.instance().value('PluginAssetsLoader.lastHash')) {
logger.info(`PluginAssetsLoader: Assets are up to date. Hash: ${hash}`);
return;
}
logger.info(`PluginAssetsLoader: Importing assets to ${destDir}`);
try {
if (shim.mobilePlatform() === 'web') {
await this.importAssetsWeb_();
} else {
await this.importAssetsMobile_();
}
} catch (error) {
this.logger().error(error);
logger.error(error);
}
await KvStore.instance().setValue('PluginAssetsLoader.lastHash', hash);

View File

@@ -4,6 +4,10 @@ import { _ } from '@joplin/lib/locale';
import { parseResourceUrl, urlProtocol } from '@joplin/lib/urlUtils';
import Logger from '@joplin/utils/Logger';
import goToNote from './util/goToNote';
import BaseItem from '@joplin/lib/models/BaseItem';
import { BaseItemEntity } from '@joplin/lib/services/database/types';
import { ModelType } from '@joplin/lib/BaseModel';
import showResource from './util/showResource';
const logger = Logger.create('openItemCommand');
@@ -22,7 +26,14 @@ export const runtime = (): CommandRuntime => {
const { itemId, hash } = parsedUrl;
logger.info(`Navigating to item ${itemId}`);
const item: BaseItemEntity = await BaseItem.loadItemById(itemId);
if (item.type_ === ModelType.Note) {
await goToNote(itemId, hash);
} else if (item.type_ === ModelType.Resource) {
await showResource(item);
} else {
logger.error('Unsupported item type for links:', item.type_);
}
} else {
logger.error(`Invalid Joplin link: ${link}`);
}

View File

@@ -0,0 +1,25 @@
import Resource from '@joplin/lib/models/Resource';
import { ResourceEntity } from '@joplin/lib/services/database/types';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
const FileViewer = require('react-native-file-viewer').default;
const logger = Logger.create('showResource');
const showResource = async (item: ResourceEntity) => {
const resourcePath = Resource.fullPath(item);
logger.info(`Opening resource: ${resourcePath}`);
if (shim.mobilePlatform() === 'web') {
const url = URL.createObjectURL(await shim.fsDriver().fileAtPath(resourcePath));
const w = window.open(url, '_blank');
w.addEventListener('close', () => {
URL.revokeObjectURL(url);
}, { once: true });
} else {
await FileViewer.open(resourcePath);
}
};
export default showResource;

View File

@@ -0,0 +1,150 @@
import * as React from 'react';
import { createContext, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Platform, StyleSheet } from 'react-native';
import { Button, Dialog, Portal, Text } from 'react-native-paper';
import Modal from './Modal';
import { _ } from '@joplin/lib/locale';
import shim from '@joplin/lib/shim';
import makeShowMessageBox from '../utils/makeShowMessageBox';
export interface PromptButton {
text: string;
onPress?: ()=> void;
style?: 'cancel'|'default'|'destructive';
}
interface PromptOptions {
cancelable?: boolean;
}
export interface DialogControl {
prompt(title: string, message: string, buttons?: PromptButton[], options?: PromptOptions): void;
}
export const DialogContext = createContext<DialogControl>(null);
interface Props {
children: React.ReactNode;
}
interface PromptDialogData {
key: string;
title: string;
message: string;
buttons: PromptButton[];
onDismiss: (()=> void)|null;
}
const styles = StyleSheet.create({
dialogContainer: {
maxWidth: 400,
minWidth: '50%',
alignSelf: 'center',
},
modalContainer: {
marginTop: 'auto',
marginBottom: 'auto',
},
});
const DialogManager: React.FC<Props> = props => {
const [dialogModels, setPromptDialogs] = useState<PromptDialogData[]>([]);
const nextDialogIdRef = useRef(0);
const dialogControl: DialogControl = useMemo(() => {
const defaultButtons = [{ text: _('OK') }];
return {
prompt: (title: string, message: string, buttons: PromptButton[] = defaultButtons, options?: PromptOptions) => {
if (Platform.OS !== 'web') {
// Alert.alert provides a more native style on iOS.
Alert.alert(title, message, buttons, options);
// Alert.alert doesn't work on web.
} else {
const onDismiss = () => {
setPromptDialogs(dialogs => dialogs.filter(d => d !== dialog));
};
const cancelable = options?.cancelable ?? true;
const dialog: PromptDialogData = {
key: `dialog-${nextDialogIdRef.current++}`,
title,
message,
buttons: buttons.map(button => ({
...button,
onPress: () => {
onDismiss();
button.onPress?.();
},
})),
onDismiss: cancelable ? onDismiss : null,
};
setPromptDialogs(dialogs => {
return [
...dialogs,
dialog,
];
});
}
},
};
}, []);
const dialogControlRef = useRef(dialogControl);
dialogControlRef.current = dialogControl;
useEffect(() => {
shim.showMessageBox = makeShowMessageBox(dialogControlRef);
return () => {
dialogControlRef.current = null;
};
}, []);
const dialogComponents: React.ReactNode[] = [];
for (const dialog of dialogModels) {
const buttons = dialog.buttons.map((button, index) => {
return (
<Button key={`${index}-${button.text}`} onPress={button.onPress}>{button.text}</Button>
);
});
dialogComponents.push(
<Dialog
testID={'prompt-dialog'}
style={styles.dialogContainer}
key={dialog.key}
visible={true}
onDismiss={dialog.onDismiss}
>
<Dialog.Title>{dialog.title}</Dialog.Title>
<Dialog.Content>
<Text variant='bodyMedium'>{dialog.message}</Text>
</Dialog.Content>
<Dialog.Actions>
{buttons}
</Dialog.Actions>
</Dialog>,
);
}
// Web: Use a <Modal> wrapper for better keyboard focus handling.
return <>
<DialogContext.Provider value={dialogControl}>
{props.children}
</DialogContext.Provider>
<Portal>
<Modal
visible={!!dialogComponents.length}
containerStyle={styles.modalContainer}
animationType='none'
backgroundColor='rgba(0, 0, 0, 0.1)'
transparent={true}
onRequestClose={dialogModels[dialogComponents.length - 1]?.onDismiss}
>
{dialogComponents}
</Modal>
</Portal>
</>;
};
export default DialogManager;

View File

@@ -33,14 +33,6 @@ const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogSize)
const maxHeight = size === DialogSize.Large ? windowSize.height : 700;
return StyleSheet.create({
webView: {
backgroundColor: 'transparent',
display: 'flex',
},
webViewContainer: {
flexGrow: 1,
flexShrink: 1,
},
closeButtonContainer: {
flexDirection: 'row',
justifyContent: 'flex-end',

View File

@@ -115,6 +115,7 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
const itemWrapperStyle: ViewStyle = {
...(this.props.itemWrapperStyle ? this.props.itemWrapperStyle : {}),
flex: 1,
flexBasis: 'auto',
justifyContent: 'center',
height: itemHeight,
paddingLeft: 20,
@@ -197,6 +198,7 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
style={headerWrapperStyle}
disabled={this.props.disabled}
onPress={this.onOpenList}
role='button'
>
<Text ellipsizeMode="tail" numberOfLines={1} style={headerStyle}>
{headerLabel}
@@ -215,6 +217,7 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
<TouchableWithoutFeedback
accessibilityElementsHidden={true}
importantForAccessibility='no-hide-descendants'
aria-hidden={true}
onPress={this.onCloseList}
style={backgroundCloseButtonStyle}
>

View File

@@ -15,8 +15,6 @@ import { Props, WebViewControl } from './types';
const logger = Logger.create('ExtendedWebView');
export { WebViewControl, Props };
const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
const webviewRef = useRef(null);
const [source, setSource] = useState<WebViewSource|undefined>(undefined);

View File

@@ -0,0 +1,176 @@
import * as React from 'react';
import {
forwardRef, Ref, useEffect, useImperativeHandle, useRef, useState,
} from 'react';
import { Props, WebViewControl } from './types';
import { View, ViewStyle } from 'react-native';
import makeSandboxedIframe from '@joplin/lib/utils/dom/makeSandboxedIframe';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('ExtendedWebView');
// At present, react-native-webview doesn't support web. As such, ExtendedWebView.web.tsx
// uses an iframe when running on web.
const iframeContainerStyles = { height: '100%', width: '100%' };
const wrapperStyle: ViewStyle = { height: '100%', width: '100%', flex: 1 };
const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
const iframeRef = useRef<HTMLIFrameElement|null>(null);
useImperativeHandle(ref, (): WebViewControl => {
return {
injectJS(js: string) {
if (!iframeRef.current) {
logger.warn(`WebView(${props.webviewInstanceId}): Tried to inject JavaScript after the iframe has unloaded.`);
return;
}
// react-native-webview doesn't seem to show a warning in the case where JavaScript
// is injected before the first page loads.
if (!iframeRef.current.contentWindow) {
return;
}
iframeRef.current.contentWindow.postMessage({
injectJs: js,
}, '*');
},
postMessage(message: unknown) {
if (!iframeRef.current || !iframeRef.current.contentWindow) {
logger.warn(`WebView(${props.webviewInstanceId}): Tried to post a message to an unloaded iframe.`);
return;
}
iframeRef.current.contentWindow.postMessage({
postMessage: message,
}, '*');
},
};
}, [props.webviewInstanceId]);
const [containerElement, setContainerElement] = useState<HTMLDivElement>();
const containerRef = useRef(containerElement);
containerRef.current = containerElement;
const onMessageRef = useRef(props.onMessage);
onMessageRef.current = props.onMessage;
const onLoadEndRef = useRef(props.onLoadEnd);
onLoadEndRef.current = props.onLoadEnd;
const onLoadStartRef = useRef(props.onLoadStart);
onLoadStartRef.current = props.onLoadStart;
// Don't re-load when injected JS changes. This should match the behavior of the native webview.
const injectedJavaScriptRef = useRef(props.injectedJavaScript);
injectedJavaScriptRef.current = props.injectedJavaScript;
useEffect(() => {
const headHtml = `
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<meta charset="utf-8"/>
<!-- Open links in a new window by default -->
<base target="_blank"/>
`;
const scripts = [
`
window.ReactNativeWebView = {
postMessage: (message) => {
parent.postMessage(message, '*');
},
supportsNonStringMessages: true,
};
window.addEventListener('message', (event) => {
if (event.source !== parent || event.origin === 'react-native') {
return;
}
if (event.data.postMessage) {
window.dispatchEvent(
new MessageEvent(
'message',
{
data: event.data.postMessage,
origin: 'react-native'
},
),
);
} else if (event.data.injectJs) {
eval('(() => { ' + event.data.injectJs + ' })()');
}
});
`,
injectedJavaScriptRef.current,
];
const { iframe } = makeSandboxedIframe({
bodyHtml: props.html,
headHtml: headHtml,
scripts,
// allow-popups-to-escape-sandbox: Allows PDF previews to work on target="_blank" links.
// allow-popups: Allows links to open in a new tab.
permissions: 'allow-scripts allow-modals allow-popups allow-popups-to-escape-sandbox',
allow: 'clipboard-write=(self) fullscreen=(self) autoplay=(self) local-fonts=* encrypted-media=*',
});
if (containerRef.current) {
containerRef.current.replaceChildren(iframe);
}
iframeRef.current = iframe;
iframe.style.height = '100%';
iframe.style.width = '100%';
iframe.style.border = 'none';
const messageListener = (event: MessageEvent) => {
if (event.source !== iframe.contentWindow) {
return;
}
onMessageRef.current?.({ nativeEvent: { data: event.data } });
};
window.addEventListener('message', messageListener);
if (!iframe.loading) {
onLoadStartRef.current?.();
onLoadEndRef.current?.();
} else {
iframe.onload = () => onLoadEndRef.current?.();
iframe.onloadstart = () => onLoadStartRef.current?.();
}
return () => {
window.removeEventListener('message', messageListener);
if (iframeRef.current.parentElement) {
iframeRef.current.remove();
}
iframeRef.current = null;
};
}, [props.html]);
useEffect(() => {
if (!iframeRef.current || !containerElement) return;
if (iframeRef.current.parentElement) {
iframeRef.current.remove();
}
containerElement.replaceChildren(iframeRef.current);
}, [containerElement]);
return (
<View style={[wrapperStyle, props.style]}>
<div
ref={setContainerElement}
className='iframe-container'
style={iframeContainerStyles}
></div>
</View>
);
};
export default forwardRef(ExtendedWebView);

View File

@@ -6,9 +6,10 @@ import * as React from 'react';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { useState, useMemo, useCallback, useRef } from 'react';
import { View, Text, Pressable, ViewStyle, StyleSheet, LayoutChangeEvent, LayoutRectangle, Animated, AccessibilityState, AccessibilityRole, TextStyle } from 'react-native';
import { Text, Pressable, ViewStyle, StyleSheet, LayoutChangeEvent, LayoutRectangle, Animated, AccessibilityState, AccessibilityRole, TextStyle, GestureResponderEvent, Platform } from 'react-native';
import { Menu, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu';
import Icon from './Icon';
import AccessibleView from './accessibility/AccessibleView';
type ButtonClickListener = ()=> void;
interface ButtonProps {
@@ -22,6 +23,10 @@ interface ButtonProps {
themeId: number;
// (web only) On web, touching buttons can cause the on-screen keyboard to be dismissed.
// Setting preventKeyboardDismiss overrides this behavior.
preventKeyboardDismiss?: boolean;
containerStyle?: ViewStyle;
contentWrapperStyle?: ViewStyle;
@@ -74,6 +79,10 @@ const IconButton = (props: ButtonProps) => {
setButtonLayout({ ...layoutEvt });
}, []);
const { onTouchStart, onTouchMove, onTouchEnd } = usePreventKeyboardDismissTouchListeners(
props.preventKeyboardDismiss, props.onPress, props.disabled,
);
const button = (
<Pressable
onPress={props.onPress}
@@ -81,6 +90,10 @@ const IconButton = (props: ButtonProps) => {
onPressIn={onPressIn}
onPressOut={onPressOut}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
style={ props.containerStyle }
disabled={ props.disabled ?? false }
@@ -108,14 +121,11 @@ const IconButton = (props: ButtonProps) => {
if (!props.description) return null;
return (
<View
<AccessibleView
// Any information given by the tooltip should also be provided via
// [accessibilityLabel]/[accessibilityHint]. As such, we can hide the tooltip
// from the screen reader.
// On Android:
importantForAccessibility='no-hide-descendants'
// On iOS:
accessibilityElementsHidden={true}
inert={true}
// Position the menu beneath the button so the tooltip appears in the
// correct location.
@@ -150,7 +160,7 @@ const IconButton = (props: ButtonProps) => {
</Text>
</MenuOptions>
</Menu>
</View>
</AccessibleView>
);
};
@@ -181,4 +191,40 @@ const useTooltipStyles = (themeId: number) => {
}, [themeId]);
};
// On web, by default, pressing buttons defocuses the active edit control, dismissing the
// virtual keyboard. This hook creates listeners that optionally prevent the keyboard from dismissing.
const usePreventKeyboardDismissTouchListeners = (preventKeyboardDismiss: boolean, onPress: ()=> void, disabled: boolean) => {
const touchStartPointRef = useRef<[number, number]>();
const isTapRef = useRef<boolean>();
const onTouchStart = useCallback((event: GestureResponderEvent) => {
if (Platform.OS === 'web' && preventKeyboardDismiss) {
const touch = event.nativeEvent.touches[0];
touchStartPointRef.current = [touch?.pageX, touch?.pageY];
isTapRef.current = true;
}
}, [preventKeyboardDismiss]);
const onTouchMove = useCallback((event: GestureResponderEvent) => {
if (Platform.OS === 'web' && preventKeyboardDismiss && isTapRef.current) {
// Update isTapRef onTouchMove, rather than onTouchEnd -- the final
// touch position is unavailable in onTouchEnd on some devices.
const touch = event.nativeEvent.touches[0];
const dx = touch?.pageX - touchStartPointRef.current[0];
const dy = touch?.pageY - touchStartPointRef.current[1];
isTapRef.current = Math.hypot(dx, dy) < 15;
}
}, [preventKeyboardDismiss]);
const onTouchEnd = useCallback((event: GestureResponderEvent) => {
if (Platform.OS === 'web' && preventKeyboardDismiss) {
if (isTapRef.current && !disabled) {
event.preventDefault();
onPress();
}
}
}, [onPress, disabled, preventKeyboardDismiss]);
return { onTouchStart, onTouchMove, onTouchEnd };
};
export default IconButton;

View File

@@ -27,6 +27,7 @@ const useStyles = (backgroundColor?: string) => {
...backgroundPadding,
backgroundColor,
flexGrow: 1,
flexShrink: 1,
},
});
}, [isLandscape, backgroundColor]);

View File

@@ -14,12 +14,13 @@ import { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnM
import Resource from '@joplin/lib/models/Resource';
import shim from '@joplin/lib/shim';
import Note from '@joplin/lib/models/Note';
import { ResourceInfo } from './hooks/useRerenderHandler';
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
interface WrapperProps {
noteBody: string;
highlightedKeywords?: string[];
noteResources?: unknown;
noteResources?: Record<string, ResourceInfo>;
onJoplinLinkClick?: HandleMessageCallback;
onScroll?: (percent: number)=> void;
onMarkForDownload?: OnMarkForDownloadCallback;

View File

@@ -4,11 +4,12 @@ import useOnMessage, { HandleMessageCallback, OnMarkForDownloadCallback } from '
import { useRef, useCallback, useState, useMemo } from 'react';
import { View, ViewStyle } from 'react-native';
import BackButtonDialogBox from '../BackButtonDialogBox';
import ExtendedWebView, { WebViewControl } from '../ExtendedWebView';
import ExtendedWebView from '../ExtendedWebView';
import { WebViewControl } from '../ExtendedWebView/types';
import useOnResourceLongPress from './hooks/useOnResourceLongPress';
import useRenderer from './hooks/useRenderer';
import { OnWebViewMessageHandler } from './types';
import useRerenderHandler from './hooks/useRerenderHandler';
import useRerenderHandler, { ResourceInfo } from './hooks/useRerenderHandler';
import useSource from './hooks/useSource';
import Setting from '@joplin/lib/models/Setting';
import uuid from '@joplin/lib/uuid';
@@ -22,8 +23,7 @@ interface Props {
noteBody: string;
noteMarkupLanguage: MarkupLanguage;
highlightedKeywords: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
noteResources: any;
noteResources: Record<string, ResourceInfo>;
paddingBottom: number;
initialScroll: number|null;
noteHash: string;

View File

@@ -12,6 +12,7 @@ const defaultRendererSettings: RendererSettings = {
codeTheme: 'atom-one-light.css',
noteHash: '',
initialScroll: 0,
readAssetBlob: async (_path: string)=>new Blob(),
createEditPopupSyntax: '',
destroyEditPopupSyntax: '',
@@ -28,6 +29,7 @@ const makeRenderer = (options: Partial<RendererSetupOptions>) => {
resourceDir: Setting.value('resourceDir'),
resourceDownloadMode: 'auto',
},
useTransferredFiles: false,
fsDriver: shim.fsDriver(),
pluginOptions: {},
};

View File

@@ -12,6 +12,11 @@ export interface RendererSetupOptions {
resourceDir: string;
resourceDownloadMode: string;
};
// True if asset and resource files should be transferred to the WebView before rendering.
// This must be true on web, where asset and resource files are virtual and can't be accessed
// without transferring.
useTransferredFiles: boolean;
fsDriver: RendererFsDriver;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
pluginOptions: Record<string, any>;
@@ -33,6 +38,7 @@ export interface RendererSettings {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
pluginSettings: Record<string, any>;
requestPluginSetting: (pluginId: string, settingKey: string)=> void;
readAssetBlob: (assetPath: string)=> Promise<Blob>;
}
export interface MarkupRecord {
@@ -45,6 +51,7 @@ export default class Renderer {
private lastSettings: RendererSettings|null = null;
private extraContentScripts: ExtraContentScript[] = [];
private lastRenderMarkup: MarkupRecord|null = null;
private resourcePathOverrides: Record<string, string> = Object.create(null);
public constructor(private setupOptions: RendererSetupOptions) {
this.recreateMarkupToHtml();
@@ -61,6 +68,18 @@ export default class Renderer {
});
}
// Intended for web, where resources can't be linked to normally.
public async setResourceFile(id: string, file: Blob) {
this.resourcePathOverrides[id] = URL.createObjectURL(file);
}
public getResourcePathOverride(resourceId: string) {
if (Object.prototype.hasOwnProperty.call(this.resourcePathOverrides, resourceId)) {
return this.resourcePathOverrides[resourceId];
}
return null;
}
public async setExtraContentScriptsAndRerender(
extraContentScripts: ExtraContentScriptSource[],
) {
@@ -108,6 +127,7 @@ export default class Renderer {
editPopupFiletypes: ['image/svg+xml'],
createEditPopupSyntax: settings.createEditPopupSyntax,
destroyEditPopupSyntax: settings.destroyEditPopupSyntax,
itemIdToUrl: this.setupOptions.useTransferredFiles ? (id: string) => this.getResourcePathOverride(id) : undefined,
settingValue: (pluginId: string, settingName: string) => {
const settingKey = `${pluginId}.${settingName}`;
@@ -151,7 +171,17 @@ export default class Renderer {
}
contentContainer.innerHTML = html;
addPluginAssets(pluginAssets);
// Adding plugin assets can be slow -- run it asynchronously.
void (async () => {
await addPluginAssets(pluginAssets, {
inlineAssets: this.setupOptions.useTransferredFiles,
readAssetBlob: settings.readAssetBlob,
});
// Some plugins require this event to be dispatched just after being added.
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
})();
this.afterRender(settings);
}
@@ -187,9 +217,6 @@ export default class Renderer {
}
}
}, 10);
// Used by some parts of the renderer (e.g. to rerender mermaid.js diagrams).
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
}
public clearCache(markupLanguage: MarkupLanguage) {

View File

@@ -8,6 +8,7 @@ export interface RendererWebViewOptions {
resourceDir: string;
resourceDownloadMode: string;
};
useTransferredFiles: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
pluginOptions: Record<string, any>;
}

View File

@@ -1,21 +1,79 @@
import { RenderResultPluginAsset } from '@joplin/renderer/types';
import { join, dirname } from 'path';
type PluginAssetRecord = {
element: HTMLElement;
};
const pluginAssetsAdded_: Record<string, PluginAssetRecord> = {};
const assetUrlMap_: Map<string, ()=> Promise<string>> = new Map();
// Some resources (e.g. CSS) reference other resources with relative paths. On web, due to sandboxing
// and how plugin assets are stored, these links need to be rewritten.
const rewriteInternalAssetLinks = async (asset: RenderResultPluginAsset, content: string) => {
if (asset.mime === 'text/css') {
const urlRegex = /(url\()([^)]+)(\))/g;
// Converting resource paths to URLs is async. To handle this, we do two passes.
// In the first, the original URLs are collected. In the second, the URLs are replaced.
const replacements: [string, string][] = [];
let replacementIndex = 0;
content = content.replace(urlRegex, (match, _group1, url, _group3) => {
const target = join(dirname(asset.path), url);
if (!assetUrlMap_.has(target)) return match;
const replaceString = `<<to-replace-with-url-${replacementIndex++}>>`;
replacements.push([replaceString, target]);
return `url(${replaceString})`;
});
for (const [replacement, path] of replacements) {
const url = await assetUrlMap_.get(path)();
content = content.replace(replacement, url);
}
return content;
} else {
return content;
}
};
interface Options {
inlineAssets: boolean;
readAssetBlob?(path: string): Promise<Blob>;
}
// Note that this function keeps track of what's been added so as not to
// add the same CSS files multiple times.
//
// Shared with app-desktop/gui-note-viewer.
//
// TODO: If possible, refactor such that this function is not duplicated.
const addPluginAssets = (assets: RenderResultPluginAsset[]) => {
const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Options) => {
if (!assets) return;
const pluginAssetsContainer = document.getElementById('joplin-container-pluginAssetsContainer');
const prepareAssetBlobUrls = () => {
for (const asset of assets) {
const path = asset.path;
if (!assetUrlMap_.has(path)) {
// Fetching assets can be expensive -- avoid refetching assets where possible.
let url: string|null = null;
assetUrlMap_.set(path, async () => {
if (url !== null) return url;
const blob = await options.readAssetBlob(path);
if (!blob) {
url = '';
} else {
url = URL.createObjectURL(blob);
}
return url;
});
}
}
};
if (options.inlineAssets) {
prepareAssetBlobUrls();
}
const processedAssetIds = [];
for (let i = 0; i < assets.length; i++) {
@@ -34,14 +92,33 @@ const addPluginAssets = (assets: RenderResultPluginAsset[]) => {
let element = null;
if (options.inlineAssets) {
if (asset.mime === 'application/javascript') {
element = document.createElement('script');
} else if (asset.mime === 'text/css') {
element = document.createElement('style');
}
if (element) {
const blob = await options.readAssetBlob(asset.path);
if (blob) {
const assetContent = await blob.text();
element.appendChild(
document.createTextNode(await rewriteInternalAssetLinks(asset, assetContent)),
);
}
}
} else {
if (asset.mime === 'application/javascript') {
element = document.createElement('script');
element.src = encodedPath;
pluginAssetsContainer.appendChild(element);
} else if (asset.mime === 'text/css') {
element = document.createElement('link');
element.rel = 'stylesheet';
element.href = encodedPath;
}
}
if (element) {
pluginAssetsContainer.appendChild(element);
}

View File

@@ -5,11 +5,22 @@ import { Theme } from '@joplin/lib/themes/type';
import { useMemo } from 'react';
import { extname } from 'path';
import shim from '@joplin/lib/shim';
import { Platform } from 'react-native';
const Icon = require('react-native-vector-icons/Ionicons').default;
export const editPopupClass = 'joplin-editPopup';
const getEditIconSrc = (theme: Theme) => {
// Use an inline edit icon on web -- getImageSourceSync isn't supported there.
if (Platform.OS === 'web') {
const svgData = `
<svg viewBox="-103 60 180 180" width="30" height="30" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<path d="m 100,19 c -11.7,0 -21.1,9.5 -21.2,21.2 0,0 42.3,0 42.3,0 0,-11.7 -9.5,-21.2 -21.2,-21.2 z M 79,43 v 143 l 21.3,26.4 21,-26.5 V 42.8 Z" style="transform: rotate(45deg)" fill=${JSON.stringify(theme.color2)}/>
</svg>
`.replace(/[ \t\n]+/, ' ');
return `data:image/svg+xml;utf8,${encodeURIComponent(svgData)}`;
}
const iconUri = Icon.getImageSourceSync('pencil', 20, theme.color2).uri;
// Copy to a location that can be read within a WebView

View File

@@ -1,13 +1,15 @@
import { useCallback } from 'react';
const { ToastAndroid } = require('react-native');
const { _ } = require('@joplin/lib/locale.js');
import { reg } from '@joplin/lib/registry';
const { dialogs } = require('../../../utils/dialogs.js');
import Resource from '@joplin/lib/models/Resource';
import { copyToCache } from '../../../utils/ShareUtils';
import isEditableResource from '../../NoteEditor/ImageEditor/isEditableResource';
const Share = require('react-native-share').default;
import shim from '@joplin/lib/shim';
import shareFile from '../../../utils/shareFile';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('useOnResourceLongPress');
interface Callbacks {
onJoplinLinkClick: (link: string)=> void;
@@ -25,7 +27,7 @@ export default function useOnResourceLongPress(callbacks: Callbacks, dialogBoxRe
// Handle the case where it's a long press on a link with no resource
if (!resource) {
reg.logger().warn(`Long-press: Resource with ID ${resourceId} does not exist (may be a note).`);
logger.warn(`Long-press: Resource with ID ${resourceId} does not exist (may be a note).`);
return;
}
@@ -46,19 +48,13 @@ export default function useOnResourceLongPress(callbacks: Callbacks, dialogBoxRe
onJoplinLinkClick(`joplin://${resourceId}`);
} else if (action === 'share') {
const fileToShare = await copyToCache(resource);
await Share.open({
type: resource.mime,
filename: resource.title,
url: `file://${fileToShare}`,
failOnCancel: false,
});
await shareFile(fileToShare, resource.mime);
} else if (action === 'edit') {
onRequestEditResource(`edit:${resourceId}`);
}
} catch (e) {
reg.logger().error('Could not handle link long press', e);
ToastAndroid.show('An error occurred, check log for details', ToastAndroid.SHORT);
logger.error('Could not handle link long press', e);
void shim.showMessageBox(`An error occurred, check log for details: ${e}`);
}
}, [onJoplinLinkClick, onRequestEditResource, dialogBoxRef]);
}

View File

@@ -1,5 +1,5 @@
import { Dispatch, RefObject, SetStateAction, useEffect, useMemo, useRef } from 'react';
import { WebViewControl } from '../../ExtendedWebView';
import { WebViewControl } from '../../ExtendedWebView/types';
import { OnScrollCallback, OnWebViewMessageHandler } from '../types';
import RNToWebViewMessenger from '../../../utils/ipc/RNToWebViewMessenger';
import { NoteViewerLocalApi, NoteViewerRemoteApi } from '../bundledJs/types';

View File

@@ -8,6 +8,15 @@ import { useEffect, useState } from 'react';
import Logger from '@joplin/utils/Logger';
import { ExtraContentScriptSource } from '../bundledJs/types';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import Resource from '@joplin/lib/models/Resource';
import { ResourceEntity } from '@joplin/lib/services/database/types';
export interface ResourceInfo {
localState: unknown;
item: ResourceEntity;
}
interface Props {
renderer: Renderer;
@@ -17,7 +26,7 @@ interface Props {
themeId: number;
highlightedKeywords: string[];
noteResources: string[];
noteResources: Record<string, ResourceInfo>;
noteHash: string;
initialScroll: number|undefined;
@@ -113,6 +122,25 @@ const useRerenderHandler = (props: Props) => {
}
let newPluginSettingKeys = pluginSettingKeys;
// On web, resources are virtual files and thus need to be transferred to the WebView.
if (shim.mobilePlatform() === 'web') {
for (const [resourceId, resource] of Object.entries(props.noteResources)) {
try {
await props.renderer.setResourceFile(
resourceId,
await shim.fsDriver().fileAtPath(Resource.fullPath(resource.item)),
);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
// This can happen if a resource hasn't been downloaded yet
logger.warn('Error: Resource file not found (ENOENT)', Resource.fullPath(resource.item), 'for ID', resource.item.id);
}
}
}
const theme = themeStyle(props.themeId);
const config = {
// We .stringify the theme to avoid a JSON serialization error involving
@@ -150,6 +178,11 @@ const useRerenderHandler = (props: Props) => {
setPluginSettingKeys(newPluginSettingKeys);
}
},
readAssetBlob: (assetPath: string) => {
const assetsDir = `${Setting.value('resourceDir')}/`;
const path = shim.fsDriver().resolveRelativePathWithinDir(assetsDir, assetPath);
return shim.fsDriver().fileAtPath(path);
},
createEditPopupSyntax,
destroyEditPopupSyntax,

View File

@@ -3,6 +3,7 @@ import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
import { RendererWebViewOptions } from '../bundledJs/types';
import { themeStyle } from '../../global-style';
import { Platform } from 'react-native';
const useSource = (tempDirPath: string, themeId: number) => {
const injectedJs = useMemo(() => {
@@ -20,6 +21,9 @@ const useSource = (tempDirPath: string, themeId: number) => {
resourceDir: Setting.value('resourceDir'),
resourceDownloadMode: Setting.value('sync.resourceDownloadMode'),
},
// Web needs files to be transferred manually, since image SRCs can't reference
// the Origin Private File System.
useTransferredFiles: Platform.OS === 'web',
pluginOptions,
};

View File

@@ -5,12 +5,14 @@ import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, BackHandler } from 'react-native';
import ExtendedWebView, { WebViewControl } from '../../ExtendedWebView';
import { MutableRefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { BackHandler, Platform } from 'react-native';
import ExtendedWebView from '../../ExtendedWebView';
import { WebViewControl } from '../../ExtendedWebView/types';
import { clearAutosave, writeAutosave } from './autosave';
import { LocalizedStrings } from './js-draw/types';
import VersionInfo from 'react-native-version-info';
import { DialogContext } from '../../DialogManager';
import { OnMessageEvent } from '../../ExtendedWebView/types';
@@ -85,6 +87,8 @@ const ImageEditor = (props: Props) => {
const webviewRef: MutableRefObject<WebViewControl>|null = useRef(null);
const [imageChanged, setImageChanged] = useState(false);
const dialogs = useContext(DialogContext);
const onRequestCloseEditor = useCallback((promptIfUnsaved: boolean) => {
const discardChangesAndClose = async () => {
await clearAutosave();
@@ -96,7 +100,7 @@ const ImageEditor = (props: Props) => {
return true;
}
Alert.alert(
dialogs.prompt(
_('Save changes?'), _('This drawing may have unsaved changes.'), [
{
text: _('Discard changes'),
@@ -114,7 +118,7 @@ const ImageEditor = (props: Props) => {
],
);
return true;
}, [webviewRef, props.onExit, imageChanged]);
}, [webviewRef, dialogs, props.onExit, imageChanged]);
useEffect(() => {
const hardwareBackPressListener = () => {
@@ -268,14 +272,28 @@ const ImageEditor = (props: Props) => {
}, [css]);
const onReadyToLoadData = useCallback(async () => {
const getInitialInjectedData = async () => {
// On mobile, it's faster to load the image within the WebView with an XMLHttpRequest.
// In this case, the image is loaded elsewhere.
if (Platform.OS !== 'web') {
return undefined;
}
// On web, however, this doesn't work, so the image needs to be loaded here.
if (!props.resourceFilename) {
return '';
}
return await shim.fsDriver().readFile(props.resourceFilename, 'utf-8');
};
// It can take some time for initialSVGData to be transferred to the WebView.
// Thus, do so after the main content has been loaded.
webviewRef.current.injectJS(`(async () => {
if (window.editorControl) {
const initialSVGPath = ${JSON.stringify(props.resourceFilename)};
const initialTemplateData = ${JSON.stringify(Setting.value('imageeditor.imageTemplate'))};
const initialData = ${JSON.stringify(await getInitialInjectedData())};
editorControl.loadImageOrTemplate(initialSVGPath, initialTemplateData);
editorControl.loadImageOrTemplate(initialSVGPath, initialTemplateData, initialData);
}
})();`);
}, [webviewRef, props.resourceFilename]);

View File

@@ -58,7 +58,7 @@ describe('createJsDrawEditor', () => {
});
// Load no image and an empty template so that autosave can start
await editorControl.loadImageOrTemplate('', '{}');
await editorControl.loadImageOrTemplate('', '{}', undefined);
expect(calledAutosaveCount).toBe(0);

View File

@@ -149,11 +149,13 @@ export const createJsDrawEditor = (
const editorControl = {
editor,
loadImageOrTemplate: async (resourceUrl: string, templateData: string) => {
loadImageOrTemplate: async (resourceUrl: string, templateData: string, svgData: string|undefined) => {
// loadFromSVG shows its own loading message. Hide the original.
editor.hideLoadingWarning();
const svgData = await fetchInitialSvgData(resourceUrl);
// On mobile, fetching the SVG data is much faster than transferring it via IPC. However, fetch
// doesn't work for this when running in a web browser (virtual file system).
svgData ??= await fetchInitialSvgData(resourceUrl);
// Load from a template if no initial data
if (svgData === '') {

View File

@@ -92,6 +92,16 @@ const Toolbar: React.FC<ToolbarProps> = (props: ToolbarProps) => {
</View>
);
const overflow = (
<ScrollView style={{ flex: 1 }}>
<ToolbarOverflowRows
buttonGroups={props.buttons}
styleSheet={props.styleSheet}
onToggleOverflow={onToggleOverflowVisible}
/>
</ScrollView>
);
return (
<View
style={{
@@ -106,14 +116,7 @@ const Toolbar: React.FC<ToolbarProps> = (props: ToolbarProps) => {
}}
onLayout={onContainerLayout}
>
<ScrollView>
<ToolbarOverflowRows
buttonGroups={props.buttons}
styleSheet={props.styleSheet}
visible={overflowButtonsVisible}
onToggleOverflow={onToggleOverflowVisible}
/>
</ScrollView>
{ overflowButtonsVisible ? overflow : null }
{ !overflowButtonsVisible ? mainButtonRow : null }
</View>
);

View File

@@ -63,6 +63,7 @@ const ToolbarButton = ({ styleSheet, spec, onActionComplete, style }: ToolbarBut
onPress={onPress}
description={ spec.description }
disabled={ disabled }
preventKeyboardDismiss={true}
iconName={spec.icon}
iconStyle={styles.iconStyle}

View File

@@ -11,7 +11,6 @@ type OnToggleOverflowCallback = ()=> void;
interface OverflowPopupProps {
buttonGroups: ButtonGroup[];
styleSheet: StyleSheetData;
visible: boolean;
// Should be created using useCallback
onToggleOverflow: OnToggleOverflowCallback;
@@ -117,16 +116,13 @@ const ToolbarOverflowRows: React.FC<OverflowPopupProps> = (props: OverflowPopupP
/>
);
if (!props.visible) {
return null;
}
return (
<View
style={{
height: props.buttonGroups.length * buttonSize,
flexDirection: 'column',
flexGrow: 1,
display: !props.visible ? 'none' : 'flex',
display: 'flex',
}}
onLayout={onContainerLayout}
>

View File

@@ -4,7 +4,8 @@ import { themeStyle } from '@joplin/lib/theme';
import themeToCss from '@joplin/lib/services/style/themeToCss';
import EditLinkDialog from './EditLinkDialog';
import { defaultSearchState, SearchPanel } from './SearchPanel';
import ExtendedWebView, { WebViewControl } from '../ExtendedWebView';
import ExtendedWebView from '../ExtendedWebView';
import { WebViewControl } from '../ExtendedWebView/types';
import * as React from 'react';
import { forwardRef, RefObject, useEffect, useImperativeHandle } from 'react';
@@ -71,9 +72,8 @@ function useCss(themeId: number): string {
body {
margin: 0;
height: 100vh;
width: 100vh;
width: 100vw;
min-width: 100vw;
/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */
width: 100%;
box-sizing: border-box;
padding-left: 1px;
@@ -83,6 +83,44 @@ function useCss(themeId: number): string {
font-size: 13pt;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(100, 100, 100, 0.7) rgba(0, 0, 0, 0.1);
}
@supports selector(::-webkit-scrollbar) {
*::-webkit-scrollbar {
width: 7px;
height: 7px;
}
*::-webkit-scrollbar-corner {
background: none;
}
*::-webkit-scrollbar-track {
border: none;
}
*::-webkit-scrollbar-thumb {
background: rgba(100, 100, 100, 0.3);
border-radius: 5px;
}
*::-webkit-scrollbar-track:hover {
background: rgba(0, 0, 0, 0.1);
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
}
* {
scrollbar-width: unset;
scrollbar-color: unset;
}
}
`;
}, [themeId]);
}
@@ -469,7 +507,7 @@ function NoteEditor(props: Props, ref: any) {
const onMessage = useCallback((event: OnMessageEvent) => {
const data = event.nativeEvent.data;
if (data.indexOf('error:') === 0) {
if (typeof data === 'string' && data.indexOf('error:') === 0) {
logger.error('CodeMirror error', data);
return;
}

View File

@@ -1,16 +1,17 @@
const React = require('react');
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
const { View, FlatList, StyleSheet } = require('react-native');
import createRootStyle from '../../utils/createRootStyle';
import ScreenHeader from '../ScreenHeader';
const { FAB, List } = require('react-native-paper');
import { Profile } from '@joplin/lib/services/profileConfig/types';
import useProfileConfig from './useProfileConfig';
import { Alert } from 'react-native';
import { _ } from '@joplin/lib/locale';
import { deleteProfileById } from '@joplin/lib/services/profileConfig';
import { saveProfileConfig, switchProfile } from '../../services/profiles';
import { themeStyle } from '../global-style';
import shim from '@joplin/lib/shim';
import { DialogContext } from '../DialogManager';
interface Props {
themeId: number;
@@ -48,18 +49,26 @@ export default (props: Props) => {
return profileConfig ? profileConfig.profiles : [];
}, [profileConfig]);
const dialogs = useContext(DialogContext);
const onProfileItemPress = useCallback(async (profile: Profile) => {
const doIt = async () => {
try {
await switchProfile(profile.id);
} catch (error) {
Alert.alert(_('Could not switch profile: %s', error.message));
dialogs.prompt(_('Error'), _('Could not switch profile: %s', error.message));
}
};
Alert.alert(
const switchProfileMessage = _('To switch the profile, the app is going to close and you will need to restart it.');
if (shim.mobilePlatform() === 'web') {
if (confirm(switchProfileMessage)) {
void doIt();
}
} else {
dialogs.prompt(
_('Confirmation'),
_('To switch the profile, the app is going to close and you will need to restart it.'),
switchProfileMessage,
[
{
text: _('Continue'),
@@ -73,7 +82,8 @@ export default (props: Props) => {
},
],
);
}, []);
}
}, [dialogs]);
const onEditProfile = useCallback(async (profileId: string) => {
props.dispatch({
@@ -90,11 +100,11 @@ export default (props: Props) => {
await saveProfileConfig(newConfig);
setProfileConfigTime(Date.now());
} catch (error) {
Alert.alert(error.message);
dialogs.prompt(_('Error'), error.message);
}
};
Alert.alert(
dialogs.prompt(
_('Delete this profile?'),
_('All data, including notes, notebooks and tags will be permanently deleted.'),
[
@@ -110,23 +120,15 @@ export default (props: Props) => {
},
],
);
}, [profileConfig]);
}, [dialogs, profileConfig]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const renderProfileItem = (event: any) => {
const profile = event.item as Profile;
const titleStyle = { fontWeight: profile.id === profileConfig.currentProfileId ? 'bold' : 'normal' };
return (
<List.Item
title={profile.name}
style={style.profileListItem}
titleStyle={titleStyle}
left={() => <List.Icon icon="file-account-outline" />}
key={profile.id}
profileId={profile.id}
onPress={() => { void onProfileItemPress(profile); }}
onLongPress={() => {
Alert.alert(
const onConfigure = (event: Event) => {
event.preventDefault();
dialogs.prompt(
_('Configuration'),
'',
[
@@ -147,7 +149,20 @@ export default (props: Props) => {
},
],
);
}}
};
const titleStyle = { fontWeight: profile.id === profileConfig.currentProfileId ? 'bold' : 'normal' };
return (
<List.Item
title={profile.name}
style={style.profileListItem}
titleStyle={titleStyle}
left={() => <List.Icon icon="file-account-outline" />}
key={profile.id}
profileId={profile.id}
onPress={() => { void onProfileItemPress(profile); }}
onLongPress={onConfigure}
onContextMenu={onConfigure}
/>
);
};

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { Linking, TextStyle, View, ViewStyle } from 'react-native';
import { Text } from 'react-native-paper';
import IconButton from '../IconButton';
import { _ } from '@joplin/lib/locale';
import { useCallback, useState } from 'react';
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
import { LinkButton } from '../buttons';
interface Props {
wrapperStyle: ViewStyle;
iconStyle: TextStyle;
themeId: number;
}
const onLeaveFeedback = () => {
void Linking.openURL('https://discourse.joplinapp.org/t/web-client-running-joplin-mobile-in-a-web-browser-with-react-native-web/38749');
};
const feedbackContainerStyles: ViewStyle = { flexGrow: 1, justifyContent: 'flex-end' };
const WebBetaButton: React.FC<Props> = props => {
const [dialogVisible, setDialogVisible] = useState(false);
const onShowDialog = useCallback(() => {
setDialogVisible(true);
}, []);
const onHideDialog = useCallback(() => {
setDialogVisible(false);
}, []);
return (
<>
<IconButton
onPress={onShowDialog}
description={_('Beta')}
themeId={props.themeId}
contentWrapperStyle={props.wrapperStyle}
iconName="material beta"
iconStyle={props.iconStyle}
/>
<DismissibleDialog
size={DialogSize.Small}
themeId={props.themeId}
visible={dialogVisible}
onDismiss={onHideDialog}
>
<Text variant='headlineMedium'>{_('Beta')}</Text>
<Text>{'At present, the web client is in beta. In the future, it may change significantly, or be removed.'}</Text>
<View style={feedbackContainerStyles}>
<LinkButton onPress={onLeaveFeedback}>{_('Give feedback')}</LinkButton>
</View>
</DismissibleDialog>
</>
);
};
export default WebBetaButton;

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { PureComponent, ReactElement } from 'react';
import { connect } from 'react-redux';
import { View, Text, StyleSheet, TouchableOpacity, Image, ScrollView, Dimensions, ViewStyle } from 'react-native';
import { View, Text, StyleSheet, TouchableOpacity, Image, ScrollView, Dimensions, ViewStyle, Platform } from 'react-native';
const Icon = require('react-native-vector-icons/Ionicons').default;
const { BackButtonService } = require('../../services/back-button.js');
import NavService from '@joplin/lib/services/NavService';
@@ -24,6 +24,7 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
import { Dispatch } from 'redux';
import WarningBanner from './WarningBanner';
import WebBetaButton from './WebBetaButton';
// Rather than applying a padding to the whole bar, it is applied to each
// individual component (button, picker, etc.) so that the touchable areas
@@ -112,7 +113,6 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
container: {
flexDirection: 'column',
backgroundColor: theme.backgroundColor2,
alignItems: 'center',
shadowColor: '#000000',
elevation: 5,
},
@@ -451,6 +451,18 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
);
};
const betaIconButton = () => {
if (Platform.OS !== 'web') return null;
return (
<WebBetaButton
themeId={themeId}
wrapperStyle={this.styles().iconButton}
iconStyle={this.styles().topIcon}
/>
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function deleteButton(styles: any, onPress: OnPressCallback, disabled: boolean) {
return (
@@ -633,6 +645,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
const sideMenuComp = !showSideMenuButton ? null : sideMenuButton(this.styles(), () => this.sideMenuButton_press());
const backButtonComp = !showBackButton ? null : backButton(this.styles(), () => this.backButton_press(), backButtonDisabled);
const pluginPanelsComp = pluginPanelToggleButton(this.styles(), () => this.pluginPanelToggleButton_press());
const betaIconComp = betaIconButton();
const selectAllButtonComp = !showSelectAllButton ? null : selectAllButton(this.styles(), () => this.selectAllButton_press());
const searchButtonComp = !showSearchButton ? null : searchButton(this.styles(), () => this.searchButton_press());
const deleteButtonComp = !selectedFolderInTrash && this.props.noteSelectionEnabled ? deleteButton(this.styles(), () => this.deleteButton_press(), headerItemDisabled) : null;
@@ -642,7 +655,10 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
// To allow the notebook dropdown (and perhaps other components) to have sufficient
// space while in use, we allow certain buttons to be hidden.
const hideableRightComponents = pluginPanelsComp;
const hideableRightComponents = <>
{pluginPanelsComp}
{betaIconComp}
</>;
const titleComp = createTitleComponent(headerItemDisabled, hideableRightComponents);
const windowHeight = Dimensions.get('window').height - 50;

View File

@@ -1,8 +1,11 @@
import * as React from 'react';
import { themeStyle } from './global-style';
import { _ } from '@joplin/lib/locale';
const { Modal, View, Button, Text, StyleSheet } = require('react-native');
const { View, Button, Text, StyleSheet } = require('react-native');
import time from '@joplin/lib/time';
import { Platform } from 'react-native';
import Modal from './Modal';
import { formatMsToLocal } from '@joplin/utils/time';
const DateTimePickerModal = require('react-native-modal-datetime-picker').default;
const styles = StyleSheet.create({
@@ -10,7 +13,6 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'center',
alignItems: 'center',
marginTop: 22,
},
modalView: {
display: 'flex',
@@ -100,9 +102,26 @@ export default class SelectDateTimeDialog extends React.PureComponent<any, any>
this.setState({ showPicker: true });
}
// web
private onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ date: new Date(event.target.value) });
};
public renderContent() {
const theme = themeStyle(this.props.themeId);
// DateTimePickerModal doesn't support web.
if (Platform.OS === 'web') {
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Date_and_time_formats#local_date_and_time_strings
// for the expected date input format:
const dateString = this.state.date ? formatMsToLocal(this.state.date.getTime(), 'YYYY-MM-DD[T]HH:mm:ss') : '';
return <input
type="datetime-local"
value={dateString}
onChange={this.onInputChange}
></input>;
}
return (
<View style={{ flex: 0, margin: 20, alignItems: 'center' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
@@ -129,22 +148,20 @@ export default class SelectDateTimeDialog extends React.PureComponent<any, any>
const theme = themeStyle(this.props.themeId);
return (
<View style={styles.centeredView}>
<Modal
transparent={true}
visible={modalVisible}
containerStyle={styles.centeredView}
onRequestClose={() => {
this.onReject();
}}
>
<View style={styles.centeredView}>
<View style={{ ...styles.modalView, backgroundColor: theme.backgroundColor }}>
<View style={{ padding: 15, paddingBottom: 0, flex: 0, width: '100%', borderBottomWidth: 1, borderBottomColor: theme.dividerColor, borderBottomStyle: 'solid' }}>
<View style={{ padding: 15, flexBasis: 'auto', paddingBottom: 0, flexGrow: 0, width: '100%', borderBottomWidth: 1, borderBottomColor: theme.dividerColor, borderBottomStyle: 'solid' }}>
<Text style={{ ...styles.modalText, color: theme.color, fontSize: 14, fontWeight: 'bold' }}>{_('Set alarm')}</Text>
</View>
{this.renderContent()}
<View style={{ padding: 20, borderTopWidth: 1, borderTopStyle: 'solid', borderTopColor: theme.dividerColor }}>
<View style={{ padding: 20, flexBasis: 'auto', borderTopWidth: 1, borderTopStyle: 'solid', borderTopColor: theme.dividerColor }}>
<View style={{ marginBottom: 10 }}>
<Button title={_('Save alarm')} onPress={() => this.onAccept()} key="saveButton" />
</View>
@@ -156,9 +173,7 @@ export default class SelectDateTimeDialog extends React.PureComponent<any, any>
</View>
</View>
</View>
</View>
</Modal>
</View>
);
}

View File

@@ -0,0 +1,56 @@
import * as React from 'react';
import { View } from 'react-native';
import Modal from '../Modal';
import { useCallback, useState } from 'react';
import { _ } from '@joplin/lib/locale';
import { PrimaryButton, SecondaryButton } from '../buttons';
interface MenuItem {
label: string;
onPress?: ()=> void;
}
interface Props {
label: string;
onPress: ()=> void;
actions: MenuItem[]|null;
}
// react-native-paper's floating action button menu is inaccessible on web
// (can't be activated by a screen reader, and, in some cases, by the tab key).
// This component provides an alternative.
const AccessibleModalMenu: React.FC<Props> = props => {
const [open, setOpen] = useState(false);
const onClick = useCallback(() => {
if (props.onPress) {
props.onPress();
} else {
setOpen(!open);
}
}, [open, props.onPress]);
const options: React.ReactElement[] = [];
for (const action of (props.actions ?? [])) {
options.push(
<PrimaryButton key={action.label} onPress={action.onPress}>
{action.label}
</PrimaryButton>,
);
}
const modal = (
<Modal visible={open}>
{options}
<SecondaryButton onPress={onClick}>{_('Close menu')}</SecondaryButton>
</Modal>
);
return <View style={{ height: 0, overflow: 'visible' }}>
{modal}
<SecondaryButton onPress={onClick}>{props.label}</SecondaryButton>
</View>;
};
export default AccessibleModalMenu;

View File

@@ -0,0 +1,74 @@
import { focus } from '@joplin/lib/utils/focusHandler';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { AccessibilityInfo, findNodeHandle, Platform, UIManager, View, ViewProps } from 'react-native';
interface Props extends ViewProps {
// Prevents a view from being interacted with by accessibility tools, the mouse, or the keyboard focus.
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert.
inert?: boolean;
// When refocusCounter changes, sets the accessibility focus to this view.
// May require accessible={true}.
refocusCounter?: number;
}
const AccessibleView: React.FC<Props> = ({ inert, refocusCounter, children, ...viewProps }) => {
const [containerRef, setContainerRef] = useState<View|HTMLElement|null>(null);
// On web, there's no clear way to disable keyboard focus for an element **and its descendants**
// without accessing the underlying HTML.
useEffect(() => {
if (!containerRef || Platform.OS !== 'web') return;
const element = containerRef as HTMLElement;
if (inert) {
element.setAttribute('inert', 'true');
} else {
element.removeAttribute('inert');
}
}, [containerRef, inert]);
useEffect(() => {
if ((refocusCounter ?? null) === null) return;
const autoFocus = () => {
// react-native-web defines UIManager.focus for setting the keyboard focus. However,
// this property is not available in standard react-native. As such, access it using type
// narrowing:
// eslint-disable-next-line no-restricted-properties
if ('focus' in UIManager && typeof UIManager.focus === 'function') {
// Disable the "use focusHandler for all focus calls" rule -- UIManager.focus requires
// an argument, which is not supported by focusHandler.
// eslint-disable-next-line no-restricted-properties
UIManager.focus(containerRef);
} else {
const handle = findNodeHandle(containerRef as View);
AccessibilityInfo.setAccessibilityFocus(handle);
}
};
focus('AccessibleView', {
focus: autoFocus,
});
}, [containerRef, refocusCounter]);
const canFocus = (refocusCounter ?? null) !== null;
return <View
importantForAccessibility={inert ? 'no-hide-descendants' : 'auto'}
accessibilityElementsHidden={inert}
aria-hidden={inert}
pointerEvents={inert ? 'box-none' : 'auto'}
// On some platforms, views must have accessible=true to be focused.
accessible={canFocus ? true : undefined}
ref={setContainerRef}
{...viewProps}
>
{children}
</View>;
};
export default AccessibleView;

View File

@@ -3,8 +3,9 @@ import { useState, useCallback, useMemo } from 'react';
import { FAB, Portal } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
import { Dispatch } from 'redux';
import { useWindowDimensions } from 'react-native';
import { Platform, useWindowDimensions, View } from 'react-native';
import shim from '@joplin/lib/shim';
import AccessibleWebMenu from '../accessibility/AccessibleModalMenu';
const Icon = require('react-native-vector-icons/Ionicons').default;
// eslint-disable-next-line no-undef -- Don't know why it says React is undefined when it's defined above
@@ -40,7 +41,7 @@ const useIcon = (iconName: string) => {
}, [iconName]);
};
const ActionButton = (props: ActionButtonProps) => {
const FloatingActionButton = (props: ActionButtonProps) => {
const [open, setOpen] = useState(false);
const onMenuToggled: FABGroupProps['onStateChange'] = useCallback(state => {
props.dispatch({
@@ -75,11 +76,22 @@ const ActionButton = (props: ActionButtonProps) => {
const marginTop = adjustMargins ? Math.max(0, windowSize.height - 140) : undefined;
const marginStart = adjustMargins ? Math.max(0, windowSize.width - 200) : undefined;
return (
<Portal>
<FAB.Group
const label = props.mainButton?.label ?? _('Add new');
// On Web, FAB.Group can't be used at all with accessibility tools. Work around this
// by hiding the FAB for accessibility, and providing a screen-reader-only custom menu.
const isWeb = Platform.OS === 'web';
const accessibleMenu = isWeb ? (
<AccessibleWebMenu
label={label}
onPress={props.mainButton?.onPress}
actions={props.buttons}
/>
) : null;
const menuContent = <FAB.Group
open={open}
accessibilityLabel={props.mainButton?.label ?? _('Add new')}
accessibilityLabel={label}
style={{ marginStart, marginTop }}
icon={ open ? openIcon : closedIcon }
fabStyle={{
@@ -89,9 +101,22 @@ const ActionButton = (props: ActionButtonProps) => {
actions={actions}
onPress={props.mainButton?.onPress ?? defaultOnPress}
visible={true}
/>
/>;
const mainMenu = isWeb ? (
<View
aria-hidden={true}
pointerEvents='box-none'
tabIndex={-1}
style={{ flex: 1 }}
>{menuContent}</View>
) : menuContent;
return (
<Portal>
{mainMenu}
{accessibleMenu}
</Portal>
);
};
export default ActionButton;
export default FloatingActionButton;

View File

@@ -1,7 +1,7 @@
import BasePluginRunner from '@joplin/lib/services/plugins/BasePluginRunner';
import PluginApiGlobal from '@joplin/lib/services/plugins/api/Global';
import Plugin from '@joplin/lib/services/plugins/Plugin';
import { WebViewControl } from '../ExtendedWebView';
import { WebViewControl } from '../ExtendedWebView/types';
import { RefObject } from 'react';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import { PluginMainProcessApi, PluginWebViewApi } from './types';

View File

@@ -1,12 +1,12 @@
import * as React from 'react';
import ExtendedWebView, { WebViewControl } from '../ExtendedWebView';
import ExtendedWebView from '../ExtendedWebView';
import { WebViewControl } from '../ExtendedWebView/types';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import shim from '@joplin/lib/shim';
import PluginRunner from './PluginRunner';
import loadPlugins from '@joplin/lib/services/plugins/loadPlugins';
import { connect, useStore } from 'react-redux';
import Logger from '@joplin/utils/Logger';
import { View } from 'react-native';
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
import { PluginHtmlContents, PluginStates } from '@joplin/lib/services/plugins/reducer';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
@@ -14,6 +14,7 @@ import PluginDialogManager from './dialogs/PluginDialogManager';
import { AppState } from '../../utils/types';
import usePrevious from '@joplin/lib/hooks/usePrevious';
import PlatformImplementation from '../../services/plugins/PlatformImplementation';
import AccessibleView from '../accessibility/AccessibleView';
const logger = Logger.create('PluginRunnerWebView');
@@ -172,9 +173,9 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
};
return (
<View style={{ display: 'none' }}>
<AccessibleView style={{ display: 'none' }} inert={true}>
{renderWebView()}
</View>
</AccessibleView>
);
};

View File

@@ -47,6 +47,22 @@ const initializeDialogWebView = (messageChannelId: string) => {
includeJsFiles: async (paths: string[]) => {
return includeScriptsOrStyles('js', paths);
},
runScript: async (key: string, scriptData: string) => {
if (loadedPaths.has(key)) {
return;
}
loadedPaths.add(key);
if (key.endsWith('.css')) {
const stylesheetLink = document.createElement('style');
stylesheetLink.appendChild(document.createTextNode(scriptData));
document.head.appendChild(stylesheetLink);
} else {
const script = document.createElement('script');
script.appendChild(document.createTextNode(scriptData));
document.head.appendChild(script);
}
},
getFormData: async () => {
return getFormData();
},

View File

@@ -2,7 +2,7 @@ import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger';
import { PluginMainProcessApi, PluginWebViewApi } from '../types';
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
import WindowMessenger from '@joplin/lib/utils/ipc/WindowMessenger';
import makeSandboxedIframe from './utils/makeSandboxedIframe';
import makeSandboxedIframe from '@joplin/lib/utils/dom/makeSandboxedIframe';
type PluginRecord = {
iframe: HTMLIFrameElement;
@@ -50,7 +50,7 @@ export const runPlugin = (
${pluginScript}
})();
`;
const backgroundIframe = makeSandboxedIframe(bodyHtml, [initialJavaScript]).iframe;
const backgroundIframe = makeSandboxedIframe({ bodyHtml, headHtml: '', scripts: [initialJavaScript] }).iframe;
loadedPlugins[pluginId] = {
iframe: backgroundIframe,

View File

@@ -1,7 +1,8 @@
import * as React from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PluginHtmlContents, ViewInfo } from '@joplin/lib/services/plugins/reducer';
import ExtendedWebView, { WebViewControl } from '../../ExtendedWebView';
import ExtendedWebView from '../../ExtendedWebView';
import { WebViewControl } from '../../ExtendedWebView/types';
import { ViewStyle } from 'react-native';
import usePlugin from '@joplin/lib/hooks/usePlugin';
import shim from '@joplin/lib/shim';
@@ -46,6 +47,7 @@ const PluginUserWebView = (props: Props) => {
setThemeCss: messenger.remoteApi.setThemeCss,
getFormData: messenger.remoteApi.getFormData,
getContentSize: messenger.remoteApi.getContentSize,
runScript: messenger.remoteApi.runScript,
});
}, [messenger, props.setDialogControl]);

View File

@@ -1,7 +1,7 @@
import { useMemo, RefObject } from 'react';
import { DialogMainProcessApi, DialogWebViewApi } from '../../types';
import Logger from '@joplin/utils/Logger';
import { WebViewControl } from '../../../ExtendedWebView';
import { WebViewControl } from '../../../ExtendedWebView/types';
import createOnLogHander from '../../utils/createOnLogHandler';
import RNToWebViewMessenger from '../../../../utils/ipc/RNToWebViewMessenger';
import { SerializableData } from '@joplin/lib/utils/ipc/types';

View File

@@ -29,8 +29,16 @@ const useWebViewSetup = (props: Props) => {
jsPaths.push(resolvedPath);
}
}
if (shim.mobilePlatform() === 'web') {
void (async () => {
for (const path of [...jsPaths, ...cssPaths]) {
void dialogControl.runScript(path, await shim.fsDriver().readFile(path, 'utf-8'));
}
})();
} else {
void dialogControl.includeCssFiles(cssPaths);
void dialogControl.includeJsFiles(jsPaths);
}
}, [dialogControl, scriptPaths, props.webViewLoadCount, pluginBaseDir]);
useEffect(() => {

View File

@@ -57,6 +57,7 @@ export interface DialogWebViewApi {
// does not reload styles/scripts).
includeCssFiles: (paths: string[])=> Promise<void>;
includeJsFiles: (paths: string[])=> Promise<void>;
runScript: (key: string, content: string)=> Promise<void>;
setThemeCss: (css: string)=> Promise<void>;
getFormData: ()=> Promise<SerializableData>;

View File

@@ -35,6 +35,7 @@ import SectionDescription from './SectionDescription';
import EnablePluginSupportPage from './plugins/EnablePluginSupportPage';
import getVersionInfoText from '../../../utils/getVersionInfoText';
import JoplinCloudConfig, { emailToNoteDescription, emailToNoteLabel } from './JoplinCloudConfig';
import shim from '@joplin/lib/shim';
interface ConfigScreenState {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -257,29 +258,15 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
return this.state.changedSettingKeys.length > 0;
}
private promptSaveChanges(): Promise<void> {
return new Promise(resolve => {
private async promptSaveChanges(): Promise<void> {
if (this.hasUnsavedChanges()) {
const dialogTitle: string|null = null;
Alert.alert(
dialogTitle,
_('There are unsaved changes.'),
[{
text: _('Save changes'),
onPress: async () => {
await this.saveButton_press();
resolve();
},
},
{
text: _('Discard changes'),
onPress: () => resolve(),
}],
);
} else {
resolve();
}
const response = await shim.showMessageBox(_('There are unsaved changes.'), {
buttons: [_('Save changes'), _('Discard changes')],
});
if (response === 0) {
await this.saveButton_press();
}
}
}
private handleNavigateToNewScreen = async (): Promise<boolean> => {

View File

@@ -3,18 +3,26 @@ import * as React from 'react';
import shim from '@joplin/lib/shim';
import { FunctionComponent, useCallback, useEffect, useState } from 'react';
import { ConfigScreenStyles } from './configScreenStyles';
import { TouchableNativeFeedback, View, Text } from 'react-native';
import { View, Text } from 'react-native';
import Setting, { SettingItem } from '@joplin/lib/models/Setting';
import { openDocumentTree } from '@joplin/react-native-saf-x';
import { UpdateSettingValueCallback } from './types';
import { reg } from '@joplin/lib/registry';
import type FsDriverWeb from '../../../utils/fs-driver/fs-driver-rn.web';
import { TouchableRipple } from 'react-native-paper';
interface Props {
styles: ConfigScreenStyles;
settingMetadata: SettingItem;
mode: 'read'|'readwrite';
updateSettingValue: UpdateSettingValueCallback;
}
type ExtendedSelf = (typeof window.self) & {
showDirectoryPicker: (options: { id: string; mode: string })=> Promise<FileSystemDirectoryHandle>;
};
declare const self: ExtendedSelf;
const FileSystemPathSelector: FunctionComponent<Props> = props => {
const [fileSystemPath, setFileSystemPath] = useState<string>('');
@@ -25,6 +33,15 @@ const FileSystemPathSelector: FunctionComponent<Props> = props => {
}, [settingId]);
const selectDirectoryButtonPress = useCallback(async () => {
if (shim.mobilePlatform() === 'web') {
// Directory picker IDs can't include certain characters.
const pickerId = `setting-${settingId}`.replace(/[^a-zA-Z]/g, '_');
const handle = await self.showDirectoryPicker({ id: pickerId, mode: props.mode });
const fsDriver = shim.fsDriver() as FsDriverWeb;
const uri = await fsDriver.mountExternalDirectory(handle, pickerId, props.mode);
await props.updateSettingValue(settingId, uri);
setFileSystemPath(uri);
} else {
try {
const doc = await openDocumentTree(true);
if (doc?.uri) {
@@ -36,19 +53,22 @@ const FileSystemPathSelector: FunctionComponent<Props> = props => {
} catch (e) {
reg.logger().info('Didn\'t pick sync dir: ', e);
}
}, [props.updateSettingValue, settingId]);
}
}, [props.updateSettingValue, settingId, props.mode]);
// Unsupported on non-Android platforms.
if (!shim.fsDriver().isUsingAndroidSAF()) {
// Supported on Android and some versions of Chrome
const supported = shim.fsDriver().isUsingAndroidSAF() || (shim.mobilePlatform() === 'web' && 'showDirectoryPicker' in self);
if (!supported) {
return null;
}
const styleSheet = props.styles.styleSheet;
return (
<TouchableNativeFeedback
<TouchableRipple
onPress={selectDirectoryButtonPress}
style={styleSheet.settingContainer}
role='button'
>
<View style={styleSheet.settingContainer}>
<Text key="label" style={styleSheet.settingText}>
@@ -58,7 +78,7 @@ const FileSystemPathSelector: FunctionComponent<Props> = props => {
{fileSystemPath}
</Text>
</View>
</TouchableNativeFeedback>
</TouchableRipple>
);
};

View File

@@ -4,12 +4,12 @@ import Logger from '@joplin/utils/Logger';
import { FunctionComponent } from 'react';
import shim from '@joplin/lib/shim';
import { join } from 'path';
import Share from 'react-native-share';
import exportAllFolders from './utils/exportAllFolders';
import { ExportProgressState } from '@joplin/lib/services/interop/types';
import { ConfigScreenStyles } from '../configScreenStyles';
import makeImportExportCacheDirectory from './utils/makeImportExportCacheDirectory';
import TaskButton, { OnProgressCallback, SetAfterCompleteListenerCallback, TaskStatus } from './TaskButton';
import shareFile from '../../../../utils/shareFile';
const logger = Logger.create('NoteExportButton');
@@ -37,12 +37,7 @@ const runExportTask = async (
setAfterCompleteListener(async (success: boolean) => {
if (success) {
await Share.open({
type: 'application/jex',
filename: 'export.jex',
url: `file://${exportTargetPath}`,
failOnCancel: false,
});
await shareFile(exportTargetPath, 'application/jex');
}
await shim.fsDriver().remove(exportTargetPath);
});

View File

@@ -40,7 +40,7 @@ const runImportTask = async (
await shim.fsDriver().remove(importTargetPath);
});
const importFiles = await pickDocument(false);
const importFiles = await pickDocument({ multiple: false });
if (importFiles.length === 0) {
logger.info('Canceled.');
return { success: false, warnings: [] };
@@ -48,7 +48,7 @@ const runImportTask = async (
const sourceFileUri = importFiles[0].uri;
const sourceFilePath = Platform.select({
android: sourceFileUri,
default: sourceFileUri,
ios: decodeURI(sourceFileUri),
});
await shim.fsDriver().copy(sourceFilePath, importTargetPath);

View File

@@ -1,11 +1,12 @@
import * as React from 'react';
import { Alert, Text } from 'react-native';
import { Text } from 'react-native';
import { _ } from '@joplin/lib/locale';
import { ProgressBar } from 'react-native-paper';
import { FunctionComponent, useCallback, useState } from 'react';
import { ConfigScreenStyles } from '../configScreenStyles';
import SettingsButton from '../SettingsButton';
import Logger from '@joplin/utils/Logger';
import shim from '@joplin/lib/shim';
// Undefined = indeterminate progress
export type OnProgressCallback = (progressFraction: number|undefined)=> void;
@@ -69,7 +70,10 @@ const TaskButton: FunctionComponent<Props> = props => {
}
} catch (error) {
logger.error(`Task ${props.taskName} failed`, error);
Alert.alert(_('Error'), _('Task "%s" failed with error: %s', props.taskName, error.toString()));
await shim.showMessageBox(_('Task "%s" failed with error: %s', props.taskName, error.toString()), {
title: _('Error'),
buttons: [_('OK')],
});
} finally {
if (!completedSuccessfully) {
setTaskStatus(TaskStatus.NotStarted);

View File

@@ -1,9 +1,8 @@
import shim from '@joplin/lib/shim';
import { CachesDirectoryPath } from 'react-native-fs';
const makeImportExportCacheDirectory = async () => {
const targetDir = `${CachesDirectoryPath}/exports`;
const targetDir = `${shim.fsDriver().getCacheDirectoryPath()}/exports`;
await shim.fsDriver().mkdir(targetDir);
return targetDir;

View File

@@ -115,9 +115,10 @@ const SettingComponent: React.FunctionComponent<Props> = props => {
</View>
);
} else if (md.type === Setting.TYPE_STRING) {
if (md.key === 'sync.2.path' && shim.fsDriver().isUsingAndroidSAF()) {
if (['sync.2.path', 'plugins.devPluginPaths'].includes(md.key) && (shim.fsDriver().isUsingAndroidSAF() || shim.mobilePlatform() === 'web')) {
return (
<FileSystemPathSelector
mode={md.key === 'sync.2.path' ? 'readwrite' : 'read'}
styles={props.styles}
settingMetadata={md}
updateSettingValue={props.updateSettingValue}

View File

@@ -53,6 +53,7 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => {
const settingContainerStyle: ViewStyle = {
flex: 1,
flexDirection: 'row',
flexBasis: 'auto',
alignItems: 'center',
borderBottomWidth: 1,
borderBottomColor: theme.dividerColor,
@@ -80,6 +81,7 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => {
const sidebarButton: SidebarButtonStyle = {
height: sidebarButtonHeight,
flex: 1,
flexBasis: 'auto',
flexDirection: 'row',
alignItems: 'center',
paddingEnd: theme.marginRight,
@@ -184,6 +186,7 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => {
...settingControlStyle,
color: undefined,
flex: 0,
flexBasis: 'auto',
},

View File

@@ -2,26 +2,10 @@ import { _ } from '@joplin/lib/locale';
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
import * as React from 'react';
import IconButton from '../../../../IconButton';
import { Alert, Linking, StyleSheet } from 'react-native';
import { Linking, StyleSheet } from 'react-native';
import { themeStyle } from '../../../../global-style';
import { useMemo } from 'react';
const onRecommendedPress = () => {
Alert.alert(
'',
_('The Joplin team has vetted this plugin and it meets our standards for security and performance.'),
[
{
text: _('Learn more'),
onPress: () => Linking.openURL('https://github.com/joplin/plugins/blob/master/readme/recommended.md'),
},
{
text: _('OK'),
},
],
{ cancelable: true },
);
};
import { useCallback, useContext, useMemo } from 'react';
import { DialogContext } from '../../../../DialogManager';
interface Props {
themeId: number;
@@ -58,6 +42,24 @@ const useStyles = (themeId: number) => {
const RecommendedBadge: React.FC<Props> = props => {
const styles = useStyles(props.themeId);
const dialogs = useContext(DialogContext);
const onRecommendedPress = useCallback(() => {
dialogs.prompt(
'',
_('The Joplin team has vetted this plugin and it meets our standards for security and performance.'),
[
{
text: _('Learn more'),
onPress: () => Linking.openURL('https://github.com/joplin/plugins/blob/master/readme/recommended.md'),
},
{
text: _('OK'),
},
],
{ cancelable: true },
);
}, [dialogs]);
if (!props.manifest._recommended || !props.isCompatible) return null;
return <IconButton

View File

@@ -80,9 +80,10 @@ const PluginBox: React.FC<Props> = props => {
const styles = useStyles(props.isCompatible);
const CardWrapper = props.onShowPluginInfo ? TouchableRipple : View;
return (
<TouchableRipple
accessibilityRole='button'
<CardWrapper
accessibilityRole={props.onShowPluginInfo ? 'button' : null}
accessible={true}
onPress={props.onShowPluginInfo ? onPress : null}
style={styles.cardContainer}
@@ -115,7 +116,7 @@ const PluginBox: React.FC<Props> = props => {
{props.onInstall ? installButton : null}
</Card.Actions>
</Card>
</TouchableRipple>
</CardWrapper>
);
};

View File

@@ -112,7 +112,10 @@ const PluginStates: React.FC<Props> = props => {
<Button onPress={reloadPluginRepo}>{_('Retry')}</Button>
</View>;
} else {
return <ProgressBar accessibilityLabel={_('Loading...')} indeterminate={true} />;
// The progress bar needs to be wrapped in a View to have the correct height on web.
return <View>
<ProgressBar accessibilityLabel={_('Loading...')} indeterminate={true} />
</View>;
}
};

View File

@@ -36,15 +36,15 @@ const PluginUploadButton: React.FC<Props> = props => {
const onInstallFromFile = useCallback(async () => {
const pluginService = PluginService.instance();
const pluginFiles = await pickDocument(false);
const pluginFiles = await pickDocument({ multiple: false });
if (pluginFiles.length === 0) {
return;
}
const selectedFile = pluginFiles[0];
const localFilePath = Platform.select({
android: selectedFile.uri,
ios: decodeURI(selectedFile.uri),
default: selectedFile.uri,
});
logger.info('Installing plugin from file', localFilePath);
@@ -73,6 +73,8 @@ const PluginUploadButton: React.FC<Props> = props => {
logger.info('Copying to', targetFile);
await fsDriver.copy(localFilePath, targetFile);
logger.debug('Copied. Now installing.');
const plugin = await pluginService.installPlugin(targetFile);
const pluginSettings = pluginService.unserializePluginSettings(props.pluginSettings);

View File

@@ -4,7 +4,7 @@ import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { _ } from '@joplin/lib/locale';
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
import { useCallback, useMemo, useState } from 'react';
import { FlatList, StyleSheet, View } from 'react-native';
import { FlatList, Platform, StyleSheet, View } from 'react-native';
import { TextInput } from 'react-native-paper';
import PluginBox, { InstallState } from './PluginBox';
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
@@ -143,6 +143,11 @@ const PluginSearch: React.FC<Props> = props => {
}
};
// scrollEnabled seems to have a different effect on web, when compared with native:
// https://github.com/necolas/react-native-web/issues/1042#issuecomment-407157580
// When not provided on web, scrolling the parent element doesn't work.
const scrollEnabled = Platform.OS === 'web';
return (
<View style={styles.container}>
<TextInput
@@ -159,7 +164,7 @@ const PluginSearch: React.FC<Props> = props => {
data={searchResults}
renderItem={renderResult}
keyExtractor={item => item.id}
scrollEnabled={false}
scrollEnabled={scrollEnabled}
/>
</View>
);

View File

@@ -1,8 +1,8 @@
import * as React from 'react';
import { FlatList, View, Text, Button, StyleSheet, Platform, Alert } from 'react-native';
import { FlatList, View, Text, Button, StyleSheet, Platform } from 'react-native';
import { connect } from 'react-redux';
import { reg } from '@joplin/lib/registry.js';
import { reg } from '@joplin/lib/registry';
import { ScreenHeader } from '../ScreenHeader';
import time from '@joplin/lib/time';
import { themeStyle } from '../global-style';
@@ -11,10 +11,10 @@ import { BaseScreenComponent } from '../base-screen';
import { _ } from '@joplin/lib/locale';
import { MenuOptionType } from '../ScreenHeader';
import { AppState } from '../../utils/types';
import Share from 'react-native-share';
import { writeTextToCacheFile } from '../../utils/ShareUtils';
import shim from '@joplin/lib/shim';
import { TextInput } from 'react-native-paper';
import shareFile from '../../utils/shareFile';
const logger = Logger.create('LogScreen');
@@ -100,18 +100,12 @@ class LogScreenComponent extends BaseScreenComponent<Props, State> {
// Using a .txt file extension causes a "No valid provider found from URL" error
// and blank share sheet on iOS for larger log files (around 200 KiB).
fileToShare = await writeTextToCacheFile(logData, 'mobile-log.log');
await Share.open({
type: 'text/plain',
filename: 'log.txt',
url: `file://${fileToShare}`,
failOnCancel: false,
});
await shareFile(fileToShare, 'text/plain');
} catch (e) {
logger.error('Unable to share log data:', e);
// Display a message to the user (e.g. in the case where the user is out of disk space).
Alert.alert(_('Error'), _('Unable to share log data. Reason: %s', e.toString()));
void shim.showMessageBox(_('Error'), _('Unable to share log data. Reason: %s', e.toString()));
} finally {
if (fileToShare) {
await shim.fsDriver().remove(fileToShare);
@@ -225,6 +219,7 @@ class LogScreenComponent extends BaseScreenComponent<Props, State> {
{this.state.filter !== undefined ? filterInput : null}
<FlatList
data={this.state.logEntries}
initialNumToRender={100}
renderItem={this.onRenderLogRow}
keyExtractor={item => { return `${item.id}`; }}
/>

View File

@@ -6,7 +6,6 @@ import UndoRedoService from '@joplin/lib/services/UndoRedoService';
import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer';
import checkPermissions from '../../utils/checkPermissions';
import NoteEditor from '../NoteEditor/NoteEditor';
const FileViewer = require('react-native-file-viewer').default;
const React = require('react');
import { Keyboard, View, TextInput, StyleSheet, Linking, Share, NativeSyntheticEvent } from 'react-native';
import { Platform, PermissionsAndroid } from 'react-native';
@@ -20,8 +19,8 @@ const Clipboard = require('@react-native-clipboard/clipboard').default;
const md5 = require('md5');
const { BackButtonService } = require('../../services/back-button.js');
import NavService, { OnNavigateCallback as OnNavigateCallback } from '@joplin/lib/services/NavService';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import ActionButton from '../ActionButton';
import { ModelType } from '@joplin/lib/BaseModel';
import FloatingActionButton from '../buttons/FloatingActionButton';
const { fileExtension, safeFileExtension } = require('@joplin/lib/path-utils');
import * as mimeUtils from '@joplin/lib/mime-utils';
import ScreenHeader, { MenuOptionType } from '../ScreenHeader';
@@ -62,7 +61,7 @@ import pickDocument from '../../utils/pickDocument';
import debounce from '../../utils/debounce';
import { focus } from '@joplin/lib/utils/focusHandler';
import CommandService from '@joplin/lib/services/CommandService';
import * as urlUtils from '@joplin/lib/urlUtils';
import { ResourceInfo } from '../NoteBodyViewer/hooks/useRerenderHandler';
import getImageDimensions from '../../utils/image/getImageDimensions';
import resizeImage from '../../utils/image/resizeImage';
@@ -105,7 +104,7 @@ interface State {
showImageEditor: boolean;
imageEditorResource: ResourceEntity;
imageEditorResourceFilepath: string;
noteResources: Record<string, ResourceEntity>;
noteResources: Record<string, ResourceInfo>;
newAndNoTitleChangeNoteId: boolean|null;
HACK_webviewLoadingState: number;
@@ -269,35 +268,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
this.onJoplinLinkClick_ = async (msg: string) => {
try {
const resourceUrlInfo = urlUtils.parseResourceUrl(msg);
if (resourceUrlInfo) {
const itemId = resourceUrlInfo.itemId;
const item = await BaseItem.loadItemById(itemId);
if (!item) throw new Error(_('No item with ID %s', itemId));
if (item.type_ === BaseModel.TYPE_NOTE) {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Note',
noteId: item.id,
noteHash: resourceUrlInfo.hash,
});
} else if (item.type_ === BaseModel.TYPE_RESOURCE) {
if (!(await Resource.isReady(item))) throw new Error(_('This attachment is not downloaded or not decrypted yet.'));
const resourcePath = Resource.fullPath(item);
logger.info(`Opening resource: ${resourcePath}`);
await FileViewer.open(resourcePath);
} else {
throw new Error(_('The Joplin mobile app does not currently support this type of link: %s', BaseModel.modelTypeToName(item.type_)));
}
} else {
if (msg.indexOf('file://') === 0) {
throw new Error(_('Links with protocol "%s" are not supported', 'file://'));
} else {
await Linking.openURL(msg);
}
}
await CommandService.instance().execute('openItem', msg);
} catch (error) {
dialogs.error(this, error.message);
}
@@ -460,6 +431,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
styles.titleContainer = {
flex: 0,
flexDirection: 'row',
flexBasis: 'auto',
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
borderBottomColor: theme.dividerColor,
@@ -493,6 +465,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
public async requestGeoLocationPermissions() {
if (!Setting.value('trackLocation')) return;
if (Platform.OS === 'web') return;
const response = await checkPermissions(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, {
message: _('In order to associate a geo-location with the note, the app needs your permission to access your location.\n\nYou may turn off this option at any time in the Configuration screen.'),
@@ -670,7 +643,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
}
private async pickDocuments() {
const result = await pickDocument(true);
const result = await pickDocument({ multiple: true });
return result;
}
@@ -726,8 +699,8 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
}
const localFilePath = Platform.select({
android: pickerResponse.uri,
ios: decodeURI(pickerResponse.uri),
default: pickerResponse.uri,
});
let mimeType = pickerResponse.type;
@@ -849,9 +822,16 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
}
}
private takePhoto_onPress() {
private async takePhoto_onPress() {
if (Platform.OS === 'web') {
const response = await pickDocument({ multiple: true, preferCamera: true });
for (const asset of response) {
await this.attachFile(asset, 'image');
}
} else {
this.setState({ showCamera: true });
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private cameraView_onPhoto(data: any) {
@@ -994,6 +974,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
}
public async onAlarmDialogAccept(date: Date) {
if (Platform.OS === 'android') {
const response = await checkPermissions(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);
// The POST_NOTIFICATIONS permission isn't supported on Android API < 33.
@@ -1003,6 +984,11 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
logger.warn('POST_NOTIFICATIONS permission was not granted');
return;
}
}
if (Platform.OS === 'web') {
alert('Warning: The due-date has been saved, but showing notifications is not supported by Joplin Web.');
}
const newNote = { ...this.state.note };
newNote.todo_due = date ? date.getTime() : 0;
@@ -1226,6 +1212,8 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
});
}
const shareSupported = Platform.OS !== 'web' || !!navigator.share;
if (shareSupported) {
output.push({
title: _('Share'),
onPress: () => {
@@ -1233,6 +1221,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
},
disabled: readOnly,
});
}
// Voice typing is enabled only for French language and on Android for now
if (voskEnabled && shim.mobilePlatform() === 'android' && isSupportedLanguage(currentLocale())) {
@@ -1270,6 +1259,9 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
this.copyMarkdownLink_onPress();
},
});
// External links are not supported on web.
if (Platform.OS !== 'web') {
output.push({
title: _('Copy external link'),
onPress: () => {
@@ -1277,6 +1269,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
},
});
}
}
output.push({
title: _('Properties'),
@@ -1584,7 +1577,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
if (this.state.mode === 'edit') return null;
return <ActionButton mainButton={editButton} dispatch={this.props.dispatch} />;
return <FloatingActionButton mainButton={editButton} dispatch={this.props.dispatch} />;
};
// Save button is not really needed anymore with the improved save logic

View File

@@ -10,7 +10,7 @@ import Setting from '@joplin/lib/models/Setting';
import { themeStyle } from '../global-style';
import { ScreenHeader } from '../ScreenHeader';
import { _ } from '@joplin/lib/locale';
import ActionButton from '../ActionButton';
import ActionButton from '../buttons/FloatingActionButton';
const { dialogs } = require('../../utils/dialogs.js');
const DialogBox = require('react-native-dialogbox').default;
const { BaseScreenComponent } = require('../base-screen');
@@ -18,6 +18,7 @@ const { BackButtonService } = require('../../services/back-button.js');
import { AppState } from '../../utils/types';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { itemIsInTrash } from '@joplin/lib/services/trash';
import AccessibleView from '../accessibility/AccessibleView';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
class NotesScreenComponent extends BaseScreenComponent<any> {
@@ -264,16 +265,13 @@ class NotesScreenComponent extends BaseScreenComponent<any> {
const actionButtonComp = this.props.noteSelectionEnabled || !this.props.visible ? null : makeActionButtonComp();
// Ensure that screen readers can't focus the notes list when it isn't visible.
// accessibilityElementsHidden is used on iOS and importantForAccessibility is used
// on Android.
const accessibilityHidden = !this.props.visible;
return (
<View
<AccessibleView
style={rootStyle}
accessibilityElementsHidden={accessibilityHidden}
importantForAccessibility={accessibilityHidden ? 'no-hide-descendants' : undefined}
inert={accessibilityHidden}
>
<ScreenHeader title={iconString + title} showBackButton={false} parentComponent={thisComp} sortButton_press={this.sortButton_press} folderPickerOptions={this.folderPickerOptions()} showSearchButton={true} showSideMenuButton={true} />
<NoteList />
@@ -284,7 +282,7 @@ class NotesScreenComponent extends BaseScreenComponent<any> {
this.dialogbox = dialogbox;
}}
/>
</View>
</AccessibleView>
);
}
}

View File

@@ -153,7 +153,7 @@ const EncryptionConfigScreen = (props: Props) => {
});
return (
<View style={{ flex: 1, borderColor: theme.dividerColor, borderWidth: 1, padding: 10, marginTop: 10, marginBottom: 10 }}>
<View style={{ flex: 1, flexBasis: 'auto', borderColor: theme.dividerColor, borderWidth: 1, padding: 10, marginTop: 10, marginBottom: 10 }}>
<View>{messageComps}</View>
<Text style={styles.normalText}>{_('Password:')}</Text>
<TextInput

View File

@@ -5,7 +5,7 @@ const { Button } = require('react-native');
const { WebView } = require('react-native-webview');
const { connect } = require('react-redux');
const { ScreenHeader } = require('../ScreenHeader');
const { reg } = require('@joplin/lib/registry.js');
const { reg } = require('@joplin/lib/registry');
const { _ } = require('@joplin/lib/locale');
const { BaseScreenComponent } = require('../base-screen');
const parseUri = require('@joplin/lib/parseUri');

View File

@@ -48,6 +48,7 @@ class StatusScreenComponent extends BaseScreenComponent<Props, State> {
},
actionButton: {
flex: 0,
flexBasis: 'auto',
marginLeft: 2,
marginRight: 2,
},

View File

@@ -25,6 +25,7 @@ class SideMenuContentNoteComponent extends Component {
},
button: {
flex: 1,
flexBasis: 'auto',
flexDirection: 'row',
height: 36,
alignItems: 'center',

View File

@@ -1,6 +1,6 @@
const React = require('react');
import { useMemo, useEffect, useCallback } from 'react';
const { Easing, Animated, TouchableOpacity, Text, StyleSheet, ScrollView, View, Alert, Image } = require('react-native');
import { useMemo, useEffect, useCallback, useContext } from 'react';
const { Easing, Animated, TouchableOpacity, Text, StyleSheet, ScrollView, View, Image } = require('react-native');
const { connect } = require('react-redux');
const Icon = require('react-native-vector-icons/Ionicons').default;
import Folder from '@joplin/lib/models/Folder';
@@ -18,6 +18,9 @@ import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash
import restoreItems from '@joplin/lib/services/trash/restoreItems';
import emptyTrash from '@joplin/lib/services/trash/emptyTrash';
import { ModelType } from '@joplin/lib/BaseModel';
import { DialogContext } from './DialogManager';
import AccessibleView from './accessibility/AccessibleView';
const { TouchableRipple } = require('react-native-paper');
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
interface Props {
@@ -71,6 +74,7 @@ const SideMenuContentComponent = (props: Props) => {
button: {
flex: 1,
flexDirection: 'row',
flexBasis: 'auto',
height: 36,
alignItems: 'center',
paddingLeft: theme.marginLeft,
@@ -144,6 +148,8 @@ const SideMenuContentComponent = (props: Props) => {
});
};
const dialogs = useContext(DialogContext);
const folder_longPress = async (folderOrAll: FolderEntity | string) => {
if (folderOrAll === 'all') return;
@@ -156,7 +162,7 @@ const SideMenuContentComponent = (props: Props) => {
menuItems.push({
text: _('Empty trash'),
onPress: async () => {
Alert.alert('', _('This will permanently delete all items in the trash. Continue?'), [
dialogs.prompt('', _('This will permanently delete all items in the trash. Continue?'), [
{
text: _('Empty trash'),
onPress: async () => {
@@ -206,7 +212,7 @@ const SideMenuContentComponent = (props: Props) => {
} else {
const generateFolderDeletion = () => {
const folderDeletion = (message: string) => {
Alert.alert('', message, [
dialogs.prompt('', message, [
{
text: _('OK'),
onPress: () => {
@@ -255,13 +261,10 @@ const SideMenuContentComponent = (props: Props) => {
style: 'cancel',
});
Alert.alert(
dialogs.prompt(
'',
_('Notebook: %s', folder.title),
menuItems,
{
cancelable: false,
},
);
};
@@ -400,6 +403,7 @@ const SideMenuContentComponent = (props: Props) => {
const folderButtonStyle: any = {
flex: 1,
flexDirection: 'row',
flexBasis: 'auto',
height: 36,
alignItems: 'center',
paddingRight: theme.marginRight,
@@ -438,14 +442,19 @@ const SideMenuContentComponent = (props: Props) => {
return (
<View key={folder.id} style={{ flex: 1, flexDirection: 'row' }}>
<TouchableOpacity
style={{ flex: 1 }}
<TouchableRipple
style={{ flex: 1, flexBasis: 'auto' }}
onPress={() => {
folder_press(folder);
}}
onLongPress={() => {
void folder_longPress(folder);
}}
onContextMenu={(event: Event) => { // web only
event.preventDefault();
void folder_longPress(folder);
}}
role='button'
>
<View style={folderButtonStyle}>
{renderFolderIcon(folder.id, theme, folderIcon)}
@@ -453,7 +462,7 @@ const SideMenuContentComponent = (props: Props) => {
{Folder.displayTitle(folder)}
</Text>
</View>
</TouchableOpacity>
</TouchableRipple>
{iconWrapper}
</View>
);
@@ -461,7 +470,7 @@ const SideMenuContentComponent = (props: Props) => {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
const renderSidebarButton = (key: string, title: string, iconName: string, onPressHandler: Function = null, selected = false) => {
let icon = <Icon name={iconName} style={styles_.sidebarIcon} />;
let icon = <Icon name={iconName} style={styles_.sidebarIcon} aria-hidden={true} />;
if (key === 'synchronize_button') {
icon = <Animated.View style={{ transform: [{ rotate: syncIconRotation }] }}>{icon}</Animated.View>;
@@ -477,7 +486,7 @@ const SideMenuContentComponent = (props: Props) => {
if (!onPressHandler) return content;
return (
<TouchableOpacity key={key} onPress={onPressHandler}>
<TouchableOpacity key={key} onPress={onPressHandler} role='button'>
{content}
</TouchableOpacity>
);
@@ -543,7 +552,7 @@ const SideMenuContentComponent = (props: Props) => {
);
}
return <View style={{ flex: 0, flexDirection: 'column', paddingBottom: theme.marginBottom }}>{items}</View>;
return <View style={{ flex: 0, flexDirection: 'column', flexBasis: 'auto', paddingBottom: theme.marginBottom }}>{items}</View>;
};
let items = [];
@@ -587,15 +596,13 @@ const SideMenuContentComponent = (props: Props) => {
opacity: isHidden ? 0.5 : undefined,
};
// Note: iOS uses accessibilityElementsHidden and Android uses importantForAccessibility
// to hide elements from the screenreader.
return (
<View
<AccessibleView
style={style}
accessibilityElementsHidden={isHidden}
importantForAccessibility={isHidden ? 'no-hide-descendants' : undefined}
// Accessibility, keyboard, and touch hidden.
inert={isHidden}
refocusCounter={isHidden ? undefined : 1}
>
<View style={{ flex: 1, opacity: props.opacity }}>
<ScrollView scrollsToTop={false} style={styles_.menu}>
@@ -603,7 +610,7 @@ const SideMenuContentComponent = (props: Props) => {
</ScrollView>
{renderBottomPanel()}
</View>
</View>
</AccessibleView>
);
};

View File

@@ -7,6 +7,9 @@ const tasks = {
encodeAssets: {
fn: require('./tools/encodeAssets'),
},
copyWebAssets: {
fn: require('./tools/copyAssets').default,
},
...injectedJsGulpTasks,
podInstall: {
fn: require('./tools/podInstall'),
@@ -37,6 +40,7 @@ gulp.task('watchInjectedJs', gulp.series(
gulp.task('build', gulp.series(
'buildInjectedJs',
'copyWebAssets',
'encodeAssets',
'podInstall',
));

View File

@@ -0,0 +1,69 @@
import { AppRegistry } from 'react-native';
import Root from './root';
require('./web/rnVectorIconsSetup.js');
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Necessary until Root doesn't extend `any`
AppRegistry.registerComponent('Joplin', () => Root as any);
// Fill properties not yet available in the TypeScript DOM types.
interface ExtendedNavigator extends Navigator {
virtualKeyboard?: { overlaysContent: boolean };
}
declare const navigator: ExtendedNavigator;
// Should prevent the browser from auto-deleting background data.
const requestPersistentStorage = async () => {
if (!(await navigator.storage.persisted())) {
await navigator.storage.persist();
}
};
const keepAppAboveKeyboard = () => {
let updateQueued = false;
// This prevents the virtual keyboard from covering content near the bottom of the screen
// (e.g. the markdown toolbar) on both iOS and Android. As of June 2024, this can't be
// done with the Virtual Keyboard API on iOS.
const handleViewportChange = () => {
if (updateQueued) return;
updateQueued = true;
requestAnimationFrame(() => {
updateQueued = false;
// The visual viewport changes as the user zooms in and out. Only adjust the body's height
// when the user's (pinch/touchpad) zoom level is roughly 100% or less.
if (window.visualViewport.scale <= 1.01) {
document.body.style.height = `${window.visualViewport.height}px`;
// Additional scroll space can also be added by the browser when focusing a text input (e.g.
// the markdown editor). Make sure that the top of the editor is still visible:
document.scrollingElement.scrollTop = 0;
} else {
document.body.style.height = '';
}
});
};
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', handleViewportChange);
}
};
addEventListener('DOMContentLoaded', async () => {
if (window.crossOriginIsolated === false) {
// Currently, reloading should be handled by serviceWorker.ts -- this change is left for
// debugging purposes.
document.body.prepend(
document.createTextNode('Warning: crossOriginIsolated is false. SharedArrayBuffer and similar APIs may not work correctly. Try refreshing the page.'),
);
}
keepAppAboveKeyboard();
void requestPersistentStorage();
AppRegistry.runApplication('Joplin', {
rootTag: document.querySelector('#root'),
});
});

View File

@@ -21,6 +21,7 @@ window.setImmediate = setImmediate;
shimInit({
nodeSqlite: sqlite3,
appVersion: () => require('./package.json').version,
React,
sharp,
});
@@ -100,6 +101,10 @@ jest.doMock('react-native-fs', () => {
};
});
shim.fsDriver().getCacheDirectoryPath = () => {
return tempDirectoryPath;
};
beforeAll(async () => {
await mkdir(tempDirectoryPath);
});

View File

@@ -8,6 +8,8 @@
"start": "BROWSERSLIST_IGNORE_OLD_DATA=true react-native start --reset-cache",
"android": "react-native run-android",
"build": "NO_FLIPPER=1 gulp build",
"web": "webpack --mode production --config ./web/webpack.config.js --progress && cp -r ./web/public/* ./web/dist/",
"serve-web": "webpack serve --mode development --static ./web/public/ --config ./web/webpack.config.js --progress",
"tsc": "tsc --project tsconfig.json",
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json",
"clean": "node tools/clean.js",
@@ -83,13 +85,15 @@
"url": "0.11.3"
},
"devDependencies": {
"@babel/core": "7.20.2",
"@babel/preset-env": "7.20.2",
"@babel/runtime": "7.20.0",
"@babel/core": "7.24.7",
"@babel/plugin-transform-export-namespace-from": "7.24.7",
"@babel/preset-env": "7.24.7",
"@babel/runtime": "7.24.7",
"@joplin/tools": "~3.0",
"@js-draw/material-icons": "1.20.0",
"@react-native/babel-preset": "0.74.83",
"@react-native/metro-config": "0.74.83",
"@sqlite.org/sqlite-wasm": "3.46.0-build2",
"@testing-library/jest-native": "5.4.3",
"@testing-library/react-native": "12.3.3",
"@tsconfig/react-native": "2.0.2",
@@ -98,10 +102,12 @@
"@types/react": "18.2.58",
"@types/react-native": "0.70.6",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.88",
"@types/tar-stream": "3.1.3",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
"babel-plugin-module-resolver": "4.1.0",
"babel-plugin-react-native-web": "0.19.12",
"fs-extra": "11.2.0",
"gulp": "4.0.2",
"jest": "29.7.0",
@@ -111,15 +117,21 @@
"jsdom": "23.2.0",
"nodemon": "3.0.3",
"punycode": "2.3.1",
"react-dom": "18.3.1",
"react-native-web": "0.19.12",
"react-test-renderer": "18.2.0",
"sharp": "0.33.2",
"sqlite3": "5.1.6",
"timers-browserify": "2.0.12",
"ts-jest": "29.1.1",
"ts-loader": "9.5.1",
"ts-node": "10.9.2",
"typescript": "5.2.2",
"uglify-js": "3.17.4",
"webpack": "5.74.0"
"url-loader": "4.1.1",
"webpack": "5.84.0",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.0.4"
},
"engines": {
"node": ">=18"

View File

@@ -0,0 +1 @@
module.exports = {"hash":"d7216e17c44aad8a0b1c3cf2e01a0135","files":["highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}

View File

@@ -28,8 +28,8 @@ import SyncTargetJoplinCloud from '@joplin/lib/SyncTargetJoplinCloud';
import SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive';
import initProfile from '@joplin/lib/services/profileConfig/initProfile';
const VersionInfo = require('react-native-version-info').default;
const { Keyboard, BackHandler, Animated, View, StatusBar, Platform, Dimensions } = require('react-native');
import { AppState as RNAppState, EmitterSubscription, Linking, NativeEventSubscription, Appearance, AccessibilityInfo } from 'react-native';
const { Keyboard, BackHandler, Animated, StatusBar, Platform, Dimensions } = require('react-native');
import { AppState as RNAppState, EmitterSubscription, View, Text, Linking, NativeEventSubscription, Appearance, AccessibilityInfo, ActivityIndicator } from 'react-native';
import getResponsiveValue from './components/getResponsiveValue';
import NetInfo from '@react-native-community/netinfo';
const DropdownAlert = require('react-native-dropdownalert').default;
@@ -69,7 +69,6 @@ import { MenuProvider } from 'react-native-popup-menu';
import SideMenu from './components/SideMenu';
import SideMenuContent from './components/side-menu-content';
const { SideMenuContentNote } = require('./components/side-menu-content-note.js');
const { DatabaseDriverReactNative } = require('./utils/database-driver-react-native');
import { reg } from '@joplin/lib/registry';
const { defaultState } = require('@joplin/lib/reducer');
import FileApiDriverLocal from '@joplin/lib/file-api-driver-local';
@@ -88,6 +87,8 @@ import initLib from '@joplin/lib/initLib';
import { isCallbackUrl, parseCallbackUrl, CallbackUrlCommand } from '@joplin/lib/callbackUrlUtils';
import JoplinCloudLoginScreen from './components/screens/JoplinCloudLoginScreen';
import SyncTargetNone from '@joplin/lib/SyncTargetNone';
SyncTargetRegistry.addClass(SyncTargetNone);
SyncTargetRegistry.addClass(SyncTargetOneDrive);
SyncTargetRegistry.addClass(SyncTargetNextcloud);
@@ -107,7 +108,6 @@ import setIgnoreTlsErrors from './utils/TlsUtils';
import ShareService from '@joplin/lib/services/share/ShareService';
import setupNotifications from './utils/setupNotifications';
import { loadMasterKeysFromSettings, migrateMasterPassword } from '@joplin/lib/services/e2ee/utils';
import SyncTargetNone from '@joplin/lib/SyncTargetNone';
import { setRSA } from '@joplin/lib/services/e2ee/ppk';
import RSA from './services/e2ee/RSA.react-native';
import { runIntegrationTests as runRsaIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils';
@@ -131,6 +131,9 @@ import PlatformImplementation from './services/plugins/PlatformImplementation';
import ShareManager from './components/screens/ShareManager';
import appDefaultState, { DEFAULT_ROUTE } from './utils/appDefaultState';
import { setDateFormat, setTimeFormat, setTimeLocale } from '@joplin/utils/time';
import DatabaseDriverReactNative from './utils/database-driver-react-native';
import DialogManager from './components/DialogManager';
import lockToSingleInstance from './utils/lockToSingleInstance';
import { AppState } from './utils/types';
import { getDisplayParentId } from '@joplin/lib/services/trash';
@@ -500,6 +503,8 @@ const getInitialActiveFolder = async () => {
return await Folder.load(folderId);
};
const singleInstanceLock = lockToSingleInstance();
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
async function initialize(dispatch: Dispatch) {
shimInit();
@@ -525,6 +530,10 @@ async function initialize(dispatch: Dispatch) {
await shim.fsDriver().mkdir(resourceDir);
// Do as much setup as possible before checking the lock -- the lock intentionally waits for
// messages from other clients for several hundred ms.
await singleInstanceLock;
const logDatabase = new Database(new DatabaseDriverReactNative());
await logDatabase.open({ name: 'log.sqlite' });
await logDatabase.exec(Logger.databaseCreateTableSql());
@@ -627,6 +636,16 @@ async function initialize(dispatch: Dispatch) {
const detectedLocale = shim.detectAndSetLocale(Setting);
reg.logger().info(`First start: detected locale as ${detectedLocale}`);
if (shim.mobilePlatform() === 'web') {
// Web browsers generally have more limited storage than desktop and mobile apps:
Setting.setValue('sync.resourceDownloadMode', 'auto');
// For now, geolocation is disabled by default on web until the web permissions workflow
// is improved. At present, trackLocation=true causes the "allow location access" prompt
// to appear without a clear indicator for why Joplin wants this information.
Setting.setValue('trackLocation', false);
logger.info('First start on web: Set resource download mode to auto and disabled location tracking.');
}
Setting.skipDefaultMigrations();
Setting.setValue('firstStart', false);
} else {
@@ -655,7 +674,6 @@ async function initialize(dispatch: Dispatch) {
Setting.setValue('welcome.enabled', false);
}
PluginAssetsLoader.instance().setLogger(mainLogger);
await PluginAssetsLoader.instance().importAssets();
// eslint-disable-next-line require-atomic-updates
@@ -826,7 +844,11 @@ async function initialize(dispatch: Dispatch) {
// just print some messages in the console.
// ----------------------------------------------------------------------------
if (Setting.value('env') === 'dev') {
if (Platform.OS !== 'web') {
await runRsaIntegrationTests();
} else {
logger.info('Skipping RSA tests -- not supported on mobile.');
}
await runOnDeviceFsDriverTests();
}
@@ -930,7 +952,7 @@ class AppComponent extends React.Component {
// This will be called right after adding the event listener
// so there's no need to check netinfo on startup
this.unsubscribeNetInfoHandler_ = NetInfo.addEventListener(({ type, details }) => {
const isMobile = details.isConnectionExpensive || type === 'cellular';
const isMobile = details?.isConnectionExpensive || type === 'cellular';
reg.setIsOnMobileData(isMobile);
this.props.dispatch({
type: 'MOBILE_DATA_WARNING_UPDATE',
@@ -942,7 +964,16 @@ class AppComponent extends React.Component {
reg.logger().info(error);
}
try {
await initialize(this.props.dispatch);
} catch (error) {
alert(`Something went wrong while starting the application: ${error}`);
this.props.dispatch({
type: 'APP_STATE_SET',
state: 'error',
});
throw error;
}
const loadedSensorInfo = await sensorInfo();
this.setState({ sensorInfo: loadedSensorInfo });
@@ -1169,7 +1200,20 @@ class AppComponent extends React.Component {
};
public render() {
if (this.props.appState !== 'ready') return null;
if (this.props.appState !== 'ready') {
if (this.props.appState === 'error') {
return <Text>Startup error.</Text>;
}
// Loading can take a particularly long time for the first time on web -- show progress.
if (Platform.OS === 'web') {
return <View style={{ marginLeft: 'auto', marginRight: 'auto', paddingTop: 20 }}>
<ActivityIndicator accessibilityLabel={_('Loading...')} />
</View>;
} else {
return null;
}
}
const theme: Theme = themeStyle(this.props.themeId);
let sideMenuContent: ReactNode = null;
@@ -1300,7 +1344,9 @@ class AppComponent extends React.Component {
},
},
}}>
<DialogManager>
{mainContent}
</DialogManager>
</PaperProvider>
);
}

View File

@@ -0,0 +1,33 @@
import Logger from '@joplin/utils/Logger';
export default class AlarmServiceDriver {
public constructor(logger: Logger) {
logger.warn('WARNING: AlarmServiceDriver is not implemented on web');
}
public hasPersistentNotifications() {
return false;
}
public notificationIsSet() {
throw new Error('Available only for non-persistent alarms');
}
public setInAppNotificationHandler(_v: unknown) {
}
public async hasPermissions(_perm: unknown = null) {
return false;
}
public async requestPermissions() {
return false;
}
public async clearNotification(_id: number) {
}
public async scheduleNotification(_notification: Notification) {
}
}

View File

@@ -1,4 +1,6 @@
import { RSA } from '@joplin/lib/services/e2ee/types';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
const RnRSA = require('react-native-rsa-native').RSA;
interface RSAKeyPair {
@@ -7,9 +9,18 @@ interface RSAKeyPair {
keySizeBits: number;
}
const logger = Logger.create('RSA');
const rsa: RSA = {
generateKeyPair: async (keySize: number): Promise<RSAKeyPair> => {
if (shim.mobilePlatform() === 'web') {
// TODO: Try to implement with SubtleCrypto. May require migrating the RSA algorithm used on
// desktop and mobile (which is not supported on web). See commit 12adcd9dbc3f723bac36ff4447701573084c4694.
logger.warn('RSA on web is not yet supported.');
return null;
}
const keys: RSAKeyPair = await RnRSA.generateKeys(keySize);
// Sanity check

View File

@@ -4,7 +4,6 @@ import Setting from '@joplin/lib/models/Setting';
import { reg } from '@joplin/lib/registry';
import BasePlatformImplementation, { Joplin } from '@joplin/lib/services/plugins/BasePlatformImplementation';
import { CreateFromPdfOptions, Implementation as ImagingImplementation } from '@joplin/lib/services/plugins/api/JoplinImaging';
import RNVersionInfo from 'react-native-version-info';
import { _ } from '@joplin/lib/locale';
import shim from '@joplin/lib/shim';
import Clipboard from '@react-native-clipboard/clipboard';
@@ -32,7 +31,7 @@ export default class PlatformImplementation extends BasePlatformImplementation {
public get versionInfo(): VersionInfo {
return {
version: RNVersionInfo.appVersion,
version: shim.appVersion(),
syncVersion: Setting.value('syncVersion'),
profileVersion: reg.db().version(),
platform: 'mobile',

View File

@@ -1,10 +1,9 @@
// Helper functions to reduce the boiler plate of loading and saving profiles on
// mobile
const RNExitApp = require('react-native-exit-app').default;
import { Profile, ProfileConfig } from '@joplin/lib/services/profileConfig/types';
import { loadProfileConfig as libLoadProfileConfig, saveProfileConfig as libSaveProfileConfig } from '@joplin/lib/services/profileConfig/index';
import RNFetchBlob from 'rn-fetch-blob';
import shim from '@joplin/lib/shim';
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
let dispatch_: Function = null;
@@ -14,7 +13,7 @@ export const setDispatch = (dispatch: Function) => {
};
export const getProfilesRootDir = () => {
return RNFetchBlob.fs.dirs.DocumentDir;
return shim.fsDriver().getAppDirectoryPath();
};
export const getProfilesConfigPath = () => {
@@ -55,5 +54,5 @@ export const switchProfile = async (profileId: string) => {
config.currentProfileId = profileId;
await saveProfileConfig(config);
RNExitApp.exitApp();
shim.restartApp();
};

View File

@@ -13,6 +13,12 @@ type TData = {
export default async (dispatch: Dispatch) => {
const userInfo = { url: '' };
if (!QuickActions.setShortcutItems) {
logger.info('QuickActions unsupported');
return null;
}
QuickActions.setShortcutItems([
{ type: 'New note', title: _('New note'), icon: 'Compose', userInfo },
{ type: 'New to-do', title: _('New to-do'), icon: 'Add', userInfo },

View File

@@ -0,0 +1,11 @@
import * as fs from 'fs-extra';
import { dirname, join } from 'path';
const copyAssets = async () => {
const appDir = dirname(__dirname);
const assetsDir = join(dirname(appDir), 'renderer', 'assets');
const webDir = join(appDir, 'web', 'public');
await fs.copy(assetsDir, join(webDir, 'pluginAssets'));
};
export default copyAssets;

View File

@@ -1,5 +1,6 @@
const utils = require('@joplin/tools/gulp/utils');
const fs = require('fs-extra');
const path = require('path');
const md5 = require('md5');
const rootDir = `${__dirname}/..`;
@@ -69,6 +70,10 @@ async function main() {
const hash = md5(hashes.join(''));
await fs.writeFile(`${outputDir}/index.js`, `module.exports = {\nhash:"${hash}", files: {\n${indexJs.join('\n')}\n}\n};`);
await fs.writeFile(`${outputDir}/index.web.js`, `module.exports = ${JSON.stringify({
hash,
files: files.map(file => path.relative(sourceAssetDir, file)),
})}`);
return;
} catch (error) {

View File

@@ -1,13 +1,12 @@
import Resource from '@joplin/lib/models/Resource';
import { ResourceEntity } from '@joplin/lib/services/database/types';
import shim from '@joplin/lib/shim';
import { CachesDirectoryPath } from 'react-native-fs';
// when refactoring this name, make sure to refactor the `SharePackage.java` (in android) as well
const DIR_NAME = 'sharedFiles';
const makeShareCacheDirectory = async () => {
const targetDir = `${CachesDirectoryPath}/${DIR_NAME}`;
const targetDir = `${shim.fsDriver().getCacheDirectoryPath()}/${DIR_NAME}`;
await shim.fsDriver().mkdir(targetDir);
return targetDir;
@@ -37,5 +36,5 @@ export const writeTextToCacheFile = async (text: string, fileName: string): Prom
// Clear previously shared files from cache
export async function clearSharedFilesCache(): Promise<void> {
return shim.fsDriver().remove(`${CachesDirectoryPath}/sharedFiles`);
return shim.fsDriver().remove(`${shim.fsDriver().getCacheDirectoryPath()}/sharedFiles`);
}

View File

@@ -1,79 +0,0 @@
const SQLite = require('react-native-sqlite-storage');
class DatabaseDriverReactNative {
constructor() {
this.lastInsertId_ = null;
}
open(options) {
// SQLite.DEBUG(true);
return new Promise((resolve, reject) => {
SQLite.openDatabase(
{ name: options.name },
db => {
this.db_ = db;
resolve();
},
error => {
reject(error);
},
);
});
}
sqliteErrorToJsError(error) {
return error;
}
selectOne(sql, params = null) {
return new Promise((resolve, reject) => {
this.db_.executeSql(
sql,
params,
r => {
resolve(r.rows.length ? r.rows.item(0) : null);
},
error => {
reject(error);
},
);
});
}
selectAll(sql, params = null) {
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
return this.exec(sql, params).then(r => {
const output = [];
for (let i = 0; i < r.rows.length; i++) {
output.push(r.rows.item(i));
}
return output;
});
}
loadExtension(path) {
throw new Error(`No extension support for ${path} in react-native-sqlite-storage`);
}
exec(sql, params = null) {
return new Promise((resolve, reject) => {
this.db_.executeSql(
sql,
params,
r => {
if ('insertId' in r) this.lastInsertId_ = r.insertId;
resolve(r);
},
error => {
reject(error);
},
);
});
}
lastInsertId() {
return this.lastInsertId_;
}
}
module.exports = { DatabaseDriverReactNative };

View File

@@ -0,0 +1,84 @@
const SQLite = require('react-native-sqlite-storage');
export default class DatabaseDriverReactNative {
private lastInsertId_: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private db_: any;
public constructor() {
this.lastInsertId_ = null;
}
public open(options: { name: string }) {
// SQLite.DEBUG(true);
return new Promise<void>((resolve, reject) => {
SQLite.openDatabase(
{ name: options.name },
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(db: any) => {
this.db_ = db;
resolve();
},
(error: Error) => {
reject(error);
},
);
});
}
public sqliteErrorToJsError(error: Error) {
return error;
}
public selectOne(sql: string, params: unknown = null) {
return new Promise<unknown>((resolve, reject) => {
this.db_.executeSql(
sql,
params,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(r: any) => {
resolve(r.rows.length ? r.rows.item(0) : null);
},
(error: Error) => {
reject(error);
},
);
});
}
public selectAll(sql: string, params: unknown = null) {
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
return this.exec(sql, params).then(r => {
const output = [];
for (let i = 0; i < r.rows.length; i++) {
output.push(r.rows.item(i));
}
return output;
});
}
public loadExtension(path: string) {
throw new Error(`No extension support for ${path} in react-native-sqlite-storage`);
}
public exec(sql: string, params: unknown = null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code from before rule was applied
return new Promise<any>((resolve, reject) => {
this.db_.executeSql(
sql,
params,
(r: { insertId: string }) => {
if ('insertId' in r) this.lastInsertId_ = r.insertId;
resolve(r);
},
(error: Error) => {
reject(error);
},
);
});
}
public lastInsertId() {
return this.lastInsertId_;
}
}

View File

@@ -0,0 +1,93 @@
const { sqlite3Worker1Promiser } = require('@sqlite.org/sqlite-wasm');
import { safeFilename } from '@joplin/utils/path';
type DbPromiser = (command: string, options: Record<string, unknown>)=> Promise<unknown>;
type DbId = unknown;
type RowResult = { rowNumber: number|null; row: unknown };
export default class DatabaseDriverReactNative {
private lastInsertId_: string;
private db_: DbPromiser;
private dbId_: DbId;
public constructor() {
this.lastInsertId_ = null;
}
public async open(options: { name: string }) {
const db = await new Promise<DbPromiser>((resolve) => {
const db = sqlite3Worker1Promiser({
onready: () => resolve(db),
});
});
const filename = `file:${safeFilename(options.name)}.sqlite3?vfs=opfs`;
type OpenResult = { dbId: number };
const { dbId } = await db('open', { filename }) as OpenResult;
this.dbId_ = dbId;
this.db_ = db;
}
public sqliteErrorToJsError(error: Error) {
return error;
}
public selectOne(sql: string, params: string[] = []) {
// eslint-disable-next-line no-async-promise-executor -- Wraps an API that mixes callbacks and promises.
return new Promise<unknown>(async (resolve, reject) => {
let resolved = false;
await this.db_('exec', {
dbId: this.dbId_,
sql,
bind: params,
rowMode: 'object',
callback: ((result: RowResult) => {
if (result.rowNumber !== 1) return;
resolved = true;
resolve(result.row);
}),
}).catch(reject);
if (!resolved) {
resolve(null);
}
});
}
public async selectAll(sql: string, params: string[] = null) {
const results: unknown[] = [];
await this.db_('exec', {
dbId: this.dbId_,
sql,
bind: params,
rowMode: 'object',
callback: ((rowResult: RowResult) => {
if (rowResult.rowNumber) {
while (results.length < rowResult.rowNumber) {
results.push(null);
}
results[rowResult.rowNumber - 1] = rowResult.row;
}
}),
});
return results;
}
public loadExtension(path: string) {
throw new Error(`No extension support for ${path} in sqlite wasm`);
}
public async exec(sql: string, params: string[]|null = null) {
const result = await this.db_('exec', {
dbId: this.dbId_,
sql,
bind: params,
});
return result;
}
public lastInsertId() {
return this.lastInsertId_;
}
}

View File

@@ -351,6 +351,14 @@ export default class FsDriverRN extends FsDriverBase {
return directory;
}
public getCacheDirectoryPath() {
return RNFS.CachesDirectoryPath;
}
public getAppDirectoryPath() {
return RNFetchBlob.fs.dirs.DocumentDir;
}
public isUsingAndroidSAF() {
return Platform.OS === 'android' && Platform.Version > 28;
}

View File

@@ -0,0 +1,231 @@
import { resolve } from 'path';
import FsDriverBase, { ReadDirStatsOptions, RemoveOptions, Stat } from '@joplin/lib/fs-driver-base';
import tarExtract, { TarExtractOptions } from './tarExtract';
import tarCreate, { TarCreateOptions } from './tarCreate';
import { Buffer } from 'buffer';
import Logger, { LogLevel, TargetType } from '@joplin/utils/Logger';
import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger';
import type { AccessMode, TransferableStat, WorkerApi } from './fs-driver-rn.web.worker';
import WorkerMessenger from '@joplin/lib/utils/ipc/WorkerMessenger';
import JoplinError from '@joplin/lib/JoplinError';
type FileHandle = {
reader: ReadableStreamDefaultReader<Uint8Array>;
buffered: Buffer;
done: boolean;
};
declare global {
interface FileSystemDirectoryHandle {
entries(): AsyncIterable<[string, FileSystemFileHandle|FileSystemDirectoryHandle]>;
keys(): AsyncIterable<string>;
}
}
const logger = new Logger();
logger.addTarget(TargetType.Console);
logger.setLevel(LogLevel.Warn);
const transferableStatToStat = (stat: TransferableStat): Stat => {
return {
mtime: new Date(stat.mtime),
birthtime: new Date(stat.birthtime),
size: stat.size,
path: stat.path,
isDirectory: () => stat.isDirectory,
};
};
interface LocalWorkerApi { }
type MessengerType = RemoteMessenger<LocalWorkerApi, WorkerApi>;
let messenger: MessengerType|null = null;
// Ensures that all instances of FsDriverWeb share the same worker. This is important
// for virtual files to be correctly handled.
const getWorkerMessenger = () => {
if (messenger) {
return messenger;
}
const worker = new Worker(
// Webpack has special syntax for creating a worker. It requires use
// of import.meta.url, which is prohibited by TypeScript in CommonJS
// modules.
//
// See also https://github.com/webpack/webpack/discussions/13655#discussioncomment-8382152
//
// TODO: Remove this after migrating to ESM.
//
// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- Required for webpack build (see above)
// @ts-ignore
new URL('./fs-driver-rn.web.worker.ts', import.meta.url),
);
messenger = new WorkerMessenger('fs-worker', worker, {});
return messenger;
};
export default class FsDriverWeb extends FsDriverBase {
private messenger_: RemoteMessenger<LocalWorkerApi, WorkerApi>;
public constructor() {
super();
this.messenger_ = getWorkerMessenger();
}
public override async writeFile(
path: string,
data: string|ArrayBuffer,
encoding: BufferEncoding|'Buffer' = 'base64',
options?: FileSystemCreateWritableOptions,
) {
await this.messenger_.remoteApi.writeFile(path, data, encoding, options);
}
public override async appendFile(path: string, content: string, encoding?: BufferEncoding) {
return this.writeFile(path, content, encoding, { keepExistingData: true });
}
public override async remove(path: string, { recursive = true }: RemoveOptions = {}) {
await this.messenger_.remoteApi.remove(path, { recursive });
}
public override async unlink(path: string) {
return this.messenger_.remoteApi.unlink(path);
}
public async fileAtPath(path: string) {
try {
return await this.messenger_.remoteApi.fileAtPath(path);
} catch (error) {
if (!await this.exists(path)) {
throw new JoplinError(`fileAtPath path doesn't exist: ${error}, stack: ${error.stack}`, 'ENOENT');
}
throw error;
}
}
public async readFile(path: string, encoding: BufferEncoding|'Buffer' = 'utf-8') {
logger.debug('readFile', path);
const file = await this.fileAtPath(path);
if (encoding === 'utf-8' || encoding === 'utf8') {
return await file.text();
} else if (encoding === 'Buffer') {
return Buffer.from(await file.arrayBuffer());
} else {
const buffer = Buffer.from(await file.arrayBuffer());
return buffer.toString(encoding);
}
}
public override async open(path: string, _mode = 'r'): Promise<FileHandle> {
const file = await this.fileAtPath(path);
return {
// TODO: Extra casting required by NodeJS types conflicting with DOM types.
reader: (file.stream() as unknown as ReadableStream).getReader(),
buffered: Buffer.from([]),
done: false,
};
}
public override async readFileChunkAsBuffer(handle: FileHandle, length: number): Promise<Buffer> {
let read: Buffer = handle.buffered;
while (read.byteLength < length && !handle.done) {
const { done, value } = await handle.reader.read();
handle.done = done;
if (value) {
if (read.byteLength > 0) {
read = Buffer.concat([read, value], read.byteLength + value.byteLength);
} else {
read = Buffer.from(value);
}
}
}
const result = read.subarray(0, length);
handle.buffered = read.subarray(length, read.length);
if (result.length === 0) {
return null;
} else {
return result;
}
}
public override async readFileChunk(handle: FileHandle, length: number, encoding: BufferEncoding = 'base64') {
return (await this.readFileChunkAsBuffer(handle, length))?.toString(encoding) ?? null;
}
public override async close(handle: FileHandle) {
handle.reader?.releaseLock();
handle.reader = null;
}
public override async mkdir(path: string) {
logger.debug('mkdir', path);
await this.messenger_.remoteApi.mkdir(path);
}
public override async copy(from: string, to: string) {
await this.messenger_.remoteApi.copy(from, to);
}
public override async stat(path: string): Promise<Stat|null> {
const stat = await this.messenger_.remoteApi.stat(path);
if (!stat) return null;
return transferableStatToStat(stat);
}
public override async readDirStats(path: string, options: ReadDirStatsOptions = { recursive: false }): Promise<Stat[]> {
const stats = (await this.messenger_.remoteApi.readDirStats(path, options))?.map(transferableStatToStat);
if (!stats) {
throw new JoplinError(`Path ${path} does not exist (readDirStats)`, 'ENOENT');
}
return stats;
}
public override async exists(path: string) {
return await this.messenger_.remoteApi.exists(path);
}
public resolve(...paths: string[]): string {
return resolve(...paths);
}
public override async md5File(path: string): Promise<string> {
return await this.messenger_.remoteApi.md5File(path);
}
public override async tarExtract(options: TarExtractOptions) {
await tarExtract({
cwd: '/cache/',
...options,
});
}
public override async tarCreate(options: TarCreateOptions, filePaths: string[]) {
await tarCreate({
cwd: '/cache/',
...options,
}, filePaths);
}
public override getCacheDirectoryPath(): string {
return '/cache/';
}
public override getAppDirectoryPath(): string {
return '/app/';
}
public async createReadOnlyVirtualFile(path: string, content: File) {
return this.messenger_.remoteApi.createReadOnlyVirtualFile(path, content);
}
public async mountExternalDirectory(handle: FileSystemDirectoryHandle, id: string, mode: AccessMode) {
const externalUri = await this.messenger_.remoteApi.mountExternalDirectory(handle, id, mode);
logger.info('Mounted handle with ID', id, 'at', externalUri);
return externalUri;
}
}

View File

@@ -0,0 +1,518 @@
import type { ReadDirStatsOptions, RemoveOptions } from '@joplin/lib/fs-driver-base';
import WorkerToWindowMessenger from '@joplin/lib/utils/ipc/WorkerToWindowMessenger';
import Logger, { LogLevel, TargetType } from '@joplin/utils/Logger';
import { resolve, dirname, basename, normalize, join } from 'path';
import { Buffer } from 'buffer';
const md5 = require('md5');
const removeReservedWords = (fileName: string) => {
return fileName.replace(/(tmp)$/g, '_$1');
};
const restoreReservedWords = (fileName: string) => {
return fileName.replace(/_tmp$/g, 'tmp');
};
export type AccessMode = 'read'|'readwrite';
declare global {
interface FileSystemSyncAccessHandle {
close(): void;
truncate(to: number): void;
write(buffer: ArrayBuffer|ArrayBufferView, options?: { at: number }): void;
read(buffer: ArrayBuffer|ArrayBufferView, options?: { at: number }): number;
getSize(): number;
flush(): void;
}
interface FileSystemHandle {
requestPermission(permission: { mode: AccessMode }): Promise<'granted'|string>;
queryPermission(permission: { mode: AccessMode }): Promise<'granted'|string>;
}
interface FileSystemFileHandle {
createSyncAccessHandle(): Promise<FileSystemSyncAccessHandle>;
}
interface FileSystemDirectoryHandle {
entries(): AsyncIterable<[string, FileSystemFileHandle|FileSystemDirectoryHandle]>;
keys(): AsyncIterable<string>;
}
}
type WriteFileOptions = { keepExistingData?: boolean };
const logger = new Logger();
logger.addTarget(TargetType.Console);
logger.setLevel(LogLevel.Info);
export interface TransferableStat {
birthtime: number;
mtime: number;
path: string;
size: number;
isDirectory: boolean;
}
const isNotFoundError = (error: DOMException) => error.name === 'NotFoundError';
const isTypeMismatchError = (error: DOMException) => error.name === 'TypeMismatchError';
const externalDirectoryPrefix = '/external/';
type AccessHandleDatabaseControl = {
clearExternalHandle(id: string): Promise<void>;
addExternalHandle(path: string, id: string, handle: FileSystemDirectoryHandle|FileSystemFileHandle, mode: AccessMode): Promise<void>;
queryExternalHandle(path: string): Promise<[FileSystemDirectoryHandle|FileSystemFileHandle, AccessMode]|null>;
};
// Allows saving and restoring file system access handles. These handles are browser-serializable, so can
// be written to indexedDB. Here, indexedDB is used, rather than localStorage or SQLite because:
// - localStorage only accepts string values (see https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem)
// - SQLite stores objects in a custom database, and so almost certainly can't store file system handles.
const createAccessHandleDatabase = async (): Promise<AccessHandleDatabaseControl> => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('fs-storage', 1);
request.onsuccess = () => {
resolve(request.result as IDBDatabase);
};
request.onerror = (event) => {
reject(new Error(`Failed to open database: ${event}`));
};
request.onupgradeneeded = (event) => {
if (!('result' in event.target)) {
reject(new Error('Invalid upgrade event type'));
return;
}
const db = event.target.result as IDBDatabase;
const store = db.createObjectStore('external-handles', { keyPath: 'id' });
store.createIndex('id', 'id', { unique: true });
store.createIndex('path', 'path');
};
});
const toUniquePath = (path: string) => {
// normalize can leave the trailing /
return normalize(path).replace(/[/]$/, '');
};
return {
clearExternalHandle(id: string) {
return new Promise<void>((resolve, reject) => {
const request = db.transaction(['external-handles'], 'readwrite')
.objectStore('external-handles')
.delete(`id:${id}`);
request.onsuccess = () => resolve();
request.onerror = (event) => reject(new Error(`Transaction failed: ${event}`));
});
},
addExternalHandle(path: string, id: string, handle: FileSystemDirectoryHandle|FileSystemFileHandle, mode: AccessMode) {
path = toUniquePath(path);
return new Promise<void>((resolve, reject) => {
const request = db.transaction(['external-handles'], 'readwrite')
.objectStore('external-handles')
.put({ path, id: `id:${id}`, handle, mode });
request.onsuccess = () => resolve();
request.onerror = (event) => reject(new Error(`Transaction failed: ${event}`));
});
},
queryExternalHandle(path: string) {
path = toUniquePath(path);
return new Promise<[FileSystemDirectoryHandle|FileSystemFileHandle, AccessMode]|null>((resolve, reject) => {
const request = db.transaction(['external-handles'], 'readonly')
.objectStore('external-handles')
.index('path')
.get(path);
request.onsuccess = () => {
const handle = request.result?.handle;
if (request.result && request.result.path !== path) {
throw new Error(`Path mismatch when querying external directory handle: ${JSON.stringify(path)} was ${JSON.stringify(request.result.path)}`);
}
resolve(handle ? [handle, request.result?.mode ?? 'readwrite'] : null);
};
request.onerror = (event) => reject(new Error(`Transaction failed: ${event}`));
});
},
};
};
export class WorkerApi {
private fsRoot_: FileSystemDirectoryHandle;
private accessHandleDatabase_: AccessHandleDatabaseControl;
private directoryHandleCache_: Map<string, FileSystemDirectoryHandle> = new Map();
private virtualFiles_: Map<string, File> = new Map();
private externalHandles_: Map<string, FileSystemFileHandle|FileSystemDirectoryHandle> = new Map();
private initPromise_: Promise<void>;
public constructor() {
this.initPromise_ = (async () => {
let lastError: Error|null = null;
for (let retry = 0; retry < 2; retry++) {
try {
this.fsRoot_ ??= await (await navigator.storage.getDirectory()).getDirectoryHandle('joplin-web', { create: true });
this.accessHandleDatabase_ ??= await createAccessHandleDatabase();
lastError = null;
break;
} catch (error) {
logger.warn('Failed to create fs-driver:', error, `(retry: ${retry})`);
lastError = error;
await new Promise<void>(resolve => setTimeout(() => resolve(), 1000));
}
}
if (lastError) {
throw lastError;
}
})();
}
private async getExternalHandle_(path: string) {
path = normalize(path);
if (!path.startsWith(externalDirectoryPrefix)) {
return null;
}
if (this.externalHandles_.has(path)) {
return this.externalHandles_.get(path);
}
const saved = await this.accessHandleDatabase_.queryExternalHandle(path);
if (!saved) {
logger.debug('External lookup failed for', path);
return null;
}
const [handle, mode] = saved;
// At present, not all browsers support .queryPermission and .requestPermission on
// saved file handles.
if (!('queryPermission' in handle)) {
logger.warn('Browser does not support .queryPermission. Loading path: ', path);
return null;
}
const permission = { mode };
if (await handle.queryPermission(permission) !== 'granted' && await handle.requestPermission(permission) !== 'granted') {
throw new Error('Missing read-write access. It might be necessary to share the folder with the application again.');
}
this.externalHandles_.set(path, handle);
return handle;
}
private async pathToDirectoryHandle_(path: string, create = false): Promise<FileSystemDirectoryHandle|null> {
await this.initPromise_;
path = resolve('/', path);
if (path === '/') {
return this.fsRoot_;
} else if (`${path}/`.startsWith(externalDirectoryPrefix)) {
if (path === externalDirectoryPrefix || `${path}/` === externalDirectoryPrefix) {
// /external/ is virtual, it doesn't exist.
return null;
}
const handle = await this.getExternalHandle_(path);
if (handle?.kind === 'directory') {
return handle;
}
}
if (this.directoryHandleCache_.has(path)) {
logger.debug('pathToDirectoryHandle_ from cache for', path);
return this.directoryHandleCache_.get(path);
}
logger.debug('pathToDirectoryHandle_', 'path:', path, 'create:', create);
const parentDirs = dirname(path);
const parent = await this.pathToDirectoryHandle_(parentDirs, create);
const folderName = removeReservedWords(basename(path));
let handle: FileSystemDirectoryHandle;
try {
handle = await parent.getDirectoryHandle(folderName, { create });
this.directoryHandleCache_.set(path, handle);
} catch (error) {
// TODO: Handle this better
logger.warn('Error getting directory handle', error, 'for', path, 'create:', create);
handle = null;
}
return handle;
}
private async pathToFileHandle_(path: string, create = false): Promise<FileSystemFileHandle> {
await this.initPromise_;
path = resolve('/', path);
if (this.externalHandles_.has(path)) {
const handle = await this.externalHandles_.get(path);
if (handle.kind !== 'file') {
throw new Error(`Not a file: ${path}`);
}
return handle;
}
logger.debug('pathToFileHandle_', 'path:', path, 'create:', create);
const parent = await this.pathToDirectoryHandle_(dirname(path));
if (!parent) {
throw new Error(`Can't get file handle for path ${path} -- parent doesn't exist (create: ${create}).`);
}
try {
return parent.getFileHandle(removeReservedWords(basename(path)), { create });
} catch (error) {
if (create) {
throw new Error(`${error} while getting file at path ${path}.`);
}
if (isNotFoundError(error)) {
return null;
}
logger.warn(error, 'getting file handle at path', path, create);
throw error;
}
}
public async writeFile(
path: string,
data: string|ArrayBuffer,
encoding: BufferEncoding|'Buffer' = 'base64',
options?: WriteFileOptions,
) {
logger.debug('writeFile', path);
const handle = await this.pathToFileHandle_(path, true);
let write, close;
try {
try {
const writer = await handle.createSyncAccessHandle();
let at = 0;
if (!options?.keepExistingData) {
writer.truncate(0);
} else {
at = writer.getSize();
}
write = (data: ArrayBufferLike) => writer.write(data, { at });
close = () => writer.close();
} catch (error) {
// In some cases, createSyncAccessHandle isn't available. In other cases,
// createWritable isn't available.
logger.warn('Failed to createSyncAccessHandle', error);
const writer = await handle.createWritable({ keepExistingData: options?.keepExistingData });
write = (data: ArrayBufferLike) => writer.write(data);
close = () => writer.close();
}
if (encoding === 'Buffer') {
await write(data as ArrayBuffer);
} else if (data instanceof ArrayBuffer) {
throw new Error('Cannot write ArrayBuffer to file without encoding = buffer');
} else if (encoding === 'utf-8' || encoding === 'utf8') {
const encoder = new TextEncoder();
await write(encoder.encode(data));
} else {
await write(Buffer.from(data, encoding).buffer);
}
} finally {
if (close) {
await close();
}
}
logger.debug('writeFile done', path);
}
public async remove(path: string, { recursive = true }: RemoveOptions = {}) {
path = normalize(path);
this.directoryHandleCache_.clear();
try {
const dirHandle = await this.pathToDirectoryHandle_(dirname(path));
if (dirHandle) {
await dirHandle.removeEntry(basename(path), { recursive });
} else {
console.warn(`remove: ENOENT: Parent directory of path ${JSON.stringify(path)} does not exist.`);
}
} catch (error) {
// remove should pass even if the item doesn't exist.
// This matches the behavior of fs-extra's remove.
if (!isNotFoundError(error)) {
throw error;
}
}
}
public async unlink(path: string) {
return await this.remove(path, { recursive: false });
}
public async fileAtPath(path: string) {
path = normalize(path);
let file: File;
if (this.virtualFiles_.has(path)) {
file = this.virtualFiles_.get(path);
} else {
const handle = await this.pathToFileHandle_(path);
file = await handle.getFile();
}
return file;
}
public async readFile(path: string, encoding: BufferEncoding = 'utf-8') {
path = normalize(path);
logger.debug('readFile', path);
const file = await this.fileAtPath(path);
if (encoding === 'utf-8' || encoding === 'utf8') {
return await file.text();
} else {
const buffer = Buffer.from(await file.arrayBuffer());
return buffer.toString(encoding);
}
}
public async mkdir(path: string) {
if (path === externalDirectoryPrefix) {
return;
}
logger.debug('mkdir', path);
await this.pathToDirectoryHandle_(path, true);
}
public async copy(from: string, to: string) {
logger.debug('copy', from, to);
const fromFile = await this.fileAtPath(from);
await this.writeFile(to, await fromFile.arrayBuffer(), 'Buffer');
}
public async stat(path: string, handle?: FileSystemDirectoryHandle|FileSystemFileHandle): Promise<TransferableStat|null> {
logger.debug('stat', path, handle ? 'with handle' : '');
handle ??= await this.pathToDirectoryHandle_(path);
try {
handle ??= await this.pathToFileHandle_(path);
} catch (error) {
// Should return null when a file isn't found.
if (!isNotFoundError(error)) {
throw error;
}
}
const virtualFile = this.virtualFiles_.get(normalize(path));
if (!handle && !virtualFile) return null;
logger.debug('has handle');
const file = await (async () => {
if (handle.kind === 'directory') return null;
return virtualFile ?? await handle.getFile();
})();
const lastModifiedTime = file?.lastModified ?? 0;
return {
birthtime: lastModifiedTime,
mtime: lastModifiedTime,
// Can't normalize protocol URIs (e.g. external:///foo)
path: path.match(/^[a-z]+:/) ? path : normalize(path),
size: file?.size ?? 0,
isDirectory: handle.kind === 'directory',
};
}
public async readDirStats(path: string, options: ReadDirStatsOptions = { recursive: false }): Promise<TransferableStat[]|null> {
const readDirStats = async (basePath: string, path: string, dirHandle?: FileSystemDirectoryHandle) => {
dirHandle ??= await this.pathToDirectoryHandle_(path);
if (!dirHandle) return null;
const result: TransferableStat[] = [];
try {
for await (const [childInternalName, childHandle] of dirHandle.entries()) {
const childFileName = restoreReservedWords(childInternalName);
const childPath = join(path, childFileName);
const stat = await this.stat(childPath, childHandle);
result.push({ ...stat, path: join(basePath, childFileName) });
if (options.recursive && childHandle.kind === 'directory') {
const childBasePath = join(basePath, childFileName);
result.push(...await readDirStats(childBasePath, childPath, childHandle));
}
}
} catch (error) {
if (isNotFoundError(error)) {
return null;
} else {
throw new Error(`readDirStats error: ${error}, path: ${basePath},${path}`);
}
}
return result;
};
return readDirStats('', path);
}
public async exists(path: string) {
logger.debug('exists?', path);
path = resolve('/', path);
if (this.virtualFiles_.has(path) || this.externalHandles_.has(path)) {
return true;
}
const parentDirectory = await this.pathToDirectoryHandle_(dirname(path));
if (!parentDirectory) return false;
const fileName = removeReservedWords(basename(path));
try {
const childHandle = await parentDirectory.getFileHandle(fileName);
return !!childHandle;
} catch (error) {
if (isNotFoundError(error)) {
return false;
} else if (isTypeMismatchError(error)) {
// A file was requested, so the path is a directory.
return true;
}
throw error;
}
}
public async md5File(path: string): Promise<string> {
const fileData = Buffer.from(await (await this.fileAtPath(path)).arrayBuffer());
return md5(fileData);
}
public async createReadOnlyVirtualFile(path: string, content: File) {
this.virtualFiles_.set(normalize(path), content);
}
public async mountExternalDirectory(handle: FileSystemDirectoryHandle, id: string, mode: AccessMode) {
if (await handle.requestPermission({ mode }) !== 'granted') {
throw new Error(`${mode} access is needed for ${id}.`);
}
const mountPath = resolve(externalDirectoryPrefix, crypto.randomUUID().replace(/-/g, ''));
this.externalHandles_.set(mountPath, handle);
await this.accessHandleDatabase_.clearExternalHandle(id);
await this.accessHandleDatabase_.addExternalHandle(mountPath, id, handle, mode);
return mountPath;
}
}
interface RemoteApi { }
new WorkerToWindowMessenger<WorkerApi, RemoteApi>('fs-worker', new WorkerApi());

View File

@@ -167,7 +167,7 @@ const testReadFileChunkUtf8 = async (tempDir: string) => {
await fsDriver.readFileChunk(handle, 1, encoding), null,
);
await fsDriver.close(filePath);
await fsDriver.close(handle);
}
// Should throw when the file doesn't exist
@@ -261,6 +261,7 @@ const runOnDeviceTests = async () => {
if (await shim.fsDriver().exists(tempDir)) {
await shim.fsDriver().remove(tempDir);
}
await shim.fsDriver().mkdir(tempDir);
try {
await testExpect();

View File

@@ -1,6 +1,6 @@
import { pack as tarStreamPack } from 'tar-stream';
import { resolve } from 'path';
import * as RNFS from 'react-native-fs';
import { Buffer } from 'buffer';
import Logger from '@joplin/utils/Logger';
import { chunkSize } from './constants';
@@ -8,7 +8,7 @@ import shim from '@joplin/lib/shim';
const logger = Logger.create('fs-driver-rn');
interface TarCreateOptions {
export interface TarCreateOptions {
cwd: string;
file: string;
}
@@ -18,7 +18,7 @@ interface TarCreateOptions {
const tarCreate = async (options: TarCreateOptions, filePaths: string[]) => {
// Choose a default cwd if not given
const cwd = options.cwd ?? RNFS.DocumentDirectoryPath;
const cwd = options.cwd ?? shim.fsDriver().getAppDirectoryPath();
const file = resolve(cwd, options.file);
const fsDriver = shim.fsDriver();
@@ -28,6 +28,12 @@ const tarCreate = async (options: TarCreateOptions, filePaths: string[]) => {
const pack = tarStreamPack();
const errors: Error[] = [];
pack.addListener('error', error => {
logger.error(`Tar error: ${error}`);
errors.push(error);
});
for (const path of filePaths) {
const absPath = resolve(cwd, path);
const stat = await fsDriver.stat(absPath);
@@ -39,10 +45,16 @@ const tarCreate = async (options: TarCreateOptions, filePaths: string[]) => {
}
});
for (let offset = 0; offset < sizeBytes; offset += chunkSize) {
// The RNFS documentation suggests using base64 for binary files.
const part = await RNFS.read(absPath, chunkSize, offset, 'base64');
entry.write(Buffer.from(part, 'base64'));
const handle = await shim.fsDriver().open(absPath, 'rw');
let offset = 0;
let lastOffset = -1;
while (offset < sizeBytes && offset !== lastOffset) {
const part = await shim.fsDriver().readFileChunkAsBuffer(handle, chunkSize);
entry.write(part);
lastOffset = offset;
offset += part.byteLength;
}
entry.end();
}
@@ -57,6 +69,10 @@ const tarCreate = async (options: TarCreateOptions, filePaths: string[]) => {
const base64Data = buff.toString('base64');
await fsDriver.appendFile(file, base64Data, 'base64');
}
if (errors.length) {
throw new Error(`tarCreate errors: ${errors.map(e => `Error: ${e}, stack: ${e?.stack}`)}`);
}
};
export default tarCreate;

View File

@@ -3,7 +3,7 @@ import { resolve, dirname } from 'path';
import shim from '@joplin/lib/shim';
import { chunkSize } from './constants';
interface TarExtractOptions {
export interface TarExtractOptions {
cwd: string;
file: string;
}
@@ -68,8 +68,7 @@ const tarExtract = async (options: TarExtractOptions) => {
const fileHandle = await fsDriver.open(filePath, 'r');
const readChunk = async () => {
const base64 = await fsDriver.readFileChunk(fileHandle, chunkSize, 'base64');
return base64 && Buffer.from(base64, 'base64');
return await fsDriver.readFileChunkAsBuffer(fileHandle, chunkSize);
};
try {

View File

@@ -19,8 +19,12 @@ const getWebViewVersionText = () => {
const getOSVersion = (): string => {
if (Platform.OS === 'android') {
return _('Android API level: %d', Platform.Version);
} else {
} else if (Platform.OS === 'ios') {
return _('iOS version: %s', Platform.Version);
} else if (Platform.OS === 'web') {
return `User agent: ${navigator.userAgent}`;
} else {
return _('Unknown platform');
}
};

View File

@@ -0,0 +1,28 @@
import shim from '@joplin/lib/shim';
import { Platform } from 'react-native';
const fileToImage = async (path: string) => {
if (Platform.OS !== 'web') throw new Error('fileToImageUrl: Not supported');
const image = new Image();
const objectUrl = URL.createObjectURL(await shim.fsDriver().fileAtPath(path));
const free = () => URL.revokeObjectURL(objectUrl);
try {
image.src = objectUrl;
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = (event) => reject(new Error(`Error loading: ${event}`));
image.onabort = (event) => reject(new Error(`Loading cancelled: ${event}`));
});
} finally {
free();
}
return {
image,
free,
};
};
export default fileToImage;

View File

@@ -1,11 +1,25 @@
import { Size } from '@joplin/utils/types';
import { Image as NativeImage } from 'react-native';
import { fileUriToPath } from '@joplin/utils/url';
import { Image as NativeImage, Platform } from 'react-native';
import fileToImage from './fileToImage.web';
const getImageDimensions = async (uri: string): Promise<Size> => {
if (uri.startsWith('/')) {
uri = `file://${uri}`;
}
// On web, image files are stored using the Origin Private File System and need special
// handling.
const isFileUrl = uri.startsWith('file://');
if (Platform.OS === 'web' && isFileUrl) {
const path = isFileUrl ? fileUriToPath(uri) : uri;
const image = await fileToImage(path);
const size = { width: image.image.width, height: image.image.height };
image.free();
return size;
}
return new Promise((resolve, reject) => {
NativeImage.getSize(
uri,

View File

@@ -1,6 +1,8 @@
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import ImageResizer from '@bam.tech/react-native-image-resizer';
import fileToImage from './fileToImage.web';
import FsDriverWeb from '../fs-driver/fs-driver-rn.web';
const logger = Logger.create('resizeImage');
@@ -16,6 +18,42 @@ interface Options {
}
const resizeImage = async (options: Options) => {
if (shim.mobilePlatform() === 'web') {
const image = await fileToImage(options.inputPath);
try {
const canvas = document.createElement('canvas');
// Choose a scale factor such that the resized image fits within a
// maxWidth x maxHeight box.
const scale = Math.min(
options.maxWidth / image.image.width,
options.maxHeight / image.image.height,
);
canvas.width = image.image.width * scale;
canvas.height = image.image.height * scale;
const ctx = canvas.getContext('2d');
ctx.drawImage(image.image, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob>((resolve, reject) => {
try {
canvas.toBlob(
(blob) => resolve(blob),
`image/${options.format.toLowerCase()}`,
options.quality,
);
} catch (error) {
reject(error);
}
});
await (shim.fsDriver() as FsDriverWeb).writeFile(
options.outputPath, await blob.arrayBuffer(), 'Buffer',
);
} finally {
image.free();
}
} else {
const resizedImage = await ImageResizer.createResizedImage(
options.inputPath,
options.maxWidth,
@@ -38,6 +76,7 @@ const resizeImage = async (options: Options) => {
} catch (error) {
logger.warn('Error when unlinking cached file: ', error);
}
}
};
export default resizeImage;

View File

@@ -1,9 +1,12 @@
import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger';
import { SerializableData } from '@joplin/lib/utils/ipc/types';
import { WebViewControl } from '../../components/ExtendedWebView';
import { WebViewControl } from '../../components/ExtendedWebView/types';
import { RefObject } from 'react';
import { OnMessageEvent } from '../../components/ExtendedWebView/types';
import { Platform } from 'react-native';
const canUseOptimizedPostMessage = Platform.OS === 'web';
export default class RNToWebViewMessenger<LocalInterface, RemoteInterface> extends RemoteMessenger<LocalInterface, RemoteInterface> {
public constructor(channelId: string, private webviewControl: WebViewControl|RefObject<WebViewControl>, localApi: LocalInterface) {
@@ -19,6 +22,9 @@ export default class RNToWebViewMessenger<LocalInterface, RemoteInterface> exten
// This is the case in testing environments where no WebView is available.
if (!webviewControl.injectJS) return;
if (canUseOptimizedPostMessage) {
webviewControl.postMessage(message);
} else {
webviewControl.injectJS(`
window.dispatchEvent(
new MessageEvent(
@@ -31,11 +37,16 @@ export default class RNToWebViewMessenger<LocalInterface, RemoteInterface> exten
);
`);
}
}
public onWebViewMessage = (event: OnMessageEvent) => {
if (!this.hasBeenClosed()) {
if (canUseOptimizedPostMessage) {
void this.onMessage(event.nativeEvent.data);
} else {
void this.onMessage(JSON.parse(event.nativeEvent.data));
}
}
};
public onWebViewLoaded = () => {

View File

@@ -2,6 +2,14 @@
import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger';
import { SerializableData } from '@joplin/lib/utils/ipc/types';
interface ExtendedWindow extends Window {
ReactNativeWebView: {
postMessage(message: unknown): void;
supportsNonStringMessages?: boolean;
};
}
declare const window: ExtendedWindow;
export default class WebViewToRNMessenger<LocalInterface, RemoteInterface> extends RemoteMessenger<LocalInterface, RemoteInterface> {
public constructor(channelId: string, localApi: LocalInterface) {
super(channelId, localApi);
@@ -24,8 +32,7 @@ export default class WebViewToRNMessenger<LocalInterface, RemoteInterface> exten
};
protected override postMessage(message: SerializableData): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(window as any).ReactNativeWebView.postMessage(JSON.stringify(message));
window.ReactNativeWebView.postMessage(window.ReactNativeWebView.supportsNonStringMessages ? message : JSON.stringify(message));
}
protected override onClose(): void {

View File

@@ -0,0 +1,22 @@
import { _ } from '@joplin/lib/locale';
import { Platform } from 'react-native';
const lockToSingleInstance = async () => {
if (Platform.OS !== 'web') return;
const channel = new BroadcastChannel('single-instance-lock');
channel.postMessage('app-opened');
await new Promise<void>((resolve, reject) => {
channel.onmessage = (event) => {
if (event.data === 'app-opened') {
channel.postMessage('already-running');
} else if (event.data === 'already-running') {
alert(_('At present, Joplin Web can only be open in one tab at a time. Please close the other instance of Joplin.'));
reject(new Error(_('Joplin is already running.')));
}
};
setTimeout(() => resolve(), 250);
});
};
export default lockToSingleInstance;

View File

@@ -1,14 +1,16 @@
import { _ } from '@joplin/lib/locale';
import { Alert, AlertButton } from 'react-native';
import { Alert } from 'react-native';
import { DialogControl, PromptButton } from '../components/DialogManager';
import { RefObject } from 'react';
interface Options {
title: string;
buttons: string[];
}
const showMessageBox = (message: string, options: Options = null) => {
const makeShowMessageBox = (dialogControl: null|RefObject<DialogControl>) => (message: string, options: Options = null) => {
return new Promise<number>(resolve => {
const defaultButtons: AlertButton[] = [
const defaultButtons: PromptButton[] = [
{
text: _('OK'),
onPress: () => resolve(0),
@@ -30,11 +32,12 @@ const showMessageBox = (message: string, options: Options = null) => {
});
}
Alert.alert(
// Web doesn't support Alert.alert -- prefer using the global dialogControl if available.
(dialogControl?.current?.prompt ?? Alert.alert)(
options?.title ?? '',
message,
buttons,
);
});
};
export default showMessageBox;
export default makeShowMessageBox;

View File

@@ -2,6 +2,8 @@ import shim from '@joplin/lib/shim';
import DocumentPicker, { DocumentPickerResponse } from 'react-native-document-picker';
import { openDocument } from '@joplin/react-native-saf-x';
import Logger from '@joplin/utils/Logger';
import type FsDriverWeb from './fs-driver/fs-driver-rn.web';
import uuid from '@joplin/lib/uuid';
interface SelectedDocument {
type: string;
@@ -12,10 +14,58 @@ interface SelectedDocument {
const logger = Logger.create('pickDocument');
const pickDocument = async (multiple: boolean): Promise<SelectedDocument[]> => {
interface Options {
multiple?: boolean;
preferCamera?: boolean;
}
const pickDocument = async ({ multiple = false, preferCamera = false }: Options = {}): Promise<SelectedDocument[]> => {
let result: SelectedDocument[] = [];
try {
if (shim.fsDriver().isUsingAndroidSAF()) {
if (shim.mobilePlatform() === 'web') {
await new Promise<void>((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.style.display = 'none';
input.multiple = multiple;
if (preferCamera) {
input.capture = 'environment';
input.accept = 'image/*';
}
document.body.appendChild(input);
input.onchange = async () => {
try {
const fsDriver = shim.fsDriver() as FsDriverWeb;
if (input.files.length > 0) {
for (const file of input.files) {
const path = `/tmp/${uuid.create()}`;
await fsDriver.createReadOnlyVirtualFile(path, file);
result.push({
type: file.type,
mime: file.type,
uri: path,
fileName: file.name,
});
}
}
resolve();
} catch (error) {
reject(error);
} finally {
input.remove();
}
};
input.oncancel = () => {
input.remove();
resolve();
};
input.click();
});
} else if (shim.fsDriver().isUsingAndroidSAF()) {
const openDocResult = await openDocument({ multiple });
if (!openDocResult) {
throw new Error('User canceled document picker');
@@ -48,7 +98,7 @@ const pickDocument = async (multiple: boolean): Promise<SelectedDocument[]> => {
});
}
} catch (error) {
if (DocumentPicker.isCancel(error) || error?.message?.includes('cancel')) {
if (DocumentPicker?.isCancel?.(error) || error?.message?.includes('cancel')) {
logger.info('user has cancelled');
return [];
} else {

Some files were not shown because too many files have changed in this diff Show More