1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-30 20:39:46 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
Laurent Cozic
683dea01ba init 2025-03-03 20:20:05 +00:00
273 changed files with 3321 additions and 6123 deletions

View File

@@ -158,7 +158,6 @@ packages/app-desktop/commands/exportFolders.js
packages/app-desktop/commands/exportNotes.js
packages/app-desktop/commands/focusElement.js
packages/app-desktop/commands/index.js
packages/app-desktop/commands/newAppInstance.js
packages/app-desktop/commands/openNoteInNewWindow.js
packages/app-desktop/commands/openProfileDirectory.js
packages/app-desktop/commands/replaceMisspelling.js
@@ -273,7 +272,6 @@ packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
packages/app-desktop/gui/NoteEditor/WarningBanner/WarningBanner.js
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteBody.js
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteTitle.js
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteViewer.js
packages/app-desktop/gui/NoteEditor/commands/focusElementToolbar.js
packages/app-desktop/gui/NoteEditor/commands/index.js
packages/app-desktop/gui/NoteEditor/commands/pasteAsText.js
@@ -583,7 +581,6 @@ packages/app-desktop/utils/restartInSafeModeFromMain.test.js
packages/app-desktop/utils/restartInSafeModeFromMain.js
packages/app-desktop/utils/window/types.js
packages/app-mobile/PluginAssetsLoader.js
packages/app-mobile/commands/dismissPluginPanels.js
packages/app-mobile/commands/index.js
packages/app-mobile/commands/newNote.test.js
packages/app-mobile/commands/newNote.js
@@ -686,15 +683,9 @@ packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/SideMenuContentNote.js
packages/app-mobile/components/TextInput.js
packages/app-mobile/components/ToggleSpaceButton.js
packages/app-mobile/components/accessibility/AccessibleModalMenu.js
packages/app-mobile/components/accessibility/AccessibleView.test.js
packages/app-mobile/components/accessibility/AccessibleView.js
packages/app-mobile/components/accessibility/FocusControl/AutoFocusProvider.js
packages/app-mobile/components/accessibility/FocusControl/FocusControl.js
packages/app-mobile/components/accessibility/FocusControl/FocusControlProvider.js
packages/app-mobile/components/accessibility/FocusControl/MainAppContent.js
packages/app-mobile/components/accessibility/FocusControl/ModalWrapper.js
packages/app-mobile/components/accessibility/FocusControl/types.js
packages/app-mobile/components/app-nav.js
packages/app-mobile/components/base-screen.js
packages/app-mobile/components/biometrics/BiometricPopup.js
@@ -747,11 +738,8 @@ packages/app-mobile/components/screens/ConfigScreen/SectionSelector/SectionTab.j
packages/app-mobile/components/screens/ConfigScreen/SectionSelector/index.js
packages/app-mobile/components/screens/ConfigScreen/SettingComponent.js
packages/app-mobile/components/screens/ConfigScreen/SettingItem.js
packages/app-mobile/components/screens/ConfigScreen/SettingTextInput.js
packages/app-mobile/components/screens/ConfigScreen/SettingsButton.js
packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js
packages/app-mobile/components/screens/ConfigScreen/ValidatedIntegerInput.test.js
packages/app-mobile/components/screens/ConfigScreen/ValidatedIntegerInput.js
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.js
packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.js
@@ -858,7 +846,7 @@ 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/hooks/useKeyboardState.js
packages/app-mobile/utils/hooks/useKeyboardVisible.js
packages/app-mobile/utils/hooks/useOnLongPressProps.js
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
packages/app-mobile/utils/image/fileToImage.web.js
@@ -1038,7 +1026,6 @@ packages/lib/commands/renderMarkup.test.js
packages/lib/commands/renderMarkup.js
packages/lib/commands/showEditorPlugin.js
packages/lib/commands/synchronize.js
packages/lib/commands/toggleAllFolders.test.js
packages/lib/commands/toggleAllFolders.js
packages/lib/commands/toggleEditorPlugin.js
packages/lib/components/EncryptionConfigScreen/utils.test.js
@@ -1076,8 +1063,6 @@ packages/lib/fs-driver-base.js
packages/lib/fs-driver-node.js
packages/lib/fsDriver.test.js
packages/lib/geolocation-node.js
packages/lib/getAppName.test.js
packages/lib/getAppName.js
packages/lib/hooks/useAsyncEffect.js
packages/lib/hooks/useElementSize.js
packages/lib/hooks/useEventListener.js

View File

@@ -57,8 +57,6 @@ module.exports = {
'tinymce': 'readonly',
'JSX': 'readonly',
'NodeJS': 'readonly',
},
'parserOptions': {
'ecmaVersion': 2018,
@@ -311,7 +309,7 @@ module.exports = {
selector: 'interface',
format: null,
'filter': {
'regex': '^(RSA|RSAKeyPair|iOS.*)$',
'regex': '^(RSA|RSAKeyPair)$',
'match': true,
},
},

View File

@@ -1,8 +1,8 @@
blank_issues_enabled: true
blank_issues_enabled: false
contact_links:
- name: Feature Requests
url: https://discourse.joplinapp.org/c/features/
about: Discuss ideas for new features or changes
- name: Support
url: https://discourse.joplinapp.org/c/support/
about: Please ask for help here
about: Please ask for help here

View File

@@ -1,34 +0,0 @@
#!/bin/bash
VERSION=$(echo "$GIT_TAG_NAME" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
echo "GIT_TAG_NAME=$GIT_TAG_NAME"
echo "VERSION=$VERSION"
echo "SERVER_TAG_PREFIX=$SERVER_TAG_PREFIX"
echo "SERVER_REPOSITORY=$SERVER_REPOSITORY"
# Check if it's a server release, otherwise exit
if [[ $GIT_TAG_NAME != $SERVER_TAG_PREFIX-* ]]; then
exit 0
fi
docker manifest inspect $SERVER_REPOSITORY:arm64-$VERSION > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo "Image $SERVER_REPOSITORY:arm64-$VERSION does not exist on the remote registry."
exit 0
fi
docker manifest inspect $SERVER_REPOSITORY:amd64-$VERSION > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo "Image $SERVER_REPOSITORY:amd64-$VERSION does not exist on the remote registry."
exit 0
fi
docker manifest create $SERVER_REPOSITORY:$VERSION \
$SERVER_REPOSITORY:arm64-$VERSION \
$SERVER_REPOSITORY:amd64-$VERSION
docker manifest annotate $SERVER_REPOSITORY:$VERSION $SERVER_REPOSITORY:arm64-$VERSION --arch arm64
docker manifest annotate $SERVER_REPOSITORY:$VERSION $SERVER_REPOSITORY:amd64-$VERSION --arch amd64
docker manifest push $SERVER_REPOSITORY:$VERSION

View File

@@ -35,8 +35,6 @@ else
IS_MACOS=1
fi
DOCKER_IMAGE_PLATFORM="linux/amd64"
# Tests can randomly fail in some cases, so only run them when not publishing
# a release
RUN_TESTS=0
@@ -45,33 +43,10 @@ if [ "$IS_SERVER_RELEASE" = 0 ] && [ "$IS_DESKTOP_RELEASE" = 0 ]; then
RUN_TESTS=1
fi
if [ "$RUNNER_ARCH" == "ARM64" ] && [ "$IS_SERVER_RELEASE" == "0" ]; then
# We exit now because nothing works properly with the ARM64 architecture.
# We only proceed if building the server image.
echo "Running on ARM64 and not trying to build server image - early exit"
exit 0
fi
if [ "$RUNNER_ARCH" == "ARM64" ]; then
# Canvas is only needed for tests and it doesn't build in ARM64 so remove it
RUN_TESTS=0
cd "$ROOT_DIR/packages/lib"
yarn remove canvas
cd "$ROOT_DIR"
DOCKER_IMAGE_PLATFORM="linux/arm64"
# Delete certain directories because `yarn install` will fail on ARM64.
rm -rf app-desktop
rm -rf app-mobile
fi
# =============================================================================
# Print environment
# =============================================================================
echo "RUNNER_OS=$RUNNER_OS"
echo "RUNNER_ARCH=$RUNNER_ARCH"
echo "GITHUB_WORKFLOW=$GITHUB_WORKFLOW"
echo "GITHUB_EVENT_NAME=$GITHUB_EVENT_NAME"
echo "GITHUB_REF=$GITHUB_REF"
@@ -80,7 +55,6 @@ echo "GIT_TAG_NAME=$GIT_TAG_NAME"
echo "BUILD_SEQUENCIAL=$BUILD_SEQUENCIAL"
echo "SERVER_REPOSITORY=$SERVER_REPOSITORY"
echo "SERVER_TAG_PREFIX=$SERVER_TAG_PREFIX"
echo "DOCKER_IMAGE_PLATFORM=$DOCKER_IMAGE_PLATFORM"
echo "IS_CONTINUOUS_INTEGRATION=$IS_CONTINUOUS_INTEGRATION"
echo "IS_PULL_REQUEST=$IS_PULL_REQUEST"
@@ -303,7 +277,7 @@ if [ "$IS_DESKTOP_RELEASE" == "1" ]; then
elif [[ $IS_LINUX = 1 ]] && [ "$IS_SERVER_RELEASE" == "1" ]; then
echo "Step: Building Docker Image..."
cd "$ROOT_DIR"
yarn buildServerDocker --platform $DOCKER_IMAGE_PLATFORM --tag-name $GIT_TAG_NAME --push-images --repository $SERVER_REPOSITORY
yarn buildServerDocker --tag-name $GIT_TAG_NAME --push-images --repository $SERVER_REPOSITORY
else
echo "Step: Building but *not* publishing desktop application..."

View File

@@ -9,12 +9,47 @@ jobs:
matrix:
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
# https://github.com/actions/runner-images/issues/6709
os: [macos-13, ubuntu-22.04, windows-2019, ubuntu-22.04-arm]
os: [macos-13, ubuntu-20.04, windows-2019]
steps:
- uses: actions/checkout@v4
- name: Setup build environment
uses: ./.github/workflows/shared/setup-build-environment
# Trying to fix random networking issues on Windows
# https://github.com/actions/runner-images/issues/1187#issuecomment-686735760
- name: Disable TCP/UDP offload on Windows
if: runner.os == 'Windows'
run: Disable-NetAdapterChecksumOffload -Name * -TcpIPv4 -UdpIPv4 -TcpIPv6 -UdpIPv6
- name: Disable TCP/UDP offload on Linux
if: runner.os == 'Linux'
run: sudo ethtool -K eth0 tx off rx off
- name: Disable TCP/UDP offload on macOS
if: runner.os == 'macOS'
run: |
sudo sysctl -w net.link.generic.system.hwcksum_tx=0
sudo sysctl -w net.link.generic.system.hwcksum_rx=0
# Silence apt-get update errors (for example when a module doesn't
# exist) since otherwise it will make the whole build fails, even though
# it might work without update. libsecret-1-dev is required for keytar -
# https://github.com/atom/node-keytar
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update || true
sudo apt-get install -y gettext
sudo apt-get install -y libsecret-1-dev
sudo apt-get install -y translate-toolkit
sudo apt-get install -y rsync
# Provides a virtual display on Linux. Used for Playwright integration
# testing.
sudo apt-get install -y xvfb
- name: Install macOs dependencies
if: runner.os == 'macOS'
run: |
# Required for building the canvas package
brew install pango
- name: Install Docker Engine
# if: runner.os == 'Linux' && startsWith(github.ref, 'refs/tags/server-v')
@@ -27,11 +62,26 @@ jobs:
sudo apt-get install -y lsb-release
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
"deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update || true
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin
sudo apt-get install -y docker-ce docker-ce-cli containerd.io
- uses: actions/checkout@v4
- uses: olegtarasov/get-tag@v2.1.3
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v4
with:
# We need to pin the version to 18.15, because 18.16+ fails with this error:
# https://github.com/facebook/react-native/issues/36440
node-version: '18.15.0'
cache: 'yarn'
- name: Install Yarn
run: |
# https://yarnpkg.com/getting-started/install
corepack enable
# Login to Docker only if we're on a server release tag. If we run this on
# a pull request it will fail because the PR doesn't have access to
# secrets
@@ -41,6 +91,15 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# macos-latest ships with Python 3.12 by default, but this removes a
# utility that's used by electron-builder (distutils) so we need to pin
# Python to an earlier version.
# Fixes error `ModuleNotFoundError: No module named 'distutils'`
# Ref: https://github.com/nodejs/node-gyp/issues/2869
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Run tests, build and publish Linux and macOS apps
if: runner.os == 'Linux' || runner.os == 'macOs'
env:
@@ -84,15 +143,6 @@ jobs:
run: |
yarn install && cd packages/app-desktop && yarn dist --publish=never
- name: Publish Docker manifest
if: runner.os == 'Linux'
env:
SERVER_REPOSITORY: joplin/server
SERVER_TAG_PREFIX: server
run: |
chmod 700 "${GITHUB_WORKSPACE}/.github/scripts/publish_docker_manifest.sh"
"${GITHUB_WORKSPACE}/.github/scripts/publish_docker_manifest.sh"
ServerDockerImage:
if: github.repository == 'laurent22/joplin'
runs-on: ${{ matrix.os }}
@@ -100,7 +150,7 @@ jobs:
matrix:
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
# https://github.com/actions/runner-images/issues/6709
os: [ubuntu-22.04, ubuntu-22.04-arm]
os: [ubuntu-20.04]
steps:
- name: Install Docker Engine
@@ -112,10 +162,10 @@ jobs:
sudo apt-get install -y lsb-release
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
"deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update || true
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin
sudo apt-get install -y docker-ce docker-ce-cli containerd.io
- uses: actions/checkout@v4
@@ -133,30 +183,17 @@ jobs:
env:
BUILD_SEQUENCIAL: 1
run: |
if [ "$RUNNER_ARCH" == "ARM64" ]; then
DOCKER_IMAGE_PLATFORM="linux/arm64"
fi
echo "RUNNER_OS=$RUNNER_OS"
echo "RUNNER_ARCH=$RUNNER_ARCH"
echo "DOCKER_IMAGE_PLATFORM=$DOCKER_IMAGE_PLATFORM"
# Canvas is only needed for tests and it doesn't build in ARM64 so remove it
cd packages/lib
yarn remove canvas
cd ../..
yarn install
yarn buildServerDocker --platform $DOCKER_IMAGE_PLATFORM --tag-name server-v0.0.0 --repository joplin/server
yarn buildServerDocker --tag-name server-v0.0.0 --repository joplin/server
# Basic test to ensure that the created build is valid. It should exit with
# code 0 if it works.
docker run joplin/server:$(dpkg --print-architecture)-0.0.0 node dist/app.js migrate list
# code 0 if it works.
docker run joplin/server:0.0.0-beta node dist/app.js migrate list
- name: Check HTTP request
run: |
# Need to pass environment variables:
docker run -p 22300:22300 joplin/server:$(dpkg --print-architecture)-0.0.0 node dist/app.js --env dev &
docker run -p 22300:22300 joplin/server:0.0.0-beta node dist/app.js --env dev &
# Wait for server to start
sleep 30

View File

@@ -1,72 +0,0 @@
name: 'Setup build environment'
description: 'Install Joplin build dependencies'
runs:
using: 'composite'
steps:
# Trying to fix random networking issues on Windows
# https://github.com/actions/runner-images/issues/1187#issuecomment-686735760
- name: Disable TCP/UDP offload on Windows
if: runner.os == 'Windows'
shell: pwsh
run: Disable-NetAdapterChecksumOffload -Name * -TcpIPv4 -UdpIPv4 -TcpIPv6 -UdpIPv6
- name: Disable TCP/UDP offload on Linux
if: runner.os == 'Linux'
shell: bash
run: sudo ethtool -K eth0 tx off rx off
- name: Disable TCP/UDP offload on macOS
if: runner.os == 'macOS'
shell: bash
run: |
sudo sysctl -w net.link.generic.system.hwcksum_tx=0
sudo sysctl -w net.link.generic.system.hwcksum_rx=0
# Silence apt-get update errors (for example when a module doesn't
# exist) since otherwise it will make the whole build fails, even though
# it might work without update. libsecret-1-dev is required for keytar -
# https://github.com/atom/node-keytar
- name: Install Linux dependencies
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update || true
sudo apt-get install -y gettext
sudo apt-get install -y libsecret-1-dev
sudo apt-get install -y translate-toolkit
sudo apt-get install -y rsync
# Provides a virtual display on Linux. Used for Playwright integration
# testing.
sudo apt-get install -y xvfb
- name: Install macOs dependencies
if: runner.os == 'macOS'
shell: bash
run: |
# Required for building the canvas package
brew install pango
- uses: olegtarasov/get-tag@v2.1.3
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v4
with:
# We need to pin the version to 18.15, because 18.16+ fails with this error:
# https://github.com/facebook/react-native/issues/36440
node-version: '18.15.0'
cache: 'yarn'
- name: Install Yarn
shell: bash
run: |
# https://yarnpkg.com/getting-started/install
corepack enable
# macos-latest ships with Python 3.12 by default, but this removes a
# utility that's used by electron-builder (distutils) so we need to pin
# Python to an earlier version.
# Fixes error `ModuleNotFoundError: No module named 'distutils'`
# Ref: https://github.com/nodejs/node-gyp/issues/2869
- uses: actions/setup-python@v5
with:
python-version: '3.11'

View File

@@ -1,29 +0,0 @@
name: Joplin UI tests
on: [push, pull_request]
permissions:
contents: read
jobs:
Main:
# Don't run on forks
if: github.repository == 'laurent22/joplin'
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-13, ubuntu-22.04]
steps:
- uses: actions/checkout@v4
- name: Setup build environment
uses: ./.github/workflows/shared/setup-build-environment
- name: Build
run: yarn install
- name: Run UI tests
run: |
cd ${GITHUB_WORKSPACE}/packages/app-desktop/
bash ./integration-tests/run-ci.sh
# See https://playwright.dev/docs/ci-intro#setting-up-github-actions
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report-${{ matrix.os }}
path: packages/app-desktop/playwright-report/
retention-days: 7

19
.gitignore vendored
View File

@@ -133,7 +133,6 @@ packages/app-desktop/commands/exportFolders.js
packages/app-desktop/commands/exportNotes.js
packages/app-desktop/commands/focusElement.js
packages/app-desktop/commands/index.js
packages/app-desktop/commands/newAppInstance.js
packages/app-desktop/commands/openNoteInNewWindow.js
packages/app-desktop/commands/openProfileDirectory.js
packages/app-desktop/commands/replaceMisspelling.js
@@ -248,7 +247,6 @@ packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
packages/app-desktop/gui/NoteEditor/WarningBanner/WarningBanner.js
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteBody.js
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteTitle.js
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteViewer.js
packages/app-desktop/gui/NoteEditor/commands/focusElementToolbar.js
packages/app-desktop/gui/NoteEditor/commands/index.js
packages/app-desktop/gui/NoteEditor/commands/pasteAsText.js
@@ -558,7 +556,6 @@ packages/app-desktop/utils/restartInSafeModeFromMain.test.js
packages/app-desktop/utils/restartInSafeModeFromMain.js
packages/app-desktop/utils/window/types.js
packages/app-mobile/PluginAssetsLoader.js
packages/app-mobile/commands/dismissPluginPanels.js
packages/app-mobile/commands/index.js
packages/app-mobile/commands/newNote.test.js
packages/app-mobile/commands/newNote.js
@@ -661,15 +658,9 @@ packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/SideMenuContentNote.js
packages/app-mobile/components/TextInput.js
packages/app-mobile/components/ToggleSpaceButton.js
packages/app-mobile/components/accessibility/AccessibleModalMenu.js
packages/app-mobile/components/accessibility/AccessibleView.test.js
packages/app-mobile/components/accessibility/AccessibleView.js
packages/app-mobile/components/accessibility/FocusControl/AutoFocusProvider.js
packages/app-mobile/components/accessibility/FocusControl/FocusControl.js
packages/app-mobile/components/accessibility/FocusControl/FocusControlProvider.js
packages/app-mobile/components/accessibility/FocusControl/MainAppContent.js
packages/app-mobile/components/accessibility/FocusControl/ModalWrapper.js
packages/app-mobile/components/accessibility/FocusControl/types.js
packages/app-mobile/components/app-nav.js
packages/app-mobile/components/base-screen.js
packages/app-mobile/components/biometrics/BiometricPopup.js
@@ -722,11 +713,8 @@ packages/app-mobile/components/screens/ConfigScreen/SectionSelector/SectionTab.j
packages/app-mobile/components/screens/ConfigScreen/SectionSelector/index.js
packages/app-mobile/components/screens/ConfigScreen/SettingComponent.js
packages/app-mobile/components/screens/ConfigScreen/SettingItem.js
packages/app-mobile/components/screens/ConfigScreen/SettingTextInput.js
packages/app-mobile/components/screens/ConfigScreen/SettingsButton.js
packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js
packages/app-mobile/components/screens/ConfigScreen/ValidatedIntegerInput.test.js
packages/app-mobile/components/screens/ConfigScreen/ValidatedIntegerInput.js
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.js
packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.js
@@ -833,7 +821,7 @@ 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/hooks/useKeyboardState.js
packages/app-mobile/utils/hooks/useKeyboardVisible.js
packages/app-mobile/utils/hooks/useOnLongPressProps.js
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
packages/app-mobile/utils/image/fileToImage.web.js
@@ -1013,7 +1001,6 @@ packages/lib/commands/renderMarkup.test.js
packages/lib/commands/renderMarkup.js
packages/lib/commands/showEditorPlugin.js
packages/lib/commands/synchronize.js
packages/lib/commands/toggleAllFolders.test.js
packages/lib/commands/toggleAllFolders.js
packages/lib/commands/toggleEditorPlugin.js
packages/lib/components/EncryptionConfigScreen/utils.test.js
@@ -1051,8 +1038,6 @@ packages/lib/fs-driver-base.js
packages/lib/fs-driver-node.js
packages/lib/fsDriver.test.js
packages/lib/geolocation-node.js
packages/lib/getAppName.test.js
packages/lib/getAppName.js
packages/lib/hooks/useAsyncEffect.js
packages/lib/hooks/useElementSize.js
packages/lib/hooks/useEventListener.js

View File

@@ -0,0 +1,62 @@
diff --git a/android/src/newarch/java/com/reactnativecommunity/slider/ReactSliderManager.java b/android/src/newarch/java/com/reactnativecommunity/slider/ReactSliderManager.java
index a5bb95eec3337b93a2338a2869a2bda176c91cae..87817688eb280c2f702c26dc35558c6a0a4db1ea 100644
--- a/android/src/newarch/java/com/reactnativecommunity/slider/ReactSliderManager.java
+++ b/android/src/newarch/java/com/reactnativecommunity/slider/ReactSliderManager.java
@@ -42,12 +42,20 @@ public class ReactSliderManager extends SimpleViewManager<ReactSlider> implement
public void onProgressChanged(SeekBar seekbar, int progress, boolean fromUser) {
ReactSlider slider = (ReactSlider)seekbar;
- if(progress < slider.getLowerLimit()) {
- progress = slider.getLowerLimit();
- seekbar.setProgress(progress);
- } else if (progress > slider.getUpperLimit()) {
- progress = slider.getUpperLimit();
- seekbar.setProgress(progress);
+ // During initialization, lowerLimit can be greater than upperLimit.
+ //
+ // If a change event is received during this, we need a check to prevent
+ // infinite recursion.
+ //
+ // Issue: https://github.com/callstack/react-native-slider/issues/571
+ if (slider.getLowerLimit() <= slider.getUpperLimit()) {
+ if(progress < slider.getLowerLimit()) {
+ progress = slider.getLowerLimit();
+ seekbar.setProgress(progress);
+ } else if (progress > slider.getUpperLimit()) {
+ progress = slider.getUpperLimit();
+ seekbar.setProgress(progress);
+ }
}
ReactContext reactContext = (ReactContext) seekbar.getContext();
diff --git a/android/src/oldarch/java/com/reactnativecommunity/slider/ReactSliderManager.java b/android/src/oldarch/java/com/reactnativecommunity/slider/ReactSliderManager.java
index 3ff5930f85a3cd92c2549925f41058abb188a57e..ab3681fdfe0b736c97020e1434e450c8183e6f18 100644
--- a/android/src/oldarch/java/com/reactnativecommunity/slider/ReactSliderManager.java
+++ b/android/src/oldarch/java/com/reactnativecommunity/slider/ReactSliderManager.java
@@ -30,12 +30,20 @@ public class ReactSliderManager extends SimpleViewManager<ReactSlider> {
public void onProgressChanged(SeekBar seekbar, int progress, boolean fromUser) {
ReactSlider slider = (ReactSlider)seekbar;
- if(progress < slider.getLowerLimit()) {
- progress = slider.getLowerLimit();
- seekbar.setProgress(progress);
- } else if(progress > slider.getUpperLimit()) {
- progress = slider.getUpperLimit();
- seekbar.setProgress(progress);
+ // During initialization, lowerLimit can be greater than upperLimit.
+ //
+ // If a change event is received during this, we need a check to prevent
+ // infinite recursion.
+ //
+ // Issue: https://github.com/callstack/react-native-slider/issues/571
+ if (slider.getLowerLimit() <= slider.getUpperLimit()) {
+ if(progress < slider.getLowerLimit()) {
+ progress = slider.getLowerLimit();
+ seekbar.setProgress(progress);
+ } else if (progress > slider.getUpperLimit()) {
+ progress = slider.getUpperLimit();
+ seekbar.setProgress(progress);
+ }
}
ReactContext reactContext = (ReactContext) seekbar.getContext();

View File

@@ -1,55 +0,0 @@
# This patch improves the note actions menu (the kebab menu)'s accessibility
# by labelling its dismiss button.
diff --git a/build/rnpm.js b/build/rnpm.js
index 1111c2de99b3d4c5651ca4eee3ba59c0ce8e13e1..d410ee12b38d02c399b0a40973217da0082d73c0 100644
--- a/build/rnpm.js
+++ b/build/rnpm.js
@@ -1573,7 +1573,9 @@
onPress = _this$props.onPress,
style = _this$props.style;
return /*#__PURE__*/React__default.createElement(reactNative.TouchableWithoutFeedback, {
- onPress: onPress
+ onPress: onPress,
+ accessibilityLabel: _this$props.accessibilityLabel,
+ accessibilityRole: 'button',
}, /*#__PURE__*/React__default.createElement(reactNative.Animated.View, {
style: [styles.fullscreen, {
opacity: this.fadeAnim
@@ -1588,7 +1590,8 @@
}(React.Component);
Backdrop.propTypes = {
- onPress: propTypes.func.isRequired
+ onPress: propTypes.func.isRequired,
+ accessibilityLabel: propTypes.string,
};
var styles = reactNative.StyleSheet.create({
fullscreen: {
@@ -1658,6 +1661,7 @@
style: styles$1.placeholder
}, /*#__PURE__*/React__default.createElement(Backdrop, {
onPress: ctx._onBackdropPress,
+ accessibilityLabel: this.props.closeButtonLabel,
style: backdropStyles,
ref: ctx.onBackdropRef
}), ctx._makeOptions());
@@ -2090,6 +2094,7 @@
}), /*#__PURE__*/React__default.createElement(MenuPlaceholder, {
ctx: this,
backdropStyles: customStyles.backdrop,
+ closeButtonLabel: this.props.closeButtonLabel,
ref: this._onPlaceholderRef
}))));
}
diff --git a/src/index.d.ts b/src/index.d.ts
index 1db1e643a915e4bfb715e33354678ec1be219f50..007157e366d1935368bdd8eff5e7a0773e183d0f 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -18,6 +18,7 @@ declare module "react-native-popup-menu" {
menuProviderWrapper?: StyleProp<ViewStyle>;
backdrop?: StyleProp<ViewStyle>;
};
+ closeButtonLabel: string;
backHandler?: boolean | Function;
skipInstanceCheck?: boolean;
children: React.ReactNode;

View File

@@ -1,77 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 682.66669 682.66669"
height="682.66669"
width="682.66669"
xml:space="preserve"
id="svg2"
version="1.1"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
sodipodi:docname="JoplinLetterBlue.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview13"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
showgrid="false"
inkscape:zoom="0.77490232"
inkscape:cx="366.49781"
inkscape:cy="360.69062"
inkscape:window-width="1366"
inkscape:window-height="708"
inkscape:window-x="0"
inkscape:window-y="30"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<defs
id="defs6">
<linearGradient
id="linearGradient26"
spreadMethod="pad"
gradientTransform="matrix(-4387.91,4387.91,4387.91,4387.91,4753.95,366.05)"
gradientUnits="userSpaceOnUse"
y2="0"
x2="1"
y1="0"
x1="0">
<stop
id="stop22"
offset="0"
style="stop-opacity:1;stop-color:#004caf" />
<stop
id="stop24"
offset="1"
style="stop-opacity:1;stop-color:#1f95f8" />
</linearGradient>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath829"><path
id="path831"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.999997"
d="M 3961.59,4435.23 H 2570.18 c -13.15,0 -23.78,-10.64 -23.78,-23.77 v -441.84 c 0,-14.87 12.04,-26.92 26.92,-26.92 h 190.77 c 77.16,0 139.73,-59.35 146.43,-134.77 V 3505 3336.23 1728.75 1717.36 h -0.052 c 0.48,-16.84 -0.1898,-33.4 -1.83,-49.71 -0.18,-2.38 -0.5003,-4.73 -0.7902,-7.09 -1.0998,-9.53 -2.3199,-19.01 -4.17,-28.29 -1.0098,-5.29 -2.4399,-10.44 -3.7098,-15.65 -1.71,-6.93 -3.09,-13.97 -5.22,-20.75 -12.5802,-40.27 -32.4702,-77.62 -59.9802,-110.5 -1.0098,-1.17 -2.2599,-2.25 -3.2598,-3.41 -8.3901,-9.72 -17.2002,-19.19 -26.9502,-28.06 -9.84,-8.95 -20.2599,-17.27 -31.2099,-25 -77.8401,-55.14 -182.61,-79.4 -299.67,-68.2 -149.2599,14.03 -297.3399,81.72 -417.03,190.62 -119.6701,108.89 -194.08,243.62 -209.4799,379.41 -13.8501,121.48 22.5498,228.38 102.42,301.05 0.21,0.1598 0.3997,0.3098 0.5602,0.48 3.09,2.77 6.4901,5.2 9.6701,7.87 57.16,47.89 131.6701,76.91 216.7,84.91 0.96,0.09 1.8801,0.24 2.79,0.3203 8.9499,0.79 18.0699,1.15 27.27,1.49 4.8099,0.1598 9.5601,0.5003 14.4399,0.54 1.62,0.023 3.1602,0.1898 4.7802,0.1898 2.8998,0 5.91,-0.3803 8.8098,-0.42 13.4001,-0.21 26.9001,-0.7601 40.6701,-1.9401 1.74,-0.1402 3.3999,-0.08 5.19,-0.24 1.2699,-0.1297 2.5299,-0.4102 3.8001,-0.54 78,-7.82 155.2299,-31.11 228.5199,-66.3999 1.53,-0.068 3.3,-0.54 5.5099,-1.7601 22.34,-12.3399 26.6201,0.9 27.2801,9.6501 v 382.2399 282.8201 c 0,19.05 -13.2501,35.8999 -31.83,39.99 -394.7601,86.88 -782.08,-3.5501 -1055.38,-252.3401 -238.7499,-217.1799 -354.24,-530.5799 -316.8201,-859.7899 33.39,-293.23 183.9102,-574.94 423.88,-793.33 233.8901,-212.79003 531.69,-345.86006 838.8801,-374.80106 42.33,-3.918 84.8601,-5.93797 126.36,-5.93797 293.3799,0 565.6099,100.59802 766.54,283.37903 190.3401,173.3 304.35,411.27 321.0799,670.16 l 1.55,1697.91 h 0.1703 v 453.97 h 0.06 v 7.92 c 1.72,80.1199 67.05,144.58 147.61,144.58 h 190.77 c 14.8599,0 26.9199,12.05 26.9199,26.9199 v 441.84 c 0,13.13 -10.6299,23.77 -23.7799,23.77" /></clipPath></defs>
<g
id="g14"
transform="matrix(0.13333333,0,0,-0.13333333,0,682.66667)"
mask="none"
clip-path="url(#clipPath829)">
<g
clip-path="url(#clipPath20)"
id="g16">
<path
id="path28"
style="fill:url(#linearGradient26);fill-opacity:1;fill-rule:nonzero;stroke:none"
d="M 3873.89,0 H 1246.11 C 560.754,0 0,560.75 0,1246.11 V 3873.88 C 0,4559.25 560.754,5120 1246.11,5120 H 3873.89 C 4559.25,5120 5120,4559.25 5120,3873.88 V 1246.11 C 5120,560.75 4559.25,0 3873.89,0" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -80,7 +80,7 @@ async function setupDownloadPage() {
if (href.indexOf('-Setup') > 0) downloadLinks['windows'] = href;
if (href.indexOf('.dmg') > 0) downloadLinks['macOs'] = href;
if (href.indexOf('arm64.DMG') > 0) downloadLinks['macOsM1'] = href;
if (href.endsWith('arm64.DMG')) downloadLinks['macOsM1'] = href;
if (href.indexOf('.AppImage') > 0) downloadLinks['linux'] = href;
});
@@ -98,8 +98,6 @@ async function setupDownloadPage() {
} else {
const os = await getOs();
console.info('Found OS: ' + os);
if (os === 'macOsUndefined') {
// If we don't know which macOS version it is, we let the user choose.
$('.main-content .intro').html('<p class="macos-m1-info">The macOS release is available for Intel processors or for Apple Silicon (M1) processors. Please select your version:</p>');

View File

@@ -398,7 +398,7 @@
<div class="text-center sponsors-org">
{{#sponsors.orgs}}
<a class="sponsor-org-item" href="{{url}}"><img alt="{{alt}}" title="{{title}}" src="{{imageBaseUrl}}/sponsors/{{imageName}}"></a>
<a class="sponsor-org-item" href="{{url}}"><img title="{{title}}" src="{{imageBaseUrl}}/sponsors/{{imageName}}"></a>
{{/sponsors.orgs}}
</div>

View File

@@ -7,9 +7,6 @@ FROM node:18 AS builder
RUN apt-get update \
&& apt-get install -y \
python3 tini \
# needed for node-canvas for ARM32 platform.
# See also https://github.com/Automattic/node-canvas/wiki/Installation:-Ubuntu-and-other-Debian-based-systems
libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev \
&& rm -rf /var/lib/apt/lists/*
# Enables Yarn
@@ -50,9 +47,9 @@ RUN sed --in-place '/onenote-converter/d' ./packages/lib/package.json
# Note that `yarn install` ignores `NODE_ENV=production` and will install dev
# dependencies too, but this is fine because we need them to build the app.
RUN --mount=type=cache,target=/build/.yarn/cache --mount=type=cache,target=/build/.yarn/berry/cache\
BUILD_SEQUENCIAL=1 yarn config set cacheFolder /build/.yarn/cache \
&& yarn install --inline-builds
RUN BUILD_SEQUENCIAL=1 yarn install --inline-builds \
&& yarn cache clean \
&& rm -rf .yarn/berry
# =============================================================================
# Final stage - we copy only the relevant files from the build stage and start
@@ -84,11 +81,10 @@ CMD ["yarn", "start-prod"]
ARG BUILD_DATE
ARG REVISION
ARG VERSION
ARG SOURCE
LABEL org.opencontainers.image.created="$BUILD_DATE" \
org.opencontainers.image.title="Joplin Server" \
org.opencontainers.image.description="Docker image for Joplin Server" \
org.opencontainers.image.url="https://joplinapp.org/" \
org.opencontainers.image.revision="$REVISION" \
org.opencontainers.image.source="$SOURCE" \
org.opencontainers.image.version="$VERSION"
org.opencontainers.image.source="https://github.com/laurent22/joplin.git" \
org.opencontainers.image.version="${VERSION}"

View File

@@ -67,23 +67,10 @@ showHelp() {
fi
}
#-----------------------------------------------------
# Setup Download Helper: DL
#-----------------------------------------------------
if [[ `command -v wget2` ]]; then
DL='wget2 -qO'
elif [[ `command -v wget` ]]; then
DL='wget -qO'
elif [[ `command -v curl` ]]; then
DL='curl -sLo'
else
print "${COLOR_RED}Error: wget2, wget, and curl not found. Please install one of these tools.${COLOR_RESET}"
exit 1
fi
#-----------------------------------------------------
# PARSE ARGUMENTS
#-----------------------------------------------------
optspec=":h-:"
while getopts "${optspec}" OPT; do
[ "${OPT}" = " " ] && continue
@@ -153,9 +140,9 @@ fi
# Get the latest version to download
if [[ "$INCLUDE_PRE_RELEASE" == true ]]; then
RELEASE_VERSION=$($DL - "https://api.github.com/repos/laurent22/joplin/releases" | grep -Po '"tag_name": ?"v\K.*?(?=")' | sort -rV | head -1)
RELEASE_VERSION=$(wget -qO - "https://api.github.com/repos/laurent22/joplin/releases" | grep -Po '"tag_name": ?"v\K.*?(?=")' | sort -rV | head -1)
else
RELEASE_VERSION=$($DL - "https://api.github.com/repos/laurent22/joplin/releases/latest" | grep -Po '"tag_name": ?"v\K.*?(?=")')
RELEASE_VERSION=$(wget -qO - "https://api.github.com/repos/laurent22/joplin/releases/latest" | grep -Po '"tag_name": ?"v\K.*?(?=")')
fi
# Check if it's in the latest version
@@ -176,8 +163,8 @@ fi
#-----------------------------------------------------
print 'Downloading Joplin...'
TEMP_DIR=$(mktemp -d)
$DL "${TEMP_DIR}/Joplin.AppImage" "https://objects.joplinusercontent.com/v${RELEASE_VERSION}/Joplin-${RELEASE_VERSION}.AppImage?source=LinuxInstallScript&type=$DOWNLOAD_TYPE"
$DL "${TEMP_DIR}/joplin.png" https://joplinapp.org/images/Icon512.png
wget -O "${TEMP_DIR}/Joplin.AppImage" "https://objects.joplinusercontent.com/v${RELEASE_VERSION}/Joplin-${RELEASE_VERSION}.AppImage?source=LinuxInstallScript&type=$DOWNLOAD_TYPE"
wget -O "${TEMP_DIR}/joplin.png" https://joplinapp.org/images/Icon512.png
#-----------------------------------------------------
print 'Installing Joplin...'
@@ -300,7 +287,7 @@ echo "$RELEASE_VERSION" > "${INSTALL_DIR}/VERSION"
#-----------------------------------------------------
if [[ "$SHOW_CHANGELOG" == true ]]; then
NOTES=$($DL - https://api.github.com/repos/laurent22/joplin/releases/latest | grep -Po '"body": "\K.*(?=")')
NOTES=$(wget -qO - https://api.github.com/repos/laurent22/joplin/releases/latest | grep -Po '"body": "\K.*(?=")')
print "${COLOR_BLUE}Changelog:${COLOR_RESET}\n${NOTES}"
fi

View File

@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
# Sponsors
<!-- SPONSORS-ORG -->
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://casinoreviews.net"><img title="Casino Reviews" width="256" src="https://joplinapp.org/images/sponsors/CasinoReviews.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://www.reddit.com/r/tiktokRise/"><img title="Tiktok Rise" width="256" src="https://joplinapp.org/images/sponsors/TiktokRise.jpg" alt="Tiktok Rise"/></a>
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://casinoreviews.net"><img title="Casino Reviews" width="256" src="https://joplinapp.org/images/sponsors/CasinoReviews.png"/></a> <a href="https://useviral.com.br/"><img title="Comprar seguidores Instagram" width="256" src="https://joplinapp.org/images/sponsors/Useviral.png"/></a> <a href="https://ca.edubirdie.com/"><img title="Achieve academic success with Edubirdie — your trusted partner for expert writing assistance and resources!" width="256" src="https://joplinapp.org/images/sponsors/Edubirdie.png" alt="EduBirdie"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="web design agency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a>
<!-- SPONSORS-ORG -->
* * *
@@ -42,7 +42,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
| <img width="50" src="https://avatars2.githubusercontent.com/u/97193607?s=96&v=4"/></br>[Akhil-CM](https://github.com/Akhil-CM) | <img width="50" src="https://avatars2.githubusercontent.com/u/552452?s=96&v=4"/></br>[andypiper](https://github.com/andypiper) | <img width="50" src="https://avatars2.githubusercontent.com/u/215668?s=96&v=4"/></br>[avanderberg](https://github.com/avanderberg) | <img width="50" src="https://avatars2.githubusercontent.com/u/67130?s=96&v=4"/></br>[chr15m](https://github.com/chr15m) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) | <img width="50" src="https://avatars2.githubusercontent.com/u/8030470?s=96&v=4"/></br>[Galliver7](https://github.com/Galliver7) | <img width="50" src="https://avatars2.githubusercontent.com/u/64712218?s=96&v=4"/></br>[Hegghammer](https://github.com/Hegghammer) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/1788010?s=96&v=4"/></br>[maxtruxa](https://github.com/maxtruxa) | <img width="50" src="https://avatars2.githubusercontent.com/u/327998?s=96&v=4"/></br>[sif](https://github.com/sif) | <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) | | | |
| | | | |
<!-- SPONSORS-GITHUB -->
# Community
@@ -50,10 +50,9 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
Name | Description
--- | ---
[Support Forum](https://discourse.joplinapp.org/) | This is the main place for general discussion about Joplin, user support, software development questions, and to discuss new features. Also where the latest beta versions are released and discussed.
[Patreon page](https://www.patreon.com/joplin) |The latest news are often posted there
[Bluesky feed](https://bsky.app/profile/joplinapp.bsky.social) | Follow us on Bluesky
[Mastodon feed](https://mastodon.social/@joplinapp) | Follow us on Mastodon
[YouTube](https://www.youtube.com/@joplinapp) | Discover information and tutorials on how to use the apps
[Patreon page](https://www.patreon.com/joplin) |The latest news are often posted there
[Discord server](https://discord.gg/VSj7AFHvpq) | Our chat server
[LinkedIn](https://www.linkedin.com/company/joplin) | Our LinkedIn page
[Lemmy Community](https://sopuli.xyz/c/joplinapp) | Also a good place to get help

View File

@@ -25,8 +25,7 @@
"version": "latest",
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"git": "latest",
"giflib": "latest",
"git": "latest",
},
"shell": {
"init_hook": [

0
node
View File

View File

@@ -108,6 +108,7 @@
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
"nanoid": "patch:nanoid@npm%3A3.3.7#./.yarn/patches/nanoid-npm-3.3.7-98824ba130.patch",
"pdfjs-dist": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
"@react-native-community/slider": "patch:@react-native-community/slider@npm%3A4.4.4#./.yarn/patches/@react-native-community-slider-npm-4.4.4-d78e472f48.patch",
"husky": "patch:husky@npm%3A3.1.0#./.yarn/patches/husky-npm-3.1.0-5cc13e4e34.patch",
"chokidar@^2.0.0": "3.5.3",
"react-native@0.74.1": "patch:react-native@npm%3A0.74.1#./.yarn/patches/react-native-npm-0.74.1-754c02ae9e.patch",
@@ -115,7 +116,6 @@
"app-builder-lib@26.0.0-alpha.7": "patch:app-builder-lib@npm%3A26.0.0-alpha.7#./.yarn/patches/app-builder-lib-npm-26.0.0-alpha.7-e1b3dca119.patch",
"app-builder-lib@24.13.3": "patch:app-builder-lib@npm%3A24.13.3#./.yarn/patches/app-builder-lib-npm-24.13.3-86a66c0bf3.patch",
"react-native-sqlite-storage@6.0.1": "patch:react-native-sqlite-storage@npm%3A6.0.1#./.yarn/patches/react-native-sqlite-storage-npm-6.0.1-8369d747bd.patch",
"react-native-paper@5.13.1": "patch:react-native-paper@npm%3A5.13.1#./.yarn/patches/react-native-paper-npm-5.13.1-f153e542e2.patch",
"react-native-popup-menu@0.16.1": "patch:react-native-popup-menu@npm%3A0.16.1#./.yarn/patches/react-native-popup-menu-npm-0.16.1-28fd66ecb5.patch"
"react-native-paper@5.13.1": "patch:react-native-paper@npm%3A5.13.1#./.yarn/patches/react-native-paper-npm-5.13.1-f153e542e2.patch"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

View File

@@ -1,12 +1,11 @@
import Logger, { LoggerWrapper, TargetType } from '@joplin/utils/Logger';
import Logger, { LoggerWrapper } from '@joplin/utils/Logger';
import { PluginMessage } from './services/plugins/PluginRunner';
import AutoUpdaterService, { defaultUpdateInterval, initialUpdateStartup } from './services/autoUpdater/AutoUpdaterService';
import type ShimType from '@joplin/lib/shim';
const shim: typeof ShimType = require('@joplin/lib/shim').default;
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import { FileLocker } from '@joplin/utils/fs';
import { IpcMessageHandler, IpcServer, Message, newHttpError, sendMessage, SendMessageOptions, startServer, stopServer } from '@joplin/utils/ipc';
import { BrowserWindow, Tray, WebContents, screen, App } from 'electron';
import { BrowserWindow, Tray, WebContents, screen } from 'electron';
import bridge from './bridge';
const url = require('url');
const path = require('path');
@@ -20,9 +19,6 @@ import handleCustomProtocols, { CustomProtocolHandler } from './utils/customProt
import { clearTimeout, setTimeout } from 'timers';
import { resolve } from 'path';
import { defaultWindowId } from '@joplin/lib/reducer';
import { msleep, Second } from '@joplin/utils/time';
import determineBaseAppDirs from '@joplin/lib/determineBaseAppDirs';
import getAppName from '@joplin/lib/getAppName';
interface RendererProcessQuitReply {
canClose: boolean;
@@ -38,21 +34,13 @@ interface SecondaryWindowData {
electronId: number;
}
export interface Options {
env: string;
profilePath: string|null;
isDebugMode: boolean;
isEndToEndTesting: boolean;
initialCallbackUrl: string;
}
export default class ElectronAppWrapper {
private logger_: Logger = null;
private electronApp_: App;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private electronApp_: any;
private env_: string;
private isDebugMode_: boolean;
private profilePath_: string;
private isEndToEndTesting_: boolean;
private win_: BrowserWindow = null;
private mainWindowHidden_ = true;
@@ -70,29 +58,13 @@ export default class ElectronAppWrapper {
private customProtocolHandler_: CustomProtocolHandler = null;
private updatePollInterval_: ReturnType<typeof setTimeout>|null = null;
private profileLocker_: FileLocker|null = null;
private ipcServer_: IpcServer|null = null;
private ipcStartPort_ = 2658;
private ipcLogger_: Logger;
public constructor(electronApp: App, { env, profilePath, isDebugMode, initialCallbackUrl, isEndToEndTesting }: Options) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public constructor(electronApp: any, env: string, profilePath: string|null, isDebugMode: boolean, initialCallbackUrl: string) {
this.electronApp_ = electronApp;
this.env_ = env;
this.isDebugMode_ = isDebugMode;
this.profilePath_ = profilePath;
this.initialCallbackUrl_ = initialCallbackUrl;
this.isEndToEndTesting_ = isEndToEndTesting;
this.profileLocker_ = new FileLocker(`${this.profilePath_}/lock`);
// Note: in certain contexts `this.logger_` doesn't seem to be available, especially for IPC
// calls, either because it hasn't been set or other issue. So we set one here specifically
// for this.
this.ipcLogger_ = new Logger();
this.ipcLogger_.addTarget(TargetType.File, {
path: `${profilePath}/log-cross-app-ipc.txt`,
});
}
public electronApp() {
@@ -438,7 +410,7 @@ export default class ElectronAppWrapper {
if (message.target === 'plugin') {
const win = this.pluginWindows_[message.pluginId];
if (!win) {
this.ipcLogger_.error(`Trying to send IPC message to non-existing plugin window: ${message.pluginId}`);
this.logger().error(`Trying to send IPC message to non-existing plugin window: ${message.pluginId}`);
return;
}
@@ -493,24 +465,12 @@ export default class ElectronAppWrapper {
});
}
private onExit() {
this.stopPeriodicUpdateCheck();
this.profileLocker_.unlockSync();
// Probably doesn't matter if the server is not closed cleanly? Thus the lack of `await`
// eslint-disable-next-line promise/prefer-await-to-then -- Needed here because onExit() is not async
void stopServer(this.ipcServer_).catch(_error => {
// Ignore it since we're stopping, and to prevent unnecessary messages.
});
}
public quit() {
this.onExit();
this.stopPeriodicUpdateCheck();
this.electronApp_.quit();
}
public exit(errorCode = 0) {
this.onExit();
this.electronApp_.exit(errorCode);
}
@@ -576,32 +536,20 @@ export default class ElectronAppWrapper {
this.tray_ = null;
}
public async sendCrossAppIpcMessage(message: Message, port: number|null = null, options: SendMessageOptions = null) {
this.ipcLogger_.info('Sending message:', message);
public ensureSingleInstance() {
if (this.env_ === 'dev') return false;
if (port === null) port = this.ipcStartPort_;
const gotTheLock = this.electronApp_.requestSingleInstanceLock();
return await sendMessage(port, {
...message,
sourcePort: this.ipcServer_.port,
secretKey: this.ipcServer_.secretKey,
}, {
logger: this.ipcLogger_,
...options,
});
}
public async ensureSingleInstance() {
// When end-to-end testing, multiple instances of Joplin are intentionally created at the same time,
// or very close to one another. The single instance handling logic can interfere with this, so disable it.
if (this.isEndToEndTesting_) return false;
interface OnSecondInstanceMessageData {
profilePath: string;
argv: string[];
if (!gotTheLock) {
// Another instance is already running - exit
this.quit();
return true;
}
const activateWindow = (argv: string[]) => {
// Someone tried to open a second instance - focus our window instead
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
this.electronApp_.on('second-instance', (_e: any, argv: string[]) => {
const win = this.mainWindow();
if (!win) return;
if (win.isMinimized()) win.restore();
@@ -614,96 +562,9 @@ export default class ElectronAppWrapper {
void this.openCallbackUrl(url);
}
}
};
const messageHandlers: Record<string, IpcMessageHandler> = {
'onSecondInstance': async (message) => {
const data = message.data as OnSecondInstanceMessageData;
if (data.profilePath === this.profilePath_) activateWindow(data.argv);
},
'restartAltInstance': async (message) => {
if (bridge().altInstanceId()) return false;
// We do this in a timeout after a short interval because we need this call to
// return the response immediately, so that the caller can call `quit()`
setTimeout(async () => {
const maxWait = 10000;
const interval = 300;
const loopCount = Math.ceil(maxWait / interval);
let callingAppGone = false;
for (let i = 0; i < loopCount; i++) {
const response = await this.sendCrossAppIpcMessage({
action: 'ping',
data: null,
secretKey: this.ipcServer_.secretKey,
}, message.sourcePort, {
sendToSpecificPortOnly: true,
});
if (!response.length) {
callingAppGone = true;
break;
}
await msleep(interval);
}
if (callingAppGone) {
// Wait a bit more because even if the app is not responding, the process
// might still be there for a short while.
await msleep(1000);
this.ipcLogger_.warn('restartAltInstance: App is gone - restarting it');
void bridge().launchNewAppInstance(this.env());
} else {
this.ipcLogger_.warn('restartAltInstance: Could not restart calling app because it was still open');
}
}, 100);
return true;
},
'ping': async (_message) => {
return true;
},
};
const defaultProfileDir = determineBaseAppDirs('', getAppName(true, this.env() === 'dev'), '').rootProfileDir;
const secretKeyFilePath = `${defaultProfileDir}/ipc_secret_key.txt`;
this.ipcLogger_.info('Starting server using secret key:', secretKeyFilePath);
this.ipcServer_ = await startServer(this.ipcStartPort_, secretKeyFilePath, async (message) => {
if (messageHandlers[message.action]) {
this.ipcLogger_.info('Got message:', message);
return messageHandlers[message.action](message);
}
throw newHttpError(404);
}, {
logger: this.ipcLogger_,
});
// First check that no other app is running from that profile folder
const gotAppLock = await this.profileLocker_.lock();
if (gotAppLock) return false;
const message: Message = {
action: 'onSecondInstance',
data: {
senderPort: this.ipcServer_.port,
profilePath: this.profilePath_,
argv: process.argv,
},
secretKey: this.ipcServer_.secretKey,
};
await this.sendCrossAppIpcMessage(message);
this.quit();
if (this.env() === 'dev') console.warn(`Closing the application because another instance is already running, or the previous instance was force-quit within the last ${Math.round(this.profileLocker_.options.interval / Second)} seconds.`);
return true;
return false;
}
public initializeCustomProtocolHandler(logger: LoggerWrapper) {
@@ -745,7 +606,7 @@ export default class ElectronAppWrapper {
// the "ready" event. So we use the function below to make sure that the app is ready.
await this.waitForElectronAppReady();
const alreadyRunning = await this.ensureSingleInstance();
const alreadyRunning = this.ensureSingleInstance();
if (alreadyRunning) return;
this.createWindow();

View File

@@ -617,11 +617,10 @@ class Application extends BaseApplication {
clipperLogger.addTarget(TargetType.Console);
ClipperServer.instance().initialize(actionApi);
ClipperServer.instance().setEnabled(!Setting.value('altInstanceId'));
ClipperServer.instance().setLogger(clipperLogger);
ClipperServer.instance().setDispatch(this.store().dispatch);
if (ClipperServer.instance().enabled() && Setting.value('clipperServer.autoStart')) {
if (Setting.value('clipperServer.autoStart')) {
void ClipperServer.instance().start();
}

View File

@@ -15,7 +15,6 @@ import isSafeToOpen from './utils/isSafeToOpen';
import { closeSync, openSync, readSync, statSync } from 'fs';
import { KB } from '@joplin/utils/bytes';
import { defaultWindowId } from '@joplin/lib/reducer';
import { execCommand } from '@joplin/utils';
interface LastSelectedPath {
file: string;
@@ -44,18 +43,16 @@ export class Bridge {
private appName_: string;
private appId_: string;
private logFilePath_ = '';
private altInstanceId_ = '';
private extraAllowedExtensions_: string[] = [];
private onAllowedExtensionsChangeListener_: OnAllowedExtensionsChange = ()=>{};
public constructor(electronWrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean, altInstanceId: string) {
public constructor(electronWrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean) {
this.electronWrapper_ = electronWrapper;
this.appId_ = appId;
this.appName_ = appName;
this.rootProfileDir_ = rootProfileDir;
this.autoUploadCrashDumps_ = autoUploadCrashDumps;
this.altInstanceId_ = altInstanceId;
this.lastSelectedPaths_ = {
file: null,
directory: null,
@@ -221,10 +218,6 @@ export class Bridge {
return this.electronApp().electronApp().getLocale();
};
public altInstanceId() {
return this.altInstanceId_;
}
// Applies to electron-context-menu@3:
//
// For now we have to disable spell checking in non-editor text
@@ -498,38 +491,7 @@ export class Bridge {
}
}
public appLaunchCommand(env: string, altInstanceId = '') {
const altInstanceArgs = altInstanceId ? ['--alt-instance-id', altInstanceId] : [];
if (env === 'dev') {
// This is convenient to quickly test on dev, but the path needs to be adjusted
// depending on how things are setup.
return {
execPath: `${homedir()}/.npm-global/bin/electron`,
args: [
`${homedir()}/src/joplin/packages/app-desktop`,
'--env', 'dev',
'--log-level', 'debug',
'--open-dev-tools',
'--no-welcome',
].concat(altInstanceArgs),
};
} else {
return {
execPath: bridge().electronApp().electronApp().getPath('exe'),
args: [].concat(altInstanceArgs),
};
}
}
public async launchNewAppInstance(env: string) {
const cmd = this.appLaunchCommand(env, 'alt1');
await execCommand([cmd.execPath].concat(cmd.args), { detached: true });
}
public async restart() {
public restart(linuxSafeRestart = true) {
// Note that in this case we are not sending the "appClose" event
// to notify services and component that the app is about to close
// but for the current use-case it's not really needed.
@@ -540,39 +502,13 @@ export class Bridge {
execPath: process.env.PORTABLE_EXECUTABLE_FILE,
};
app.relaunch(options);
} else if (this.altInstanceId_) {
// Couldn't get it to work using relaunch() - it would just "close" the app, but it
// would still be open in the tray except unusable. Or maybe it reopens it quickly but
// in a broken state. It might be due to the way it is launched from the main instance.
// So here we ask the main instance to relaunch this app after a short delay.
const responses = await this.electronApp().sendCrossAppIpcMessage({
action: 'restartAltInstance',
data: null,
});
// However is the main instance is not running, we're stuck, so the user needs to
// manually restart. `relaunch()` doesn't appear to work even when the main instance is
// not running.
const r = responses.find(r => !!r.response);
if (!r || !r.response) {
this.showInfoMessageBox(_('The app is now going to close. Please relaunch it to complete the process.'));
// Note: this should work, but doesn't:
// const cmd = this.appLaunchCommand(this.env(), this.altInstanceId_);
// app.relaunch({
// execPath: cmd.execPath,
// args: cmd.args,
// });
}
} else if (shim.isLinux() && linuxSafeRestart) {
this.showInfoMessageBox(_('The app is now going to close. Please relaunch it to complete the process.'));
} else {
app.relaunch();
}
this.electronApp().exit();
app.exit();
}
public createImageFromPath(path: string) {
@@ -598,9 +534,9 @@ export class Bridge {
let bridge_: Bridge = null;
export function initBridge(wrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean, altInstanceId: string) {
export function initBridge(wrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean) {
if (bridge_) throw new Error('Bridge already initialized');
bridge_ = new Bridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps, altInstanceId);
bridge_ = new Bridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps);
return bridge_;
}

View File

@@ -6,7 +6,6 @@ import * as exportDeletionLog from './exportDeletionLog';
import * as exportFolders from './exportFolders';
import * as exportNotes from './exportNotes';
import * as focusElement from './focusElement';
import * as newAppInstance from './newAppInstance';
import * as openNoteInNewWindow from './openNoteInNewWindow';
import * as openProfileDirectory from './openProfileDirectory';
import * as replaceMisspelling from './replaceMisspelling';
@@ -29,7 +28,6 @@ const index: any[] = [
exportFolders,
exportNotes,
focusElement,
newAppInstance,
openNoteInNewWindow,
openProfileDirectory,
replaceMisspelling,

View File

@@ -1,19 +0,0 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import bridge from '../services/bridge';
import Setting from '@joplin/lib/models/Setting';
export const declaration: CommandDeclaration = {
name: 'newAppInstance',
label: () => _('New application instance...'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext) => {
await bridge().launchNewAppInstance(Setting.value('env'));
},
enabledCondition: '!isAltInstance',
};
};

View File

@@ -24,7 +24,6 @@ class ClipperConfigScreenComponent extends React.Component {
}
private enableClipperServer_click() {
if (!ClipperServer.instance().enabled()) return;
Setting.setValue('clipperServer.autoStart', true);
void ClipperServer.instance().start();
}
@@ -71,8 +70,6 @@ class ClipperConfigScreenComponent extends React.Component {
const webClipperStatusComps = [];
const clipperEnabled = ClipperServer.instance().enabled();
if (this.props.clipperServerAutoStart) {
webClipperStatusComps.push(
<p key="text_1" style={theme.textStyle}>
@@ -98,22 +95,13 @@ class ClipperConfigScreenComponent extends React.Component {
</button>,
);
} else {
if (!clipperEnabled) {
webClipperStatusComps.push(
<p key="text_4" style={theme.textStyle}>
{_('The web clipper service cannot be enabled in this instance of Joplin.')}
</p>,
);
} else {
webClipperStatusComps.push(
<p key="text_4" style={theme.textStyle}>
{_('The web clipper service is not enabled.')}
</p>,
);
}
webClipperStatusComps.push(
<button key="enable_button" style={buttonStyle} onClick={this.enableClipperServer_click} disabled={!clipperEnabled}>
<p key="text_4" style={theme.textStyle}>
{_('The web clipper service is not enabled.')}
</p>,
);
webClipperStatusComps.push(
<button key="enable_button" style={buttonStyle} onClick={this.enableClipperServer_click}>
{_('Enable Web Clipper Service')}
</button>,
);

View File

@@ -83,7 +83,6 @@ interface Props {
notesColumns: NoteListColumns;
showInvalidJoplinCloudCredential: boolean;
toast: Toast;
shouldSwitchToAppleSiliconVersion: boolean;
}
interface ShareFolderDialogOptions {
@@ -479,10 +478,6 @@ class MainScreenComponent extends React.Component<Props, State> {
});
};
const onDisableSync = () => {
Setting.setValue('sync.target', null);
};
const onViewSyncSettingsScreen = () => {
this.props.dispatch({
type: 'NAV_GO',
@@ -493,11 +488,6 @@ class MainScreenComponent extends React.Component<Props, State> {
});
};
const onDownloadAppleSiliconVersion = () => {
// The website should redirect to the correct version
shim.openUrl('https://joplinapp.org/download/');
};
const onRestartAndUpgrade = async () => {
Setting.setValue('sync.upgradeState', Setting.SYNC_UPGRADE_STATE_MUST_DO);
await Setting.saveAll();
@@ -580,19 +570,11 @@ class MainScreenComponent extends React.Component<Props, State> {
);
} else if (this.props.mustUpgradeAppMessage) {
msg = this.renderNotificationMessage(this.props.mustUpgradeAppMessage);
} else if (this.props.shouldSwitchToAppleSiliconVersion) {
msg = this.renderNotificationMessage(
_('You are running the Intel version of Joplin on an Apple Silicon processor. Download the Apple Silicon one for better performance.'),
_('Download it now'),
onDownloadAppleSiliconVersion,
);
} else if (this.props.showInvalidJoplinCloudCredential) {
msg = this.renderNotificationMessage(
_('Your Joplin Cloud credentials are invalid, please login.'),
_('Login to Joplin Cloud.'),
onViewJoplinCloudLoginScreen,
_('Disable synchronisation'),
onDisableSync,
);
}
@@ -623,8 +605,7 @@ class MainScreenComponent extends React.Component<Props, State> {
this.showShareInvitationNotification(props) ||
this.props.needApiAuth ||
!!this.props.mustUpgradeAppMessage ||
props.showInvalidJoplinCloudCredential ||
props.shouldSwitchToAppleSiliconVersion;
props.showInvalidJoplinCloudCredential;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -852,7 +833,6 @@ const mapStateToProps = (state: AppState) => {
notesColumns: validateColumns(state.settings['notes.columns']),
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
toast: state.toast,
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && process.arch !== 'arm64',
};
};

View File

@@ -172,7 +172,6 @@ interface Props {
pluginMenus: any[];
['spellChecker.enabled']: boolean;
['spellChecker.languages']: string[];
markdownEditorVisible: boolean;
plugins: PluginStates;
customCss: string;
locale: string;
@@ -279,7 +278,6 @@ function useMenuStates(menu: any, props: Props) {
props['notes.sortOrder.reverse'],
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
props['folders.sortOrder.reverse'],
props.markdownEditorVisible,
props.tabMovesFocus,
props.noteListRendererId,
props.showNoteCounts,
@@ -481,7 +479,6 @@ function useMenu(props: Props) {
menuItemDic.focusElementNoteList,
menuItemDic.focusElementNoteTitle,
menuItemDic.focusElementNoteBody,
menuItemDic.focusElementNoteViewer,
menuItemDic.focusElementToolbar,
];
@@ -555,7 +552,6 @@ function useMenu(props: Props) {
const newFolderItem = menuItemDic.newFolder;
const newSubFolderItem = menuItemDic.newSubFolder;
const printItem = menuItemDic.print;
const newAppInstance = menuItemDic.newAppInstance;
const switchProfileItem = {
label: _('Switch profile'),
submenu: switchProfileMenuItems,
@@ -719,11 +715,8 @@ function useMenu(props: Props) {
}, {
type: 'separator',
},
printItem, {
type: 'separator',
},
printItem,
switchProfileItem,
newAppInstance,
],
};
@@ -796,7 +789,6 @@ function useMenu(props: Props) {
shim.isMac() ? noItem : menuItemDic.toggleMenuBar,
menuItemDic.toggleNoteList,
menuItemDic.toggleVisiblePanes,
menuItemDic.toggleEditorPlugin,
{
label: _('Layout button sequence'),
submenu: layoutButtonSequenceMenuItems,
@@ -1147,7 +1139,7 @@ function MenuBar(props: Props): any {
const mapStateToProps = (state: AppState): Partial<Props> => {
const whenClauseContext = stateToWhenClauseContext(state, { windowId: state.windowId });
const whenClauseContext = stateToWhenClauseContext(state);
const secondaryWindowFocused = state.windowId !== defaultWindowId;
@@ -1173,7 +1165,6 @@ const mapStateToProps = (state: AppState): Partial<Props> => {
pluginMenus: stateUtils.selectArrayShallow({ array: pluginUtils.viewsByType(state.pluginService.plugins, 'menu') }, 'menuBar.pluginMenus'),
['spellChecker.languages']: state.settings['spellChecker.languages'],
['spellChecker.enabled']: state.settings['spellChecker.enabled'],
markdownEditorVisible: whenClauseContext.markdownEditorVisible,
plugins: state.pluginService.plugins,
customCss: state.customViewerCss,
profileConfig: state.profileConfig,

View File

@@ -129,14 +129,6 @@ const useEditorCommands = (props: Props) => {
props.webviewRef.current.send('focus');
}
},
'viewer.focus': () => {
if (props.visiblePanes.includes('viewer')) {
const editorCursorLine = editorRef.current.getCursor().line;
props.webviewRef.current.focusLine(editorCursorLine);
} else {
logger.info('Viewer not focused (not visible).');
}
},
search: () => {
return editorRef.current.execCommand(EditorCommandType.ShowSearch);
},

View File

@@ -738,24 +738,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
code: { inline: 'code', remove: 'all', attributes: { spellcheck: 'false' } },
forecolor: { inline: 'span', styles: { color: '%value' } },
},
text_patterns: props.enableTextPatterns ? [
// See https://www.tiny.cloud/docs/tinymce/latest/content-behavior-options/#text_patterns
// for the default value
{ start: '==', end: '==', format: 'joplinHighlight' },
{ start: '`', end: '`', format: 'code' },
{ start: '*', end: '*', format: 'italic' },
{ start: '**', end: '**', format: 'bold' },
{ start: '#', format: 'h1' },
{ start: '##', format: 'h2' },
{ start: '###', format: 'h3' },
{ start: '####', format: 'h4' },
{ start: '#####', format: 'h5' },
{ start: '######', format: 'h6' },
{ start: '1.', cmd: 'InsertOrderedList' },
{ start: '*', cmd: 'InsertUnorderedList' },
{ start: '-', cmd: 'InsertUnorderedList' },
] : [],
setup: (editor: Editor) => {
editor.addCommand('joplinAttach', () => {
insertResourcesIntoContentRef.current();
@@ -1123,13 +1105,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
};
}, [editor]);
useEffect(() => {
if (!editor) return;
// Meta+P is bound by default to print by TinyMCE. It can be unbound, but it seems necessary
// to do so after the editor loads. Meta+P should be able to trigger Joplin built-in shortcuts.
editor.shortcuts.remove('Meta+P');
}, [editor]);
// -----------------------------------------------------------------------------------------
// Handle onChange event
// -----------------------------------------------------------------------------------------

View File

@@ -451,7 +451,6 @@ function NoteEditorContent(props: NoteEditorProps) {
searchMarkers: searchMarkers,
visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'],
keyboardMode: Setting.value('editor.keyboardMode'),
enableTextPatterns: Setting.value('editor.enableTextPatterns'),
tabMovesFocus: props.tabMovesFocus,
locale: Setting.value('locale'),
onDrop: onDrop,

View File

@@ -1,22 +0,0 @@
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { FocusElementOptions } from '../../../commands/focusElement';
import { WindowCommandDependencies } from '../utils/types';
export const declaration: CommandDeclaration = {
name: 'focusElementNoteViewer',
label: () => _('Note viewer'),
parentLabel: () => _('Focus'),
};
export const runtime = (dependencies: WindowCommandDependencies): CommandRuntime => {
return {
execute: async (_context: unknown, options?: FocusElementOptions) => {
await dependencies.editorRef.current.execCommand({
name: 'viewer.focus',
value: options,
});
},
enabledCondition: 'markdownEditorVisible',
};
};

View File

@@ -1,7 +1,6 @@
// AUTO-GENERATED using `gulp buildScriptIndexes`
import * as focusElementNoteBody from './focusElementNoteBody';
import * as focusElementNoteTitle from './focusElementNoteTitle';
import * as focusElementNoteViewer from './focusElementNoteViewer';
import * as focusElementToolbar from './focusElementToolbar';
import * as pasteAsText from './pasteAsText';
import * as showLocalSearch from './showLocalSearch';
@@ -10,7 +9,6 @@ import * as showRevisions from './showRevisions';
const index: any[] = [
focusElementNoteBody,
focusElementNoteTitle,
focusElementNoteViewer,
focusElementToolbar,
pasteAsText,
showLocalSearch,

View File

@@ -163,9 +163,6 @@ const declarations: CommandDeclaration[] = [
{
name: 'editor.execCommand',
},
{
name: 'viewer.focus',
},
];
export default declarations;

View File

@@ -124,7 +124,6 @@ export interface NoteBodyEditorProps {
visiblePanes: string[];
keyboardMode: string;
tabMovesFocus: boolean;
enableTextPatterns: boolean;
resourceInfos: ResourceInfos;
resourceDirectory: string;
locale: string;

View File

@@ -97,15 +97,7 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
await initNoteState(n, false);
if (event.cancelled) return;
setFormNoteRefreshScheduled(oldValue => {
// If a new refresh was scheduled between initNoteState
// and now:
if (oldValue !== formNoteRefreshScheduled) {
return oldValue;
}
// A refresh is no longer scheduled
return 0;
});
setFormNoteRefreshScheduled(0);
};
await loadNote();

View File

@@ -10,7 +10,6 @@ const commandsWithDependencies = [
require('../commands/showLocalSearch'),
require('../commands/focusElementNoteTitle'),
require('../commands/focusElementNoteBody'),
require('../commands/focusElementNoteViewer'),
require('../commands/focusElementToolbar'),
require('../commands/pasteAsText'),
];

View File

@@ -28,7 +28,6 @@ export interface NoteViewerControl {
domReady(): boolean;
setHtml(html: string, options: SetHtmlOptions): void;
send(channel: string, arg0?: unknown, arg1?: unknown): void;
focusLine(editorLine: number): void;
focus(): void;
hasFocus(): boolean;
}
@@ -108,10 +107,6 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
win.postMessage({ target: 'webview', name: 'focus', data: {} }, '*');
}
if (channel === 'focusLine') {
win.postMessage({ target: 'webview', name: 'focusLine', data: { line: arg0 } }, '*');
}
// External code should use .setHtml (rather than send('setHtml', ...))
if (channel === 'setHtml') {
win.postMessage({ target: 'webview', name: 'setHtml', data: { html: arg0, options: arg1 } }, '*');
@@ -144,15 +139,6 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
hasFocus: () => {
return webviewRef.current?.contains(parentDoc.activeElement);
},
focusLine: (lineNumber: number) => {
if (webviewRef.current) {
focus('NoteTextViewer::focusLine', webviewRef.current);
// A timeout seems necessary after focusing the viewer to prevent focus from jumping to the top
setTimeout(() => {
result.send('focusLine', lineNumber);
}, 100);
}
},
};
return result;
}, [parentDoc]);

View File

@@ -22,14 +22,13 @@ interface CollapseExpandAllButtonProps {
}
const CollapseExpandAllButton = (props: CollapseExpandAllButtonProps) => {
// To allow it to be accessed by accessibility tools, the toggle button
// To allow it to be accessed by accessibility tools, the new folder button
// is not included in the portion of the list with role='tree'.
const icon = props.allFoldersCollapsed ? 'far fa-caret-square-right' : 'far fa-caret-square-down';
const label = props.allFoldersCollapsed ? _('Expand all notebooks') : _('Collapse all notebooks');
return <button onClick={() => onToggleAllFolders(props.allFoldersCollapsed)} className='sidebar-header-button -collapseall'>
<i
aria-label={label}
aria-label={_('Collapse / Expand all notebooks')}
role='img'
className={icon}
/>

View File

@@ -6,9 +6,7 @@ interface Props {
}
const EmptyExpandLink: React.FC<Props> = props => {
return <a className={`sidebar-expand-link ${props.className ?? ''}`}>
<ExpandIcon isVisible={false} isExpanded={false}/>
</a>;
return <a className={`sidebar-expand-link ${props.className ?? ''}`}><ExpandIcon isVisible={false} isExpanded={false}/></a>;
};
export default EmptyExpandLink;

View File

@@ -1,10 +1,15 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
interface ExpandIconProps {
type ExpandIconProps = {
isExpanded: boolean;
isVisible: boolean;
}
isVisible: true;
targetTitle: string;
}|{
isExpanded: boolean;
isVisible: false;
targetTitle?: string;
};
const ExpandIcon: React.FC<ExpandIconProps> = props => {
const classNames = ['sidebar-expand-icon'];
@@ -18,17 +23,12 @@ const ExpandIcon: React.FC<ExpandIconProps> = props => {
return undefined;
}
if (props.isExpanded) {
return _('Expanded');
return _('Expanded, press space to collapse.');
}
return _('Collapsed');
return _('Collapsed, press space to expand.');
};
const label = getLabel();
return <i
className={classNames.join(' ')}
aria-hidden={!props.isVisible}
aria-label={label}
role='img'
></i>;
return <i className={classNames.join(' ')} aria-label={label} role='img'></i>;
};
export default ExpandIcon;

View File

@@ -5,6 +5,7 @@ import EmptyExpandLink from './EmptyExpandLink';
interface ExpandLinkProps {
folderId: string;
folderTitle: string;
hasChildren: boolean;
isExpanded: boolean;
className: string;
@@ -14,7 +15,7 @@ interface ExpandLinkProps {
const ExpandLink: React.FC<ExpandLinkProps> = props => {
return props.hasChildren ? (
<a className={`sidebar-expand-link ${props.className}`} data-folder-id={props.folderId} onClick={props.onClick} role='button'>
<ExpandIcon isVisible={true} isExpanded={props.isExpanded} />
<ExpandIcon isVisible={true} isExpanded={props.isExpanded} targetTitle={props.folderTitle}/>
</a>
) : (
<EmptyExpandLink className={props.className}/>

View File

@@ -11,7 +11,6 @@ import { ModelType } from '@joplin/lib/BaseModel';
import { _ } from '@joplin/lib/locale';
import NoteCount from './NoteCount';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
import { useId } from 'react';
const renderFolderIcon = (folderIcon: FolderIcon) => {
if (!folderIcon) {
@@ -66,7 +65,6 @@ function FolderItem(props: FolderItemProps) {
if (!showFolderIcon) return null;
return renderFolderIcon(folderIcon);
};
const titleId = useId();
return (
<ListItemWrapper
@@ -87,13 +85,9 @@ function FolderItem(props: FolderItemProps) {
data-folder-id={folderId}
data-id={folderId}
data-type={ModelType.Folder}
// Accessibility labels: Don't include the expand/collapse link in the description,
// since this information is already conveyed by aria-* props.
aria-labelledby={titleId}
>
<StyledListItemAnchor
className="list-item"
id={titleId}
isConflictFolder={folderId === Folder.conflictFolderId()}
selected={selected}
shareId={shareId}
@@ -112,6 +106,7 @@ function FolderItem(props: FolderItemProps) {
// title first.
className='toggle'
hasChildren={hasChildren}
folderTitle={folderTitle}
folderId={folderId}
onClick={onFolderToggleClick_}
isExpanded={isExpanded}

View File

@@ -23,9 +23,7 @@ interface Props {
draggable?: boolean;
'data-folder-id'?: string;
'data-id'?: string;
'data-tag-id'?: string;
'data-type'?: ModelType;
'aria-labelledby'?: string;
}
const ListItemWrapper: React.FC<Props> = props => {
@@ -57,9 +55,7 @@ const ListItemWrapper: React.FC<Props> = props => {
style={style}
data-folder-id={props['data-folder-id']}
data-id={props['data-id']}
data-tag-id={props['data-tag-id']}
data-type={props['data-type']}
aria-labelledby={props['aria-labelledby']}
>
{props.children}
</div>

View File

@@ -3,7 +3,6 @@ import { _ } from '@joplin/lib/locale';
import setLayoutItemProps from '../../ResizableLayout/utils/setLayoutItemProps';
import layoutItemProp from '../../ResizableLayout/utils/layoutItemProp';
import { AppState } from '../../../app.reducer';
import { WindowControl } from '../utils/useWindowControl';
export const declaration: CommandDeclaration = {
name: 'toggleNoteList',
@@ -11,16 +10,14 @@ export const declaration: CommandDeclaration = {
iconName: 'fas fa-align-justify',
};
export const runtime = (control: WindowControl): CommandRuntime => {
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext) => {
const layout = (context.state as AppState).mainLayout;
const visible = !layoutItemProp(layout, 'noteList', 'visible');
const newLayout = setLayoutItemProps(layout, 'noteList', {
visible,
visible: !layoutItemProp(layout, 'noteList', 'visible'),
});
control.announcePanelVisibility(_('Note list'), visible);
// Toggling the sidebar will affect the size of most other on-screen components.
// Dispatching a window resize event is a bit of a hack, but it ensures that any

View File

@@ -3,7 +3,6 @@ import { _ } from '@joplin/lib/locale';
import setLayoutItemProps from '../../ResizableLayout/utils/setLayoutItemProps';
import layoutItemProp from '../../ResizableLayout/utils/layoutItemProp';
import { AppState } from '../../../app.reducer';
import { WindowControl } from '../utils/useWindowControl';
export const declaration: CommandDeclaration = {
name: 'toggleSideBar',
@@ -11,16 +10,14 @@ export const declaration: CommandDeclaration = {
iconName: 'fas fa-bars',
};
export const runtime = (control: WindowControl): CommandRuntime => {
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext) => {
const layout = (context.state as AppState).mainLayout;
const visible = !layoutItemProp(layout, 'sideBar', 'visible');
const newLayout = setLayoutItemProps(layout, 'sideBar', {
visible,
visible: !layoutItemProp(layout, 'sideBar', 'visible'),
});
control.announcePanelVisibility(_('Sidebar'), visible);
// Toggling the sidebar will affect the size of most other on-screen components.
// Dispatching a window resize event is a bit of a hack, but it ensures that any

View File

@@ -2,13 +2,10 @@ import * as React from 'react';
import { useMemo, useRef } from 'react';
import { DialogState } from '../types';
import { PrintCallback } from './usePrintToCallback';
import { _ } from '@joplin/lib/locale';
import announceForAccessibility from '../../utils/announceForAccessibility';
export interface WindowControl {
setState: (update: Partial<DialogState>)=> void;
printTo: PrintCallback;
announcePanelVisibility(panelName: string, visible: boolean): void;
}
export type OnSetDialogState = React.Dispatch<React.SetStateAction<DialogState>>;
@@ -27,11 +24,6 @@ const useWindowControl = (setDialogState: OnSetDialogState, onPrint: PrintCallba
}));
},
printTo: (target, options) => onPrintRef.current(target, options),
announcePanelVisibility: (panelName, visible) => {
announceForAccessibility(
visible ? _('Panel "%s" is visible', panelName) : _('Panel %s is hidden', panelName),
);
},
};
}, [setDialogState]);
};

View File

@@ -4,7 +4,6 @@ export default function() {
'copyDevCommand',
'exportPdf',
'focusElementNoteBody',
'focusElementNoteViewer',
'focusElementNoteList',
'focusElementNoteTitle',
'focusElementSideBar',
@@ -44,11 +43,9 @@ export default function() {
'togglePerFolderSortOrder',
'toggleSideBar',
'toggleVisiblePanes',
'toggleEditorPlugin',
'toggleTabMovesFocus',
'editor.deleteLine',
'editor.duplicateLine',
'newAppInstance',
// We cannot put the undo/redo commands in the menu because they are
// editor-specific commands. If we put them there it will break the
// undo/redo in regular text fields.

View File

@@ -139,8 +139,11 @@
const viewerPercent = scrollmap.translateL2V(percent);
const newScrollTop = viewerPercent * maxScrollTop();
// The next scroll event cannot be skipped in order to correctly
// scroll to the target section in a different note when follwing a link
// Even if the scroll position hasn't changed (percent is the same),
// we still ignore the next scroll event, so that it doesn't create
// undesired side effects.
// https://github.com/laurent22/joplin/issues/7617
ignoreNextScrollEvent();
if (Math.floor(contentElement.scrollTop) !== Math.floor(newScrollTop)) {
percentScroll_ = percent;
@@ -374,53 +377,6 @@
contentElement.scrollTop = scrollTop;
}
const getLineCorrespondingTo = (editorLineNumber) => {
const lineElements = document.getElementsByClassName('maps-to-line');
let lastLineElement;
let lastLine = 0;
for (const element of lineElements) {
// Stop just before the element that corresponds to a greater position
if (Number(element.getAttribute('source-line')) > editorLineNumber) {
break;
}
lastLineElement = element;
}
return lastLineElement;
};
const makeTemporarilyFocusable = (element) => {
const dataOriginalTabIndexAttr = 'data-original-tabindex';
const originalTabIndex = (
element.getAttribute(dataOriginalTabIndexAttr) ?? element.getAttribute('tabindex')
);
element.setAttribute(dataOriginalTabIndexAttr, originalTabIndex);
element.setAttribute('tabindex', '0');
return {
reset: () => {
element.setAttribute('tabindex', originalTabIndex);
element.removeAttribute(dataOriginalTabIndexAttr);
},
};
};
ipc.focusLine = (event) => {
const targetLine = event.line;
const lineElement = getLineCorrespondingTo(targetLine);
if (lineElement) {
// To allow focusing, the element needs to briefly have tabindex=0.
const { reset } = makeTemporarilyFocusable(lineElement);
lineElement.focus({ preventScroll: true });
// Reset the tabindex after the browser has had time to focus the element.
// When a screen reader is enabled, focus stays on the lineElement.
setTimeout(() => {
reset();
}, 50);
}
}
const rewriteFileUrls = (accessKey) => {
if (!accessKey) return;

View File

@@ -1,9 +1,7 @@
# Integration tests
The integration tests in this directory can be run with `yarn test-ui`.
The integration tests in this directory can be run with `yarn playwright test`.
- To run all tests from a specific file, use `yarn test-ui testFileName`. For example, `yarn test-ui wcag` to run the tests in `wcag.ts`.
- To run all tests matching a pattern, use `yarn test-ui -g "pattern here"`, where `-g` is short for "grep".
- Tests use a `test-profile` directory that should be re-created before every test.
- Only one Electron application should be instantiated per test file.
- Files in the `models/` directory follow [the page object model](https://playwright.dev/docs/pom).
@@ -17,13 +15,3 @@ with Playwright:
- [The Playwright ElectronApp docs](https://playwright.dev/docs/api/class-electronapplication)
- [Electron Playwright example repository](https://github.com/spaceagetv/electron-playwright-example)
- [Playwright best practices](https://playwright.dev/docs/best-practices)
- [Running and debugging tests from VSCode](https://playwright.dev/docs/getting-started-vscode#running-tests).
# FAQ
## How do I fix timeout-related test failures?
If Playwright tests are timing out, consider modifying `playwright.config.ts` in the `app-desktop` folder. For example, increase the `timeout` option to `120_000` (2 minutes).
Alternatively, try temporarily disabling `fullyParallel` (which disables running tests in parallel).

View File

@@ -116,10 +116,7 @@ test.describe('main', () => {
await editor.attachFileButton.click();
const viewerFrame = editor.getNoteViewerFrameLocator();
const renderedImage = viewerFrame
.getByAltText(filename)
// Work around occasional "resolved to 2 elements" errors in CI
.last();
const renderedImage = viewerFrame.getByAltText(filename);
const fullSize = await getImageSourceSize(renderedImage);

View File

@@ -230,28 +230,5 @@ test.describe('markdownEditor', () => {
// Editor should be focused
await expect(focusInMarkdownEditor).toBeAttached();
});
test('focusElementNoteViewer should move focus to the viewer', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
const noteEditor = mainScreen.noteEditor;
await mainScreen.createNewNote('Note');
await noteEditor.focusCodeMirrorEditor();
await mainWindow.keyboard.type('# Test');
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.type('Test paragraph.');
// Wait for rendering
await expect(noteEditor.getNoteViewerFrameLocator().getByText('Test paragraph.')).toBeAttached();
// Move focus
await mainScreen.goToAnything.runCommand(electronApp, 'focusElementNoteViewer');
// Note viewer should be focused
await expect(noteEditor.noteViewerContainer).toBeFocused();
});
});

View File

@@ -25,7 +25,6 @@ export default class NoteList {
public async focusContent(electronApp: ElectronApplication) {
await activateMainMenuItem(electronApp, 'Note list', 'Focus');
await expect(this.container.locator(':focus')).toBeAttached();
}
// The resultant locator may fail to resolve if the item is not visible

View File

@@ -7,7 +7,7 @@ export CI=true
if test "$RUNNER_OS" = "Linux" ; then
# The Ubuntu Github CI doesn't have a display server.
# Start a virtual one with xvfb-run.
xvfb-run -- yarn test-ui
xvfb-run -- yarn playwright test
else
yarn test-ui
yarn playwright test
fi

View File

@@ -1,13 +1,8 @@
import { dirname, resolve } from 'path';
const createStartupArgs = (profileDirectory: string) => {
// Input paths need to be absolute when running from VSCode
const baseDirectory = dirname(dirname(__dirname));
const mainPath = resolve(baseDirectory, 'main.js');
// We need to run with --env dev to disable the single instance check.
return [
mainPath, '--env', 'dev', '--no-welcome', '--running-tests', '--profile', resolve(profileDirectory),
'main.js', '--env', 'dev', '--no-welcome', '--profile', profileDirectory,
];
};

View File

@@ -1,6 +1,6 @@
import { resolve, join, dirname } from 'path';
import { remove, mkdirp, readFile, pathExists } from 'fs-extra';
import { _electron as electron, Page, ElectronApplication, test as base, TestInfo } from '@playwright/test';
import { remove, mkdirp } from 'fs-extra';
import { _electron as electron, Page, ElectronApplication, test as base } from '@playwright/test';
import uuid from '@joplin/lib/uuid';
import createStartupArgs from './createStartupArgs';
import firstNonDevToolsWindow from './firstNonDevToolsWindow';
@@ -20,7 +20,7 @@ type JoplinFixtures = {
// A custom fixture that loads an electron app. See
// https://playwright.dev/docs/test-fixtures
const initializeMainWindow = async (electronApp: ElectronApplication) => {
const getAndResizeMainWindow = async (electronApp: ElectronApplication) => {
const mainWindow = await firstNonDevToolsWindow(electronApp);
// Setting the viewport size helps keep test environments consistent.
@@ -48,18 +48,6 @@ const waitForStartupPlugins = async (electronApp: ElectronApplication) => {
await waitForMainMessage(electronApp, 'startup-plugins-loaded');
};
const attachJoplinLog = async (profileDirectory: string, testInfo: TestInfo) => {
const logFile = join(profileDirectory, 'log.txt');
if (await pathExists(logFile)) {
await testInfo.attach('log.txt', {
body: await readFile(logFile, 'utf8'),
contentType: 'text/plain',
});
} else {
console.warn('Missing log file');
}
};
const testDir = dirname(__dirname);
export const test = base.extend<JoplinFixtures>({
@@ -79,7 +67,7 @@ export const test = base.extend<JoplinFixtures>({
await remove(profileSubdir);
},
electronApp: async ({ profileDirectory }, use, testInfo) => {
electronApp: async ({ profileDirectory }, use) => {
const startupArgs = createStartupArgs(profileDirectory);
const electronApp = await electron.launch({ args: startupArgs });
const startupPromise = waitForAppLoaded(electronApp);
@@ -88,9 +76,6 @@ export const test = base.extend<JoplinFixtures>({
await use(electronApp);
// For debugging purposes, attach the Joplin log file to the test:
await attachJoplinLog(profileDirectory, testInfo);
await electronApp.firstWindow();
await electronApp.close();
},
@@ -111,7 +96,7 @@ export const test = base.extend<JoplinFixtures>({
],
});
const startupPromise = waitForAppLoaded(electronApp);
const mainWindowPromise = initializeMainWindow(electronApp);
const mainWindowPromise = getAndResizeMainWindow(electronApp);
await waitForStartupPlugins(electronApp);
await startupPromise;
@@ -132,7 +117,7 @@ export const test = base.extend<JoplinFixtures>({
},
mainWindow: async ({ electronApp }, use) => {
await use(await initializeMainWindow(electronApp));
await use(await getAndResizeMainWindow(electronApp));
},
});

View File

@@ -54,12 +54,6 @@ test.describe('wcag', () => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
// Ensure that there is at least one sub-folder in the sidebar
const folder1 = await mainScreen.sidebar.createNewFolder('Test folder 1');
const folder2 = await mainScreen.sidebar.createNewFolder('Test folder 2');
await folder2.dragTo(folder1);
await expect(folder2).toHaveJSProperty('ariaLevel', '3'); // Should be a sub-folder
await mainScreen.createNewNote('Test');
// Ensure that `:hover` styling is consistent between tests:

View File

@@ -29,6 +29,7 @@ const EncryptionService = require('@joplin/lib/services/e2ee/EncryptionService')
const FileApiDriverLocal = require('@joplin/lib/file-api-driver-local').default;
const React = require('react');
const nodeSqlite = require('sqlite3');
const nodeSqliteCipher = require('@journeyapps/sqlcipher');
const initLib = require('@joplin/lib/initLib').default;
const pdfJs = require('pdfjs-dist');
require('@sentry/electron/renderer');
@@ -109,6 +110,7 @@ const main = async () => {
appVersion,
electronBridge: bridge(),
nodeSqlite,
nodeSqliteCipher,
pdfJs,
});

View File

@@ -3,7 +3,7 @@
const electronApp = require('electron').app;
require('@electron/remote/main').initialize();
const ElectronAppWrapper = require('./ElectronAppWrapper').default;
const { pathExistsSync, readFileSync, mkdirpSync } = require('fs-extra');
const { pathExistsSync, readFileSync } = require('fs-extra');
const { initBridge } = require('./bridge');
const Logger = require('@joplin/utils/Logger').default;
const FsDriverNode = require('@joplin/lib/fs-driver-node').default;
@@ -25,33 +25,28 @@ process.on('unhandledRejection', (reason, p) => {
process.exit(1);
});
const getFlagValueFromArgs = (args, flag, defaultValue) => {
// Likewise, we want to know if a profile is specified early, in particular
// to save the window state data.
function getProfileFromArgs(args) {
if (!args) return null;
const index = args.indexOf(flag);
if (index <= 0 || index >= args.length - 1) return defaultValue;
const value = args[index + 1];
return value ? value : defaultValue;
};
const profileIndex = args.indexOf('--profile');
if (profileIndex <= 0 || profileIndex >= args.length - 1) return null;
const profileValue = args[profileIndex + 1];
return profileValue ? profileValue : null;
}
Logger.fsDriver_ = new FsDriverNode();
const env = envFromArgs(process.argv);
const profileFromArgs = getFlagValueFromArgs(process.argv, '--profile', null);
const profileFromArgs = getProfileFromArgs(process.argv);
const isDebugMode = !!process.argv && process.argv.indexOf('--debug') >= 0;
const isEndToEndTesting = !!process.argv?.includes('--running-tests');
const altInstanceId = getFlagValueFromArgs(process.argv, '--alt-instance-id', '');
// We initialize all these variables here because they are needed from the main process. They are
// then passed to the renderer process via the bridge.
const appId = `net.cozic.joplin${env === 'dev' ? 'dev' : ''}-desktop`;
let appName = env === 'dev' ? 'joplindev' : 'joplin';
if (appId.indexOf('-desktop') >= 0) appName += '-desktop';
const { rootProfileDir } = determineBaseAppDirs(profileFromArgs, appName, altInstanceId);
// We create the profile dir as soon as we know where it's going to be located since it's used in
// various places early in the initialisation code.
mkdirpSync(rootProfileDir);
const { rootProfileDir } = determineBaseAppDirs(profileFromArgs, appName);
const settingsPath = `${rootProfileDir}/settings.json`;
let autoUploadCrashDumps = false;
@@ -70,11 +65,9 @@ void registerCustomProtocols();
const initialCallbackUrl = process.argv.find((arg) => isCallbackUrl(arg));
const wrapper = new ElectronAppWrapper(electronApp, {
env, profilePath: rootProfileDir, isDebugMode, initialCallbackUrl, isEndToEndTesting,
});
const wrapper = new ElectronAppWrapper(electronApp, env, rootProfileDir, isDebugMode, initialCallbackUrl);
initBridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps, altInstanceId);
initBridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps);
wrapper.start().catch((error) => {
console.error('Electron App fatal error:');

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.3.3",
"version": "3.3.2",
"description": "Joplin for Desktop",
"main": "main.js",
"private": true,
@@ -14,7 +14,7 @@
"start": "gulp before-start && electron . --env dev --log-level debug --open-dev-tools --no-welcome",
"test": "jest",
"test-ui": "playwright test",
"test-ci": "yarn test",
"test-ci": "yarn test && sh ./integration-tests/run-ci.sh",
"modifyReleaseAssets": "node tools/modifyReleaseAssets.js"
},
"repository": {
@@ -134,7 +134,7 @@
"@electron/rebuild": "3.6.0",
"@joplin/default-plugins": "~3.3",
"@joplin/tools": "~3.3",
"@playwright/test": "1.51.1",
"@playwright/test": "1.45.3",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.12",
"@types/node": "18.19.67",
@@ -144,7 +144,7 @@
"@types/styled-components": "5.1.32",
"@types/tesseract.js": "2.0.0",
"axios": "^1.7.7",
"electron": "35.0.1",
"electron": "34.0.0",
"electron-builder": "24.13.3",
"glob": "10.4.5",
"gulp": "4.0.2",
@@ -166,6 +166,7 @@
"@joplin/lib": "~3.3",
"@joplin/renderer": "~3.3",
"@joplin/utils": "~3.3",
"@journeyapps/sqlcipher": "5.3.1",
"@sentry/electron": "4.24.0",
"@types/mustache": "4.2.5",
"async-mutex": "0.5.0",

View File

@@ -21,13 +21,10 @@ export default defineConfig({
workers: process.env.CI ? 1 : undefined,
// Reporter to use. See https://playwright.dev/docs/test-reporters
reporter: process.env.CI ? [
['dot'], // Give realtime workflow progress in CI
['html'],
] : 'html',
reporter: process.env.CI ? 'line' : 'html',
// The CI machines can sometimes be very slow. Increase per-test timeout in CI.
timeout: process.env.CI ? 70_000 : 60_000, // milliseconds
timeout: process.env.CI ? 50_000 : 30_000, // milliseconds
// Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions.
use: {

View File

@@ -180,7 +180,7 @@ fi
if [ "$IS_DESKTOP" = "1" ]; then
cd "$ROOT_DIR/packages/app-desktop"
yarn start --profile "$PROFILE_DIR" --alt-instance-id $USER_NUM
yarn start --profile "$PROFILE_DIR"
else
cd "$ROOT_DIR/packages/app-cli"
if [[ $CMD == "--" ]]; then

View File

@@ -12,7 +12,6 @@ export default function stateToWhenClauseContext(state: AppState, options: WhenC
const windowId = options?.windowId ?? defaultWindowId;
const isMainWindow = windowId === defaultWindowId;
const windowState = stateUtils.windowStateById(state, windowId);
const isAltInstance = !!state.settings.altInstanceId;
return {
...libStateToWhenClauseContext(state, options),
@@ -27,7 +26,6 @@ export default function stateToWhenClauseContext(state: AppState, options: WhenC
gotoAnythingVisible: !!state.visibleDialogs['gotoAnything'],
sidebarVisible: isMainWindow && !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
noteListHasNotes: !!windowState.notes.length,
isAltInstance,
// Deprecated
sideBarVisible: !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),

View File

@@ -2,9 +2,9 @@ import Setting from '@joplin/lib/models/Setting';
import bridge from './bridge';
export default async () => {
export default async (linuxSafeRestart = true) => {
Setting.setValue('wasClosedSuccessfully', true);
await Setting.saveAll();
await bridge().restart();
bridge().restart(linuxSafeRestart);
};

View File

@@ -25,7 +25,7 @@ async function main() {
// wrong one. However it means it will have to be manually upgraded for each
// new Electron release. Some ABI map there:
// https://github.com/electron/node-abi/tree/master/test
const forceAbiArgs = '--force-abi 134';
const forceAbiArgs = '--force-abi 132';
if (isWindows()) {
// Cannot run this in parallel, or the 64-bit version might end up

View File

@@ -21,14 +21,14 @@ const restartInSafeModeFromMain = async () => {
shimInit({});
const startFlags = await processStartFlags(bridge().processArgv());
const { rootProfileDir } = determineBaseAppDirs(startFlags.matched.profileDir, appName, Setting.value('altInstanceId'));
const { rootProfileDir } = determineBaseAppDirs(startFlags.matched.profileDir, appName);
const { profileDir } = await initProfile(rootProfileDir);
// We can't access the database, so write to a file instead.
const safeModeFlagFile = join(profileDir, safeModeFlagFilename);
await writeFile(safeModeFlagFile, 'true', 'utf8');
await bridge().restart();
bridge().restart();
};
export default restartInSafeModeFromMain;

View File

@@ -86,8 +86,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097767
versionName "3.3.4"
versionCode 2097764
versionName "3.3.1"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@@ -7,8 +7,8 @@
#include "findLongestSilence.h"
#include "androidUtil.h"
WhisperSession::WhisperSession(const std::string& modelPath, std::string lang, std::string prompt, bool shortAudioContext)
: lang_ {std::move(lang)}, prompt_ {std::move(prompt)}, shortAudioContext_ {shortAudioContext} {
WhisperSession::WhisperSession(const std::string& modelPath, std::string lang, std::string prompt)
: lang_ {std::move(lang)}, prompt_ {std::move(prompt)} {
whisper_context_params contextParams = whisper_context_default_params();
// Lifetime(pModelPath): Whisper.cpp creates a copy of pModelPath and stores it in a std::string.
@@ -34,9 +34,9 @@ WhisperSession::buildWhisperParams_() {
// WHISPER_SAMPLING_BEAM_SEARCH is an alternative to greedy:
// params.beam_search = { .beam_size = 2 };
params.print_realtime = false;
// Disable timestamps: They make creating custom Whisper models more difficult:
// Disable timestamps: They make creating custom Whisper models more difficult:
params.print_timestamps = false;
params.no_timestamps = true;
params.no_timestamps = true;
params.print_progress = false;
params.translate = false;
@@ -54,7 +54,6 @@ WhisperSession::buildWhisperParams_() {
params.initial_prompt = prompt_.c_str();
params.prompt_tokens = nullptr;
params.prompt_n_tokens = 0;
params.audio_ctx = 0;
// Lifetime: lifetime(params) < lifetime(lang_) = lifetime(this).
params.language = lang_.c_str();
@@ -64,32 +63,12 @@ WhisperSession::buildWhisperParams_() {
std::string
WhisperSession::transcribe_(const std::vector<float>& audio, size_t transcribeCount) {
// Whisper won't transcribe anything shorter than 1s.
int minTranscribeLength = WHISPER_SAMPLE_RATE; // 1s
int minTranscribeLength = WHISPER_SAMPLE_RATE / 2; // 0.5s
if (transcribeCount < minTranscribeLength) {
return "";
}
float seconds = static_cast<float>(transcribeCount) / WHISPER_SAMPLE_RATE;
if (seconds > 30.0f) {
LOGW("Warning: Audio is longer than 30 seconds. Not all audio will be transcribed");
}
whisper_full_params params = buildWhisperParams_();
// If supported by the model, allow shortening the transcription. This can significantly
// improve performance, but requires a fine-tuned model.
// See https://github.com/futo-org/whisper-acft
if (this->shortAudioContext_) {
// audio_ctx: 1500 every 30 seconds (50 units in one second).
// See https://github.com/futo-org/whisper-acft/issues/6
float padding = 64.0f;
params.audio_ctx = static_cast<int>(seconds * (1500.0f / 30.0f) + padding);
if (params.audio_ctx > 1500) {
params.audio_ctx = 1500;
}
}
whisper_reset_timings(pContext_);
transcribeCount = std::min(audio.size(), transcribeCount);
@@ -125,125 +104,51 @@ WhisperSession::splitAndTranscribeBefore_(int transcribeUpTo, int trimTo) {
return result;
}
bool WhisperSession::isBufferSilent_() {
int toleranceSamples = WHISPER_SAMPLE_RATE / 8; // 0.125s
auto silence = findLongestSilence(
audioBuffer_,
LongestSilenceOptions {
.sampleRate = WHISPER_SAMPLE_RATE,
.minSilenceLengthSeconds = 0.0f,
.maximumSilenceStartSamples = toleranceSamples, // 0.5s
.returnFirstMatch = true
}
);
return silence.end >= audioBuffer_.size() - toleranceSamples;
}
std::string
WhisperSession::transcribeNextChunk() {
std::stringstream result;
WhisperSession::transcribeNextChunk(const float *pAudio, int sizeAudio) {
std::string finalizedContent;
// Handles a silence detected between (splitStart, splitEnd).
auto splitAndProcess = [&] (int splitStart, int splitEnd) {
int tolerance = WHISPER_SAMPLE_RATE / 20; // 0.05s
bool isCompletelySilent = splitStart < tolerance && splitEnd > audioBuffer_.size() - tolerance;
LOGD("WhisperSession: Found silence range from %.2f -> %.2f", splitStart / (float) WHISPER_SAMPLE_RATE, splitEnd / (float) WHISPER_SAMPLE_RATE);
if (isCompletelySilent) {
audioBuffer_.clear();
return false;
} else if (splitEnd > tolerance) { // Anything to transcribe?
// Include some of the silence between the start and the end. Excluding it
// seems to make Whisper more likely to omit trailing punctuation.
int maximumSilentSamples = WHISPER_SAMPLE_RATE;
int silentSamplesToAdd = std::min(maximumSilentSamples, (splitEnd - splitStart) / 2);
splitStart += silentSamplesToAdd;
result << splitAndTranscribeBefore_(splitStart, splitEnd) << "\n\n";
return true;
}
return false;
};
int maximumSamples = WHISPER_SAMPLE_RATE * 25;
// Handle paragraph breaks indicated by long pauses
while (audioBuffer_.size() > WHISPER_SAMPLE_RATE * 3) {
LOGD("WhisperSession: Checking for a longer pauses.");
// Allow brief pauses to create new paragraphs:
float minSilenceSeconds = 1.5f;
auto splitPoint = findLongestSilence(
audioBuffer_,
LongestSilenceOptions {
.sampleRate = WHISPER_SAMPLE_RATE,
.minSilenceLengthSeconds = minSilenceSeconds,
.maximumSilenceStartSamples = maximumSamples,
.returnFirstMatch = true
}
);
if (!splitPoint.isValid) {
break;
}
if (!splitAndProcess(splitPoint.start, splitPoint.end)) {
break;
}
// Update the local audio buffer
for (int i = 0; i < sizeAudio; i++) {
audioBuffer_.push_back(pAudio[i]);
}
// If there are no long pauses, force a paragraph break somewhere
// Does the audio buffer need to be split somewhere?
int maximumSamples = WHISPER_SAMPLE_RATE * 25;
if (audioBuffer_.size() >= maximumSamples) {
LOGD("WhisperSession: Allowing shorter pauses to break.");
float minSilenceSeconds = 0.3f;
auto silenceRange = findLongestSilence(
audioBuffer_,
LongestSilenceOptions {
.sampleRate = WHISPER_SAMPLE_RATE,
.minSilenceLengthSeconds = minSilenceSeconds,
.maximumSilenceStartSamples = maximumSamples,
.returnFirstMatch = false
}
audioBuffer_, WHISPER_SAMPLE_RATE, minSilenceSeconds, maximumSamples
);
// In this case, the audio is long enough that it needs to be split somewhere. If there's
// no suitable pause available, default to splitting in the middle.
int halfBufferSize = audioBuffer_.size() / 2;
int splitStart = silenceRange.isValid ? silenceRange.start : halfBufferSize;
int splitEnd = silenceRange.isValid ? silenceRange.end : halfBufferSize;
splitAndProcess(splitStart, splitEnd);
int transcribeTo = silenceRange.isValid ? silenceRange.start : halfBufferSize;
int trimTo = silenceRange.isValid ? silenceRange.end : halfBufferSize;
finalizedContent = splitAndTranscribeBefore_(transcribeTo, trimTo);
} else if (audioBuffer_.size() > WHISPER_SAMPLE_RATE * 3) {
// Allow brief pauses to create new paragraphs:
float minSilenceSeconds = 2.0f;
auto splitPoint = findLongestSilence(
audioBuffer_, WHISPER_SAMPLE_RATE, minSilenceSeconds, maximumSamples
);
if (splitPoint.isValid) {
int tolerance = WHISPER_SAMPLE_RATE / 20; // 0.05s
bool isCompletelySilent = splitPoint.start < tolerance && splitPoint.end > audioBuffer_.size() - tolerance;
if (isCompletelySilent) {
audioBuffer_.clear();
} else {
finalizedContent = splitAndTranscribeBefore_(splitPoint.start, splitPoint.end);
}
}
}
return result.str();
previewText_ = transcribe_(audioBuffer_, audioBuffer_.size());
return finalizedContent;
}
void WhisperSession::addAudio(const float *pAudio, int sizeAudio) {
// Update the local audio buffer
for (int i = 0; i < sizeAudio; i++) {
audioBuffer_.push_back(pAudio[i]);
}
}
std::string WhisperSession::transcribeAll() {
if (isBufferSilent_()) {
return "";
}
std::stringstream result;
std::string transcribed;
auto update_transcribed = [&] {
transcribed = transcribeNextChunk();
return !transcribed.empty();
};
while (update_transcribed()) {
result << transcribed << "\n\n";
}
// Transcribe content considered by transcribeNextChunk as partial:
if (!isBufferSilent_()) {
result << transcribe_(audioBuffer_, audioBuffer_.size());
}
audioBuffer_.clear();
return result.str();
std::string WhisperSession::getPreview() {
return previewText_;
}

View File

@@ -5,26 +5,22 @@
class WhisperSession {
public:
WhisperSession(const std::string& modelPath, std::string lang, std::string prompt, bool shortAudioContext);
WhisperSession(const std::string& modelPath, std::string lang, std::string prompt);
~WhisperSession();
// Adds to the buffer
void addAudio(const float *pAudio, int sizeAudio);
// Returns the next finalized slice of audio (if any) and updates the preview.
std::string transcribeNextChunk();
// Transcribes all buffered audio data that hasn't been finalized yet
std::string transcribeAll();
std::string transcribeNextChunk(const float *pAudio, int sizeAudio);
std::string getPreview();
private:
// Current preview state
std::string previewText_;
whisper_full_params buildWhisperParams_();
std::string transcribe_(const std::vector<float>& audio, size_t samplesToTranscribe);
std::string splitAndTranscribeBefore_(int transcribeUpTo, int trimTo);
bool isBufferSilent_();
whisper_context *pContext_;
const std::string lang_;
const std::string prompt_;
const bool shortAudioContext_;
std::vector<float> audioBuffer_;
};

View File

@@ -19,18 +19,14 @@ static void highpass(std::vector<float>& data, int sampleRate) {
SilenceRange findLongestSilence(
const std::vector<float>& audioData,
LongestSilenceOptions options
int sampleRate,
float minSilenceLengthSeconds,
int maxSilencePosition
) {
// Options variables
int sampleRate = options.sampleRate;
int maxSilencePosition = options.maximumSilenceStartSamples;
float minSilenceLengthSeconds = options.minSilenceLengthSeconds;
bool returnFirstMatch = options.returnFirstMatch;
// State
int bestCandidateLength = 0;
int bestCandidateStart = -1;
int bestCandidateEnd = -1;
int currentCandidateStart = -1;
std::vector<float> processedAudio { audioData };
@@ -39,7 +35,7 @@ SilenceRange findLongestSilence(
// Break into windows of size `windowSize`:
int windowSize = 256;
int windowsPerSecond = sampleRate / windowSize;
int quietWindows = 0; // Number of relatively quiet windows encountered
int quietWindows = 0;
// Finishes the current candidate for longest silence
auto finalizeCandidate = [&] (int currentOffset) {
@@ -90,20 +86,12 @@ SilenceRange findLongestSilence(
}
int minQuietWindows = static_cast<int>(windowsPerSecond * minSilenceLengthSeconds);
if (quietWindows >= minQuietWindows && currentCandidateStart == -1) { // Found silence
// Ignore the first window, which probably contains some of the start of the audio
// and the most recent window, which came after windowOffset.
int windowsToIgnore = 2;
int estimatedQuietSamples = std::max(0, quietWindows - windowsToIgnore) * windowSize;
currentCandidateStart = windowOffset - estimatedQuietSamples;
} else if (quietWindows == 0) { // Silence ended
if (quietWindows >= minQuietWindows && currentCandidateStart == -1) {
// Found a candidate. Start it.
currentCandidateStart = windowOffset;
} else if (quietWindows == 0) {
// Ended a candidate. Is it better than the best?
finalizeCandidate(windowOffset);
// Search for more candidates or return now?
if (returnFirstMatch && bestCandidateLength > 0) {
break;
}
}
}

View File

@@ -10,24 +10,15 @@ struct SilenceRange {
int end;
};
struct LongestSilenceOptions {
int sampleRate;
// Minimum length of a silence range (e.g. 3.0 seconds)
float minSilenceLengthSeconds;
// The maximum position for a silence range to start (ignore
// all silences after this position).
int maximumSilenceStartSamples;
// Return the first silence satisfying the conditions instead of
// the longest.
bool returnFirstMatch;
};
SilenceRange findLongestSilence(
const std::vector<float>& audioData,
LongestSilenceOptions options
int sampleRate,
// Minimum length of silence in seconds
float minSilenceLengthSeconds,
// Doesn't check for silence at a position greater than maximumSilenceStart
int maximumSilenceStart
);

View File

@@ -122,12 +122,9 @@ static float samplesToSeconds(int samples, int sampleRate) {
static void expectNoSilence(const GeneratedAudio& audio, const std::string& testLabel) {
auto silence = findLongestSilence(
audio.data,
LongestSilenceOptions {
.sampleRate = audio.sampleRate,
.minSilenceLengthSeconds = 0.02f,
.maximumSilenceStartSamples = audio.sampleCount,
.returnFirstMatch = false,
}
audio.sampleRate,
0.02f,
audio.sampleCount
);
if (silence.isValid) {
std::stringstream errorBuilder;
@@ -144,12 +141,9 @@ static void expectNoSilence(const GeneratedAudio& audio, const std::string& test
static void expectSilenceBetween(const GeneratedAudio& audio, float startTimeSeconds, float stopTimeSeconds, const std::string& testLabel) {
auto silenceResult = findLongestSilence(
audio.data,
LongestSilenceOptions {
.sampleRate = audio.sampleRate,
.minSilenceLengthSeconds = 0.02f,
.maximumSilenceStartSamples = audio.sampleCount,
.returnFirstMatch = false,
}
audio.sampleRate,
0.02f,
audio.sampleCount
);
if (!silenceResult.isValid) {

View File

@@ -54,14 +54,13 @@ Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_init(
jobject thiz,
jstring modelPath,
jstring language,
jstring prompt,
jboolean useShortAudioContext
jstring prompt
) {
whisper_log_set(log_android, nullptr);
try {
auto *pSession = new WhisperSession(
stringToCXX(env, modelPath), stringToCXX(env, language), stringToCXX(env, prompt), useShortAudioContext
stringToCXX(env, modelPath), stringToCXX(env, language), stringToCXX(env, prompt)
);
return (jlong) pSession;
} catch (const std::exception& exception) {
@@ -79,8 +78,8 @@ Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_free(JNIEnv *env, jo
}
extern "C"
JNIEXPORT void JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_addAudio(JNIEnv *env,
JNIEXPORT jstring JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_fullTranscribe(JNIEnv *env,
jobject thiz,
jlong pointer,
jfloatArray audio_data) {
@@ -90,53 +89,28 @@ Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_addAudio(JNIEnv *env
std::string result;
try {
pSession->addAudio(pAudioData, lenAudioData);
LOGD("Starting Whisper, transcribe %d", lenAudioData);
result = pSession->transcribeNextChunk(pAudioData, lenAudioData);
auto preview = pSession->getPreview();
LOGD("Ran Whisper. Got %s (preview %s)", result.c_str(), preview.c_str());
} catch (const std::exception& exception) {
LOGW("Failed to add to audio buffer: %s", exception.what());
LOGW("Failed to run whisper: %s", exception.what());
throwException(env, exception.what());
}
// JNI_ABORT: "free the buffer without copying back the possible changes", pass 0 to copy
// changes (there should be no changes)
env->ReleaseFloatArrayElements(audio_data, pAudioData, JNI_ABORT);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_transcribeNextChunk(JNIEnv *env,
jobject thiz,
jlong pointer) {
auto *pSession = reinterpret_cast<WhisperSession *> (pointer);
std::string result;
try {
result = pSession->transcribeNextChunk();
} catch (const std::exception& exception) {
LOGW("Failed to run whisper: %s", exception.what());
throwException(env, exception.what());
return nullptr;
}
return stringToJava(env, result);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_transcribeRemaining(JNIEnv *env,
jobject thiz,
jlong pointer) {
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_getPreview(
JNIEnv *env, jobject thiz, jlong pointer
) {
auto *pSession = reinterpret_cast<WhisperSession *> (pointer);
std::string result;
try {
result = pSession->transcribeAll();
} catch (const std::exception& exception) {
LOGW("Failed to run whisper: %s", exception.what());
throwException(env, exception.what());
return nullptr;
}
return stringToJava(env, result);
return stringToJava(env, pSession->getPreview());
}
extern "C"
@@ -148,4 +122,4 @@ Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_runTests(JNIEnv *env
LOGW("Failed to run tests: %s", exception.what());
throwException(env, exception.what());
}
}
}

View File

@@ -15,9 +15,7 @@ typealias AudioRecorderFactory = (context: Context)->AudioRecorder;
class AudioRecorder(context: Context) : Closeable {
private val sampleRate = 16_000
// Don't allow the unprocessed audio buffer to grow indefinitely -- discard
// data if longer than this:
private val maxLengthSeconds = 120
private val maxLengthSeconds = 30 // Whisper supports a maximum of 30s
private val maxBufferSize = sampleRate * maxLengthSeconds
private val buffer = FloatArray(maxBufferSize)
private var bufferWriteOffset = 0

View File

@@ -6,7 +6,6 @@ class NativeWhisperLib(
modelPath: String,
languageCode: String,
prompt: String,
shortAudioContext: Boolean,
) : Closeable {
companion object {
init {
@@ -17,39 +16,30 @@ class NativeWhisperLib(
// TODO: The example whisper.cpp project transfers pointers as Longs to the Kotlin code.
// This seems unsafe. Try changing how this is managed.
private external fun init(modelPath: String, languageCode: String, prompt: String, shortAudioContext: Boolean): Long;
private external fun init(modelPath: String, languageCode: String, prompt: String): Long;
private external fun free(pointer: Long): Unit;
private external fun addAudio(pointer: Long, audioData: FloatArray): Unit;
private external fun transcribeNextChunk(pointer: Long): String;
private external fun transcribeRemaining(pointer: Long): String;
private external fun fullTranscribe(pointer: Long, audioData: FloatArray): String;
private external fun getPreview(pointer: Long): String;
}
private var closed = false
private val pointer: Long = init(modelPath, languageCode, prompt, shortAudioContext)
private val pointer: Long = init(modelPath, languageCode, prompt)
fun addAudio(audioData: FloatArray) {
if (closed) {
throw Exception("Cannot add audio data to a closed session")
}
Companion.addAudio(pointer, audioData)
}
fun transcribeNextChunk(): String {
fun transcribe(audioData: FloatArray): String {
if (closed) {
throw Exception("Cannot transcribe using a closed session")
}
return Companion.transcribeNextChunk(pointer)
return fullTranscribe(pointer, audioData)
}
fun transcribeRemaining(): String {
fun getPreview(): String {
if (closed) {
throw Exception("Cannot transcribeAll using a closed session")
throw Exception("Cannot get preview from a closed session")
}
return Companion.transcribeRemaining(pointer)
return getPreview(pointer)
}
override fun close() {

View File

@@ -8,7 +8,6 @@ class SpeechToTextConverter(
modelPath: String,
locale: String,
prompt: String,
useShortAudioCtx: Boolean,
recorderFactory: AudioRecorderFactory,
context: Context,
) : Closeable {
@@ -18,7 +17,6 @@ class SpeechToTextConverter(
modelPath,
languageCode,
prompt,
useShortAudioCtx,
)
fun start() {
@@ -27,8 +25,7 @@ class SpeechToTextConverter(
private fun convert(data: FloatArray): String {
Log.d("Whisper", "Pre-transcribe data of size ${data.size}")
whisper.addAudio(data)
val result = whisper.transcribeNextChunk()
val result = whisper.transcribe(data)
Log.d("Whisper", "Post transcribe. Got $result")
return result;
}
@@ -50,8 +47,11 @@ class SpeechToTextConverter(
// Converts as many seconds of buffered data as possible, without waiting
fun convertRemaining(): String {
val buffer = recorder.pullAvailable()
whisper.addAudio(buffer)
return whisper.transcribeRemaining()
return convert(buffer)
}
fun getPreview(): String {
return whisper.getPreview()
}
override fun close() {

View File

@@ -43,11 +43,11 @@ class SpeechToTextPackage : ReactPackage {
}
@ReactMethod
fun openSession(modelPath: String, locale: String, prompt: String, useShortAudioCtx: Boolean, promise: Promise) {
fun openSession(modelPath: String, locale: String, prompt: String, promise: Promise) {
val appContext = context.applicationContext
try {
val sessionId = sessionManager.openSession(modelPath, locale, prompt, useShortAudioCtx, appContext)
val sessionId = sessionManager.openSession(modelPath, locale, prompt, appContext)
promise.resolve(sessionId)
} catch (exception: Throwable) {
promise.reject(exception)
@@ -79,6 +79,11 @@ class SpeechToTextPackage : ReactPackage {
sessionManager.convertAvailable(sessionId, promise)
}
@ReactMethod
fun getPreview(sessionId: Int, promise: Promise) {
sessionManager.getPreview(sessionId, promise)
}
@ReactMethod
fun closeSession(sessionId: Int, promise: Promise) {
sessionManager.closeSession(sessionId, promise)

View File

@@ -21,13 +21,12 @@ class SpeechToTextSessionManager(
modelPath: String,
locale: String,
prompt: String,
useShortAudioCtx: Boolean,
context: Context,
): Int {
val sessionId = nextSessionId++
sessions[sessionId] = SpeechToTextSession(
SpeechToTextConverter(
modelPath, locale, prompt, useShortAudioCtx, recorderFactory = AudioRecorder.factory, context,
modelPath, locale, prompt, recorderFactory = AudioRecorder.factory, context,
)
)
return sessionId
@@ -102,6 +101,13 @@ class SpeechToTextSessionManager(
}
}
fun getPreview(sessionId: Int, promise: Promise) {
this.concurrentWithSession(sessionId, promise::reject) { session ->
val result = session.converter.getPreview()
promise.resolve(result)
}
}
fun closeSession(sessionId: Int, promise: Promise) {
this.concurrentWithSession(sessionId) { session ->
session.converter.close()

View File

@@ -1,16 +0,0 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
export const declaration: CommandDeclaration = {
name: 'dismissPluginPanels',
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext) => {
context.dispatch({
type: 'SET_PLUGIN_PANELS_DIALOG_VISIBLE',
visible: false,
});
},
};
};

View File

@@ -1,12 +1,10 @@
// AUTO-GENERATED using `gulp buildScriptIndexes`
import * as dismissPluginPanels from './dismissPluginPanels';
import * as newNote from './newNote';
import * as openItem from './openItem';
import * as openNote from './openNote';
import * as scrollToHash from './scrollToHash';
const index: any[] = [
dismissPluginPanels,
newNote,
openItem,
openNote,

View File

@@ -1,12 +1,7 @@
import * as React from 'react';
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import { GestureResponderEvent, Modal, ModalProps, Platform, Pressable, ScrollView, StyleSheet, View, ViewStyle, useWindowDimensions } from 'react-native';
import { RefObject, useCallback, useMemo, useRef } from 'react';
import { GestureResponderEvent, Modal, ModalProps, ScrollView, StyleSheet, View, ViewStyle, useWindowDimensions } from 'react-native';
import { hasNotch } from 'react-native-device-info';
import FocusControl from './accessibility/FocusControl/FocusControl';
import { msleep, Second } from '@joplin/utils/time';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { ModalState } from './accessibility/FocusControl/types';
import { _ } from '@joplin/lib/locale';
interface ModalElementProps extends ModalProps {
children: React.ReactNode;
@@ -54,13 +49,6 @@ const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) =>
// This makes it possible to vertically center the content of scrollable modals.
flexGrow: 1,
},
dismissButton: {
position: 'absolute',
bottom: 0,
height: 12,
width: '100%',
zIndex: -1,
},
});
}, [hasScrollView, isLandscape, backgroundColor]);
};
@@ -79,36 +67,6 @@ const useBackgroundTouchListeners = (onRequestClose: (event: GestureResponderEve
return { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished };
};
const useModalStatus = (containerComponent: View|null, visible: boolean) => {
const contentMounted = !!containerComponent;
const [controlsFocus, setControlsFocus] = useState(false);
useAsyncEffect(async (event) => {
if (contentMounted) {
setControlsFocus(true);
} else {
// Accessibility: Work around Android's default focus-setting behavior.
// By default, React Native's Modal on Android sets focus about 0.8 seconds
// after the modal is dismissed. As a result, the Modal controls focus until
// roughly one second after the modal is dismissed.
if (Platform.OS === 'android') {
await msleep(Second);
}
if (!event.cancelled) {
setControlsFocus(false);
}
}
}, [contentMounted]);
let modalStatus = ModalState.Closed;
if (controlsFocus) {
modalStatus = visible ? ModalState.Open : ModalState.Closing;
} else if (visible) {
modalStatus = ModalState.Open;
}
return modalStatus;
};
const ModalElement: React.FC<ModalElementProps> = ({
children,
containerStyle,
@@ -126,48 +84,29 @@ const ModalElement: React.FC<ModalElementProps> = ({
</View>
);
const backgroundRef = useRef<View>();
const { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished } = useBackgroundTouchListeners(modalProps.onRequestClose, backgroundRef);
const [containerComponent, setContainerComponent] = useState<View|null>(null);
const modalStatus = useModalStatus(containerComponent, modalProps.visible);
const containerRef = useRef<View|null>(null);
containerRef.current = containerComponent;
const { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished } = useBackgroundTouchListeners(modalProps.onRequestClose, containerRef);
// A close button for accessibility tools. Since iOS accessibility focus order is based on the position
// of the element on the screen, the close button is placed after the modal content, rather than behind.
const closeButton = modalProps.onRequestClose ? <Pressable
style={styles.dismissButton}
onPress={modalProps.onRequestClose}
accessibilityLabel={_('Close dialog')}
accessibilityRole='button'
/> : null;
const contentAndBackdrop = <View
ref={setContainerComponent}
ref={backgroundRef}
style={styles.modalBackground}
onStartShouldSetResponder={onShouldBackgroundCaptureTouch}
onResponderRelease={onBackgroundTouchFinished}
>
{content}
{closeButton}
</View>;
>{content}</View>;
// supportedOrientations: On iOS, this allows the dialog to be shown in non-portrait orientations.
return (
<FocusControl.ModalWrapper state={modalStatus}>
<Modal
// supportedOrientations: On iOS, this allows the dialog to be shown in non-portrait orientations.
supportedOrientations={['portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right']}
{...modalProps}
>
{scrollOverflow ? (
<ScrollView
style={styles.modalScrollView}
contentContainerStyle={styles.modalScrollViewContent}
>{contentAndBackdrop}</ScrollView>
) : contentAndBackdrop}
</Modal>
</FocusControl.ModalWrapper>
<Modal
supportedOrientations={['portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right']}
{...modalProps}
>
{scrollOverflow ? (
<ScrollView
style={styles.modalScrollView}
contentContainerStyle={styles.modalScrollViewContent}
>{contentAndBackdrop}</ScrollView>
) : contentAndBackdrop}
</Modal>
);
};

View File

@@ -7,6 +7,7 @@ import '@testing-library/jest-native/extend-expect';
import NoteBodyViewer from './NoteBodyViewer';
import Setting from '@joplin/lib/models/Setting';
import { MenuProvider } from 'react-native-popup-menu';
import { resourceFetcher, setupDatabaseAndSynchronizer, supportDir, switchClient, synchronizerStart } from '@joplin/lib/testing/test-utils';
import { MarkupLanguage } from '@joplin/renderer';
import { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage';
@@ -15,8 +16,6 @@ import shim from '@joplin/lib/shim';
import Note from '@joplin/lib/models/Note';
import { ResourceInfo } from './hooks/useRerenderHandler';
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
import TestProviderStack from '../testing/TestProviderStack';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
interface WrapperProps {
noteBody: string;
@@ -30,7 +29,6 @@ interface WrapperProps {
const emptyObject = {};
const emptyArray: string[] = [];
const noOpFunction = () => {};
const testStore = createMockReduxStore();
const WrappedNoteViewer: React.FC<WrapperProps> = (
{
noteBody,
@@ -41,7 +39,7 @@ const WrappedNoteViewer: React.FC<WrapperProps> = (
onMarkForDownload,
}: WrapperProps,
) => {
return <TestProviderStack store={testStore}>
return <MenuProvider>
<NoteBodyViewer
themeId={Setting.THEME_LIGHT}
style={emptyObject}
@@ -58,7 +56,7 @@ const WrappedNoteViewer: React.FC<WrapperProps> = (
onScroll={onScroll}
pluginStates={emptyObject}
/>
</TestProviderStack>;
</MenuProvider>;
};
const getNoteViewerDom = async () => {

View File

@@ -1,12 +1,10 @@
import * as React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { StyleSheet, TextStyle, View, Text, ScrollView, useWindowDimensions, Platform } from 'react-native';
import { StyleSheet, TextStyle, View, Text, ScrollView, useWindowDimensions } from 'react-native';
import { themeStyle } from '../global-style';
import { Menu, MenuOption as MenuOptionComponent, MenuOptions, MenuTrigger } from 'react-native-popup-menu';
import AccessibleView from '../accessibility/AccessibleView';
import debounce from '../../utils/debounce';
import FocusControl from '../accessibility/FocusControl/FocusControl';
import { ModalState } from '../accessibility/FocusControl/types';
interface MenuOptionDivider {
isDivider: true;
@@ -83,22 +81,18 @@ const MenuComponent: React.FC<Props> = props => {
// When undefined/null: Don't auto-focus anything.
const [refocusCounter, setRefocusCounter] = useState<number|undefined>(undefined);
let keyCounter = 0;
let key = 0;
let isFirst = true;
for (const option of props.options) {
if (option.isDivider === true) {
menuOptionComponents.push(
<View key={`menuOption_divider_${keyCounter++}`} style={styles.divider} />,
<View key={`menuOption_divider_${key++}`} style={styles.divider} />,
);
} else {
// Don't auto-focus on iOS -- as of RN 0.74, this causes focus to get stuck. However,
// the auto-focus seems to be necessary on web (and possibly Android) to avoid first focusing
// the dismiss button and other items not in the menu:
const canAutoFocus = isFirst && Platform.OS !== 'ios';
const key = `menuOption_${option.key ?? keyCounter++}`;
const canAutoFocus = isFirst;
menuOptionComponents.push(
<MenuOptionComponent value={option.onPress} key={key} style={styles.contextMenuItem} disabled={!!option.disabled}>
<AccessibleView refocusCounter={canAutoFocus ? refocusCounter : undefined} testID={key}>
<MenuOptionComponent value={option.onPress} key={`menuOption_${option.key ?? key++}`} style={styles.contextMenuItem} disabled={!!option.disabled}>
<AccessibleView refocusCounter={canAutoFocus ? refocusCounter : undefined}>
<Text
style={option.disabled ? styles.contextMenuItemTextDisabled : styles.contextMenuItemText}
disabled={!!option.disabled}
@@ -111,47 +105,42 @@ const MenuComponent: React.FC<Props> = props => {
}
}
const [open, setOpen] = useState(false);
const onMenuItemSelect = useCallback((value: unknown) => {
if (typeof value === 'function') {
value();
}
setRefocusCounter(undefined);
setOpen(false);
}, []);
// debounce: If the menu is focused during its transition animation, it briefly
// appears to be in the wrong place. As such, add a brief delay before focusing.
const onMenuOpened = useMemo(() => debounce(() => {
const onMenuOpen = useMemo(() => debounce(() => {
setRefocusCounter(counter => (counter ?? 0) + 1);
setOpen(true);
}, 200), []);
// Resetting the refocus counter to undefined causes the menu to not be focused immediately
// after opening.
const onMenuClosed = useCallback(() => {
const onMenuClose = useCallback(() => {
setRefocusCounter(undefined);
setOpen(false);
}, []);
return (
<Menu
onSelect={onMenuItemSelect}
onClose={onMenuClosed}
onOpen={onMenuOpened}
onClose={onMenuClose}
onOpen={onMenuOpen}
style={styles.contextMenu}
>
<MenuTrigger style={styles.contextMenuButton} testID='screen-header-menu-trigger'>
{props.children}
</MenuTrigger>
<MenuOptions>
<FocusControl.ModalWrapper state={open ? ModalState.Open : ModalState.Closed}>
<ScrollView
style={styles.menuContentScroller}
testID={`menu-content-${refocusCounter ? 'refocusing' : ''}`}
>{menuOptionComponents}</ScrollView>
</FocusControl.ModalWrapper>
<ScrollView
style={styles.menuContentScroller}
aria-modal={true}
accessibilityViewIsModal={true}
testID={`menu-content-${refocusCounter ? 'refocusing' : ''}`}
>{menuOptionComponents}</ScrollView>
</MenuOptions>
</Menu>
);

View File

@@ -502,15 +502,15 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function sortButton(styles: any, onPress: OnPressCallback) {
return (
<IconButton
<TouchableOpacity
onPress={onPress}
themeId={themeId}
description={_('Sort notes by')}
iconName='ionicon filter-outline'
contentWrapperStyle={styles.iconButton}
iconStyle={styles.topIcon}
/>
accessibilityLabel={_('Sort notes by')}
accessibilityRole="button">
<View style={styles.iconButton}>
<Icon name="filter-outline" style={styles.topIcon} />
</View>
</TouchableOpacity>
);
}

View File

@@ -271,14 +271,12 @@ const SideMenuComponent: React.FC<Props> = props => {
<AccessibleView
inert={!open}
style={styles.menuWrapper}
testID='menu-wrapper'
>
<AccessibleView
// Auto-focuses an empty view at the beginning of the sidemenu -- if we instead
// focus the container view, VoiceOver fails to focus to any components within
// the sidebar.
refocusCounter={!open ? 1 : undefined}
testID='sidemenu-menu-focus-region'
/>
{props.menu}
@@ -289,9 +287,8 @@ const SideMenuComponent: React.FC<Props> = props => {
<AccessibleView
inert={open}
style={styles.contentWrapper}
testID='content-wrapper'
>
<AccessibleView refocusCounter={open ? 1 : undefined} testID='sidemenu-content-focus-region' />
<AccessibleView refocusCounter={open ? 1 : undefined} />
{props.children}
</AccessibleView>
);

View File

@@ -0,0 +1,97 @@
// On some devices, the SafeAreaView conflicts with the KeyboardAvoidingView, creating
// additional (or a lack of additional) space at the bottom of the screen. Because this
// is different on different devices, this button allows toggling additional space a the bottom
// of the screen to compensate.
// Works around https://github.com/facebook/react-native/issues/13393 by adding additional
// space below the given component when the keyboard is visible unless a button is pressed.
import Setting from '@joplin/lib/models/Setting';
import { themeStyle } from '@joplin/lib/theme';
import * as React from 'react';
import { ReactNode, useCallback, useState, useEffect } from 'react';
import { Platform, useWindowDimensions, View, ViewStyle } from 'react-native';
import IconButton from './IconButton';
import useKeyboardVisible from '../utils/hooks/useKeyboardVisible';
interface Props {
children: ReactNode;
themeId: number;
style?: ViewStyle;
}
const ToggleSpaceButton = (props: Props) => {
const [additionalSpace, setAdditionalSpace] = useState(0);
const [decreaseSpaceBtnVisible, setDecreaseSpaceBtnVisible] = useState(true);
// Some devices need space added, others need space removed.
const additionalPositiveSpace = 14;
const additionalNegativeSpace = -14;
// Switch from adding +14px to -14px.
const onDecreaseSpace = useCallback(() => {
setAdditionalSpace(additionalNegativeSpace);
setDecreaseSpaceBtnVisible(false);
Setting.setValue('editor.mobile.removeSpaceBelowToolbar', true);
}, [setAdditionalSpace, setDecreaseSpaceBtnVisible, additionalNegativeSpace]);
useEffect(() => {
if (Setting.value('editor.mobile.removeSpaceBelowToolbar')) {
onDecreaseSpace();
}
}, [onDecreaseSpace]);
const theme = themeStyle(props.themeId);
const decreaseSpaceButton = (
<>
<View style={{
height: additionalPositiveSpace,
zIndex: -2,
}} />
<IconButton
themeId={props.themeId}
description={'Move toolbar to bottom of screen'}
containerStyle={{
height: additionalPositiveSpace,
width: '100%',
// Ensure that the icon is near the bottom of the screen,
// and thus invisible on devices where it isn't necessary.
position: 'absolute',
bottom: 0,
// Don't show the button on top of views with content.
zIndex: -1,
alignItems: 'center',
}}
onPress={onDecreaseSpace}
iconName='material chevron-down'
iconStyle={{ color: theme.color }}
/>
</>
);
const { keyboardVisible } = useKeyboardVisible();
const windowSize = useWindowDimensions();
const isPortrait = windowSize.height > windowSize.width;
const spaceApplicable = keyboardVisible && Platform.OS === 'ios' && isPortrait;
const style: ViewStyle = {
marginBottom: spaceApplicable ? additionalSpace : 0,
...props.style,
};
return (
<View style={style}>
{props.children}
{ decreaseSpaceBtnVisible && spaceApplicable ? decreaseSpaceButton : null }
</View>
);
};
export default ToggleSpaceButton;

View File

@@ -1,75 +0,0 @@
import * as React from 'react';
import FocusControl from './FocusControl/FocusControl';
import { render } from '@testing-library/react-native';
import AccessibleView from './AccessibleView';
import { AccessibilityInfo } from 'react-native';
import ModalWrapper from './FocusControl/ModalWrapper';
import { ModalState } from './FocusControl/types';
interface TestContentWrapperProps {
mainContent: React.ReactNode;
dialogs: React.ReactNode;
}
const TestContentWrapper: React.FC<TestContentWrapperProps> = props => {
return <FocusControl.Provider>
{props.dialogs}
<FocusControl.MainAppContent>
{props.mainContent}
</FocusControl.MainAppContent>
</FocusControl.Provider>;
};
jest.mock('react-native', () => {
const ReactNative = jest.requireActual('react-native');
ReactNative.AccessibilityInfo.setAccessibilityFocus = jest.fn();
return ReactNative;
});
describe('AccessibleView', () => {
test('should wait for the currently-open dialog to dismiss before applying focus requests', () => {
const setFocusMock = AccessibilityInfo.setAccessibilityFocus as jest.Mock;
setFocusMock.mockClear();
interface TestContentOptions {
modalState: ModalState;
refocusCounter: undefined|number;
}
const renderTestContent = ({ modalState, refocusCounter }: TestContentOptions) => {
const mainContent = <AccessibleView refocusCounter={refocusCounter}/>;
const visibleDialog = <ModalWrapper state={modalState}>{null}</ModalWrapper>;
return <TestContentWrapper
mainContent={mainContent}
dialogs={visibleDialog}
/>;
};
render(renderTestContent({
refocusCounter: undefined,
modalState: ModalState.Open,
}));
// Increasing the refocusCounter for a background view while a dialog is visible
// should not try to focus the background view.
render(renderTestContent({
refocusCounter: 1,
modalState: ModalState.Open,
}));
expect(setFocusMock).not.toHaveBeenCalled();
// Focus should not be set until done closing
render(renderTestContent({
refocusCounter: 1,
modalState: ModalState.Closing,
}));
expect(setFocusMock).not.toHaveBeenCalled();
// Keeping the same refocus counter, but dismissing the dialog should focus
// the test view.
render(renderTestContent({
refocusCounter: 1,
modalState: ModalState.Closed,
}));
expect(setFocusMock).toHaveBeenCalled();
});
});

View File

@@ -1,9 +1,8 @@
import { focus } from '@joplin/lib/utils/focusHandler';
import Logger from '@joplin/utils/Logger';
import * as React from 'react';
import { useContext, useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { AccessibilityInfo, findNodeHandle, Platform, UIManager, View, ViewProps } from 'react-native';
import { AutoFocusContext } from './FocusControl/AutoFocusProvider';
const logger = Logger.create('AccessibleView');
@@ -17,68 +16,9 @@ interface Props extends ViewProps {
refocusCounter?: number;
}
const useAutoFocus = (refocusCounter: number|null, containerNode: View|HTMLElement|null, debugLabel: string) => {
const autoFocusControl = useContext(AutoFocusContext);
const autoFocusControlRef = useRef(autoFocusControl);
autoFocusControlRef.current = autoFocusControl;
const debugLabelRef = useRef(debugLabel);
debugLabelRef.current = debugLabel;
useEffect(() => {
if ((refocusCounter ?? null) === null) return () => {};
if (!containerNode) return () => {};
const focusContainer = () => {
const doFocus = () => {
if (Platform.OS === 'web') {
// 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') {
throw new Error('Failed to focus sidebar. UIManager.focus is not a 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(containerNode);
} else {
const handle = findNodeHandle(containerNode as View);
if (handle !== null) {
AccessibilityInfo.setAccessibilityFocus(handle);
} else {
logger.warn('Couldn\'t find a view to focus.');
}
}
};
focus(`AccessibleView::${debugLabelRef.current}`, {
focus: doFocus,
});
};
const canFocusNow = !autoFocusControlRef.current || autoFocusControlRef.current.canAutoFocus();
if (canFocusNow) {
focusContainer();
return () => {};
} else { // Delay autofocus
logger.debug(`Delaying autofocus for ${debugLabelRef.current}`);
// Allows the view to be refocused when, for example, a dialog is dismissed
autoFocusControlRef.current?.setAutofocusCallback(focusContainer);
return () => {
autoFocusControlRef.current?.removeAutofocusCallback(focusContainer);
};
}
}, [containerNode, refocusCounter]);
};
const AccessibleView: React.FC<Props> = ({ inert, refocusCounter, children, ...viewProps }) => {
const [containerRef, setContainerRef] = useState<View|HTMLElement|null>(null);
const debugLabel = viewProps.testID ?? 'AccessibleView';
useAutoFocus(refocusCounter, containerRef, debugLabel);
// On web, there's no clear way to disable keyboard focus for an element **and its descendants**
// without accessing the underlying HTML.
useEffect(() => {
@@ -92,6 +32,39 @@ const AccessibleView: React.FC<Props> = ({ inert, refocusCounter, children, ...v
}
}, [containerRef, inert]);
useEffect(() => {
if ((refocusCounter ?? null) === null) return;
if (!containerRef) return;
const autoFocus = () => {
if (Platform.OS === 'web') {
// 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') {
throw new Error('Failed to focus sidebar. UIManager.focus is not a 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);
if (handle !== null) {
AccessibilityInfo.setAccessibilityFocus(handle);
} else {
logger.warn('Couldn\'t find a view to focus.');
}
}
};
focus('AccessibleView', {
focus: autoFocus,
});
}, [containerRef, refocusCounter]);
const canFocus = (refocusCounter ?? null) !== null;
return <View

View File

@@ -1,62 +0,0 @@
import * as React from 'react';
import { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
type AutoFocusCallback = ()=> void;
interface AutoFocusControl {
// It isn't always possible to autofocus (e.g. due to a dialog obscuring focus).
canAutoFocus(): boolean;
// Sets the callback to be triggered when it becomes possible to autofocus
setAutofocusCallback(callback: AutoFocusCallback): void;
removeAutofocusCallback(callback: AutoFocusCallback): void;
}
export const AutoFocusContext = createContext<AutoFocusControl|null>(null);
interface Props {
children: React.ReactNode;
allowAutoFocus: boolean;
}
const AutoFocusProvider: React.FC<Props> = ({ allowAutoFocus, children }) => {
const [autoFocusCallback, setAutofocusCallback] = useState<AutoFocusCallback|null>(null);
const allowAutoFocusRef = useRef(allowAutoFocus);
allowAutoFocusRef.current = allowAutoFocus;
useEffect(() => {
if (allowAutoFocus && autoFocusCallback) {
autoFocusCallback();
setAutofocusCallback(null);
}
}, [autoFocusCallback, allowAutoFocus]);
const removeAutofocusCallback = useCallback((toRemove: AutoFocusCallback) => {
setAutofocusCallback(callback => {
// Update the callback only if it's different
if (callback === toRemove) {
return null;
} else {
return callback;
}
});
}, []);
const autoFocusControl = useMemo((): AutoFocusControl => {
return {
canAutoFocus: () => {
return allowAutoFocusRef.current;
},
setAutofocusCallback: (callback) => {
setAutofocusCallback(() => callback);
},
removeAutofocusCallback,
};
}, [removeAutofocusCallback, setAutofocusCallback]);
return <AutoFocusContext.Provider value={autoFocusControl}>
{children}
</AutoFocusContext.Provider>;
};
export default AutoFocusProvider;

View File

@@ -1,11 +0,0 @@
import FocusControlProvider from './FocusControlProvider';
import MainAppContent from './MainAppContent';
import ModalWrapper from './ModalWrapper';
const FocusControl = {
Provider: FocusControlProvider,
ModalWrapper,
MainAppContent,
};
export default FocusControl;

View File

@@ -1,51 +0,0 @@
import * as React from 'react';
import { createContext, useCallback, useMemo, useState } from 'react';
import { ModalState } from './types';
export interface FocusControl {
setModalState(dialogId: string, state: ModalState): void;
hasOpenModal: boolean;
hasClosingModal: boolean;
}
export const FocusControlContext = createContext<FocusControl|null>(null);
interface Props {
children: React.ReactNode;
}
const FocusControlProvider: React.FC<Props> = props => {
type ModalStates = Record<string, ModalState>;
const [modalStates, setModalStates] = useState<ModalStates>({});
const setModalOpen = useCallback((dialogId: string, state: ModalState) => {
setModalStates(modalStates => {
modalStates = { ...modalStates };
if (state === ModalState.Closed) {
delete modalStates[dialogId];
} else {
modalStates[dialogId] = state;
}
return modalStates;
});
}, []);
const modalStateValues = Object.values(modalStates);
const hasOpenModal = modalStateValues.includes(ModalState.Open);
const hasClosingModal = modalStateValues.includes(ModalState.Closing);
const focusControl = useMemo((): FocusControl => {
return {
hasOpenModal: hasOpenModal,
hasClosingModal: hasClosingModal,
setModalState: setModalOpen,
};
}, [hasOpenModal, hasClosingModal, setModalOpen]);
return <FocusControlContext.Provider value={focusControl}>
{props.children}
</FocusControlContext.Provider>;
};
export default FocusControlProvider;

View File

@@ -1,27 +0,0 @@
import * as React from 'react';
import AccessibleView from '../AccessibleView';
import { FocusControlContext } from './FocusControlProvider';
import { useContext } from 'react';
import { StyleProp, ViewStyle } from 'react-native';
import AutoFocusProvider from './AutoFocusProvider';
interface Props {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
}
// A region that should not be accessibility focusable while a dialog
// is open.
const MainAppContent: React.FC<Props> = props => {
const { hasOpenModal, hasClosingModal } = useContext(FocusControlContext);
const blockFocus = hasOpenModal;
const allowAutoFocus = !hasClosingModal && !blockFocus;
return <AccessibleView inert={blockFocus} style={props.style}>
<AutoFocusProvider allowAutoFocus={allowAutoFocus}>
{props.children}
</AutoFocusProvider>
</AccessibleView>;
};
export default MainAppContent;

View File

@@ -1,34 +0,0 @@
import * as React from 'react';
import { useContext, useEffect, useId } from 'react';
import { FocusControlContext } from './FocusControlProvider';
import { ModalState } from './types';
interface Props {
children: React.ReactNode;
state: ModalState;
}
// A wrapper component that notifies the focus handler that a modal-like component
// is visible. Modals that capture focus should wrap their content in this component.
const ModalWrapper: React.FC<Props> = props => {
const { setModalState: setDialogState } = useContext(FocusControlContext);
const id = useId();
useEffect(() => {
if (!setDialogState) {
throw new Error('ModalContent components must have a FocusControlProvider as an ancestor. Is FocusControlProvider part of the provider stack?');
}
setDialogState(id, props.state);
}, [id, props.state, setDialogState]);
useEffect(() => {
return () => {
setDialogState?.(id, ModalState.Closed);
};
}, [id, setDialogState]);
return <>
{props.children}
</>;
};
export default ModalWrapper;

View File

@@ -1,13 +0,0 @@
// eslint-disable-next-line import/prefer-default-export -- FocusControl currently only has one shared type for external use
export enum ModalState {
// When `Open`, a modal blocks focus for the main app content.
Open,
// When `Closing`, a modal doesn't block main app content focus, but focus
// shouldn't be moved to the main app content yet.
// This is useful for native Modals, which have their own focus handling logic.
// If Joplin moves accessibility focus before the native Modal focus handling
// has completed, the Joplin-specified accessibility focus may be ignored.
Closing,
Closed,
}

View File

@@ -2,12 +2,15 @@ import * as React from 'react';
import { connect } from 'react-redux';
import NotesScreen from './screens/Notes';
import SearchScreen from './screens/SearchScreen';
import { KeyboardAvoidingView, Platform, View } from 'react-native';
import { Component } from 'react';
import { KeyboardAvoidingView, Keyboard, Platform, View, KeyboardEvent, Dimensions, EmitterSubscription } from 'react-native';
import { AppState } from '../utils/types';
import { themeStyle } from './global-style';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import useKeyboardState from '../utils/hooks/useKeyboardState';
import usePrevious from '@joplin/lib/hooks/usePrevious';
interface State {
autoCompletionBarExtraHeight: number;
floatingKeyboardEnabled: boolean;
}
interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -19,58 +22,107 @@ interface Props {
themeId: number;
}
const AppNavComponent: React.FC<Props> = (props) => {
const keyboardState = useKeyboardState();
const safeAreaPadding = useSafeAreaInsets();
class AppNavComponent extends Component<Props, State> {
private previousRouteName_: string|null = null;
private keyboardDidShowListener: EmitterSubscription|null = null;
private keyboardDidHideListener: EmitterSubscription|null = null;
private keyboardWillChangeFrameListener: EmitterSubscription|null = null;
if (!props.route) throw new Error('Route must not be null');
public constructor(props: Props) {
super(props);
// Note: certain screens are kept into memory, in particular Notes and Search
// so that the scroll position is not lost when the user navigate away from them.
const route = props.route;
let Screen = null;
let notesScreenVisible = false;
let searchScreenVisible = false;
if (route.routeName === 'Notes') {
notesScreenVisible = true;
} else if (route.routeName === 'Search') {
searchScreenVisible = true;
} else {
Screen = props.screens[route.routeName].screen;
this.previousRouteName_ = null;
this.state = {
autoCompletionBarExtraHeight: 0, // Extra padding for the auto completion bar at the top of the keyboard
floatingKeyboardEnabled: false,
};
}
const previousRouteName = usePrevious(route.routeName, '');
public UNSAFE_componentWillMount() {
if (Platform.OS === 'ios') {
this.keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', this.keyboardDidShow.bind(this));
this.keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', this.keyboardDidHide.bind(this));
this.keyboardWillChangeFrameListener = Keyboard.addListener('keyboardWillChangeFrame', this.keyboardWillChangeFrame);
}
}
// Keep the search screen loaded if the user is viewing a note from that search screen
// so that if the back button is pressed, the screen is still loaded. However, unload
// it if navigating away.
const searchScreenLoaded = searchScreenVisible || (previousRouteName === 'Search' && route.routeName === 'Note');
public componentWillUnmount() {
this.keyboardDidShowListener?.remove();
this.keyboardDidHideListener?.remove();
this.keyboardWillChangeFrameListener?.remove();
const theme = themeStyle(props.themeId);
this.keyboardDidShowListener = null;
this.keyboardDidHideListener = null;
this.keyboardWillChangeFrameListener = null;
}
const style = { flex: 1, backgroundColor: theme.backgroundColor };
public keyboardDidShow() {
this.setState({ autoCompletionBarExtraHeight: 30 });
}
// When the floating keyboard is enabled, the KeyboardAvoidingView can have a very small
// height. Don't use the KeyboardAvoidingView when the floating keyboard is enabled.
// See https://github.com/facebook/react-native/issues/29473
const keyboardAvoidingViewEnabled = !keyboardState.isFloatingKeyboard;
const autocompletionBarPadding = Platform.OS === 'ios' && keyboardState.keyboardVisible ? safeAreaPadding.top : 0;
public keyboardDidHide() {
this.setState({ autoCompletionBarExtraHeight: 0 });
}
return (
<KeyboardAvoidingView
enabled={keyboardAvoidingViewEnabled}
behavior={Platform.OS === 'ios' ? 'padding' : null}
style={style}
>
<NotesScreen visible={notesScreenVisible} />
{searchScreenLoaded && <SearchScreen visible={searchScreenVisible} />}
{!notesScreenVisible && !searchScreenVisible && <Screen navigation={{ state: route }} themeId={props.themeId} dispatch={props.dispatch} />}
<View style={{ height: autocompletionBarPadding }} />
</KeyboardAvoidingView>
);
};
private keyboardWillChangeFrame = (evt: KeyboardEvent) => {
const windowWidth = Dimensions.get('window').width;
// If the keyboard isn't as wide as the window, the floating keyboard is disabled.
// See https://github.com/facebook/react-native/issues/29473#issuecomment-696658937
this.setState({
floatingKeyboardEnabled: evt.endCoordinates.width < windowWidth,
});
};
public render() {
if (!this.props.route) throw new Error('Route must not be null');
// Note: certain screens are kept into memory, in particular Notes and Search
// so that the scroll position is not lost when the user navigate away from them.
const route = this.props.route;
let Screen = null;
let notesScreenVisible = false;
let searchScreenVisible = false;
if (route.routeName === 'Notes') {
notesScreenVisible = true;
} else if (route.routeName === 'Search') {
searchScreenVisible = true;
} else {
Screen = this.props.screens[route.routeName].screen;
}
// Keep the search screen loaded if the user is viewing a note from that search screen
// so that if the back button is pressed, the screen is still loaded. However, unload
// it if navigating away.
const searchScreenLoaded = searchScreenVisible || (this.previousRouteName_ === 'Search' && route.routeName === 'Note');
this.previousRouteName_ = route.routeName;
const theme = themeStyle(this.props.themeId);
const style = { flex: 1, backgroundColor: theme.backgroundColor };
// When the floating keyboard is enabled, the KeyboardAvoidingView can have a very small
// height. Don't use the KeyboardAvoidingView when the floating keyboard is enabled.
// See https://github.com/facebook/react-native/issues/29473
const keyboardAvoidingViewEnabled = !this.state.floatingKeyboardEnabled;
return (
<KeyboardAvoidingView
enabled={keyboardAvoidingViewEnabled}
behavior={Platform.OS === 'ios' ? 'padding' : null}
style={style}
>
<NotesScreen visible={notesScreenVisible} />
{searchScreenLoaded && <SearchScreen visible={searchScreenVisible} />}
{!notesScreenVisible && !searchScreenVisible && <Screen navigation={{ state: route }} themeId={this.props.themeId} dispatch={this.props.dispatch} />}
<View style={{ height: this.state.autoCompletionBarExtraHeight }} />
</KeyboardAvoidingView>
);
}
}
const AppNav = connect((state: AppState) => {
return {

View File

@@ -15,7 +15,6 @@ const baseStyle = {
fontSizeSmaller: 14,
disabledOpacity: 0.2,
lineHeight: '1.6em',
listTabSize: '1.7em',
// The default, may be overridden in settings:
noteViewerFontSize: 16,
};

View File

@@ -7,8 +7,6 @@ import PluginService from '@joplin/lib/services/plugins/PluginService';
import WebviewController, { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
import useViewInfos from './hooks/useViewInfos';
import PluginPanelViewer from './PluginPanelViewer';
import FocusControl from '../../accessibility/FocusControl/FocusControl';
import { ModalState } from '../../accessibility/FocusControl/types';
interface Props {
themeId: number;
@@ -42,14 +40,12 @@ const PluginDialogManager: React.FC<Props> = props => {
visible={true}
onDismiss={() => dismissDialog(viewInfo)}
>
<FocusControl.ModalWrapper state={ModalState.Open}>
<PluginDialogWebView
viewInfo={viewInfo}
themeId={props.themeId}
pluginStates={props.pluginStates}
pluginHtmlContents={props.pluginHtmlContents}
/>
</FocusControl.ModalWrapper>
<PluginDialogWebView
viewInfo={viewInfo}
themeId={props.themeId}
pluginStates={props.pluginStates}
pluginHtmlContents={props.pluginHtmlContents}
/>
</Modal>
</Portal>,
);

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