1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-01-08 00:14:28 +02:00

Compare commits

..

24 Commits

Author SHA1 Message Date
Laurent Cozic
831a0480c9 doc 2023-09-18 12:51:21 +01:00
Laurent Cozic
fc8e54319d refactor 2023-09-17 19:16:17 +01:00
Laurent Cozic
eb24e5328d update 2023-09-17 19:12:37 +01:00
Laurent Cozic
aacec5ad84 support for note.tags 2023-09-17 18:33:21 +01:00
Laurent Cozic
0a61fc6d1a Merge branch 'dev' into note_list_plugin 2023-09-17 18:09:50 +01:00
Laurent Cozic
4908bcf226 Merge branch 'dev' into note_list_plugin 2023-09-17 18:08:13 +01:00
Laurent Cozic
da9b36df77 speed up thumb rendering 2023-09-17 18:04:53 +01:00
Laurent Cozic
0228da1131 fix ts 2023-09-17 17:59:00 +01:00
Laurent Cozic
829b0b60f2 comments 2023-09-17 17:56:21 +01:00
Laurent Cozic
79b79d68fd refactor note item 2023-09-17 17:52:31 +01:00
Laurent Cozic
6d72166eb3 update 2023-09-17 15:14:40 +01:00
Laurent Cozic
b14c85771c Merge branch 'dev' into note_list_plugin 2023-09-17 14:11:08 +01:00
Laurent Cozic
073fd5f9fa update 2023-09-17 13:17:28 +01:00
Laurent Cozic
f4490ef3ed plugin types 2023-09-17 13:15:37 +01:00
Laurent Cozic
517788a6c6 Merge branch 'dev' into note_list_plugin 2023-09-17 11:48:03 +01:00
Laurent Cozic
96b2929846 Delete packages/app-cli/tests/support/updateTypes.sh 2023-09-17 10:14:15 +01:00
Laurent Cozic
eb1915e495 demo plugin 2023-09-17 10:11:33 +01:00
Laurent Cozic
966d3a0535 update 2023-09-16 18:24:33 +01:00
Laurent Cozic
dd2212b559 udpate plugins 2023-09-16 18:01:24 +01:00
Laurent Cozic
46a04bf5e7 Merge branch 'dev' into note_list_plugin 2023-09-16 12:08:23 +01:00
Laurent Cozic
6034c0ba25 update 2023-09-16 10:34:20 +01:00
Laurent Cozic
99cf53e971 Merge branch 'dev' into note_list_plugin 2023-09-16 09:35:15 +01:00
Laurent Cozic
1a4418ab70 support multiple renderer in menu 2023-09-16 09:12:27 +01:00
Laurent Cozic
cdece235ab init 2023-09-15 12:22:08 +01:00
228 changed files with 4357 additions and 10625 deletions

View File

@@ -47,7 +47,7 @@ packages/app-desktop/packageInfo.js
packages/app-desktop/services/electron-context-menu.js
packages/app-desktop/vendor/lib/
packages/app-mobile/android
packages/app-mobile/components/NoteEditor/**/*.bundle.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.bundle.js
packages/app-mobile/ios
packages/app-mobile/lib/rnInjectedJs/
packages/app-mobile/locales
@@ -215,11 +215,12 @@ packages/app-desktop/gui/MenuBar.js
packages/app-desktop/gui/MultiNoteActions.js
packages/app-desktop/gui/Navigator.js
packages/app-desktop/gui/NoteContentPropertiesDialog.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/styles/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/setupVim.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js
@@ -231,13 +232,6 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useStyles.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useWebviewIpcMessage.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands.js
@@ -413,24 +407,28 @@ packages/app-mobile/components/ExtendedWebView.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Modal.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/createEditor.js
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/forceFullParse.js
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/loadLanguages.js
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js
packages/app-mobile/components/NoteEditor/CodeMirror/types.js
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/autosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/applyTemplateToEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.test.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js
@@ -441,6 +439,7 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/SelectionFormatting.js
packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteList.js
packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js
@@ -462,7 +461,6 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExport
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/exportAllFolders.js
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/Notes.js
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
@@ -481,7 +479,6 @@ packages/app-mobile/services/voiceTyping/vosk.ios.js
packages/app-mobile/setupQuickActions.js
packages/app-mobile/tools/buildInjectedJs.js
packages/app-mobile/utils/ShareExtension.js
packages/app-mobile/utils/ShareUtils.test.js
packages/app-mobile/utils/ShareUtils.js
packages/app-mobile/utils/TlsUtils.js
packages/app-mobile/utils/autodetectTheme.js
@@ -492,39 +489,6 @@ packages/app-mobile/utils/fs-driver-rn.js
packages/app-mobile/utils/setupNotifications.js
packages/app-mobile/utils/shareHandler.js
packages/app-mobile/utils/types.js
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js
packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.js
packages/editor/CodeMirror/CodeMirrorControl.test.js
packages/editor/CodeMirror/CodeMirrorControl.js
packages/editor/CodeMirror/PluginLoader.js
packages/editor/CodeMirror/configFromSettings.js
packages/editor/CodeMirror/createEditor.test.js
packages/editor/CodeMirror/createEditor.js
packages/editor/CodeMirror/editorCommands/editorCommands.js
packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/editorCommands/swapLine.js
packages/editor/CodeMirror/getScrollFraction.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.test.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.js
packages/editor/CodeMirror/markdown/decoratorExtension.js
packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.js
packages/editor/CodeMirror/markdown/markdownCommands.test.js
packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js
packages/editor/CodeMirror/markdown/markdownCommands.js
packages/editor/CodeMirror/markdown/markdownMathParser.test.js
packages/editor/CodeMirror/markdown/markdownMathParser.js
packages/editor/CodeMirror/markdown/markdownReformatter.test.js
packages/editor/CodeMirror/markdown/markdownReformatter.js
packages/editor/CodeMirror/markdown/syntaxHighlightingLanguages.js
packages/editor/CodeMirror/testUtil/createEditorSettings.js
packages/editor/CodeMirror/testUtil/createTestEditor.js
packages/editor/CodeMirror/testUtil/forceFullParse.js
packages/editor/CodeMirror/testUtil/loadLanguages.js
packages/editor/CodeMirror/theme.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
packages/editor/types.js
packages/fork-htmlparser2/src/CollectingHandler.js
packages/fork-htmlparser2/src/FeedHandler.spec.js
packages/fork-htmlparser2/src/FeedHandler.js
@@ -803,7 +767,6 @@ packages/lib/services/rest/routes/master_keys.js
packages/lib/services/rest/routes/notes.js
packages/lib/services/rest/routes/ping.js
packages/lib/services/rest/routes/resources.js
packages/lib/services/rest/routes/revisions.js
packages/lib/services/rest/routes/search.js
packages/lib/services/rest/routes/tags.js
packages/lib/services/rest/utils/collectionToPaginatedResults.js

View File

@@ -86,7 +86,7 @@ if [ "$IS_PULL_REQUEST" == "1" ] || [ "$IS_DEV_BRANCH" = "1" ]; then
# Allocation failed - JavaScript heap out of memory
#
# https://stackoverflow.com/questions/38558989
export NODE_OPTIONS="--max-old-space-size=32768"
export NODE_OPTIONS="--max-old-space-size=4096"
yarn run test-ci
testResult=$?
if [ $testResult -ne 0 ]; then

View File

@@ -3,9 +3,7 @@ on: [push, pull_request]
jobs:
pre_job:
if: github.repository == 'laurent22/joplin'
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
# https://github.com/actions/runner-images/issues/6709
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip }}
steps:
@@ -21,9 +19,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
# https://github.com/actions/runner-images/issues/6709
os: [macos-latest, ubuntu-20.04, windows-2019]
os: [macos-latest, ubuntu-latest, windows-2019]
steps:
# Trying to fix random networking issues on Windows
@@ -143,9 +139,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
# https://github.com/actions/runner-images/issues/6709
os: [ubuntu-20.04]
os: [ubuntu-latest]
steps:
- name: Install Docker Engine

77
.gitignore vendored
View File

@@ -201,11 +201,12 @@ packages/app-desktop/gui/MenuBar.js
packages/app-desktop/gui/MultiNoteActions.js
packages/app-desktop/gui/Navigator.js
packages/app-desktop/gui/NoteContentPropertiesDialog.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/styles/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/setupVim.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js
@@ -217,13 +218,6 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useStyles.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useWebviewIpcMessage.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands.js
@@ -399,24 +393,28 @@ packages/app-mobile/components/ExtendedWebView.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Modal.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/createEditor.js
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/forceFullParse.js
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/loadLanguages.js
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js
packages/app-mobile/components/NoteEditor/CodeMirror/types.js
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/autosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/applyTemplateToEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.test.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js
@@ -427,6 +425,7 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/SelectionFormatting.js
packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteList.js
packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js
@@ -448,7 +447,6 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExport
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/exportAllFolders.js
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/Notes.js
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
@@ -467,7 +465,6 @@ packages/app-mobile/services/voiceTyping/vosk.ios.js
packages/app-mobile/setupQuickActions.js
packages/app-mobile/tools/buildInjectedJs.js
packages/app-mobile/utils/ShareExtension.js
packages/app-mobile/utils/ShareUtils.test.js
packages/app-mobile/utils/ShareUtils.js
packages/app-mobile/utils/TlsUtils.js
packages/app-mobile/utils/autodetectTheme.js
@@ -478,39 +475,6 @@ packages/app-mobile/utils/fs-driver-rn.js
packages/app-mobile/utils/setupNotifications.js
packages/app-mobile/utils/shareHandler.js
packages/app-mobile/utils/types.js
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js
packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.js
packages/editor/CodeMirror/CodeMirrorControl.test.js
packages/editor/CodeMirror/CodeMirrorControl.js
packages/editor/CodeMirror/PluginLoader.js
packages/editor/CodeMirror/configFromSettings.js
packages/editor/CodeMirror/createEditor.test.js
packages/editor/CodeMirror/createEditor.js
packages/editor/CodeMirror/editorCommands/editorCommands.js
packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/editorCommands/swapLine.js
packages/editor/CodeMirror/getScrollFraction.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.test.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.js
packages/editor/CodeMirror/markdown/decoratorExtension.js
packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.js
packages/editor/CodeMirror/markdown/markdownCommands.test.js
packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js
packages/editor/CodeMirror/markdown/markdownCommands.js
packages/editor/CodeMirror/markdown/markdownMathParser.test.js
packages/editor/CodeMirror/markdown/markdownMathParser.js
packages/editor/CodeMirror/markdown/markdownReformatter.test.js
packages/editor/CodeMirror/markdown/markdownReformatter.js
packages/editor/CodeMirror/markdown/syntaxHighlightingLanguages.js
packages/editor/CodeMirror/testUtil/createEditorSettings.js
packages/editor/CodeMirror/testUtil/createTestEditor.js
packages/editor/CodeMirror/testUtil/forceFullParse.js
packages/editor/CodeMirror/testUtil/loadLanguages.js
packages/editor/CodeMirror/theme.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
packages/editor/types.js
packages/fork-htmlparser2/src/CollectingHandler.js
packages/fork-htmlparser2/src/FeedHandler.spec.js
packages/fork-htmlparser2/src/FeedHandler.js
@@ -789,7 +753,6 @@ packages/lib/services/rest/routes/master_keys.js
packages/lib/services/rest/routes/notes.js
packages/lib/services/rest/routes/ping.js
packages/lib/services/rest/routes/resources.js
packages/lib/services/rest/routes/revisions.js
packages/lib/services/rest/routes/search.js
packages/lib/services/rest/routes/tags.js
packages/lib/services/rest/utils/collectionToPaginatedResults.js

View File

@@ -5,7 +5,6 @@
"exceptions": [
"@joplin/lib",
"@joplin/renderer",
"@joplin/editor",
"@joplin/pdf-viewer",
"@joplin/fork-htmlparser2",
"@joplin/fork-sax",

View File

@@ -1,15 +0,0 @@
<svg width="84" height="84" viewBox="0 0 84 84" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_52_28)">
<rect width="84" height="84" fill="url(#paint0_linear_52_28)"/>
<path d="M66 48.5424C66 51.4195 64.8173 54.0531 62.9108 55.9371C61.0108 57.8274 58.3546 59 55.4529 59H49.1324V53.5533H55.4529C56.8553 53.5533 58.1026 52.9959 59.0268 52.086C59.9445 51.1696 60.5067 49.9393 60.5067 48.5424C60.5067 47.1519 59.9445 45.9152 59.0268 44.9989C58.1026 44.089 56.8618 43.5315 55.4529 43.5315H50.1083L50.4056 40.539C50.4379 40.2314 50.4508 39.9303 50.4508 39.6163C50.4508 37.0724 49.4168 34.7976 47.7429 33.1316C46.0626 31.4655 43.7684 30.4467 41.2027 30.4467C38.637 30.4467 36.3428 31.4719 34.6625 33.1316C32.9822 34.7976 31.9546 37.0724 31.9546 39.6163C31.9546 39.9239 31.9675 40.2314 31.9999 40.539L32.2971 43.5315H27.5535C26.1511 43.5315 24.9039 44.089 23.9797 44.9989C23.062 45.9152 22.4997 47.1455 22.4997 48.5424C22.4997 49.9329 23.062 51.1696 23.9797 52.086C24.9039 52.9959 26.1447 53.5533 27.5535 53.5533H35.2118C36.6077 53.3291 37.9003 52.6755 38.9278 51.663C40.2139 50.3815 40.9248 48.6834 40.9248 46.8828V41.0837H35.3605V38.9819C35.3605 37.1941 36.8275 35.7331 38.637 35.7331H46.3211V46.8828C46.3211 50.0739 45.0222 53.1945 42.7473 55.4437C40.7439 57.4365 38.0619 58.6732 35.2441 58.9359L34.5332 59H27.5471C24.6453 59 21.9892 57.8274 20.0892 55.9371C18.1827 54.0531 17 51.4195 17 48.5424C17 45.6653 18.1827 43.0317 20.0892 41.1478C21.763 39.4817 24.0249 38.3732 26.526 38.1361C26.875 34.6951 28.4389 31.5937 30.772 29.2805C33.4346 26.6404 37.1377 24.9936 41.1963 25C45.2613 25 48.9644 26.6404 51.6205 29.2805C53.9471 31.5809 55.5046 34.6759 55.8601 38.1041C58.6002 38.213 61.0884 39.3472 62.8979 41.1478C64.8173 43.0317 66 45.6653 66 48.5424Z" fill="white"/>
</g>
<defs>
<linearGradient id="paint0_linear_52_28" x1="3" y1="4" x2="78" y2="79" gradientUnits="userSpaceOnUse">
<stop offset="0.14" stop-color="#3873DB"/>
<stop offset="0.946667" stop-color="#163467"/>
</linearGradient>
<clipPath id="clip0_52_28">
<rect width="84" height="84" rx="20" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,75 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="84"
height="84"
viewBox="0 0 84 84"
fill="none"
version="1.1"
id="svg938"
sodipodi:docname="JoplinCloudIcon copie.svg"
inkscape:version="1.1.1 (c3084ef, 2021-09-22)"
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="namedview940"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="3.5021331"
inkscape:cx="-6.1391156"
inkscape:cy="29.838957"
inkscape:window-width="1310"
inkscape:window-height="969"
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="g926" />
<g
clip-path="url(#clip0_52_28)"
id="g926">
<rect
width="84"
height="84"
fill="url(#paint0_linear_52_28)"
id="rect922" />
<path
d="m 73.705932,49.824628 c 0,3.73216 -1.534192,7.148453 -4.007294,9.592368 -2.464671,2.452087 -5.91028,3.973178 -9.674351,3.973178 h -8.198921 v -7.065432 h 8.198921 c 1.819186,0 3.437178,-0.723056 4.636045,-1.903374 1.190436,-1.188749 1.919719,-2.784688 1.919719,-4.59674 0,-1.803749 -0.729283,-3.40799 -1.919719,-4.59661 -1.198867,-1.180318 -2.808427,-1.903504 -4.636045,-1.903504 h -6.932988 l 0.385656,-3.881856 c 0.0419,-0.399017 0.05863,-0.789603 0.05863,-1.196922 0,-3.299934 -1.341299,-6.250793 -3.512674,-8.41192 -2.179677,-2.161256 -5.155701,-3.482839 -8.483914,-3.482839 -3.328214,0 -6.304238,1.329885 -8.483915,3.482839 -2.179677,2.161127 -3.512674,5.111986 -3.512674,8.41192 0,0.399017 0.01673,0.797905 0.05876,1.196922 l 0.385526,3.881856 H 23.83332 c -1.819186,0 -3.437048,0.723186 -4.635915,1.903504 -1.190436,1.18862 -1.919849,2.784559 -1.919849,4.59661 0,1.80375 0.729413,3.407991 1.919849,4.59674 1.198867,1.180318 2.808427,1.903374 4.635915,1.903374 h 9.934308 c 1.810755,-0.290831 3.487509,-1.138677 4.820377,-2.452087 1.668322,-1.662356 2.590498,-3.865122 2.590498,-6.200852 V 40.14924 h -7.217982 v -2.726444 c 0,-2.319125 1.902985,-4.214327 4.250259,-4.214327 h 9.967776 v 14.463334 c 0,4.139479 -1.684926,8.187506 -4.635915,11.105157 -2.598801,2.58505 -6.077877,4.189291 -9.733113,4.530064 l -0.922176,0.08315 h -9.062334 c -3.7642,0 -7.20968,-1.521091 -9.67435,-3.973178 -2.473097,-2.443915 -4.007289,-5.860208 -4.007289,-9.592368 0,-3.732159 1.534192,-7.148452 4.007295,-9.592238 2.171245,-2.161256 5.10537,-3.599197 8.349784,-3.906762 0.452721,-4.463648 2.481404,-8.486768 5.50789,-11.487439 3.453911,-3.424725 8.257554,-5.560946 13.522349,-5.552643 5.273098,0 10.076741,2.127918 13.52222,5.552643 3.018054,2.984067 5.038435,6.998885 5.499588,11.445929 3.554444,0.141265 6.782124,1.612543 9.129399,3.948272 2.489836,2.443786 4.024028,5.860079 4.024028,9.592238 z"
fill="#ffffff"
id="path924"
style="stroke-width:1.29719" />
</g>
<defs
id="defs936">
<linearGradient
id="paint0_linear_52_28"
x1="3"
y1="4"
x2="78"
y2="79"
gradientUnits="userSpaceOnUse">
<stop
offset="0.14"
stop-color="#3873DB"
id="stop928" />
<stop
offset="0.946667"
stop-color="#163467"
id="stop930" />
</linearGradient>
<clipPath
id="clip0_52_28">
<rect
width="84"
height="84"
rx="20"
fill="white"
id="rect933" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1,16 +0,0 @@
<svg width="84" height="84" viewBox="0 0 84 84" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_309_806)">
<rect width="84" height="84" fill="url(#paint0_linear_309_806)"/>
<path d="M42.5797 45.6916H33.6235V36.276C33.6235 33.3538 34.7629 30.6033 36.8396 28.5204C38.4998 26.8725 40.5826 25.8127 42.8431 25.4513V16.7033C38.2854 17.1138 33.9359 19.1354 30.6891 22.3822C27.0258 26.0333 24.9307 31.0995 24.9307 36.2821V54.3845H37.3052C40.2151 54.3845 42.5797 52.0198 42.5797 49.11V45.6916Z" fill="#1883E6"/>
<path d="M42.4204 35.89V39.3022H51.3767V48.7179C51.3767 51.64 50.2372 54.3906 48.1605 56.4735C46.5003 58.1214 44.4175 59.1812 42.157 59.5426V68.2906C46.7147 67.8801 51.0642 65.8585 54.311 62.6117C57.9744 58.9606 60.0695 53.8944 60.0695 48.7118V30.6155H47.6949C44.7851 30.6155 42.4204 32.9863 42.4204 35.89Z" fill="#1883E6"/>
</g>
<defs>
<linearGradient id="paint0_linear_309_806" x1="8.5" y1="3" x2="76.5" y2="81.5" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#B6D3F9"/>
</linearGradient>
<clipPath id="clip0_309_806">
<rect width="84" height="84" rx="20" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -667,30 +667,6 @@ footer .bottom-links-row p {
color: #0557ba;
}
.joplin-cloud-feature-list .feature-description {
max-width: 600px;
font-size: .8em;
color: #555555;
margin-top: 5px;
}
.joplin-cloud-feature-list .feature-title {
text-decoration: none;
color: #000000;
margin-left: 10px;
border: 2px solid black;
display: inline-block;
width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 100px;
height: 20px;
font-weight: bold;
font-size: 0.8em;
opacity: 0.5;
}
/*****************************************************************
WHAT'S NEW PAGE
*****************************************************************/

View File

@@ -86,7 +86,11 @@ https://github.com/laurent22/joplin/blob/dev/{{{sourceMarkdownFile}}}
{{> footer}}
</div>
<script src="{{jsBaseUrl}}/bootstrap5.0.2.bundle.min.js" rel="preload" as="script"></script>
<script
src="{{jsBaseUrl}}/bootstrap5.0.2.bundle.min.js"
rel="preload"
as="script"
></script>
<script src="{{{assetUrls.js.script}}}"></script>
{{> analytics}}

View File

@@ -138,14 +138,6 @@
} else {
applyPeriod('yearly');
}
$('.feature-description').hide();
$('.feature-title').click((event) => {
event.preventDefault();
const featureId = event.currentTarget.getAttribute('data-id');
$('.feature-description-' + featureId).toggle(200);
});
});
</script>
</div>

View File

@@ -22,11 +22,11 @@ Three types of applications are available: for **desktop** (Windows, macOS and L
Operating System | Download
---|---
Windows (32 and 64-bit) | <a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-Setup-2.12.18.exe?source=JoplinWebsite&type=New'><img alt='Get it on Windows' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeWindows.png'/></a>
macOS | <a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-2.12.18.dmg?source=JoplinWebsite&type=New'><img alt='Get it on macOS' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeMacOS.png'/></a>
Linux | <a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-2.12.18.AppImage?source=JoplinWebsite&type=New'><img alt='Get it on Linux' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeLinux.png'/></a>
Windows (32 and 64-bit) | <a href='https://objects.joplinusercontent.com/v2.12.17/Joplin-Setup-2.12.17.exe?source=JoplinWebsite&type=New'><img alt='Get it on Windows' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeWindows.png'/></a>
macOS | <a href='https://objects.joplinusercontent.com/v2.12.17/Joplin-2.12.17.dmg?source=JoplinWebsite&type=New'><img alt='Get it on macOS' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeMacOS.png'/></a>
Linux | <a href='https://objects.joplinusercontent.com/v2.12.17/Joplin-2.12.17.AppImage?source=JoplinWebsite&type=New'><img alt='Get it on Linux' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeLinux.png'/></a>
**On Windows**, you may also use the <a href='https://objects.joplinusercontent.com/v2.12.18/JoplinPortable.exe?source=JoplinWebsite&type=New'>Portable version</a>. The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
**On Windows**, you may also use the <a href='https://objects.joplinusercontent.com/v2.12.17/JoplinPortable.exe?source=JoplinWebsite&type=New'>Portable version</a>. The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
**On Linux**, the recommended way is to use the following installation script as it will handle the desktop icon too:

View File

@@ -68,10 +68,10 @@
"devDependencies": {
"@joplin/utils": "~2.12",
"@seiyab/eslint-plugin-react-hooks": "4.5.1-beta.0",
"@typescript-eslint/eslint-plugin": "6.0.0",
"@typescript-eslint/parser": "6.0.0",
"@typescript-eslint/eslint-plugin": "5.62.0",
"@typescript-eslint/parser": "5.62.0",
"cspell": "5.21.2",
"eslint": "8.47.0",
"eslint": "8.46.0",
"eslint-interactive": "10.8.0",
"eslint-plugin-import": "2.28.1",
"eslint-plugin-jest": "27.2.3",
@@ -79,27 +79,27 @@
"eslint-plugin-react": "7.33.2",
"execa": "5.1.1",
"fs-extra": "11.1.1",
"glob": "10.3.4",
"glob": "10.3.3",
"gulp": "4.0.2",
"husky": "3.1.0",
"lerna": "3.22.1",
"lint-staged": "13.3.0",
"lint-staged": "13.2.3",
"madge": "6.1.0",
"npm-package-json-lint": "7.0.0",
"npm-package-json-lint": "6.4.0",
"typescript": "5.1.6"
},
"dependencies": {
"@types/fs-extra": "11.0.2",
"@types/fs-extra": "11.0.1",
"eslint-plugin-github": "4.9.2",
"http-server": "14.1.1",
"node-gyp": "9.4.0",
"nodemon": "3.0.1"
"nodemon": "2.0.22"
},
"packageManager": "yarn@3.6.3",
"resolutions": {
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
"react-native-vosk@0.1.12": "patch:react-native-vosk@npm%3A0.1.12#./.yarn/patches/react-native-vosk-npm-0.1.12-76b1caaae8.patch",
"eslint": "patch:eslint@8.47.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
"eslint": "patch:eslint@npm%3A8.39.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
"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",
"react-native@0.71.10": "patch:react-native@npm%3A0.71.10#./.yarn/patches/react-native-animation-fix/react-native-npm-0.71.10-f9c32562d8.patch"
}

View File

@@ -50,9 +50,6 @@ class Command extends BaseCommand {
{
type: BaseModel.TYPE_TAG,
},
{
type: BaseModel.TYPE_REVISION,
},
];
const lines = [];
@@ -414,7 +411,7 @@ async function fetchAllNotes() {
lines.push('');
lines.push('If no `cursor` property is provided, the API will respond with the latest change ID. That can be used to retrieve future events later on.');
lines.push('');
lines.push('The results are paginated so you may need multiple calls to retrieve all the events. Use the `has_more` property to know if more can be retrieved.');
lines.push('The results are paginated so will need to may multiple calls to retrieve all the events. Use the `has_more` property to know if more can be retrieved.');
lines.push('');
lines.push('## GET /events/:id');
lines.push('');

View File

@@ -58,7 +58,7 @@
"read-chunk": "2.1.0",
"server-destroy": "1.0.1",
"sharp": "0.32.5",
"sprintf-js": "1.1.3",
"sprintf-js": "1.1.2",
"sqlite3": "5.1.6",
"string-padding": "1.0.2",
"strip-ansi": "6.0.1",
@@ -71,9 +71,9 @@
},
"devDependencies": {
"@joplin/tools": "~2.13",
"@types/fs-extra": "11.0.2",
"@types/fs-extra": "11.0.1",
"@types/jest": "29.5.4",
"@types/node": "18.17.17",
"@types/node": "18.17.12",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.6.4",

View File

@@ -1,204 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
<en-export export-date="20230724T173816Z" application="Evernote" version="10.58.8">
<note>
<title>Boomwhackers - Rio</title>
<created>20230412T112328Z</created>
<updated>20230524T133616Z</updated>
<note-attributes>
</note-attributes>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div><br/></div><en-media hash="b687cb1ab2d8f3f10b95c3a500841c66" type="application/zip" /><div><br/></div></en-note> ]]>
</content>
<resource>
<data encoding="base64">
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
</data>
<mime>application/zip</mime>
<resource-attributes>
<file-name>Boomwhackers - Rio.mscz</file-name>
<source-url>en-cache://tokenKey%3D%22AuthToken%3AUser%3A75937676%22+32040eaf-3c97-1f46-f34c-1d4e8608c682+b687cb1ab2d8f3f10b95c3a500841c66+https://www.evernote.com/shard/s438/res/9521d2af-a8de-26f3-4f86-8f7f4deebb6b</source-url>
</resource-attributes>
</resource>
</note>
</en-export>

File diff suppressed because one or more lines are too long

View File

@@ -2,20 +2,5 @@ joplin.plugins.register({
onStart: async function() {
const folder = await joplin.data.post(['folders'], null, { title: "my plugin folder" });
await joplin.data.post(['notes'], null, { parent_id: folder.id, title: "testing plugin!" });
// await joplin.commands.register({
// name: 'updateCurrentNote',
// label: 'Update current note via the data API',
// iconName: 'fas fa-music',
// execute: async () => {
// const noteIds = await joplin.workspace.selectedNoteIds();
// const noteId = noteIds.length === 1 ? noteIds[0] : null;
// if (!noteId) return;
// console.info('Modifying current note...');
// await joplin.data.put(['notes', noteId], null, { body: "New note body " + Date.now() });
// },
// });
// await joplin.views.toolbarButtons.create('updateCurrentNoteButton', 'updateCurrentNote', 'editorToolbar');
},
});

View File

@@ -1,7 +0,0 @@
---
title: Example
---
note body

View File

@@ -1,4 +0,0 @@
---
title: Example
---
note body

View File

@@ -1,5 +1,6 @@
// v0.4.4 - https://github.com/mozilla/readability/commit/49d345a455da1f4aa93f8b41e0f50422f9959c7c
// v0.4.1 - https://github.com/mozilla/readability/commit/28843b6de84447dd6cef04058fda336938e628dc
/* eslint-env es6:false */
/*
* Copyright (c) 2010 Arc90 Inc
*
@@ -54,7 +55,7 @@ var REGEXPS = {
var defaultOptions = { minScore: 20, minContentLength: 140, visibilityChecker: isNodeVisible };
options = Object.assign(defaultOptions, options);
var nodes = doc.querySelectorAll("p, pre, article");
var nodes = doc.querySelectorAll("p, pre");
// Get <div> nodes which have <br> node(s) and append them into the `nodes` variable.
// Some articles' DOM structures might look like
@@ -105,6 +106,5 @@ var REGEXPS = {
}
if (typeof module === "object") {
/* global module */
module.exports = isProbablyReaderable;
}

View File

@@ -1,5 +1,6 @@
// v0.4.4 - https://github.com/mozilla/readability/commit/49d345a455da1f4aa93f8b41e0f50422f9959c7c
// v0.4.1 - https://github.com/mozilla/readability/commit/28843b6de84447dd6cef04058fda336938e628dc
/*eslint-env es6:false*/
/*
* Copyright (c) 2010 Arc90 Inc
*
@@ -26,7 +27,7 @@
* @param {HTMLDocument} doc The document to parse.
* @param {Object} options The options object.
*/
function Readability(doc, options) {
function Readability(doc, options) {
// In some older versions, people passed a URI as the first argument. Cope:
if (options && options.documentElement) {
doc = options;
@@ -55,7 +56,6 @@ function Readability(doc, options) {
return el.innerHTML;
};
this._disableJSONLD = !!options.disableJSONLD;
this._allowedVideoRegex = options.allowedVideoRegex || this.REGEXPS.videos;
// Start with all flags set
this._flags = this.FLAG_STRIP_UNLIKELYS |
@@ -75,7 +75,12 @@ function Readability(doc, options) {
return `<${node.localName} ${attrPairs}>`;
};
this.log = function () {
if (typeof console !== "undefined") {
if (typeof dump !== "undefined") {
var msg = Array.prototype.map.call(arguments, function(x) {
return (x && x.nodeName) ? logNode(x) : x;
}).join(" ");
dump("Reader: (Readability) " + msg + "\n");
} else if (typeof console !== "undefined") {
let args = Array.from(arguments, arg => {
if (arg && arg.nodeType == this.ELEMENT_NODE) {
return logNode(arg);
@@ -84,12 +89,6 @@ function Readability(doc, options) {
});
args.unshift("Reader: (Readability)");
console.log.apply(console, args);
} else if (typeof dump !== "undefined") {
/* global dump */
var msg = Array.prototype.map.call(arguments, function(x) {
return (x && x.nodeName) ? logNode(x) : x;
}).join(" ");
dump("Reader: (Readability) " + msg + "\n");
}
};
} else {
@@ -143,9 +142,6 @@ function Readability(doc, options) {
hashUrl: /^#.+/,
srcsetUrl: /(\S+)(\s+[\d.]+[xw])?(\s*(?:,|$))/g,
b64DataUrl: /^data:\s*([^\s;,]+)\s*;\s*base64\s*,/i,
// Commas as used in Latin, Sindhi, Chinese and various other scripts.
// see: https://en.wikipedia.org/wiki/Comma#Comma_variants
commas: /\u002C|\u060C|\uFE50|\uFE10|\uFE11|\u2E41|\u2E34|\u2E32|\uFF0C/g,
// See: https://schema.org/Article
jsonLdArticleTypes: /^Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference$/
},
@@ -902,11 +898,6 @@ function Readability(doc, options) {
let shouldRemoveTitleHeader = true;
while (node) {
if (node.tagName === "HTML") {
this._articleLang = node.getAttribute("lang");
}
var matchString = node.className + " " + node.id;
if (!this._isProbablyVisible(node)) {
@@ -915,12 +906,6 @@ function Readability(doc, options) {
continue;
}
// User is not able to see elements applied with both "aria-modal = true" and "role = dialog"
if (node.getAttribute("aria-modal") == "true" && node.getAttribute("role") == "dialog") {
node = this._removeAndGetNext(node);
continue;
}
// Check to see if this node is a byline, and remove it if it is.
if (this._checkByline(node, matchString)) {
node = this._removeAndGetNext(node);
@@ -1035,7 +1020,7 @@ function Readability(doc, options) {
contentScore += 1;
// Add points for any commas within this paragraph.
contentScore += innerText.split(this.REGEXPS.commas).length;
contentScore += innerText.split(",").length;
// For every 100 characters in this paragraph, add another point. Up to 3 points.
contentScore += Math.min(Math.floor(innerText.length / 100), 3);
@@ -1374,88 +1359,72 @@ function Readability(doc, options) {
_getJSONLD: function (doc) {
var scripts = this._getAllNodesWithTag(doc, ["script"]);
var metadata;
this._forEachNode(scripts, function(jsonLdElement) {
if (!metadata && jsonLdElement.getAttribute("type") === "application/ld+json") {
try {
// Strip CDATA markers if present
var content = jsonLdElement.textContent.replace(/^\s*<!\[CDATA\[|\]\]>\s*$/g, "");
var parsed = JSON.parse(content);
if (
!parsed["@context"] ||
!parsed["@context"].match(/^https?\:\/\/schema\.org$/)
) {
return;
}
if (!parsed["@type"] && Array.isArray(parsed["@graph"])) {
parsed = parsed["@graph"].find(function(it) {
return (it["@type"] || "").match(
this.REGEXPS.jsonLdArticleTypes
);
});
}
if (
!parsed ||
!parsed["@type"] ||
!parsed["@type"].match(this.REGEXPS.jsonLdArticleTypes)
) {
return;
}
metadata = {};
if (typeof parsed.name === "string" && typeof parsed.headline === "string" && parsed.name !== parsed.headline) {
// we have both name and headline element in the JSON-LD. They should both be the same but some websites like aktualne.cz
// put their own name into "name" and the article title to "headline" which confuses Readability. So we try to check if either
// "name" or "headline" closely matches the html title, and if so, use that one. If not, then we use "name" by default.
var title = this._getArticleTitle();
var nameMatches = this._textSimilarity(parsed.name, title) > 0.75;
var headlineMatches = this._textSimilarity(parsed.headline, title) > 0.75;
if (headlineMatches && !nameMatches) {
metadata.title = parsed.headline;
} else {
metadata.title = parsed.name;
}
} else if (typeof parsed.name === "string") {
metadata.title = parsed.name.trim();
} else if (typeof parsed.headline === "string") {
metadata.title = parsed.headline.trim();
}
if (parsed.author) {
if (typeof parsed.author.name === "string") {
metadata.byline = parsed.author.name.trim();
} else if (Array.isArray(parsed.author) && parsed.author[0] && typeof parsed.author[0].name === "string") {
metadata.byline = parsed.author
.filter(function(author) {
return author && typeof author.name === "string";
})
.map(function(author) {
return author.name.trim();
})
.join(", ");
}
}
if (typeof parsed.description === "string") {
metadata.excerpt = parsed.description.trim();
}
if (
parsed.publisher &&
typeof parsed.publisher.name === "string"
) {
metadata.siteName = parsed.publisher.name.trim();
}
return;
} catch (err) {
this.log(err.message);
}
}
var jsonLdElement = this._findNode(scripts, function(el) {
return el.getAttribute("type") === "application/ld+json";
});
return metadata ? metadata : {};
if (jsonLdElement) {
try {
// Strip CDATA markers if present
var content = jsonLdElement.textContent.replace(/^\s*<!\[CDATA\[|\]\]>\s*$/g, "");
var parsed = JSON.parse(content);
var metadata = {};
if (
!parsed["@context"] ||
!parsed["@context"].match(/^https?\:\/\/schema\.org$/)
) {
return metadata;
}
if (!parsed["@type"] && Array.isArray(parsed["@graph"])) {
parsed = parsed["@graph"].find(function(it) {
return (it["@type"] || "").match(
this.REGEXPS.jsonLdArticleTypes
);
});
}
if (
!parsed ||
!parsed["@type"] ||
!parsed["@type"].match(this.REGEXPS.jsonLdArticleTypes)
) {
return metadata;
}
if (typeof parsed.name === "string") {
metadata.title = parsed.name.trim();
} else if (typeof parsed.headline === "string") {
metadata.title = parsed.headline.trim();
}
if (parsed.author) {
if (typeof parsed.author.name === "string") {
metadata.byline = parsed.author.name.trim();
} else if (Array.isArray(parsed.author) && parsed.author[0] && typeof parsed.author[0].name === "string") {
metadata.byline = parsed.author
.filter(function(author) {
return author && typeof author.name === "string";
})
.map(function(author) {
return author.name.trim();
})
.join(", ");
}
}
if (typeof parsed.description === "string") {
metadata.excerpt = parsed.description.trim();
}
if (
parsed.publisher &&
typeof parsed.publisher.name === "string"
) {
metadata.siteName = parsed.publisher.name.trim();
}
return metadata;
} catch (err) {
this.log(err.message);
}
}
return {};
},
/**
@@ -1654,7 +1623,12 @@ function Readability(doc, options) {
* @param Element
**/
_removeScripts: function(doc) {
this._removeNodes(this._getAllNodesWithTag(doc, ["script", "noscript"]));
this._removeNodes(this._getAllNodesWithTag(doc, ["script"]), function(scriptNode) {
scriptNode.nodeValue = "";
scriptNode.removeAttribute("src");
return true;
});
this._removeNodes(this._getAllNodesWithTag(doc, ["noscript"]));
},
/**
@@ -1844,13 +1818,13 @@ function Readability(doc, options) {
if (isEmbed) {
// First, check the elements attributes to see if any of them contain youtube or vimeo
for (var i = 0; i < element.attributes.length; i++) {
if (this._allowedVideoRegex.test(element.attributes[i].value)) {
if (this.REGEXPS.videos.test(element.attributes[i].value)) {
return false;
}
}
// For embed with <object> tag, check inner HTML as well.
if (element.tagName === "object" && this._allowedVideoRegex.test(element.innerHTML)) {
if (element.tagName === "object" && this.REGEXPS.videos.test(element.innerHTML)) {
return false;
}
}
@@ -2119,13 +2093,13 @@ function Readability(doc, options) {
for (var i = 0; i < embeds.length; i++) {
// If this embed has attribute that matches video regex, don't delete it.
for (var j = 0; j < embeds[i].attributes.length; j++) {
if (this._allowedVideoRegex.test(embeds[i].attributes[j].value)) {
if (this.REGEXPS.videos.test(embeds[i].attributes[j].value)) {
return false;
}
}
// For embed with <object> tag, check inner HTML as well.
if (embeds[i].tagName === "object" && this._allowedVideoRegex.test(embeds[i].innerHTML)) {
if (embeds[i].tagName === "object" && this.REGEXPS.videos.test(embeds[i].innerHTML)) {
return false;
}
@@ -2143,21 +2117,6 @@ function Readability(doc, options) {
(!isList && weight < 25 && linkDensity > 0.2) ||
(weight >= 25 && linkDensity > 0.5) ||
((embedCount === 1 && contentLength < 75) || embedCount > 1);
// Allow simple lists of images to remain in pages
if (isList && haveToRemove) {
for (var x = 0; x < node.children.length; x++) {
let child = node.children[x];
// Don't filter in lists with li's that contain more than one child
if (child.children.length > 1) {
return haveToRemove;
}
}
let li_count = node.getElementsByTagName("li").length;
// Only allow the list to remain if every li contains an image
if (img == li_count) {
return false;
}
}
return haveToRemove;
}
return false;
@@ -2290,7 +2249,6 @@ function Readability(doc, options) {
title: this._articleTitle,
byline: metadata.byline || this._articleByline,
dir: this._articleDir,
lang: this._articleLang,
content: this._serializer(articleContent),
textContent: textContent,
length: textContent.length,
@@ -2301,6 +2259,5 @@ function Readability(doc, options) {
};
if (typeof module === "object") {
/* global module */
module.exports = Readability;
}

View File

@@ -422,27 +422,6 @@
cleanUpElement(convertToMarkup, cleanDocument, imageSizes, imageIndexes);
const stylesheets = convertToMarkup === 'html' ? getStyleSheets(document) : null;
// The <BODY> tag may have a style in the CSS stylesheets. This
// style can be overriden by setting the `style` attribute on the
// BODY tag. Since we don't keep the body tag, it means we may be
// missing some styling, which may break the page.
//
// For example, on this page:
// https://devblogs.microsoft.com/oldnewthing/20180529-00/?p=98855
// The BODY tag has visibility set to hidden in the stylesheet, and
// made visible by setting the style attribute. Because of that,
// previously that imported note would show blank content, while now
// it will be visible.
//
// Fixes https://github.com/laurent22/joplin/issues/7925
if (document.body.getAttribute('style')) {
stylesheets.push({
type: 'text',
value: `body { ${document.body.getAttribute('style')} }`,
});
}
return clippedContentResponse(pageTitle(), cleanDocument.innerHTML, imageSizes, getAnchorNames(document), stylesheets);
} else if (command.name === 'selectedHtml') {

View File

@@ -67,7 +67,6 @@ import eventManager from '@joplin/lib/eventManager';
import path = require('path');
import { checkPreInstalledDefaultPlugins, installDefaultPlugins, setSettingsForDefaultPlugins } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils';
import userFetcher, { initializeUserFetcher } from '@joplin/lib/utils/userFetcher';
import { parseNotesParent } from '@joplin/lib/reducer';
const pluginClasses = [
require('./plugins/GotoAnything').default,
@@ -193,7 +192,7 @@ class Application extends BaseApplication {
// The '*' and '!important' parts are necessary to make sure Russian text is displayed properly
// https://github.com/laurent22/joplin/issues/155
const css = `.CodeMirror *, .cm-editor .cm-content { font-family: ${fontFamilies.join(', ')} !important; }`;
const css = `.CodeMirror * { font-family: ${fontFamilies.join(', ')} !important; }`;
const styleTag = document.createElement('style');
styleTag.type = 'text/css';
styleTag.appendChild(document.createTextNode(css));
@@ -448,35 +447,10 @@ class Application extends BaseApplication {
// items: masterKeys,
// });
const getNotesParent = async () => {
let notesParent = parseNotesParent(Setting.value('notesParent'), Setting.value('activeFolderId'));
if (notesParent.type === 'Tag' && !(await Tag.load(notesParent.selectedItemId))) {
notesParent = {
type: 'Folder',
selectedItemId: Setting.value('activeFolderId'),
};
}
return notesParent;
};
const notesParent = await getNotesParent();
if (notesParent.type === 'SmartFilter') {
this.store().dispatch({
type: 'SMART_FILTER_SELECT',
id: notesParent.selectedItemId,
});
} else if (notesParent.type === 'Tag') {
this.store().dispatch({
type: 'TAG_SELECT',
id: notesParent.selectedItemId,
});
} else {
this.store().dispatch({
type: 'FOLDER_SELECT',
id: notesParent.selectedItemId,
});
}
this.store().dispatch({
type: 'FOLDER_SELECT',
id: Setting.value('activeFolderId'),
});
this.store().dispatch({
type: 'FOLDER_SET_COLLAPSED_ALL',

View File

@@ -6,7 +6,7 @@ import KvStore from '@joplin/lib/services/KvStore';
import * as ArrayUtils from '@joplin/lib/ArrayUtils';
import { CheckForUpdateOptions, extractVersionInfo, GitHubRelease } from './utils/checkForUpdatesUtils';
const packageInfo = require('./packageInfo.js');
import { compareVersions } from 'compare-versions';
const compareVersions = require('compare-versions');
const logger = Logger.create('checkForUpdates');

View File

@@ -79,7 +79,6 @@ interface Props {
startupPluginsLoaded: boolean;
shareInvitations: ShareInvitation[];
isSafeMode: boolean;
enableBetaMarkdownEditor: boolean;
needApiAuth: boolean;
processingShareInvitationResponse: boolean;
isResettingLayout: boolean;
@@ -738,11 +737,7 @@ class MainScreenComponent extends React.Component<Props, State> {
},
editor: () => {
let bodyEditor = this.props.settingEditorCodeView ? 'CodeMirror' : 'TinyMCE';
if (this.props.settingEditorCodeView && this.props.enableBetaMarkdownEditor) {
bodyEditor = 'CodeMirror6';
}
const bodyEditor = this.props.settingEditorCodeView ? 'CodeMirror' : 'TinyMCE';
return <NoteEditor key={key} bodyEditor={bodyEditor} />;
},
};
@@ -914,7 +909,6 @@ const mapStateToProps = (state: AppState) => {
shareInvitations: state.shareService.shareInvitations,
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
isSafeMode: state.settings.isSafeMode,
enableBetaMarkdownEditor: state.settings['editor.beta'],
needApiAuth: state.needApiAuth,
showInstallTemplatesPlugin: state.hasLegacyTemplates && !state.pluginService.plugins['joplin.plugin.templates'],
isResettingLayout: state.isResettingLayout,

View File

@@ -1,45 +1,55 @@
import * as React from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef } from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo } from 'react';
// eslint-disable-next-line no-unused-vars
import { EditorCommand, NoteBodyEditorProps, NoteBodyEditorRef } from '../../../utils/types';
import { commandAttachFileToBody, getResourcesFromPasteEvent } from '../../../utils/resourceHandling';
import { ScrollOptions, ScrollOptionTypes } from '../../../utils/types';
import { CommandValue } from '../../../utils/types';
import { usePrevious, cursorPositionToTextOffset } from '../utils';
import useScrollHandler from '../utils/useScrollHandler';
import { EditorCommand, NoteBodyEditorProps } from '../../utils/types';
import { commandAttachFileToBody, getResourcesFromPasteEvent } from '../../utils/resourceHandling';
import { ScrollOptions, ScrollOptionTypes } from '../../utils/types';
import { CommandValue } from '../../utils/types';
import { usePrevious, cursorPositionToTextOffset } from './utils';
import useScrollHandler from './utils/useScrollHandler';
import useElementSize from '@joplin/lib/hooks/useElementSize';
import Toolbar from '../Toolbar';
import { RenderedBody, defaultRenderedBody } from '../utils/types';
import NoteTextViewer from '../../../../NoteTextViewer';
import Toolbar from './Toolbar';
import styles_ from './styles';
import { RenderedBody, defaultRenderedBody } from './utils/types';
import NoteTextViewer from '../../../NoteTextViewer';
import Editor from './Editor';
import usePluginServiceRegistration from '../../../utils/usePluginServiceRegistration';
import usePluginServiceRegistration from '../../utils/usePluginServiceRegistration';
import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
import { _ } from '@joplin/lib/locale';
import bridge from '../../../../../services/bridge';
import bridge from '../../../../services/bridge';
import markdownUtils from '@joplin/lib/markdownUtils';
import shim from '@joplin/lib/shim';
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import { themeStyle } from '@joplin/lib/theme';
import { ThemeAppearance } from '@joplin/lib/themes/type';
import dialogs from '../../../../dialogs';
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
import dialogs from '../../../dialogs';
import convertToScreenCoordinates from '../../../utils/convertToScreenCoordinates';
import { MarkupToHtml } from '@joplin/renderer';
const { clipboard } = require('electron');
const debounce = require('debounce');
import shared from '@joplin/lib/components/shared/note-screen-shared';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
import { reg } from '@joplin/lib/registry';
import ErrorBoundary from '../../../../ErrorBoundary';
import { MarkupToHtmlOptions } from '../../../utils/useMarkupToHtml';
import useStyles from '../utils/useStyles';
import useContextMenu from '../utils/useContextMenu';
import useWebviewIpcMessage from '../utils/useWebviewIpcMessage';
import ErrorBoundary from '../../../ErrorBoundary';
import { MarkupToHtmlOptions } from '../../utils/useMarkupToHtml';
import eventManager from '@joplin/lib/eventManager';
import { EditContextMenuFilterObject } from '@joplin/lib/services/plugins/api/JoplinWorkspace';
import type { ContextMenuEvent, ContextMenuParams } from 'electron';
const menuUtils = new MenuUtils(CommandService.instance());
function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions {
return { ...override };
}
function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditorRef>) {
const styles = useStyles(props);
function CodeMirror(props: NoteBodyEditorProps, ref: any) {
const styles = styles_(props);
const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content
const [renderedBodyContentKey, setRenderedBodyContentKey] = useState<string>(null);
@@ -589,13 +599,29 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
setWebviewReady(true);
}, []);
const webview_ipcMessage = useWebviewIpcMessage({
editorRef,
setEditorPercentScroll,
getLineScrollPercent,
content: props.content,
onMessage: props.onMessage,
});
const webview_ipcMessage = useCallback((event: any) => {
const msg = event.channel ? event.channel : '';
const args = event.args;
const arg0 = args && args.length >= 1 ? args[0] : null;
if (msg.indexOf('checkboxclick:') === 0) {
const { line, from, to } = shared.toggleCheckboxRange(msg, props.content);
if (editorRef.current) {
// To cancel CodeMirror's layout drift, the scroll position
// is recorded before updated, and then it is restored.
// Ref. https://github.com/laurent22/joplin/issues/5890
const percent = getLineScrollPercent();
editorRef.current.replaceRange(line, from, to);
setEditorPercentScroll(percent);
}
} else if (msg === 'percentScroll') {
const percent = arg0;
setEditorPercentScroll(percent);
} else {
props.onMessage(event);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.onMessage, props.content, setEditorPercentScroll]);
useEffect(() => {
let cancelled = false;
@@ -724,29 +750,19 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
return output;
}, [styles.cellViewer, props.visiblePanes]);
// Disable this effect to fix this:
//
// https://github.com/laurent22/joplin/issues/6514 It doesn't seem essential
// to automatically focus the editor when the layout changes. The workaround
// is to toggle the layout Cmd+L, then manually focus the editor Cmd+Shift+B.
//
// On the other hand, if we automatically focus the editor, and the user
// does not want this, there's no workaround, so it's better to have this
// disabled.
const editorPaneVisible = props.visiblePanes.indexOf('editor') >= 0;
// const editorPaneVisible = props.visiblePanes.indexOf('editor') >= 0;
useEffect(() => {
if (!editorRef.current) return;
// useEffect(() => {
// if (!editorRef.current) return;
// // Anytime the user toggles the visible panes AND the editor is visible as a result
// // we should focus the editor
// // The intuition is that a panel toggle (with editor in view) is the equivalent of
// // an editor interaction so users should expect the editor to be focused
// if (editorPaneVisible) {
// editorRef.current.focus();
// }
// }, [editorPaneVisible]);
// Anytime the user toggles the visible panes AND the editor is visible as a result
// we should focus the editor
// The intuition is that a panel toggle (with editor in view) is the equivalent of
// an editor interaction so users should expect the editor to be focused
if (editorPaneVisible) {
editorRef.current.focus();
}
}, [editorPaneVisible]);
useEffect(() => {
if (!editorRef.current) return;
@@ -763,12 +779,142 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
editorRef.current.refresh();
}, [rootSize, styles.editor, props.visiblePanes]);
useContextMenu({
plugins: props.plugins,
editorCutText, editorCopyText, editorPaste,
editorRef,
editorClassName: 'codeMirrorEditor',
});
// The below code adds support for spellchecking when it is enabled
// It might be buggy, refer to the below issue
// https://github.com/laurent22/joplin/pull/3974#issuecomment-718936703
useEffect(() => {
const isAncestorOfCodeMirrorEditor = (elem: HTMLElement) => {
for (; elem.parentElement; elem = elem.parentElement) {
if (elem.classList.contains('codeMirrorEditor')) {
return true;
}
}
return false;
};
let lastInCodeMirrorContextMenuTimestamp = 0;
// The browser's contextmenu event provides additional information about the
// target of the event, not provided by the Electron context-menu event.
const onBrowserContextMenu = (event: Event) => {
if (isAncestorOfCodeMirrorEditor(event.target as HTMLElement)) {
lastInCodeMirrorContextMenuTimestamp = Date.now();
}
};
function pointerInsideEditor(params: ContextMenuParams) {
const x = params.x, y = params.y, isEditable = params.isEditable;
const elements = document.getElementsByClassName('codeMirrorEditor');
// Note: We can't check inputFieldType here. When spellcheck is enabled,
// params.inputFieldType is "none". When spellcheck is disabled,
// params.inputFieldType is "plainText". Thus, such a check would be inconsistent.
if (!elements.length || !isEditable) return false;
const maximumMsSinceBrowserEvent = 100;
if (Date.now() - lastInCodeMirrorContextMenuTimestamp > maximumMsSinceBrowserEvent) {
return false;
}
const rect = convertToScreenCoordinates(Setting.value('windowContentZoomFactor'), elements[0].getBoundingClientRect());
return rect.x < x && rect.y < y && rect.right > x && rect.bottom > y;
}
async function onContextMenu(event: ContextMenuEvent, params: ContextMenuParams) {
if (!pointerInsideEditor(params)) return;
// Don't show the default menu.
event.preventDefault();
const menu = new Menu();
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ;
menu.append(
new MenuItem({
label: _('Cut'),
enabled: hasSelectedText,
click: async () => {
editorCutText();
},
}),
);
menu.append(
new MenuItem({
label: _('Copy'),
enabled: hasSelectedText,
click: async () => {
editorCopyText();
},
}),
);
menu.append(
new MenuItem({
label: _('Paste'),
enabled: true,
click: async () => {
editorPaste();
},
}),
);
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
for (const item of spellCheckerMenuItems) {
menu.append(new MenuItem(item));
}
// Typically CodeMirror handles all interactions itself (highlighting etc.)
// But in the case of clicking a mispelled word, we need electron to handle the click
// The result is that CodeMirror doesn't know what's been selected and doesn't
// move the cursor into the correct location.
// and when the user selects a new spelling it will be inserted in the wrong location
// So in this situation, we use must manually align the internal codemirror selection
// to the contextmenu selection
if (editorRef.current && spellCheckerMenuItems.length > 0) {
editorRef.current.alignSelection(params);
}
let filterObject: EditContextMenuFilterObject = {
items: [],
};
filterObject = await eventManager.filterEmit('editorContextMenu', filterObject);
for (const item of filterObject.items) {
menu.append(new MenuItem({
label: item.label,
click: async () => {
const args = item.commandArgs || [];
void CommandService.instance().execute(item.commandName, ...args);
},
type: item.type,
}));
}
// eslint-disable-next-line github/array-foreach -- Old code before rule was applied
menuUtils.pluginContextMenuItems(props.plugins, MenuItemLocation.EditorContextMenu).forEach((item: any) => {
menu.append(new MenuItem(item));
});
menu.popup();
}
// Prepend the event listener so that it gets called before
// the listener that shows the default menu.
bridge().window().webContents.prependListener('context-menu', onContextMenu);
window.addEventListener('contextmenu', onBrowserContextMenu);
return () => {
bridge().window().webContents.off('context-menu', onContextMenu);
window.removeEventListener('contextmenu', onBrowserContextMenu);
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.plugins]);
function renderEditor() {

View File

@@ -12,15 +12,15 @@ import 'codemirror/addon/scroll/annotatescrollbar';
import 'codemirror/addon/search/matchesonscrollbar';
import 'codemirror/addon/search/searchcursor';
import useListIdent from '../utils/useListIdent';
import useScrollUtils from '../utils/useScrollUtils';
import useCursorUtils from '../utils/useCursorUtils';
import useLineSorting from '../utils/useLineSorting';
import useEditorSearch from '../utils/useEditorSearch';
import useJoplinMode from '../utils/useJoplinMode';
import useKeymap from '../utils/useKeymap';
import useExternalPlugins from '../utils/useExternalPlugins';
import useJoplinCommands from '../utils/useJoplinCommands';
import useListIdent from './utils/useListIdent';
import useScrollUtils from './utils/useScrollUtils';
import useCursorUtils from './utils/useCursorUtils';
import useLineSorting from './utils/useLineSorting';
import useEditorSearch from './utils/useEditorSearch';
import useJoplinMode from './utils/useJoplinMode';
import useKeymap from './utils/useKeymap';
import useExternalPlugins from './utils/useExternalPlugins';
import useJoplinCommands from './utils/useJoplinCommands';
import 'codemirror/keymap/emacs';
import 'codemirror/keymap/vim';

View File

@@ -0,0 +1,60 @@
import { NoteBodyEditorProps } from '../../../utils/types';
const { buildStyle } = require('@joplin/lib/theme');
export default function styles(props: NoteBodyEditorProps) {
return buildStyle(['CodeMirror', props.fontSize], props.themeId, (theme: any) => {
return {
root: {
position: 'relative',
display: 'flex',
flexDirection: 'column',
...props.style,
},
rowToolbar: {
position: 'relative',
display: 'flex',
flexDirection: 'row',
},
rowEditorViewer: {
position: 'relative',
display: 'flex',
flexDirection: 'row',
flex: 1,
paddingTop: 10,
},
cellEditor: {
position: 'relative',
display: 'flex',
flex: 1,
},
cellViewer: {
position: 'relative',
display: 'flex',
flex: 1,
borderLeftWidth: 1,
borderLeftColor: theme.dividerColor,
borderLeftStyle: 'solid',
},
viewer: {
display: 'flex',
overflow: 'hidden',
verticalAlign: 'top',
boxSizing: 'border-box',
width: '100%',
},
editor: {
display: 'flex',
width: 'auto',
height: 'auto',
flex: 1,
overflowY: 'hidden',
paddingTop: 0,
lineHeight: `${Math.round(17 * props.fontSize / 12)}px`,
fontSize: `${props.fontSize}px`,
color: theme.color,
backgroundColor: theme.backgroundColor,
codeMirrorTheme: theme.codeMirrorTheme, // Defined in theme.js
},
};
});
}

View File

@@ -1,12 +0,0 @@
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
const setupVim = (CodeMirror: CodeMirrorControl) => {
CodeMirror.Vim.defineAction('swapLineDown', CodeMirror.commands.swapLineDown);
CodeMirror.Vim.mapCommand('<A-j>', 'action', 'swapLineDown', {}, { context: 'normal', isEdit: true });
CodeMirror.Vim.defineAction('swapLineUp', CodeMirror.commands.swapLineUp);
CodeMirror.Vim.mapCommand('<A-k>', 'action', 'swapLineUp', {}, { context: 'normal', isEdit: true });
CodeMirror.Vim.defineAction('insertListElement', CodeMirror.commands.vimInsertListElement);
CodeMirror.Vim.mapCommand('o', 'action', 'insertListElement', { after: true }, { context: 'normal', isEdit: true, interlaceInsertRepeat: true });
};
export default setupVim;

View File

@@ -1,175 +0,0 @@
import { ContextMenuEvent, ContextMenuParams } from 'electron';
import { useEffect, RefObject } from 'react';
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import convertToScreenCoordinates from '../../../../utils/convertToScreenCoordinates';
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
import { EditContextMenuFilterObject } from '@joplin/lib/services/plugins/api/JoplinWorkspace';
import type CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import eventManager from '@joplin/lib/eventManager';
import bridge from '../../../../../services/bridge';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const menuUtils = new MenuUtils(CommandService.instance());
interface ContextMenuProps {
plugins: PluginStates;
editorCutText: ()=> void;
editorCopyText: ()=> void;
editorPaste: ()=> void;
editorRef: RefObject<CodeMirrorControl>;
editorClassName: string;
}
const useContextMenu = (props: ContextMenuProps) => {
const editorRef = props.editorRef;
// The below code adds support for spellchecking when it is enabled
// It might be buggy, refer to the below issue
// https://github.com/laurent22/joplin/pull/3974#issuecomment-718936703
useEffect(() => {
const isAncestorOfCodeMirrorEditor = (elem: HTMLElement) => {
for (; elem.parentElement; elem = elem.parentElement) {
if (elem.classList.contains(props.editorClassName)) {
return true;
}
}
return false;
};
let lastInCodeMirrorContextMenuTimestamp = 0;
// The browser's contextmenu event provides additional information about the
// target of the event, not provided by the Electron context-menu event.
const onBrowserContextMenu = (event: Event) => {
if (isAncestorOfCodeMirrorEditor(event.target as HTMLElement)) {
lastInCodeMirrorContextMenuTimestamp = Date.now();
}
};
function pointerInsideEditor(params: ContextMenuParams) {
const x = params.x, y = params.y, isEditable = params.isEditable;
const elements = document.getElementsByClassName(props.editorClassName);
// Note: We can't check inputFieldType here. When spellcheck is enabled,
// params.inputFieldType is "none". When spellcheck is disabled,
// params.inputFieldType is "plainText". Thus, such a check would be inconsistent.
if (!elements.length || !isEditable) return false;
const maximumMsSinceBrowserEvent = 100;
if (Date.now() - lastInCodeMirrorContextMenuTimestamp > maximumMsSinceBrowserEvent) {
return false;
}
const rect = convertToScreenCoordinates(Setting.value('windowContentZoomFactor'), elements[0].getBoundingClientRect());
return rect.x < x && rect.y < y && rect.right > x && rect.bottom > y;
}
async function onContextMenu(event: ContextMenuEvent, params: ContextMenuParams) {
if (!pointerInsideEditor(params)) return;
// Don't show the default menu.
event.preventDefault();
const menu = new Menu();
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ;
menu.append(
new MenuItem({
label: _('Cut'),
enabled: hasSelectedText,
click: async () => {
props.editorCutText();
},
}),
);
menu.append(
new MenuItem({
label: _('Copy'),
enabled: hasSelectedText,
click: async () => {
props.editorCopyText();
},
}),
);
menu.append(
new MenuItem({
label: _('Paste'),
enabled: true,
click: async () => {
props.editorPaste();
},
}),
);
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
for (const item of spellCheckerMenuItems) {
menu.append(new MenuItem(item));
}
// CodeMirror 5 only:
// Typically CodeMirror handles all interactions itself (highlighting etc.)
// But in the case of clicking a mispelled word, we need electron to handle the click
// The result is that CodeMirror doesn't know what's been selected and doesn't
// move the cursor into the correct location.
// and when the user selects a new spelling it will be inserted in the wrong location
// So in this situation, we use must manually align the internal codemirror selection
// to the contextmenu selection
if (editorRef.current && !editorRef.current.cm6 && spellCheckerMenuItems.length > 0) {
(editorRef.current as any).alignSelection(params);
}
let filterObject: EditContextMenuFilterObject = {
items: [],
};
filterObject = await eventManager.filterEmit('editorContextMenu', filterObject);
for (const item of filterObject.items) {
menu.append(new MenuItem({
label: item.label,
click: async () => {
const args = item.commandArgs || [];
void CommandService.instance().execute(item.commandName, ...args);
},
type: item.type,
}));
}
// eslint-disable-next-line github/array-foreach -- Old code before rule was applied
menuUtils.pluginContextMenuItems(props.plugins, MenuItemLocation.EditorContextMenu).forEach((item: any) => {
menu.append(new MenuItem(item));
});
menu.popup();
}
// Prepend the event listener so that it gets called before
// the listener that shows the default menu.
bridge().window().webContents.prependListener('context-menu', onContextMenu);
window.addEventListener('contextmenu', onBrowserContextMenu);
return () => {
bridge().window().webContents.off('context-menu', onContextMenu);
window.removeEventListener('contextmenu', onBrowserContextMenu);
};
}, [
props.plugins, props.editorClassName, editorRef,
props.editorCutText, props.editorCopyText, props.editorPaste,
]);
};
export default useContextMenu;

View File

@@ -4,7 +4,6 @@ import KeymapService, { KeymapItem } from '@joplin/lib/services/KeymapService';
import { EditorCommand } from '../../../utils/types';
import shim from '@joplin/lib/shim';
import { reg } from '@joplin/lib/registry';
import setupVim from './setupVim';
export default function useKeymap(CodeMirror: any) {
@@ -18,6 +17,14 @@ export default function useKeymap(CodeMirror: any) {
CodeMirror.keyMap.emacs['Shift-Tab'] = 'smartListUnindent';
}
function setupVim() {
CodeMirror.Vim.defineAction('swapLineDown', CodeMirror.commands.swapLineDown);
CodeMirror.Vim.mapCommand('<A-j>', 'action', 'swapLineDown', {}, { context: 'normal', isEdit: true });
CodeMirror.Vim.defineAction('swapLineUp', CodeMirror.commands.swapLineUp);
CodeMirror.Vim.mapCommand('<A-k>', 'action', 'swapLineUp', {}, { context: 'normal', isEdit: true });
CodeMirror.Vim.defineAction('insertListElement', CodeMirror.commands.vimInsertListElement);
CodeMirror.Vim.mapCommand('o', 'action', 'insertListElement', { after: true }, { context: 'normal', isEdit: true, interlaceInsertRepeat: true });
}
function isEditorCommand(command: string) {
return command.startsWith('editor.');
}
@@ -177,7 +184,7 @@ export default function useKeymap(CodeMirror: any) {
keymapService.on('keymapChange', registerKeymap);
setupEmacs();
setupVim(CodeMirror);
setupVim();
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
}

View File

@@ -1,82 +0,0 @@
import { Theme } from '@joplin/lib/themes/type';
import { NoteBodyEditorProps } from '../../../utils/types';
import { buildStyle } from '@joplin/lib/theme';
import { useMemo } from 'react';
const useStyles = (props: NoteBodyEditorProps) => {
return useMemo(() => {
return buildStyle(['CodeMirror', props.fontSize], props.themeId, (theme: Theme) => {
return {
root: {
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
...props.style,
},
rowToolbar: {
position: 'relative',
display: 'flex',
flexDirection: 'row',
},
rowEditorViewer: {
position: 'relative',
display: 'flex',
flexDirection: 'row',
flex: 1,
paddingTop: 10,
// Allow the editor container to shrink (allowing the editor to scroll)
minHeight: 0,
},
cellEditor: {
position: 'relative',
display: 'flex',
flex: 1,
},
cellViewer: {
position: 'relative',
display: 'flex',
flex: 1,
borderLeftWidth: 1,
borderLeftColor: theme.dividerColor,
borderLeftStyle: 'solid',
},
viewer: {
display: 'flex',
overflow: 'hidden',
verticalAlign: 'top',
boxSizing: 'border-box',
width: '100%',
},
editor: {
display: 'flex',
width: 'auto',
height: 'auto',
flex: 1,
overflowY: 'hidden',
paddingTop: 0,
lineHeight: `${Math.round(17 * props.fontSize / 12)}px`,
fontSize: `${props.fontSize}px`,
color: theme.color,
backgroundColor: theme.backgroundColor,
// CM5 only
codeMirrorTheme: theme.codeMirrorTheme, // Defined in theme.js
},
// CM6 only
globalTheme: {
...theme,
fontFamily: 'inherit',
fontSize: props.fontSize,
fontSizeUnits: 'px',
isDesktop: true,
},
};
});
}, [props.style, props.themeId, props.fontSize]);
};
export default useStyles;

View File

@@ -1,46 +0,0 @@
import type CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation';
import shared from '@joplin/lib/components/shared/note-screen-shared';
import { useCallback, RefObject } from 'react';
interface Props {
onMessage(event: any): void;
getLineScrollPercent(): number;
setEditorPercentScroll(fraction: number): void;
editorRef: RefObject<CodeMirror5Emulation>;
content: string;
}
const useWebviewIpcMessage = (props: Props) => {
const editorRef = props.editorRef;
return useCallback((event: any) => {
const msg = event.channel ? event.channel : '';
const args = event.args;
const arg0 = args && args.length >= 1 ? args[0] : null;
if (msg.indexOf('checkboxclick:') === 0) {
const { line, from, to } = shared.toggleCheckboxRange(msg, props.content);
if (editorRef.current) {
// To cancel CodeMirror's layout drift, the scroll position
// is recorded before updated, and then it is restored.
// Ref. https://github.com/laurent22/joplin/issues/5890
const percent = props.getLineScrollPercent();
editorRef.current.replaceRange(line, from, to);
props.setEditorPercentScroll(percent);
}
} else if (msg === 'percentScroll') {
const percent = arg0;
props.setEditorPercentScroll(percent);
} else {
props.onMessage(event);
}
}, [
props.onMessage,
props.content,
editorRef,
props.getLineScrollPercent,
props.setEditorPercentScroll,
]);
};
export default useWebviewIpcMessage;

View File

@@ -1,439 +0,0 @@
import * as React from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef } from 'react';
import { EditorCommand, NoteBodyEditorProps, NoteBodyEditorRef, OnChangeEvent } from '../../../utils/types';
import { getResourcesFromPasteEvent } from '../../../utils/resourceHandling';
import { ScrollOptions, ScrollOptionTypes } from '../../../utils/types';
import NoteTextViewer from '../../../../NoteTextViewer';
import Editor from './Editor';
import usePluginServiceRegistration from '../../../utils/usePluginServiceRegistration';
import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
import { _ } from '@joplin/lib/locale';
import bridge from '../../../../../services/bridge';
import shim from '@joplin/lib/shim';
import { MarkupToHtml } from '@joplin/renderer';
const { clipboard } = require('electron');
import { reg } from '@joplin/lib/registry';
import ErrorBoundary from '../../../../ErrorBoundary';
import { MarkupToHtmlOptions } from '../../../utils/useMarkupToHtml';
import { EditorKeymap, EditorLanguageType, EditorSettings } from '@joplin/editor/types';
import useStyles from '../utils/useStyles';
import { EditorEvent, EditorEventType } from '@joplin/editor/events';
import useScrollHandler from '../utils/useScrollHandler';
import Logger from '@joplin/utils/Logger';
import useEditorCommands from './useEditorCommands';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import useContextMenu from '../utils/useContextMenu';
import useWebviewIpcMessage from '../utils/useWebviewIpcMessage';
import Toolbar from '../Toolbar';
const logger = Logger.create('CodeMirror6');
const logDebug = (message: string) => logger.debug(message);
interface RenderedBody {
html: string;
pluginAssets: any[];
}
function defaultRenderedBody(): RenderedBody {
return {
html: '',
pluginAssets: [],
};
}
function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions {
return { ...override };
}
const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditorRef>) => {
const styles = useStyles(props);
const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content
const [renderedBodyContentKey, setRenderedBodyContentKey] = useState<string>(null);
const [webviewReady, setWebviewReady] = useState(false);
const editorRef = useRef<CodeMirrorControl>(null);
const rootRef = useRef(null);
const webviewRef = useRef(null);
type OnChangeCallback = (event: OnChangeEvent)=> void;
const props_onChangeRef = useRef<OnChangeCallback>(null);
props_onChangeRef.current = props.onChange;
const [selectionRange, setSelectionRange] = useState({ from: 0, to: 0 });
const {
resetScroll, editor_scroll, setEditorPercentScroll, setViewerPercentScroll, getLineScrollPercent,
} = useScrollHandler(editorRef, webviewRef, props.onScroll);
usePluginServiceRegistration(ref);
const codeMirror_change = useCallback((newBody: string) => {
if (newBody !== props.content) {
props_onChangeRef.current({ changeId: null, content: newBody });
}
}, [props.content]);
const onEditorPaste = useCallback(async (event: any = null) => {
const resourceMds = await getResourcesFromPasteEvent(event);
if (!resourceMds.length) return;
if (editorRef.current) {
editorRef.current.insertText(resourceMds.join('\n'));
}
}, []);
const editorCutText = useCallback(() => {
if (editorRef.current) {
const selections = editorRef.current.getSelections();
if (selections.length > 0 && selections[0]) {
clipboard.writeText(selections[0]);
// Easy way to wipe out just the first selection
selections[0] = '';
editorRef.current.replaceSelections(selections);
} else {
const cursor = editorRef.current.getCursor();
const line = editorRef.current.getLine(cursor.line);
clipboard.writeText(`${line}\n`);
const startLine = editorRef.current.getCursor('head');
startLine.ch = 0;
const endLine = {
line: startLine.line + 1,
ch: 0,
};
editorRef.current.replaceRange('', startLine, endLine);
}
}
}, []);
const editorCopyText = useCallback(() => {
if (editorRef.current) {
const selections = editorRef.current.getSelections();
// Handle the case when there is a selection - copy the selection to the clipboard
// When there is no selection, the selection array contains an empty string.
if (selections.length > 0 && selections[0]) {
clipboard.writeText(selections[0]);
} else {
// This is the case when there is no selection - copy the current line to the clipboard
const cursor = editorRef.current.getCursor();
const line = editorRef.current.getLine(cursor.line);
clipboard.writeText(line);
}
}
}, []);
const editorPasteText = useCallback(async () => {
if (editorRef.current) {
const modifiedMd = await Note.replaceResourceExternalToInternalLinks(clipboard.readText(), { useAbsolutePaths: true });
editorRef.current.insertText(modifiedMd);
}
}, []);
const editorPaste = useCallback(() => {
const clipboardText = clipboard.readText();
if (clipboardText) {
void editorPasteText();
} else {
// To handle pasting images
void onEditorPaste();
}
}, [editorPasteText, onEditorPaste]);
const commands = useEditorCommands({
webviewRef,
editorRef,
selectionRange,
editorCopyText, editorCutText, editorPaste,
editorContent: props.content,
visiblePanes: props.visiblePanes,
});
useImperativeHandle(ref, () => {
return {
content: () => props.content,
resetScroll: () => {
resetScroll();
},
scrollTo: (options: ScrollOptions) => {
if (options.type === ScrollOptionTypes.Hash) {
if (!webviewRef.current) return;
webviewRef.current.send('scrollToHash', options.value as string);
} else if (options.type === ScrollOptionTypes.Percent) {
const percent = options.value as number;
setEditorPercentScroll(percent);
setViewerPercentScroll(percent);
} else {
throw new Error(`Unsupported scroll options: ${options.type}`);
}
},
supportsCommand: (name: string) => {
return name in commands || editorRef.current.supportsCommand(name);
},
execCommand: async (cmd: EditorCommand) => {
if (!editorRef.current) return false;
logger.debug('execCommand', cmd);
let commandOutput = null;
if (cmd.name in commands) {
commandOutput = (commands as any)[cmd.name](cmd.value);
} else if (editorRef.current.supportsCommand(cmd.name)) {
commandOutput = editorRef.current.execCommand(cmd.name);
} else if (editorRef.current.supportsJoplinCommand(cmd.name)) {
commandOutput = editorRef.current.execJoplinCommand(cmd.name);
} else {
reg.logger().warn('CodeMirror: unsupported Joplin command: ', cmd);
}
return commandOutput;
},
};
}, [props.content, commands, resetScroll, setEditorPercentScroll, setViewerPercentScroll]);
const webview_domReady = useCallback(() => {
setWebviewReady(true);
}, []);
const webview_ipcMessage = useWebviewIpcMessage({
editorRef,
setEditorPercentScroll,
getLineScrollPercent,
content: props.content,
onMessage: props.onMessage,
});
useEffect(() => {
let cancelled = false;
// When a new note is loaded (contentKey is different), we want the note to be displayed
// right away. However once that's done, we put a small delay so that the view is not
// being constantly updated while the user changes the note.
const interval = renderedBodyContentKey !== props.contentKey ? 0 : 500;
const timeoutId = shim.setTimeout(async () => {
let bodyToRender = props.content;
if (!bodyToRender.trim() && props.visiblePanes.indexOf('viewer') >= 0 && props.visiblePanes.indexOf('editor') < 0) {
// Fixes https://github.com/laurent22/joplin/issues/217
bodyToRender = `<i>${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}</i>`;
}
const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({
resourceInfos: props.resourceInfos,
contentMaxWidth: props.contentMaxWidth,
mapsToLine: true,
// Always using useCustomPdfViewer for now, we can add a new setting for it in future if we need to.
useCustomPdfViewer: props.useCustomPdfViewer,
noteId: props.noteId,
vendorDir: bridge().vendorDir(),
}));
if (cancelled) return;
setRenderedBody(result);
// Since we set `renderedBodyContentKey` here, it means this effect is going to
// be triggered again, but that's hard to avoid and the second call would be cheap
// anyway since the renderered markdown is cached by MdToHtml. We could use a ref
// to avoid this, but a second rendering might still happens anyway to render images,
// resources, or for other reasons. So it's best to focus on making any second call
// to this effect as cheap as possible with caching, etc.
setRenderedBodyContentKey(props.contentKey);
}, interval);
return () => {
cancelled = true;
shim.clearTimeout(timeoutId);
};
}, [
props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage,
props.visiblePanes, props.resourceInfos, props.markupToHtml, props.contentMaxWidth,
props.noteId, props.useCustomPdfViewer,
]);
useEffect(() => {
if (!webviewReady) return;
let lineCount = 0;
if (editorRef.current) {
lineCount = editorRef.current.editor.state.doc.lines;
}
const options: any = {
pluginAssets: renderedBody.pluginAssets,
downloadResources: Setting.value('sync.resourceDownloadMode'),
markupLineCount: lineCount,
};
// It seems when there's an error immediately when the component is
// mounted, webviewReady might be true, but webviewRef.current will be
// undefined. Maybe due to the error boundary that unmount components.
// Since we can't do much about it we just print an error.
if (webviewRef.current) {
// To keep consistency among CodeMirror's editing and scroll percents
// of Editor and Viewer.
const percent = getLineScrollPercent();
setEditorPercentScroll(percent);
options.percent = percent;
webviewRef.current.send('setHtml', renderedBody.html, options);
} else {
console.error('Trying to set HTML on an undefined webview ref');
}
}, [renderedBody, webviewReady, getLineScrollPercent, setEditorPercentScroll]);
const cellEditorStyle = useMemo(() => {
const output = { ...styles.cellEditor };
if (!props.visiblePanes.includes('editor')) {
output.display = 'none'; // Seems to work fine since the refactoring
}
return output;
}, [styles.cellEditor, props.visiblePanes]);
const cellViewerStyle = useMemo(() => {
const output = { ...styles.cellViewer };
if (!props.visiblePanes.includes('viewer')) {
// Note: setting webview.display to "none" is currently not supported due
// to this bug: https://github.com/electron/electron/issues/8277
// So instead setting the width 0.
output.width = 1;
output.maxWidth = 1;
} else if (!props.visiblePanes.includes('editor')) {
output.borderLeftStyle = 'none';
}
return output;
}, [styles.cellViewer, props.visiblePanes]);
// Disable this effect to fix this:
//
// https://github.com/laurent22/joplin/issues/6514 It doesn't seem essential
// to automatically focus the editor when the layout changes. The workaround
// is to toggle the layout Cmd+L, then manually focus the editor Cmd+Shift+B.
//
// On the other hand, if we automatically focus the editor, and the user
// does not want this, there's no workaround, so it's better to have this
// disabled.
// const editorPaneVisible = props.visiblePanes.indexOf('editor') >= 0;
// useEffect(() => {
// if (!editorRef.current) return;
// // Anytime the user toggles the visible panes AND the editor is visible as a result
// // we should focus the editor
// // The intuition is that a panel toggle (with editor in view) is the equivalent of
// // an editor interaction so users should expect the editor to be focused
// if (editorPaneVisible) {
// editorRef.current.focus();
// }
// }, [editorPaneVisible]);
useContextMenu({
plugins: props.plugins,
editorCutText, editorCopyText, editorPaste,
editorRef,
editorClassName: 'cm-editor',
});
const onEditorEvent = useCallback((event: EditorEvent) => {
if (event.kind === EditorEventType.Scroll) {
editor_scroll();
} else if (event.kind === EditorEventType.Change) {
codeMirror_change(event.value);
} else if (event.kind === EditorEventType.SelectionRangeChange) {
setSelectionRange({ from: event.from, to: event.to });
}
}, [editor_scroll, codeMirror_change]);
const editorSettings = useMemo((): EditorSettings => {
const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML;
let keyboardMode = EditorKeymap.Default;
if (props.keyboardMode === 'vim') {
keyboardMode = EditorKeymap.Vim;
} else if (props.keyboardMode === 'emacs') {
keyboardMode = EditorKeymap.Emacs;
}
return {
language: isHTMLNote ? EditorLanguageType.Html : EditorLanguageType.Markdown,
readOnly: props.disabled || props.visiblePanes.indexOf('editor') < 0,
katexEnabled: Setting.value('markdown.plugin.katex'),
themeData: {
...styles.globalTheme,
monospaceFont: Setting.value('style.editor.monospaceFontFamily'),
},
automatchBraces: Setting.value('editor.autoMatchingBraces'),
useExternalSearch: false,
ignoreModifiers: true,
spellcheckEnabled: Setting.value('editor.spellcheckBeta'),
keymap: keyboardMode,
indentWithTabs: true,
};
}, [
props.contentMarkupLanguage, props.disabled, props.visiblePanes,
props.keyboardMode, styles.globalTheme,
]);
// Update the editor's value
useEffect(() => {
if (editorRef.current?.updateBody(props.content)) {
editorRef.current?.clearHistory();
}
}, [props.content]);
const renderEditor = () => {
return (
<div style={cellEditorStyle}>
<Editor
style={styles.editor}
initialText={props.content}
ref={editorRef}
settings={editorSettings}
pluginStates={props.plugins}
onEvent={onEditorEvent}
onLogMessage={logDebug}
onEditorPaste={onEditorPaste}
/>
</div>
);
};
const renderViewer = () => {
return (
<div style={cellViewerStyle}>
<NoteTextViewer
ref={webviewRef}
themeId={props.themeId}
viewerStyle={styles.viewer}
onIpcMessage={webview_ipcMessage}
onDomReady={webview_domReady}
contentMaxWidth={props.contentMaxWidth}
/>
</div>
);
};
return (
<ErrorBoundary message="The text editor encountered a fatal error and could not continue. The error might be due to a plugin, so please try to disable some of them and try again.">
<div style={styles.root} ref={rootRef}>
<div style={styles.rowToolbar}>
<Toolbar themeId={props.themeId}/>
{props.noteToolbar}
</div>
<div style={styles.rowEditorViewer}>
{renderEditor()}
{renderViewer()}
</div>
</div>
</ErrorBoundary>
);
};
export default forwardRef(CodeMirror);

View File

@@ -1,121 +0,0 @@
import * as React from 'react';
import { ForwardedRef } from 'react';
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react';
import { EditorProps, LogMessageCallback, OnEventCallback, PluginData } from '@joplin/editor/types';
import createEditor from '@joplin/editor/CodeMirror/createEditor';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
import shim from '@joplin/lib/shim';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import setupVim from '../utils/setupVim';
interface Props extends EditorProps {
style: React.CSSProperties;
pluginStates: PluginStates;
onEditorPaste: (event: Event)=> void;
}
const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
const editorContainerRef = useRef<HTMLDivElement>();
const [editor, setEditor] = useState<CodeMirrorControl|null>(null);
// The editor will only be created once, so callbacks that could
// change need to be stored as references.
const onEventRef = useRef<OnEventCallback>(props.onEvent);
const onLogMessageRef = useRef<LogMessageCallback>(props.onLogMessage);
useEffect(() => {
onEventRef.current = props.onEvent;
onLogMessageRef.current = props.onLogMessage;
}, [props.onEvent, props.onLogMessage]);
useEffect(() => {
if (!editor) {
return () => {};
}
const pasteEventHandler = (_editor: any, event: Event) => {
props.onEditorPaste(event);
};
editor.on('paste', pasteEventHandler);
return () => {
editor.off('paste', pasteEventHandler);
};
}, [editor, props.onEditorPaste]);
useImperativeHandle(ref, () => {
return editor;
}, [editor]);
useEffect(() => {
if (!editor) {
return;
}
const plugins: PluginData[] = [];
for (const pluginId in props.pluginStates) {
const pluginState = props.pluginStates[pluginId];
const codeMirrorContentScripts = pluginState.contentScripts[ContentScriptType.CodeMirrorPlugin] ?? [];
for (const contentScript of codeMirrorContentScripts) {
plugins.push({
pluginId,
contentScriptId: contentScript.id,
contentScriptJs: () => shim.fsDriver().readFile(contentScript.path),
postMessageHandler: (message: any) => {
const plugin = PluginService.instance().pluginById(pluginId);
return plugin.emitContentScriptMessage(contentScript.id, message);
},
});
}
}
void editor.setPlugins(plugins);
}, [editor, props.pluginStates]);
useEffect(() => {
if (!editorContainerRef.current) return () => {};
const editorProps: EditorProps = {
...props,
onEvent: event => onEventRef.current(event),
onLogMessage: message => onLogMessageRef.current(message),
};
const editor = createEditor(editorContainerRef.current, editorProps);
editor.addStyles({
'.cm-scroller': { overflow: 'auto' },
});
setEditor(editor);
return () => {
editor.remove();
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Should run just once
}, []);
useEffect(() => {
editor?.updateSettings(props.settings);
}, [props.settings, editor]);
useEffect(() => {
if (!editor) {
return;
}
setupVim(editor);
}, [editor]);
return (
<div
style={props.style}
ref={editorContainerRef}
></div>
);
};
export default forwardRef(Editor);

View File

@@ -1,136 +0,0 @@
import { RefObject, useMemo } from 'react';
import { CommandValue } from '../../../utils/types';
import { commandAttachFileToBody } from '../../../utils/resourceHandling';
import { _ } from '@joplin/lib/locale';
import dialogs from '../../../../dialogs';
import { EditorCommandType } from '@joplin/editor/types';
import Logger from '@joplin/utils/Logger';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
const logger = Logger.create('CodeMirror 6 commands');
const wrapSelectionWithStrings = (editor: CodeMirrorControl, string1: string, string2 = '', defaultText = '') => {
if (editor.somethingSelected()) {
editor.wrapSelections(string1, string2);
} else {
editor.wrapSelections(string1 + defaultText, string2);
// Now select the default text so the user can replace it
const selections = editor.listSelections();
const newSelections = [];
for (let i = 0; i < selections.length; i++) {
const s = selections[i];
const anchor = { line: s.anchor.line, ch: s.anchor.ch + string1.length };
const head = { line: s.head.line, ch: s.head.ch - string2.length };
newSelections.push({ anchor: anchor, head: head });
}
editor.setSelections(newSelections);
}
};
interface Props {
webviewRef: RefObject<any>;
editorRef: RefObject<CodeMirrorControl>;
editorContent: string;
editorCutText(): void;
editorCopyText(): void;
editorPaste(): void;
selectionRange: { from: number; to: number };
visiblePanes: string[];
}
const useEditorCommands = (props: Props) => {
const editorRef = props.editorRef;
return useMemo(() => {
const selectedText = () => {
if (!editorRef.current) return '';
return editorRef.current.getSelection();
};
return {
dropItems: async (cmd: any) => {
if (cmd.type === 'notes') {
editorRef.current.insertText(cmd.markdownTags.join('\n'));
} else if (cmd.type === 'files') {
const pos = props.selectionRange.from;
const newBody = await commandAttachFileToBody(props.editorContent, cmd.paths, { createFileURL: !!cmd.createFileURL, position: pos });
editorRef.current.updateBody(newBody);
} else {
logger.warn('CodeMirror: unsupported drop item: ', cmd);
}
},
selectedText: () => {
return selectedText();
},
selectedHtml: () => {
return selectedText();
},
replaceSelection: (value: string) => {
return editorRef.current.insertText(value);
},
textCopy: () => {
props.editorCopyText();
},
textCut: () => {
props.editorCutText();
},
textPaste: () => {
props.editorPaste();
},
textSelectAll: () => {
return editorRef.current.execCommand(EditorCommandType.SelectAll);
},
textLink: async () => {
const url = await dialogs.prompt(_('Insert Hyperlink'));
editorRef.current.focus();
if (url) wrapSelectionWithStrings(editorRef.current, '[', `](${url})`);
},
insertText: (value: any) => editorRef.current.insertText(value),
attachFile: async () => {
const newBody = await commandAttachFileToBody(
props.editorContent, null, { position: props.selectionRange.from },
);
if (newBody) {
editorRef.current.updateBody(newBody);
}
},
textHorizontalRule: () => editorRef.current.insertText('* * *'),
'editor.execCommand': (value: CommandValue) => {
if (!('args' in value)) value.args = [];
if ((editorRef.current as any)[value.name]) {
const result = (editorRef.current as any)[value.name](...value.args);
return result;
} else if (editorRef.current.commandExists(value.name)) {
const result = editorRef.current.execCommand(value.name);
return result;
} else {
logger.warn('CodeMirror execCommand: unsupported command: ', value.name);
}
},
'editor.focus': () => {
if (props.visiblePanes.indexOf('editor') >= 0) {
editorRef.current.editor.focus();
} else {
// If we just call focus() then the iframe is focused,
// but not its content, such that scrolling up / down
// with arrow keys fails
props.webviewRef.current.send('focus');
}
},
search: () => {
editorRef.current.execCommand(EditorCommandType.ShowSearch);
},
};
}, [
props.visiblePanes, props.editorContent, props.editorCopyText, props.editorCutText, props.editorPaste,
props.selectionRange,
props.webviewRef, editorRef,
]);
};
export default useEditorCommands;

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import TinyMCE from './NoteBody/TinyMCE/TinyMCE';
import CodeMirror from './NoteBody/CodeMirror/CodeMirror';
import { connect } from 'react-redux';
import MultiNoteActions from '../MultiNoteActions';
import { htmlToMarkdown, formNoteToNote } from './utils';
@@ -45,8 +46,6 @@ import { ModelType } from '@joplin/lib/BaseModel';
import BaseItem from '@joplin/lib/models/BaseItem';
import { ErrorCode } from '@joplin/lib/errors';
import ItemChange from '@joplin/lib/models/ItemChange';
import CodeMirror6 from './NoteBody/CodeMirror/v6/CodeMirror';
import CodeMirror5 from './NoteBody/CodeMirror/v5/CodeMirror';
const commands = [
require('./commands/showRevisions'),
@@ -381,7 +380,7 @@ function NoteEditor(props: NoteEditorProps) {
};
}, [setShowRevisions]);
const onScroll = useCallback((event: { percent: number }) => {
const onScroll = useCallback((event: any) => {
props.dispatch({
type: 'EDITOR_SCROLL_PERCENT_SET',
// In callbacks of setTimeout()/setInterval(), props/state cannot be used
@@ -463,9 +462,7 @@ function NoteEditor(props: NoteEditorProps) {
if (props.bodyEditor === 'TinyMCE') {
editor = <TinyMCE {...editorProps}/>;
} else if (props.bodyEditor === 'CodeMirror') {
editor = <CodeMirror5 {...editorProps}/>;
} else if (props.bodyEditor === 'CodeMirror6') {
editor = <CodeMirror6 {...editorProps}/>;
editor = <CodeMirror {...editorProps}/>;
} else {
throw new Error(`Invalid editor: ${props.bodyEditor}`);
}
@@ -605,7 +602,7 @@ function NoteEditor(props: NoteEditorProps) {
disabled={isReadOnly}
/>
{renderSearchInfo()}
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%', minHeight: '0' }}>
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%' }}>
{editor}
</div>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>

View File

@@ -109,7 +109,7 @@ export default function NoteTitleBar(props: Props) {
className="title-input"
type="text"
ref={props.titleInputRef}
placeholder={props.isProvisional ? (props.noteIsTodo ? _('Creating new to-do...') : _('Creating new note...')) : ''}
placeholder={props.isProvisional ? _('Creating new %s...', props.noteIsTodo ? _('to-do') : _('note')) : ''}
style={styles.titleInput}
readOnly={props.disabled}
onChange={props.onTitleChange}

View File

@@ -48,15 +48,6 @@ export interface NoteEditorProps {
syncUserId: string;
}
export interface NoteBodyEditorRef {
content(): string|Promise<string>;
resetScroll(): void;
scrollTo(options: ScrollOptions): void;
supportsCommand(name: string): boolean;
execCommand(command: CommandValue): Promise<void>;
}
export interface NoteBodyEditorProps {
style: any;
ref: any;
@@ -68,7 +59,7 @@ export interface NoteBodyEditorProps {
onChange(event: OnChangeEvent): void;
onWillChange(event: any): void;
onMessage(event: any): void;
onScroll(event: { percent: number }): void;
onScroll(event: any): void;
markupToHtml: (markupLanguage: MarkupLanguage, markup: string, options: MarkupToHtmlOptions)=> Promise<RenderResult>;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
htmlToMarkdown: Function;

View File

@@ -3,16 +3,6 @@ import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/
import { renderHook } from '@testing-library/react-hooks';
import useFormNote, { HookDependencies } from './useFormNote';
const defaultFormNoteProps: HookDependencies = {
syncStarted: false,
decryptionStarted: false,
noteId: '',
isProvisional: false,
titleInputRef: null,
editorRef: null,
onBeforeLoad: ()=>{},
onAfterLoad: ()=>{},
};
describe('useFormNote', () => {
beforeEach(async () => {
@@ -25,10 +15,14 @@ describe('useFormNote', () => {
const makeFormNoteProps = (syncStarted: boolean, decryptionStarted: boolean): HookDependencies => {
return {
...defaultFormNoteProps,
syncStarted,
decryptionStarted,
noteId: testNote.id,
isProvisional: false,
titleInputRef: null,
editorRef: null,
onBeforeLoad: ()=>{},
onAfterLoad: ()=>{},
};
};
@@ -76,37 +70,4 @@ describe('useFormNote', () => {
});
});
});
// It seems this test is crashing the worker on CI (out of memory), so disabling it for now.
// it('should reload the note when it is changed outside of the editor', async () => {
// const note = await Note.save({ title: 'Test Note!' });
// const makeFormNoteProps = (dbNote: DbNote): HookDependencies => {
// return {
// ...defaultFormNoteProps,
// noteId: note.id,
// dbNote,
// };
// };
// const formNote = renderHook(props => useFormNote(props), {
// initialProps: makeFormNoteProps({ id: note.id, updated_time: note.updated_time }),
// });
// await formNote.waitFor(() => {
// expect(formNote.result.current.formNote.title).toBe('Test Note!');
// });
// // Simulate the note being modified outside the editor
// const modifiedNote = await Note.save({ id: note.id, title: 'Modified' });
// // NoteEditor then would update `dbNote`
// formNote.rerender(makeFormNoteProps({ id: note.id, updated_time: modifiedNote.updated_time }));
// await formNote.waitFor(() => {
// expect(formNote.result.current.formNote.title).toBe('Modified');
// });
// });
});

View File

@@ -271,7 +271,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
controlComp = (
<Datetime
ref="editField"
initialValue={value}
defaultValue={value}
dateFormat={time.dateFormat()}
timeFormat={time.timeFormat()}
inputProps={{

View File

@@ -40,17 +40,18 @@ a {
}
::-webkit-scrollbar-thumb {
background: rgba(100, 100, 100, 0.3);
background: rgba(150, 149, 149, 0.4);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(223, 223, 223, 0.6);
}
::-webkit-scrollbar-track:hover {
background: rgba(0, 0, 0, 0.1);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
}
.fade_out {
-webkit-transition: 0.15s;

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "2.13.2",
"version": "2.13.1",
"description": "Joplin for Desktop",
"main": "main.js",
"private": true,
@@ -118,13 +118,13 @@
"@joplin/tools": "~2.13",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.4",
"@types/node": "18.17.17",
"@types/node": "18.17.12",
"@types/react": "18.2.21",
"@types/react-redux": "7.1.26",
"@types/styled-components": "5.1.27",
"electron": "25.8.1",
"@types/styled-components": "5.1.26",
"electron": "25.3.1",
"electron-builder": "24.4.0",
"glob": "10.3.4",
"glob": "10.3.3",
"gulp": "4.0.2",
"jest": "29.6.4",
"jest-environment-jsdom": "29.6.4",
@@ -140,10 +140,9 @@
},
"dependencies": {
"@electron/notarize": "2.1.0",
"@electron/remote": "2.0.11",
"@electron/remote": "2.0.10",
"@fortawesome/fontawesome-free": "5.15.4",
"@joeattardi/emoji-button": "4.6.4",
"@joplin/editor": "~2.13",
"@joplin/lib": "~2.13",
"@joplin/renderer": "~2.13",
"@joplin/utils": "~2.13",
@@ -151,7 +150,7 @@
"async-mutex": "0.4.0",
"codemirror": "5.65.9",
"color": "3.2.1",
"compare-versions": "6.1.0",
"compare-versions": "3.6.0",
"countable": "3.0.1",
"debounce": "1.2.1",
"electron-window-state": "5.0.3",

View File

@@ -5,22 +5,17 @@
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
TEMP_PATH=~/src/plugin-tests
NEED_COMPILING=0
PLUGIN_PATH=~/src/joplin/packages/app-cli/tests/support/plugins/simple
PLUGIN_PATH=~/src/joplin/packages/app-cli/tests/support/plugins/note_list_renderer
if [[ $NEED_COMPILING == 1 ]]; then
mkdir -p "$TEMP_PATH"
PLUGIN_NAME=$(echo "$PLUGIN_PATH" | awk -F/ '{print $NF}')
TEMP_PLUGIN_PATH="$TEMP_PATH/$PLUGIN_NAME"
mkdir -p "$TEMP_PATH"
PLUGIN_NAME=$(echo "$PLUGIN_PATH" | awk -F/ '{print $NF}')
TEMP_PLUGIN_PATH="$TEMP_PATH/$PLUGIN_NAME"
echo "Copying from: $PLUGIN_PATH"
echo "To: $TEMP_PLUGIN_PATH"
echo "Copying from: $PLUGIN_PATH"
echo "To: $TEMP_PLUGIN_PATH"
rsync -a --delete "$PLUGIN_PATH/" "$TEMP_PLUGIN_PATH/"
rsync -a --delete "$PLUGIN_PATH/" "$TEMP_PLUGIN_PATH/"
npm install --prefix="$TEMP_PLUGIN_PATH" && yarn start --dev-plugins "$TEMP_PLUGIN_PATH"
else
yarn start --dev-plugins "$PLUGIN_PATH"
fi
npm install --prefix="$TEMP_PLUGIN_PATH" && yarn start --dev-plugins "$TEMP_PLUGIN_PATH"
# Add eg "--profile $HOME/.config/joplindev-desktop-1" to test with a different profile

View File

@@ -66,10 +66,9 @@ yarn-error.log
lib/csstojs/
lib/rnInjectedJs/
dist/
components/NoteEditor/**/*.bundle.js
components/NoteEditor/CodeMirror/CodeMirror.bundle.js
components/NoteEditor/CodeMirror/CodeMirror.bundle.min.js
components/NoteEditor/**/*.bundle.js.md5
components/NoteEditor/**/*.bundle.min.js
components/NoteEditor/**/*.bundle.js.LICENSE.txt
utils/fs-driver-android.js
android/app/build-*

View File

@@ -1,7 +1,7 @@
import { useRef, useCallback } from 'react';
import useSource from './hooks/useSource';
import useOnMessage, { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage';
import useOnMessage from './hooks/useOnMessage';
import useOnResourceLongPress from './hooks/useOnResourceLongPress';
const React = require('react');
@@ -19,11 +19,14 @@ interface Props {
noteResources: any;
paddingBottom: number;
noteHash: string;
onJoplinLinkClick: HandleMessageCallback;
onCheckboxChange?: HandleMessageCallback;
onRequestEditResource?: HandleMessageCallback;
onMarkForDownload?: OnMarkForDownloadCallback;
onLoadEnd?: ()=> void;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onJoplinLinkClick: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onCheckboxChange?: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onMarkForDownload?: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onLoadEnd?: Function;
}
const webViewStyle = {
@@ -44,22 +47,16 @@ export default function NoteBodyViewer(props: Props) {
);
const onResourceLongPress = useOnResourceLongPress(
{
onJoplinLinkClick: props.onJoplinLinkClick,
onRequestEditResource: props.onRequestEditResource,
},
props.onJoplinLinkClick,
dialogBoxRef,
);
const onMessage = useOnMessage(
props.onCheckboxChange,
props.noteBody,
{
onCheckboxChange: props.onCheckboxChange,
onMarkForDownload: props.onMarkForDownload,
onJoplinLinkClick: props.onJoplinLinkClick,
onRequestEditResource: props.onRequestEditResource,
onResourceLongPress,
},
props.onMarkForDownload,
props.onJoplinLinkClick,
onResourceLongPress,
);
const onLoadEnd = useCallback(() => {

View File

@@ -1,86 +0,0 @@
/**
* @jest-environment jsdom
*/
import { writeFileSync } from 'fs-extra';
import { join } from 'path';
import Setting from '@joplin/lib/models/Setting';
// Mock react-native-vector-icons -- it uses ESM imports, which, by default, are not
// supported by jest.
jest.doMock('react-native-vector-icons/Ionicons', () => {
return {
default: {
getImageSourceSync: () => {
// Create an empty file that can be read/used as an image resource.
const iconPath = join(Setting.value('cacheDir'), 'test-icon.png');
writeFileSync(iconPath, '', 'utf-8');
return { uri: iconPath };
},
},
};
});
import lightTheme from '@joplin/lib/themes/light';
import { editPopupClass, getEditPopupSource } from './useEditPopup';
import { describe, it, expect, beforeAll, jest } from '@jest/globals';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
const createEditPopup = (target: HTMLElement) => {
const { createEditPopupSyntax } = getEditPopupSource(lightTheme);
eval(`(${createEditPopupSyntax})`)(target, 'someresourceid', '() => {}');
};
const destroyEditPopup = () => {
const { destroyEditPopupSyntax } = getEditPopupSource(lightTheme);
eval(`(${destroyEditPopupSyntax})`)();
};
describe('useEditPopup', () => {
beforeAll(async () => {
// useEditPopup relies on the resourceDir setting, which is set by
// switchClient.
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
});
it('should attach an edit popup to an image', () => {
const container = document.createElement('div');
const targetImage = document.createElement('img');
container.appendChild(targetImage);
createEditPopup(targetImage);
// Popup should be present in the document
expect(container.querySelector(`.${editPopupClass}`)).not.toBeNull();
// Destroy the edit popup
jest.useFakeTimers();
destroyEditPopup();
// Give time for the popup's fade out animation to run.
jest.advanceTimersByTime(1000 * 10);
// Popup should be destroyed.
expect(container.querySelector(`.${editPopupClass}`)).toBeNull();
targetImage.remove();
});
it('should auto-remove the edit popup after a delay', () => {
jest.useFakeTimers();
const container = document.createElement('div');
const targetImage = document.createElement('img');
container.appendChild(targetImage);
jest.useFakeTimers();
createEditPopup(targetImage);
expect(container.querySelector(`.${editPopupClass}`)).not.toBeNull();
jest.advanceTimersByTime(1000 * 20); // ms
expect(container.querySelector(`.${editPopupClass}`)).toBeNull();
});
});

View File

@@ -1,145 +0,0 @@
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { useMemo } from 'react';
import { extname } from 'path';
import shim from '@joplin/lib/shim';
const Icon = require('react-native-vector-icons/Ionicons').default;
export const editPopupClass = 'joplin-editPopup';
const getEditIconSrc = (theme: Theme) => {
const iconUri = Icon.getImageSourceSync('pencil', 20, theme.color2).uri;
// Copy to a location that can be read within a WebView
// (necessary on iOS)
const destPath = `${Setting.value('resourceDir')}/edit-icon${extname(iconUri)}`;
// Copy in the background -- the edit icon popover script doesn't need the
// icon immediately.
void (async () => {
await shim.fsDriver().copy(iconUri, destPath);
})();
return destPath;
};
// Creates JavaScript/CSS that can be used to create an "Edit" button.
// Exported to facilitate testing.
export const getEditPopupSource = (theme: Theme) => {
const fadeOutDelay = 400;
const editPopupDestroyDelay = 5000;
const editPopupCss = `
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
.${editPopupClass} {
display: inline-block;
position: relative;
/* Don't take up any space in the line, overlay the button */
width: 0;
height: 0;
overflow: visible;
--edit-popup-width: 40px;
--edit-popup-padding: 10px;
/* Shift the popup such that it overlaps with the previous element. */
left: calc(0px - var(--edit-popup-width));
/* Match the top of the image */
vertical-align: top;
}
.${editPopupClass} > button {
padding: var(--edit-popup-padding);
width: var(--edit-popup-width);
animation: fade-in 0.4s ease;
background-color: ${theme.backgroundColor2};
color: ${theme.color2};
border: none;
}
.${editPopupClass} img {
/* Make the image take up as much space as possible (minus padding) */
width: calc(var(--edit-popup-width) - var(--edit-popup-padding));
}
.${editPopupClass}.fadeOut {
animation: fade-out ${fadeOutDelay}ms ease;
}
`;
const destroyEditPopupSyntax = `() => {
if (!window.editPopup) {
return;
}
const popup = editPopup;
popup.classList.add('fadeOut');
window.editPopup = null;
setTimeout(() => {
popup.remove();
}, ${fadeOutDelay});
}`;
const createEditPopupSyntax = `(parent, resourceId, onclick) => {
if (window.editPopupTimeout) {
clearTimeout(window.editPopupTimeout);
window.editPopupTimeout = undefined;
}
window.editPopupTimeout = setTimeout(${destroyEditPopupSyntax}, ${editPopupDestroyDelay});
if (window.lastEditPopupTarget !== parent) {
(${destroyEditPopupSyntax})();
} else if (window.editPopup) {
return;
}
window.editPopup = document.createElement('div');
const popupButton = document.createElement('button');
const popupIcon = new Image();
popupIcon.alt = ${JSON.stringify(_('Edit'))};
popupIcon.title = popupIcon.alt;
popupIcon.src = ${JSON.stringify(getEditIconSrc(theme))};
popupButton.appendChild(popupIcon);
popupButton.onclick = onclick;
editPopup.appendChild(popupButton);
editPopup.classList.add(${JSON.stringify(editPopupClass)});
parent.insertAdjacentElement('afterEnd', editPopup);
// Ensure that the edit popup is focused immediately by screen
// readers.
editPopup.focus();
window.lastEditPopupTarget = parent;
}`;
return { createEditPopupSyntax, destroyEditPopupSyntax, editPopupCss };
};
const useEditPopup = (themeId: number) => {
return useMemo(() => {
return getEditPopupSource(themeStyle(themeId));
}, [themeId]);
};
export default useEditPopup;

View File

@@ -1,31 +1,8 @@
import { useCallback } from 'react';
import shared from '@joplin/lib/components/shared/note-screen-shared';
export type HandleMessageCallback = (message: string)=> void;
export type OnMarkForDownloadCallback = (resource: { resourceId: string })=> void;
interface MessageCallbacks {
onMarkForDownload?: OnMarkForDownloadCallback;
onJoplinLinkClick: HandleMessageCallback;
onResourceLongPress: HandleMessageCallback;
onRequestEditResource?: HandleMessageCallback;
onCheckboxChange: HandleMessageCallback;
}
export default function useOnMessage(
noteBody: string,
callbacks: MessageCallbacks,
) {
// Dectructure callbacks. Because we have that ({ a: 1 }) !== ({ a: 1 }),
// we can expect the `callbacks` variable from the last time useOnMessage was called to
// not equal the current` callbacks` variable, even if the callbacks themselves are the
// same.
//
// Thus, useCallback should depend on each callback individually.
const {
onMarkForDownload, onResourceLongPress, onCheckboxChange, onRequestEditResource, onJoplinLinkClick,
} = callbacks;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
export default function useOnMessage(onCheckboxChange: Function, noteBody: string, onMarkForDownload: Function, onJoplinLinkClick: Function, onResourceLongPress: Function) {
return useCallback((event: any) => {
// 2021-05-19: Historically this was unescaped twice as it was
// apparently needed after an upgrade to RN 58 (or 59). However this is
@@ -40,15 +17,13 @@ export default function useOnMessage(
if (msg.indexOf('checkboxclick:') === 0) {
const newBody = shared.toggleCheckbox(msg, noteBody);
onCheckboxChange?.(newBody);
if (onCheckboxChange) onCheckboxChange(newBody);
} else if (msg.indexOf('markForDownload:') === 0) {
const splittedMsg = msg.split(':');
const resourceId = splittedMsg[1];
onMarkForDownload?.({ resourceId: resourceId });
if (onMarkForDownload) onMarkForDownload({ resourceId: resourceId });
} else if (msg.startsWith('longclick:')) {
onResourceLongPress(msg);
} else if (msg.startsWith('edit:')) {
onRequestEditResource?.(msg);
} else if (msg.startsWith('joplin:')) {
onJoplinLinkClick(msg);
} else if (msg.startsWith('error:')) {
@@ -56,12 +31,5 @@ export default function useOnMessage(
} else {
onJoplinLinkClick(msg);
}
}, [
noteBody,
onCheckboxChange,
onMarkForDownload,
onJoplinLinkClick,
onResourceLongPress,
onRequestEditResource,
]);
}, [onCheckboxChange, noteBody, onMarkForDownload, onJoplinLinkClick, onResourceLongPress]);
}

View File

@@ -6,40 +6,20 @@ import { reg } from '@joplin/lib/registry';
const { dialogs } = require('../../../utils/dialogs.js');
import Resource from '@joplin/lib/models/Resource';
import { copyToCache } from '../../../utils/ShareUtils';
import isEditableResource from '../../NoteEditor/ImageEditor/isEditableResource';
const Share = require('react-native-share').default;
interface Callbacks {
onJoplinLinkClick: (link: string)=> void;
onRequestEditResource: (message: string)=> void;
}
export default function useOnResourceLongPress(callbacks: Callbacks, dialogBoxRef: any) {
const { onJoplinLinkClick, onRequestEditResource } = callbacks;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
export default function useOnResourceLongPress(onJoplinLinkClick: Function, dialogBoxRef: any) {
return useCallback(async (msg: string) => {
try {
const resourceId = msg.split(':')[1];
const resource = await Resource.load(resourceId);
// Handle the case where it's a long press on a link with no resource
if (!resource) {
reg.logger().warn(`Long-press: Resource with ID ${resourceId} does not exist (may be a note).`);
return;
}
const name = resource.title ? resource.title : resource.file_name;
const mime: string|undefined = resource.mime;
const actions = [];
actions.push({ text: _('Open'), id: 'open' });
if (mime && isEditableResource(mime)) {
actions.push({ text: _('Edit'), id: 'edit' });
}
actions.push({ text: _('Share'), id: 'share' });
const action = await dialogs.pop({ dialogbox: dialogBoxRef.current }, name, actions);
const action = await dialogs.pop({ dialogbox: dialogBoxRef.current }, name, [
{ text: _('Open'), id: 'open' },
{ text: _('Share'), id: 'share' },
]);
if (action === 'open') {
onJoplinLinkClick(`joplin://${resourceId}`);
@@ -52,12 +32,11 @@ export default function useOnResourceLongPress(callbacks: Callbacks, dialogBoxRe
url: `file://${fileToShare}`,
failOnCancel: false,
});
} else if (action === 'edit') {
onRequestEditResource(`edit:${resourceId}`);
}
} catch (e) {
reg.logger().error('Could not handle link long press', e);
ToastAndroid.show('An error occurred, check log for details', ToastAndroid.SHORT);
}
}, [onJoplinLinkClick, onRequestEditResource, dialogBoxRef]);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [onJoplinLinkClick]);
}

View File

@@ -3,7 +3,6 @@ import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
const { themeStyle } = require('../../global-style.js');
import markupLanguageUtils from '@joplin/lib/markupLanguageUtils';
import useEditPopup from './useEditPopup';
import Logger from '@joplin/utils/Logger';
const { assetsToHeaders } = require('@joplin/renderer');
@@ -91,8 +90,6 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
const onlyNoteBodyHasChanged = Object.keys(changedDeps).length === 1 && changedDeps[0];
const onlyCheckboxesHaveChanged = previousDeps[0] && changedDeps[0] && onlyCheckboxHasChangedHack(previousDeps[0], noteBody);
const { createEditPopupSyntax, destroyEditPopupSyntax, editPopupCss } = useEditPopup(themeId);
useEffect(() => {
if (onlyNoteBodyHasChanged && onlyCheckboxesHaveChanged) {
logger.info('Only a checkbox has changed - not updating HTML');
@@ -115,11 +112,6 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
codeTheme: theme.codeThemeCss,
postMessageSyntax: 'window.joplinPostMessage_',
enableLongPress: true,
// Show an 'edit' popup over SVG images
editPopupFiletypes: ['image/svg+xml'],
createEditPopupSyntax,
destroyEditPopupSyntax,
};
// Whenever a resource state changes, for example when it goes from "not downloaded" to "downloaded", the "noteResources"
@@ -217,9 +209,7 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
${defaultCss}
${shim.mobilePlatform() === 'ios' ? iOSSpecificCss : ''}
${editPopupCss}
${shim.mobilePlatform() === 'ios' ? `${iOSSpecificCss}\n${defaultCss}` : defaultCss}
</style>
${assetsToHeaders(result.pluginAssets, { asHtml: true })}
</head>

View File

@@ -0,0 +1,59 @@
/**
* @jest-environment jsdom
*/
import { EditorSettings } from '../types';
import { initCodeMirror } from './CodeMirror';
import { themeStyle } from '@joplin/lib/theme';
import Setting from '@joplin/lib/models/Setting';
import { forceParsing } from '@codemirror/language';
import loadLangauges from './testUtil/loadLanguages';
import { expect, describe, it } from '@jest/globals';
const createEditorSettings = (themeId: number) => {
const themeData = themeStyle(themeId);
const editorSettings: EditorSettings = {
katexEnabled: true,
spellcheckEnabled: true,
readOnly: false,
themeId,
themeData,
};
return editorSettings;
};
describe('CodeMirror', () => {
// This checks for a regression -- occasionally, when updating packages,
// syntax highlighting in the CodeMirror editor stops working. This is usually
// fixed by
// 1. removing all `@codemirror/` and `@lezer/` dependencies from yarn.lock,
// 2. upgrading all CodeMirror packages to the latest versions in package.json, and
// 3. re-running `yarn install`.
//
// See https://github.com/laurent22/joplin/issues/7253
it('should give headings a different style', async () => {
const headerLineText = '# Testing...';
const initialText = `${headerLineText}\nThis is a test.`;
const editorSettings = createEditorSettings(Setting.THEME_LIGHT);
await loadLangauges();
const editor = initCodeMirror(document.body, initialText, editorSettings);
// Force the generation of the syntax tree now.
forceParsing(editor.editor);
// CodeMirror nests the tag that styles the header within .cm-headerLine:
// <div class='cm-headerLine'><span class='someclass'>Testing...</span></div>
const headerLineContent = document.body.querySelector('.cm-headerLine > span')!;
expect(headerLineContent.textContent).toBe(headerLineText);
const style = getComputedStyle(headerLineContent);
expect(style.borderBottom).not.toBe('');
expect(style.fontSize).toBe('1.6em');
});
});

View File

@@ -9,24 +9,448 @@
// wrapper to access CodeMirror functionalities. Anything else should be done
// from NoteEditor.tsx.
import { EditorSettings } from '@joplin/editor/types';
import createEditor from '@joplin/editor/CodeMirror/createEditor';
import { MarkdownMathExtension } from './markdownMathParser';
import createTheme from './theme';
import decoratorExtension from './decoratorExtension';
import syntaxHighlightingLanguages from './syntaxHighlightingLanguages';
import { EditorState } from '@codemirror/state';
import { markdown } from '@codemirror/lang-markdown';
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
import { indentOnInput, indentUnit, syntaxTree } from '@codemirror/language';
import {
openSearchPanel, closeSearchPanel, SearchQuery, setSearchQuery, getSearchQuery,
/* highlightSelectionMatches, */ search, findNext, findPrevious, replaceAll, replaceNext,
} from '@codemirror/search';
import {
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command,
} from '@codemirror/view';
import { undo, redo, history, undoDepth, redoDepth, indentWithTab } from '@codemirror/commands';
import { keymap, KeyBinding } from '@codemirror/view';
import { searchKeymap } from '@codemirror/search';
import { historyKeymap, defaultKeymap } from '@codemirror/commands';
import { CodeMirrorControl } from './types';
import { EditorSettings, ListType, SearchState } from '../types';
import { ChangeEvent, SelectionChangeEvent, Selection } from '../types';
import SelectionFormatting from '../SelectionFormatting';
import { logMessage, postMessage } from './webviewLogger';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import {
decreaseIndent, increaseIndent,
toggleBolded, toggleCode,
toggleHeaderLevel, toggleItalicized,
toggleList, toggleMath, updateLink,
} from './markdownCommands';
export function initCodeMirror(
parentElement: HTMLElement, initialText: string, settings: EditorSettings,
): CodeMirrorControl {
return createEditor(parentElement, {
initialText,
settings,
onLogMessage: message => {
logMessage(message);
},
onEvent: (event): void => {
postMessage('onEditorEvent', event);
},
});
interface CodeMirrorResult extends CodeMirrorControl {
editor: EditorView;
}
export function initCodeMirror(
parentElement: any, initialText: string, settings: EditorSettings,
): CodeMirrorResult {
logMessage('Initializing CodeMirror...');
const theme = settings.themeData;
let searchVisible = false;
let schedulePostUndoRedoDepthChangeId_: any = 0;
const schedulePostUndoRedoDepthChange = (editor: EditorView, doItNow = false) => {
if (schedulePostUndoRedoDepthChangeId_) {
if (doItNow) {
clearTimeout(schedulePostUndoRedoDepthChangeId_);
} else {
return;
}
}
schedulePostUndoRedoDepthChangeId_ = setTimeout(() => {
schedulePostUndoRedoDepthChangeId_ = null;
postMessage('onUndoRedoDepthChange', {
undoDepth: undoDepth(editor.state),
redoDepth: redoDepth(editor.state),
});
}, doItNow ? 0 : 1000);
};
const notifyDocChanged = (viewUpdate: ViewUpdate) => {
if (viewUpdate.docChanged) {
const event: ChangeEvent = {
value: editor.state.doc.toString(),
};
postMessage('onChange', event);
schedulePostUndoRedoDepthChange(editor);
}
};
const notifyLinkEditRequest = () => {
postMessage('onRequestLinkEdit', null);
};
const showSearchDialog = () => {
const query = getSearchQuery(editor.state);
const searchState: SearchState = {
searchText: query.search,
replaceText: query.replace,
useRegex: query.regexp,
caseSensitive: query.caseSensitive,
dialogVisible: true,
};
postMessage('onRequestShowSearch', searchState);
searchVisible = true;
};
const hideSearchDialog = () => {
postMessage('onRequestHideSearch', null);
searchVisible = false;
};
const notifySelectionChange = (viewUpdate: ViewUpdate) => {
if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
const mainRange = viewUpdate.state.selection.main;
const selection: Selection = {
start: mainRange.from,
end: mainRange.to,
};
const event: SelectionChangeEvent = {
selection,
};
postMessage('onSelectionChange', event);
}
};
const notifySelectionFormattingChange = (viewUpdate?: ViewUpdate) => {
// If we can't determine the previous formatting, post the update regardless
if (!viewUpdate) {
const formatting = computeSelectionFormatting(editor.state);
postMessage('onSelectionFormattingChange', formatting.toJSON());
} else if (viewUpdate.docChanged || !viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
// Only post the update if something changed
const oldFormatting = computeSelectionFormatting(viewUpdate.startState);
const newFormatting = computeSelectionFormatting(viewUpdate.state);
if (!oldFormatting.eq(newFormatting)) {
postMessage('onSelectionFormattingChange', newFormatting.toJSON());
}
}
};
const computeSelectionFormatting = (state: EditorState): SelectionFormatting => {
const range = state.selection.main;
const formatting: SelectionFormatting = new SelectionFormatting();
formatting.selectedText = state.doc.sliceString(range.from, range.to);
formatting.spellChecking = editor.contentDOM.spellcheck;
const parseLinkData = (nodeText: string) => {
const linkMatch = nodeText.match(/\[([^\]]*)\]\(([^)]*)\)/);
if (linkMatch) {
return {
linkText: linkMatch[1],
linkURL: linkMatch[2],
};
}
return null;
};
// Find nodes that overlap/are within the selected region
syntaxTree(state).iterate({
from: range.from, to: range.to,
enter: node => {
// Checklists don't have a specific containing node. As such,
// we're in a checklist if we've selected a 'Task' node.
if (node.name === 'Task') {
formatting.inChecklist = true;
}
// Only handle notes that contain the entire range.
if (node.from > range.from || node.to < range.to) {
return;
}
// Lazily compute the node's text
const nodeText = () => state.doc.sliceString(node.from, node.to);
switch (node.name) {
case 'StrongEmphasis':
formatting.bolded = true;
break;
case 'Emphasis':
formatting.italicized = true;
break;
case 'ListItem':
formatting.listLevel += 1;
break;
case 'BulletList':
formatting.inUnorderedList = true;
break;
case 'OrderedList':
formatting.inOrderedList = true;
break;
case 'TaskList':
formatting.inChecklist = true;
break;
case 'InlineCode':
case 'FencedCode':
formatting.inCode = true;
formatting.unspellCheckableRegion = true;
break;
case 'InlineMath':
case 'BlockMath':
formatting.inMath = true;
formatting.unspellCheckableRegion = true;
break;
case 'ATXHeading1':
formatting.headerLevel = 1;
break;
case 'ATXHeading2':
formatting.headerLevel = 2;
break;
case 'ATXHeading3':
formatting.headerLevel = 3;
break;
case 'ATXHeading4':
formatting.headerLevel = 4;
break;
case 'ATXHeading5':
formatting.headerLevel = 5;
break;
case 'URL':
formatting.inLink = true;
formatting.linkData.linkURL = nodeText();
formatting.unspellCheckableRegion = true;
break;
case 'Link':
formatting.inLink = true;
formatting.linkData = parseLinkData(nodeText());
break;
}
},
});
// The markdown parser marks checklists as unordered lists. Ensure
// that they aren't marked as such.
if (formatting.inChecklist) {
if (!formatting.inUnorderedList) {
// Even if the selection contains a Task, because an unordered list node
// must contain a valid Task node, we're only in a checklist if we're also in
// an unordered list.
formatting.inChecklist = false;
} else {
formatting.inUnorderedList = false;
}
}
if (formatting.unspellCheckableRegion) {
formatting.spellChecking = false;
}
return formatting;
};
// Returns a keyboard command that returns true (so accepts the keybind)
const keyCommand = (key: string, run: Command): KeyBinding => {
return {
key,
run,
preventDefault: true,
};
};
const editor = new EditorView({
state: EditorState.create({
// See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
// for a sample configuration.
extensions: [
markdown({
extensions: [
GitHubFlavoredMarkdownExtension,
// Don't highlight KaTeX if the user disabled it
settings.katexEnabled ? MarkdownMathExtension : [],
],
codeLanguages: syntaxHighlightingLanguages,
}),
...createTheme(theme),
history(),
search({
createPanel(_: EditorView) {
return {
// The actual search dialog is implemented with react native,
// use a dummy element.
dom: document.createElement('div'),
mount() {
showSearchDialog();
},
destroy() {
hideSearchDialog();
},
};
},
}),
drawSelection(),
highlightSpecialChars(),
// highlightSelectionMatches(),
indentOnInput(),
// By default, indent with four spaces
indentUnit.of(' '),
EditorState.tabSize.of(4),
// Apply styles to entire lines (block-display decorations)
decoratorExtension,
EditorView.lineWrapping,
EditorView.contentAttributes.of({
autocapitalize: 'sentence',
autocorrect: settings.spellcheckEnabled ? 'true' : 'false',
spellcheck: settings.spellcheckEnabled ? 'true' : 'false',
}),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
notifyDocChanged(viewUpdate);
notifySelectionChange(viewUpdate);
notifySelectionFormattingChange(viewUpdate);
}),
keymap.of([
// Custom mod-f binding: Toggle the external dialog implementation
// (don't show/hide the Panel dialog).
keyCommand('Mod-f', (_: EditorView) => {
if (searchVisible) {
hideSearchDialog();
} else {
showSearchDialog();
}
return true;
}),
// Markdown formatting keyboard shortcuts
keyCommand('Mod-b', toggleBolded),
keyCommand('Mod-i', toggleItalicized),
keyCommand('Mod-$', toggleMath),
keyCommand('Mod-`', toggleCode),
keyCommand('Mod-[', decreaseIndent),
keyCommand('Mod-]', increaseIndent),
keyCommand('Mod-k', (_: EditorView) => {
notifyLinkEditRequest();
return true;
}),
...defaultKeymap, ...historyKeymap, indentWithTab, ...searchKeymap,
]),
EditorState.readOnly.of(settings.readOnly),
],
doc: initialText,
}),
parent: parentElement,
});
// HACK: 09/02/22: Work around https://github.com/laurent22/joplin/issues/6802 by creating a copy mousedown
// event to prevent the Editor's .preventDefault from making the context menu not appear.
// TODO: Track the upstream issue at https://github.com/codemirror/dev/issues/935 and remove this workaround
// when the upstream bug is fixed.
document.body.addEventListener('mousedown', (evt) => {
if (!evt.isTrusted) {
return;
}
// Walk up the tree -- is evt.target or any of its parent nodes the editor's input region?
for (let current: Record<string, any> = evt.target; current; current = current.parentElement) {
if (current === editor.contentDOM) {
evt.stopPropagation();
const copyEvent = new Event('mousedown', evt);
editor.contentDOM.dispatchEvent(copyEvent);
return;
}
}
}, true);
const updateSearchQuery = (newState: SearchState) => {
const query = new SearchQuery({
search: newState.searchText,
caseSensitive: newState.caseSensitive,
regexp: newState.useRegex,
replace: newState.replaceText,
});
editor.dispatch({
effects: setSearchQuery.of(query),
});
};
const editorControls = {
editor,
undo: () => {
undo(editor);
schedulePostUndoRedoDepthChange(editor, true);
},
redo: () => {
redo(editor);
schedulePostUndoRedoDepthChange(editor, true);
},
select: (anchor: number, head: number) => {
editor.dispatch(editor.state.update({
selection: { anchor, head },
scrollIntoView: true,
}));
},
scrollSelectionIntoView: () => {
editor.dispatch(editor.state.update({
scrollIntoView: true,
}));
},
insertText: (text: string) => {
editor.dispatch(editor.state.replaceSelection(text));
},
toggleFindDialog: () => {
const opened = openSearchPanel(editor);
if (!opened) {
closeSearchPanel(editor);
}
},
// Formatting
toggleBolded: () => { toggleBolded(editor); },
toggleItalicized: () => { toggleItalicized(editor); },
toggleCode: () => { toggleCode(editor); },
toggleMath: () => { toggleMath(editor); },
increaseIndent: () => { increaseIndent(editor); },
decreaseIndent: () => { decreaseIndent(editor); },
toggleList: (kind: ListType) => { toggleList(kind)(editor); },
toggleHeaderLevel: (level: number) => { toggleHeaderLevel(level)(editor); },
updateLink: (label: string, url: string) => { updateLink(label, url)(editor); },
// Search
searchControl: {
findNext: () => {
findNext(editor);
},
findPrevious: () => {
findPrevious(editor);
},
replaceCurrent: () => {
replaceNext(editor);
},
replaceAll: () => {
replaceAll(editor);
},
setSearchState: (state: SearchState) => {
updateSearchQuery(state);
},
showSearch: () => {
showSearchDialog();
},
hideSearch: () => {
hideSearchDialog();
},
},
};
return editorControls;
}

View File

@@ -40,10 +40,6 @@ const urlDecoration = Decoration.mark({
attributes: { class: 'cm-url', ...noSpellCheckAttrs },
});
const htmlTagNameDecoration = Decoration.mark({
attributes: { class: 'cm-htmlTag', ...noSpellCheckAttrs },
});
const blockQuoteDecoration = Decoration.line({
attributes: { class: 'cm-blockQuote' },
});
@@ -52,26 +48,6 @@ const headerLineDecoration = Decoration.line({
attributes: { class: 'cm-headerLine' },
});
const tableHeaderDecoration = Decoration.line({
attributes: { class: 'cm-tableHeader' },
});
const tableBodyDecoration = Decoration.line({
attributes: { class: 'cm-tableRow' },
});
const tableDelimiterDecoration = Decoration.line({
attributes: { class: 'cm-tableDelimiter' },
});
const horizontalRuleDecoration = Decoration.mark({
attributes: { class: 'cm-hr' },
});
const taskMarkerDecoration = Decoration.mark({
attributes: { class: 'cm-taskMarker' },
});
type DecorationDescription = { pos: number; length?: number; decoration: Decoration };
// Returns a set of [Decoration]s, associated with block syntax groups that require
@@ -149,25 +125,6 @@ const computeDecorations = (view: EditorView) => {
case 'ATXHeading6':
addDecorationToLines(viewFrom, viewTo, headerLineDecoration);
break;
case 'HTMLTag':
case 'TagName':
addDecorationToRange(viewFrom, viewTo, htmlTagNameDecoration);
break;
case 'TableHeader':
addDecorationToLines(viewFrom, viewTo, tableHeaderDecoration);
break;
case 'TableDelimiter':
addDecorationToLines(viewFrom, viewTo, tableDelimiterDecoration);
break;
case 'TableRow':
addDecorationToLines(viewFrom, viewTo, tableBodyDecoration);
break;
case 'HorizontalRule':
addDecorationToRange(viewFrom, viewTo, horizontalRuleDecoration);
break;
case 'TaskMarker':
addDecorationToRange(viewFrom, viewTo, taskMarkerDecoration);
break;
}
// Only block decorations will have differing first and last lines

View File

@@ -1,6 +1,6 @@
import { EditorSelection } from '@codemirror/state';
import { ListType } from '../../types';
import createTestEditor from '../testUtil/createTestEditor';
import { ListType } from '../types';
import createEditor from './testUtil/createEditor';
import { toggleList } from './markdownCommands';
describe('markdownCommands.bulletedVsChecklist', () => {
@@ -13,7 +13,7 @@ describe('markdownCommands.bulletedVsChecklist', () => {
const expectedTags = ['BulletList', 'Task'];
it('should remove a checklist following a bulleted list without modifying the bulleted list', async () => {
const editor = await createTestEditor(
const editor = await createEditor(
initialDocText, EditorSelection.cursor(bulletedListPart.length + 5), expectedTags,
);
@@ -24,7 +24,7 @@ describe('markdownCommands.bulletedVsChecklist', () => {
});
it('should remove an unordered list following a checklist without modifying the checklist', async () => {
const editor = await createTestEditor(
const editor = await createEditor(
initialDocText, EditorSelection.cursor(bulletedListPart.length - 5), expectedTags,
);
@@ -35,7 +35,7 @@ describe('markdownCommands.bulletedVsChecklist', () => {
});
it('should replace a selection of unordered and task lists with a correctly-numbered list', async () => {
const editor = await createTestEditor(
const editor = await createEditor(
initialDocText, EditorSelection.range(0, initialDocText.length), expectedTags,
);

View File

@@ -2,7 +2,7 @@ import { EditorSelection } from '@codemirror/state';
import {
toggleBolded, toggleCode, toggleHeaderLevel, toggleItalicized, toggleMath, updateLink,
} from './markdownCommands';
import createTestEditor from '../testUtil/createTestEditor';
import createEditor from './testUtil/createEditor';
import { blockMathTagName } from './markdownMathParser';
describe('markdownCommands', () => {
@@ -11,7 +11,7 @@ describe('markdownCommands', () => {
it('should bold/italicize everything selected', async () => {
const initialDocText = 'Testing...';
const editor = await createTestEditor(
const editor = await createEditor(
initialDocText, EditorSelection.range(0, initialDocText.length), [],
);
@@ -38,7 +38,7 @@ describe('markdownCommands', () => {
it('for a cursor, bolding, then italicizing, should produce a bold-italic region', async () => {
const initialDocText = '';
const editor = await createTestEditor(
const editor = await createEditor(
initialDocText, EditorSelection.cursor(0), [],
);
@@ -56,7 +56,7 @@ describe('markdownCommands', () => {
it('toggling math should both create and navigate out of math regions', async () => {
const initialDocText = 'Testing... ';
const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
toggleMath(editor);
expect(editor.state.doc.toString()).toBe('Testing... $$');
@@ -72,7 +72,7 @@ describe('markdownCommands', () => {
it('toggling inline code should both create and navigate out of an inline code region', async () => {
const initialDocText = 'Testing...\n\n';
const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
toggleCode(editor);
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
@@ -84,7 +84,7 @@ describe('markdownCommands', () => {
it('should set headers to the proper levels (when toggling)', async () => {
const initialDocText = 'Testing...\nThis is a test.';
const editor = await createTestEditor(initialDocText, EditorSelection.cursor(3), []);
const editor = await createEditor(initialDocText, EditorSelection.cursor(3), []);
toggleHeaderLevel(1)(editor);
@@ -110,7 +110,7 @@ describe('markdownCommands', () => {
it('headers should toggle properly within block quotes', async () => {
const initialDocText = 'Testing...\n\n> This is a test.\n> ...a test';
const editor = await createTestEditor(
const editor = await createEditor(
initialDocText,
EditorSelection.cursor('Testing...\n\n> This'.length),
['Blockquote'],
@@ -134,7 +134,7 @@ describe('markdownCommands', () => {
it('block math should be created correctly within block quotes', async () => {
const initialDocText = 'Testing...\n\n> This is a test.\n> y = mx + b\n> ...a test';
const editor = await createTestEditor(
const editor = await createEditor(
initialDocText,
EditorSelection.range(
'Testing...\n\n> This'.length,
@@ -157,7 +157,7 @@ describe('markdownCommands', () => {
it('block math should be correctly removed within block quotes', async () => {
const initialDocText = 'Testing...\n\n> $$\n> This is a test.\n> y = mx + b\n> $$\n> ...a test';
const editor = await createTestEditor(
const editor = await createEditor(
initialDocText,
EditorSelection.cursor('Testing...\n\n> $$\n> This is'.length),
['Blockquote', blockMathTagName],
@@ -173,7 +173,7 @@ describe('markdownCommands', () => {
it('updateLink should replace link titles and isolate URLs if no title is given', async () => {
const initialDocText = '[foo](http://example.com/)';
const editor = await createTestEditor(initialDocText, EditorSelection.cursor('[f'.length), ['Link']);
const editor = await createEditor(initialDocText, EditorSelection.cursor('[f'.length), ['Link']);
updateLink('bar', 'https://example.com/')(editor);
expect(editor.state.doc.toString()).toBe(
@@ -188,7 +188,7 @@ describe('markdownCommands', () => {
it('toggling math twice, starting on a line with content, should a math block', async () => {
const initialDocText = 'Testing... ';
const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
toggleMath(editor);
toggleMath(editor);
@@ -198,7 +198,7 @@ describe('markdownCommands', () => {
it('toggling math twice on an empty line should create an empty math block', async () => {
const initialDocText = 'Testing...\n\n';
const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
toggleMath(editor);
toggleMath(editor);
@@ -208,7 +208,7 @@ describe('markdownCommands', () => {
it('toggling code twice on an empty line should create an empty code block', async () => {
const initialDocText = 'Testing...\n\n';
const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
// Toggling code twice should create a block code region
toggleCode(editor);
@@ -222,7 +222,7 @@ describe('markdownCommands', () => {
it('toggling math twice inside a block quote should produce an empty math block', async () => {
const initialDocText = '> Testing...> \n> ';
const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), ['Blockquote']);
const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), ['Blockquote']);
toggleMath(editor);
toggleMath(editor);

View File

@@ -2,8 +2,8 @@ import { EditorSelection, EditorState } from '@codemirror/state';
import {
increaseIndent, toggleList,
} from './markdownCommands';
import { ListType } from '../../types';
import createTestEditor from '../testUtil/createTestEditor';
import { ListType } from '../types';
import createEditor from './testUtil/createEditor';
describe('markdownCommands.toggleList', () => {
@@ -12,7 +12,7 @@ describe('markdownCommands.toggleList', () => {
it('should remove the same type of list', async () => {
const initialDocText = '- testing\n- this is a `test`\n';
const editor = await createTestEditor(
const editor = await createEditor(
initialDocText,
EditorSelection.cursor(5),
['BulletList', 'InlineCode'],
@@ -26,7 +26,7 @@ describe('markdownCommands.toggleList', () => {
it('should insert a numbered list with correct numbering', async () => {
const initialDocText = 'Testing...\nThis is a test\nof list toggling...';
const editor = await createTestEditor(
const editor = await createEditor(
initialDocText,
EditorSelection.cursor('Testing...\nThis is a'.length),
[],
@@ -51,7 +51,7 @@ describe('markdownCommands.toggleList', () => {
const unorderedListText = '- 1\n- 2\n- 3\n- 4\n- 5\n- 6\n- 7';
it('should correctly replace an unordered list with a numbered list', async () => {
const editor = await createTestEditor(
const editor = await createEditor(
unorderedListText,
EditorSelection.cursor(unorderedListText.length),
['BulletList'],
@@ -154,7 +154,7 @@ describe('markdownCommands.toggleList', () => {
it('should toggle a numbered list without changing its sublists', async () => {
const initialDocText = '1. Foo\n2. Bar\n3. Baz\n\t- Test\n\t- of\n\t- sublists\n4. Foo';
const editor = await createTestEditor(
const editor = await createEditor(
initialDocText,
EditorSelection.cursor(0),
['OrderedList', 'BulletList'],
@@ -169,7 +169,7 @@ describe('markdownCommands.toggleList', () => {
it('should toggle a sublist without changing the parent list', async () => {
const initialDocText = '1. This\n2. is\n3. ';
const editor = await createTestEditor(
const editor = await createEditor(
initialDocText,
EditorSelection.cursor(initialDocText.length),
['OrderedList'],
@@ -192,7 +192,7 @@ describe('markdownCommands.toggleList', () => {
it('should toggle lists properly within block quotes', async () => {
const preSubListText = '> # List test\n> * This\n> * is\n';
const initialDocText = `${preSubListText}> \t* a\n> \t* test\n> * of list toggling`;
const editor = await createTestEditor(
const editor = await createEditor(
initialDocText, EditorSelection.cursor(preSubListText.length + 3),
['BlockQuote', 'BulletList'],
);

View File

@@ -2,7 +2,7 @@
import { EditorView, Command } from '@codemirror/view';
import { ListType } from '../../types';
import { ListType } from '../types';
import {
SelectionRange, EditorSelection, ChangeSpec, Line, TransactionSpec,
} from '@codemirror/state';

View File

@@ -3,11 +3,11 @@ import { SyntaxNode } from '@lezer/common';
import { EditorSelection, EditorState } from '@codemirror/state';
import { blockMathTagName, inlineMathContentTagName, inlineMathTagName } from './markdownMathParser';
import createTestEditor from '../testUtil/createTestEditor';
import createEditor from './testUtil/createEditor';
// Creates an EditorState with math and markdown extensions
const createEditorState = async (initialText: string, expectedTags: string[]): Promise<EditorState> => {
return (await createTestEditor(initialText, EditorSelection.cursor(0), expectedTags)).state;
return (await createEditor(initialText, EditorSelection.cursor(0), expectedTags)).state;
};
// Returns a list of all nodes with the given name in the given editor's syntax tree.

View File

@@ -3,13 +3,13 @@ import { GFM as GithubFlavoredMarkdownExt } from '@lezer/markdown';
import { indentUnit, syntaxTree } from '@codemirror/language';
import { SelectionRange, EditorSelection, EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { MarkdownMathExtension } from '../markdown/markdownMathParser';
import { MarkdownMathExtension } from '../markdownMathParser';
import forceFullParse from './forceFullParse';
import loadLangauges from './loadLanguages';
// Creates and returns a minimal editor with markdown extensions. Waits to return the editor
// until all syntax tree tags in `expectedSyntaxTreeTags` exist.
const createTestEditor = async (
const createEditor = async (
initialText: string, initialSelection: SelectionRange, expectedSyntaxTreeTags: string[],
): Promise<EditorView> => {
await loadLangauges();
@@ -62,4 +62,4 @@ const createTestEditor = async (
return editor;
};
export default createTestEditor;
export default createEditor;

View File

@@ -1,4 +1,4 @@
import syntaxHighlightingLanguages from '../markdown/syntaxHighlightingLanguages';
import syntaxHighlightingLanguages from '../syntaxHighlightingLanguages';
// Ensure languages we use are loaded. Without this, tests may randomly fail (LanguageDescriptions
// are loaded asyncronously, in the background).

View File

@@ -8,7 +8,7 @@ import { tags } from '@lezer/highlight';
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { inlineMathTag, mathTag } from './markdown/markdownMathParser';
import { inlineMathTag, mathTag } from './markdownMathParser';
// For an example on how to customize the theme, see:
//
@@ -25,12 +25,6 @@ import { inlineMathTag, mathTag } from './markdown/markdownMathParser';
//
// [theme] should be a joplin theme (see @joplin/lib/theme)
const createTheme = (theme: any): Extension[] => {
// If the theme hasn't loaded yet, return nothing.
// (createTheme should be called again after the theme has loaded).
if (!theme) {
return [];
}
const isDarkTheme = theme.appearance === 'dark';
const baseGlobalStyle: Record<string, string> = {
@@ -40,19 +34,15 @@ const createTheme = (theme: any): Extension[] => {
// On iOS, apply system font scaling (e.g. font scaling
// set in accessibility settings).
font: '-apple-system-body',
// Fill container horizontally
width: '100%',
boxSizing: 'border-box',
};
const baseCursorStyle: Record<string, string> = { };
const baseContentStyle: Record<string, string> = {
fontFamily: theme.fontFamily,
fontSize: `${theme.fontSize}${theme.fontSizeUnits ?? 'px'}`,
// Avoid using units here -- 1.55em, for example, can cause lines to overlap
// if some lines contain text with a large enough font size.
lineHeight: theme.isDesktop ? '1.55' : undefined,
// To allow accessibility font scaling, we also need to set the
// fontSize to a value in `em`s (relative scaling relative to
// parent font size).
fontSize: `${theme.fontSize}em`,
};
const baseSelectionStyle: Record<string, string> = { };
const blurredSelectionStyle: Record<string, string> = { };
@@ -70,42 +60,17 @@ const createTheme = (theme: any): Extension[] => {
blurredSelectionStyle.backgroundColor = '#444';
}
const monospaceStyle = {
fontFamily: theme.monospaceFont || 'monospace',
};
// This is equivalent to the default selection style -- our styling must
// be at least this specific.
const selectionBackgroundSelector = '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground';
const codeMirrorTheme = EditorView.theme({
const baseTheme = EditorView.baseTheme({
'&': baseGlobalStyle,
// These must be !important or more specific than CodeMirror's built-ins
'.cm-content': {
fontFamily: theme.fontFamily,
...baseContentStyle,
paddingBottom: theme.isDesktop ? '400px' : undefined,
},
'&.cm-focused .cm-cursor': baseCursorStyle,
// The desktop app sets the font for these elements to a specific font.
// Override this.
'& div, & span, & a': {
fontFamily: 'inherit',
},
// Override the default border around CodeMirror panels
'& > .cm-panels': {
border: 'none',
},
// &.cm-focused is used to give these styles higher specificity
// than the defaults.
[selectionBackgroundSelector]: baseSelectionStyle,
'&.cm-focused ::selection': baseSelectionStyle,
'& ::selection': blurredSelectionStyle,
'& .cm-selectionLayer .cm-selectionBackground': blurredSelectionStyle,
'&.cm-focused .cm-selectionBackground, ::selection': baseSelectionStyle,
'.cm-selectionBackground': blurredSelectionStyle,
'&.cm-editor.cm-focused': {
outline: 'none !important',
@@ -136,8 +101,6 @@ const createTheme = (theme: any): Extension[] => {
borderStyle: 'solid',
borderColor: theme.colorFaded,
backgroundColor: 'rgba(155, 155, 155, 0.1)',
...(theme.isDesktop ? monospaceStyle : {}),
},
// CodeMirror wraps the existing inline span in an additional element.
@@ -150,21 +113,12 @@ const createTheme = (theme: any): Extension[] => {
borderStyle: 'solid',
borderColor: isDarkTheme ? 'rgba(200, 200, 200, 0.5)' : 'rgba(100, 100, 100, 0.5)',
borderRadius: '4px',
...(theme.isDesktop ? monospaceStyle : {}),
},
'& .cm-mathBlock, & .cm-inlineMath': {
color: isDarkTheme ? '#9fa' : '#276',
},
'& .cm-tableHeader, & .cm-tableRow, & .cm-tableDelimiter': monospaceStyle,
'& .cm-taskMarker': monospaceStyle,
// Override the default URL style when the URL is within a link
'& .tok-url.tok-link, & .tok-link.tok-meta, & .tok-link.tok-string': {
opacity: theme.isDesktop ? 0.6 : 1,
},
// Style the search widget. Use ':root' to increase the selector's precedence
// (override the existing preset styles).
@@ -174,7 +128,9 @@ const createTheme = (theme: any): Extension[] => {
color: isDarkTheme ? 'white' : 'black',
},
},
}, { dark: isDarkTheme });
});
const appearanceTheme = EditorView.theme({}, { dark: isDarkTheme });
const baseHeadingStyle = {
fontWeight: 'bold',
@@ -194,6 +150,7 @@ const createTheme = (theme: any): Extension[] => {
...baseHeadingStyle,
tag: tags.heading1,
fontSize: '1.6em',
borderBottom: `1px solid ${theme.dividerColor}`,
},
{
...baseHeadingStyle,
@@ -232,7 +189,7 @@ const createTheme = (theme: any): Extension[] => {
{
tag: tags.link,
color: theme.urlColor,
textDecoration: theme.isDesktop ? undefined : 'underline',
textDecoration: 'underline',
},
{
tag: [mathTag, inlineMathTag],
@@ -263,7 +220,8 @@ const createTheme = (theme: any): Extension[] => {
]);
return [
codeMirrorTheme,
baseTheme,
appearanceTheme,
syntaxHighlighting(highlightingStyle),
// If we haven't defined highlighting for tags, fall back

View File

@@ -0,0 +1,27 @@
import { ListType, SearchControl } from '../types';
// Controls for the CodeMirror portion of the editor
export interface CodeMirrorControl {
undo(): void;
redo(): void;
select(anchor: number, head: number): void;
insertText(text: string): void;
// Toggle whether we're in a type of region.
toggleBolded(): void;
toggleItalicized(): void;
toggleList(kind: ListType): void;
toggleCode(): void;
toggleMath(): void;
toggleHeaderLevel(level: number): void;
// Create a new link or update the currently selected link with
// the given [label] and [url].
updateLink(label: string, url: string): void;
increaseIndent(): void;
decreaseIndent(): void;
scrollSelectionIntoView(): void;
searchControl: SearchControl;
}

View File

@@ -9,8 +9,8 @@ import Modal from '../Modal';
import { themeStyle } from '@joplin/lib/theme';
import { _ } from '@joplin/lib/locale';
import { EditorControl } from './types';
import SelectionFormatting from './SelectionFormatting';
import { useCallback } from 'react';
import SelectionFormatting from '@joplin/editor/SelectionFormatting';
interface LinkDialogProps {
editorControl: EditorControl;
@@ -21,7 +21,7 @@ interface LinkDialogProps {
const EditLinkDialog = (props: LinkDialogProps) => {
// The content of the link selected in the editor (if any)
const editorLinkData = props.selectionState.linkData;
const editorLinkData = props.selectionState.linkData ?? {};
const [linkLabel, setLinkLabel] = useState('');
const [linkURL, setLinkURL] = useState('');

View File

@@ -1,302 +0,0 @@
const React = require('react');
import { _ } from '@joplin/lib/locale';
import Logger from '@joplin/utils/Logger';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, BackHandler } from 'react-native';
import { WebViewMessageEvent } from 'react-native-webview';
import ExtendedWebView, { WebViewControl } from '../../ExtendedWebView';
import { clearAutosave, writeAutosave } from './autosave';
import { LocalizedStrings } from './js-draw/types';
const logger = Logger.create('ImageEditor');
type OnSaveCallback = (svgData: string)=> void;
type OnCancelCallback = ()=> void;
type LoadInitialSVGCallback = ()=> Promise<string>;
interface Props {
themeId: number;
loadInitialSVGData: LoadInitialSVGCallback|null;
onSave: OnSaveCallback;
onExit: OnCancelCallback;
}
const useCss = (editorTheme: Theme) => {
return useMemo(() => {
// Ensure we have contrast between the background and selection. Some themes
// have the same backgroundColor and selectionColor2. (E.g. Aritim Dark)
let selectionBackgroundColor = editorTheme.selectedColor2;
if (selectionBackgroundColor === editorTheme.backgroundColor) {
selectionBackgroundColor = editorTheme.selectedColor;
}
return `
:root .imageEditorContainer {
--background-color-1: ${editorTheme.backgroundColor};
--foreground-color-1: ${editorTheme.color};
--background-color-2: ${editorTheme.backgroundColor3};
--foreground-color-2: ${editorTheme.color3};
--background-color-3: ${editorTheme.raisedBackgroundColor};
--foreground-color-3: ${editorTheme.raisedColor};
--selection-background-color: ${editorTheme.backgroundColorHover3};
--selection-foreground-color: ${editorTheme.color3};
--primary-action-foreground-color: ${editorTheme.color4};
--primary-shadow-color: ${editorTheme.colorFaded};
width: 100vw;
height: 100vh;
box-sizing: border-box;
}
body, html {
padding: 0;
margin: 0;
}
/* Hide the scrollbar. See scrollbar accessibility concerns
(https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-width#accessibility_concerns)
for why this isn't done in js-draw itself. */
.toolbar-tool-row::-webkit-scrollbar {
display: none;
height: 0;
}
/* Hide the save/close icons on small screens. This isn't done in the upstream
js-draw repository partially beause it isn't as well localized as Joplin
(icons can be used to suggest the meaning of a button when a translation is
unavailable). */
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--save .toolbar-icon,
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--exit .toolbar-icon {
display: none;
}
`;
}, [editorTheme]);
};
const ImageEditor = (props: Props) => {
const editorTheme: Theme = themeStyle(props.themeId);
const webviewRef: MutableRefObject<WebViewControl>|null = useRef(null);
const [imageChanged, setImageChanged] = useState(false);
const onRequestCloseEditor = useCallback((promptIfUnsaved: boolean) => {
const discardChangesAndClose = async () => {
await clearAutosave();
props.onExit();
};
if (!imageChanged || !promptIfUnsaved) {
void discardChangesAndClose();
return true;
}
Alert.alert(
_('Save changes?'), _('This drawing may have unsaved changes.'), [
{
text: _('Discard changes'),
onPress: discardChangesAndClose,
style: 'destructive',
},
{
text: _('Save changes'),
onPress: () => {
// saveDrawing calls props.onSave(...) which may close the
// editor.
webviewRef.current.injectJS('window.editorControl.saveThenExit()');
},
},
],
);
return true;
}, [webviewRef, props.onExit, imageChanged]);
useEffect(() => {
const hardwareBackPressListener = () => {
onRequestCloseEditor(true);
return true;
};
BackHandler.addEventListener('hardwareBackPress', hardwareBackPressListener);
return () => {
BackHandler.removeEventListener('hardwareBackPress', hardwareBackPressListener);
};
}, [onRequestCloseEditor]);
const css = useCss(editorTheme);
const html = useMemo(() => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/>
<style>
${css}
</style>
</head>
<body></body>
</html>
`, [css]);
// A set of localization overrides (Joplin is better localized than js-draw).
// All localizable strings (some unused?) can be found at
// https://github.com/personalizedrefrigerator/js-draw/blob/main/.github/ISSUE_TEMPLATE/translation-js-draw-new.yml
const localizedStrings: LocalizedStrings = useMemo(() => ({
save: _('Save'),
close: _('Close'),
undo: _('Undo'),
redo: _('Redo'),
}), []);
const injectedJavaScript = useMemo(() => `
window.onerror = (message, source, lineno) => {
window.ReactNativeWebView.postMessage(
"error: " + message + " in file://" + source + ", line " + lineno
);
};
const setImageHasChanges = (hasChanges) => {
window.ReactNativeWebView.postMessage(
JSON.stringify({
action: 'set-image-has-changes',
data: hasChanges,
}),
);
};
window.updateEditorTemplate = (templateData) => {
window.ReactNativeWebView.postMessage(
JSON.stringify({
action: 'set-image-template-data',
data: templateData,
}),
);
};
const notifyReadyToLoadSVG = () => {
window.ReactNativeWebView.postMessage(
JSON.stringify({
action: 'ready-to-load-data',
})
);
};
const saveDrawing = async (drawing, isAutosave) => {
window.ReactNativeWebView.postMessage(
JSON.stringify({
action: isAutosave ? 'autosave' : 'save',
data: drawing.outerHTML,
}),
);
};
const closeEditor = (promptIfUnsaved) => {
window.ReactNativeWebView.postMessage(JSON.stringify({
action: 'close',
promptIfUnsaved,
}));
};
try {
if (window.editorControl === undefined) {
${shim.injectedJs('svgEditorBundle')}
window.editorControl = svgEditorBundle.createJsDrawEditor(
{
saveDrawing,
closeEditor,
updateEditorTemplate,
setImageHasChanges,
},
${JSON.stringify(Setting.value('imageeditor.jsdrawToolbar'))},
${JSON.stringify(Setting.value('locale'))},
${JSON.stringify(localizedStrings)},
);
// Start loading the SVG file (if present) after loading the editor.
// This shows the user that progress is being made (loading large SVGs
// from disk into memory can take several seconds).
notifyReadyToLoadSVG();
}
} catch(e) {
window.ReactNativeWebView.postMessage(
'error: ' + e.message + ': ' + JSON.stringify(e)
);
}
true;
`, [localizedStrings]);
useEffect(() => {
webviewRef.current?.injectJS(`
if (window.editorControl) {
window.editorControl.onThemeUpdate();
}
`);
}, [editorTheme]);
const onReadyToLoadData = useCallback(async () => {
const initialSVGData = await props.loadInitialSVGData?.() ?? '';
// It can take some time for initialSVGData to be transferred to the WebView.
// Thus, do so after the main content has been loaded.
webviewRef.current.injectJS(`(async () => {
if (window.editorControl) {
const initialSVGData = ${JSON.stringify(initialSVGData)};
const initialTemplateData = ${JSON.stringify(Setting.value('imageeditor.imageTemplate'))};
editorControl.loadImageOrTemplate(initialSVGData, initialTemplateData);
}
})();`);
}, [webviewRef, props.loadInitialSVGData]);
const onMessage = useCallback(async (event: WebViewMessageEvent) => {
const data = event.nativeEvent.data;
if (data.startsWith('error:')) {
logger.error('ImageEditor:', data);
return;
}
const json = JSON.parse(data);
if (json.action === 'save') {
await clearAutosave();
props.onSave(json.data);
} else if (json.action === 'autosave') {
await writeAutosave(json.data);
} else if (json.action === 'save-toolbar') {
Setting.setValue('imageeditor.jsdrawToolbar', json.data);
} else if (json.action === 'close') {
onRequestCloseEditor(json.promptIfUnsaved);
} else if (json.action === 'ready-to-load-data') {
void onReadyToLoadData();
} else if (json.action === 'set-image-has-changes') {
setImageChanged(json.data);
} else if (json.action === 'set-image-template-data') {
Setting.setValue('imageeditor.imageTemplate', json.data);
} else {
logger.error('Unknown action,', json.action);
}
}, [props.onSave, onRequestCloseEditor, onReadyToLoadData]);
const onError = useCallback((event: any) => {
logger.error('ImageEditor: WebView error: ', event);
}, []);
return (
<ExtendedWebView
themeId={props.themeId}
html={html}
injectedJavaScript={injectedJavaScript}
onMessage={onMessage}
onError={onError}
ref={webviewRef}
webviewInstanceId={'image-editor-js-draw'}
/>
);
};
export default ImageEditor;

View File

@@ -1,26 +0,0 @@
import Logger from '@joplin/utils/Logger';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
export const autosaveFilename = 'autosaved-drawing.joplin.svg';
const logger = Logger.create('ImageEditor/autosave');
export const getAutosaveFilepath = () => {
return `${Setting.value('resourceDir')}/${autosaveFilename}`;
};
export const writeAutosave = async (data: string) => {
const filePath = getAutosaveFilepath();
logger.info(`Auto-saving drawing to ${JSON.stringify(filePath)}`);
await shim.fsDriver().writeFile(filePath, data, 'utf8');
};
export const readAutosave = async (): Promise<string|null> => {
return await shim.fsDriver().readFile(getAutosaveFilepath());
};
export const clearAutosave = async () => {
await shim.fsDriver().remove(getAutosaveFilepath());
};

View File

@@ -1,6 +0,0 @@
const isEditableResource = (resourceMime: string) => {
return resourceMime === 'image/svg+xml';
};
export default isEditableResource;

View File

@@ -1,67 +0,0 @@
import { AbstractComponent, Editor, BackgroundComponentBackgroundType, Erase, Vec2, Rect2 } from 'js-draw';
const applyTemplateToEditor = async (editor: Editor, templateData: string) => {
let backgroundComponent: AbstractComponent|null = null;
let imageSize = editor.getImportExportRect().size;
let autoresize = true;
try {
const templateJSON = JSON.parse(templateData);
const isEmptyTemplate =
!('imageSize' in templateJSON) && !('backgroundData' in templateJSON);
// If the template is empty, add a default background
if (isEmptyTemplate) {
templateJSON.backgroundData = {
'name': 'image-background',
'zIndex': 0,
'data': {
'mainColor': '#ffffff',
'backgroundType': BackgroundComponentBackgroundType.SolidColor,
},
};
}
if ('backgroundData' in templateJSON) {
backgroundComponent = AbstractComponent.deserialize(
templateJSON['backgroundData'],
);
}
if ('imageSize' in templateJSON) {
imageSize = Vec2.ofXY(templateJSON.imageSize);
}
if ('autoresize' in templateJSON) {
autoresize = !!templateJSON.autoresize;
}
} catch (e) {
console.error('Warning: Invalid image template data: ', e);
}
if (backgroundComponent) {
// Remove the old background (if any)
const previousBackground = editor.image.getBackgroundComponents();
if (previousBackground.length > 0) {
const removeBackgroundCommand = new Erase(previousBackground);
await editor.dispatchNoAnnounce(removeBackgroundCommand, false);
}
// Add the new background
const addBackgroundCommand = editor.image.addElement(backgroundComponent);
await editor.dispatchNoAnnounce(addBackgroundCommand, false);
}
// Set the image size
const imageSizeCommand = editor.setImportExportRect(new Rect2(0, 0, imageSize.x, imageSize.y));
await editor.dispatchNoAnnounce(imageSizeCommand, false);
// Enable/disable autoresize
await editor.dispatchNoAnnounce(editor.image.setAutoresizeEnabled(autoresize), false);
// And zoom to the template (false = don't make undoable)
await editor.dispatchNoAnnounce(editor.viewport.zoomTo(editor.getImportExportRect()), false);
};
export default applyTemplateToEditor;

View File

@@ -1,109 +0,0 @@
/** @jest-environment jsdom */
// Hide warnings from js-draw.
// jsdom doesn't support ResizeObserver and HTMLCanvasElement.getContext.
HTMLCanvasElement.prototype.getContext = () => null;
window.ResizeObserver = class { public observe() { } } as any;
import { describe, it, expect, jest } from '@jest/globals';
import { Color4, EditorImage, EditorSettings, Path, pathToRenderable, StrokeComponent } from 'js-draw';
import { RenderingMode } from 'js-draw';
import createJsDrawEditor from './createJsDrawEditor';
import { BackgroundComponent } from 'js-draw';
import { BackgroundComponentBackgroundType } from 'js-draw';
import { ImageEditorCallbacks } from './types';
import applyTemplateToEditor from './applyTemplateToEditor';
const createEditorWithCallbacks = (callbacks: Partial<ImageEditorCallbacks>) => {
const toolbarState = '';
const locale = 'en';
const allCallbacks: ImageEditorCallbacks = {
saveDrawing: () => {},
closeEditor: ()=> {},
setImageHasChanges: ()=> {},
updateEditorTemplate: ()=> {},
...callbacks,
};
const editorOptions: Partial<EditorSettings> = {
// Don't use a CanvasRenderer: jsdom doesn't support DrawingContext2D
renderingMode: RenderingMode.DummyRenderer,
};
const localizations = {
save: 'Save',
close: 'Close',
undo: 'Undo',
redo: 'Redo',
};
return createJsDrawEditor(allCallbacks, toolbarState, locale, localizations, editorOptions);
};
describe('createJsDrawEditor', () => {
it('should trigger autosave callback every few minutes', async () => {
let calledAutosaveCount = 0;
jest.useFakeTimers();
const editorControl = createEditorWithCallbacks({
saveDrawing: (_drawing: SVGElement, isAutosave: boolean) => {
if (isAutosave) {
calledAutosaveCount ++;
}
},
});
// Load no image and an empty template so that autosave can start
await editorControl.loadImageOrTemplate(undefined, '{}');
expect(calledAutosaveCount).toBe(0);
// Using the synchronous version of advanceTimersByTime seems to not
// run the asynchronous code used to autosave drawings in createJsDrawEditor.ts.
await jest.advanceTimersByTimeAsync(1000 * 60 * 4);
const lastAutosaveCount = calledAutosaveCount;
expect(calledAutosaveCount).toBeGreaterThanOrEqual(1);
expect(calledAutosaveCount).toBeLessThan(10);
await jest.advanceTimersByTimeAsync(1000 * 60 * 10);
expect(calledAutosaveCount).toBeGreaterThan(lastAutosaveCount);
});
it('should fire has changes callback on first change', () => {
let hasChanges = false;
const editorControl = createEditorWithCallbacks({
setImageHasChanges: (newHasChanges: boolean) => {
hasChanges = newHasChanges;
},
});
expect(hasChanges).toBe(false);
const stroke = new StrokeComponent([
// A filled shape
pathToRenderable(Path.fromString('m0,0 l10,0 l0,10'), { fill: Color4.red }),
]);
void editorControl.editor.dispatch(EditorImage.addElement(stroke));
expect(hasChanges).toBe(true);
});
it('default template should be a white grid background', async () => {
const editorControl = createEditorWithCallbacks({});
const editor = editorControl.editor;
await applyTemplateToEditor(editor, '{}');
expect(editor.image.getBackgroundComponents()).toHaveLength(1);
// Should have a white, solid background
const background = editor.image.getBackgroundComponents()[0] as BackgroundComponent;
expect(editor.estimateBackgroundColor().eq(Color4.white)).toBe(true);
expect(background.getBackgroundType()).toBe(BackgroundComponentBackgroundType.SolidColor);
});
});

View File

@@ -1,168 +0,0 @@
import { Editor, AbstractToolbar, EditorEventType, EditorSettings, getLocalizationTable, adjustEditorThemeForContrast, BaseWidget } from 'js-draw';
import { MaterialIconProvider } from '@js-draw/material-icons';
import 'js-draw/bundledStyles';
import applyTemplateToEditor from './applyTemplateToEditor';
import watchEditorForTemplateChanges from './watchEditorForTemplateChanges';
import { ImageEditorCallbacks, LocalizedStrings } from './types';
import startAutosaveLoop from './startAutosaveLoop';
declare namespace ReactNativeWebView {
const postMessage: (data: any)=> void;
}
const restoreToolbarState = (toolbar: AbstractToolbar, state: string) => {
if (state) {
// deserializeState throws on invalid argument.
try {
toolbar.deserializeState(state);
} catch (e) {
console.warn('Error deserializing toolbar state: ', e);
}
}
};
const listenToolbarState = (editor: Editor, toolbar: AbstractToolbar) => {
editor.notifier.on(EditorEventType.ToolUpdated, () => {
const state = toolbar.serializeState();
ReactNativeWebView.postMessage(
JSON.stringify({
action: 'save-toolbar',
data: state,
}),
);
});
};
export const createJsDrawEditor = (
callbacks: ImageEditorCallbacks,
initialToolbarState: string,
locale: string,
defaultLocalizations: LocalizedStrings,
// Intended for automated tests.
editorSettings: Partial<EditorSettings> = {},
) => {
const parentElement = document.body;
const editor = new Editor(parentElement, {
// Try to use the Joplin locale, but fall back to the system locale if
// js-draw doesn't support it.
localization: {
...getLocalizationTable([locale, ...navigator.languages]),
...defaultLocalizations,
},
iconProvider: new MaterialIconProvider(),
...editorSettings,
});
const toolbar = editor.addToolbar();
const maxSpacerSize = '20px';
toolbar.addSpacer({
grow: 1,
maxSize: maxSpacerSize,
});
// Override the default "Exit" label:
toolbar.addExitButton(
() => callbacks.closeEditor(true), {
label: defaultLocalizations.close,
},
);
toolbar.addSpacer({
grow: 1,
maxSize: maxSpacerSize,
});
// saveButton needs to be defined after the following callbacks.
// As such, this variable can't be made const.
// eslint-disable-next-line prefer-const
let saveButton: BaseWidget;
let lastHadChanges: boolean|null = null;
const setImageHasChanges = (hasChanges: boolean) => {
if (lastHadChanges !== hasChanges) {
saveButton.setDisabled(!hasChanges);
callbacks.setImageHasChanges(hasChanges);
lastHadChanges = hasChanges;
}
};
const saveNow = () => {
callbacks.saveDrawing(editor.toSVG({
// Grow small images to this minimum size
minDimension: 50,
}), false);
// The image is now up-to-date with the resource
setImageHasChanges(false);
};
saveButton = toolbar.addSaveButton(saveNow);
// Load and save toolbar-realated state (e.g. pen sizes/colors).
restoreToolbarState(toolbar, initialToolbarState);
listenToolbarState(editor, toolbar);
setImageHasChanges(false);
editor.notifier.on(EditorEventType.UndoRedoStackUpdated, () => {
setImageHasChanges(true);
});
// Disable save (a full save can't be done until the entire image
// has been loaded).
saveButton.setDisabled(true);
// Show a loading message until the template is loaded.
editor.showLoadingWarning(0);
editor.setReadOnly(true);
const editorControl = {
editor,
loadImageOrTemplate: async (svgData: string|undefined, templateData: string) => {
// loadFromSVG shows its own loading message. Hide the original.
editor.hideLoadingWarning();
if (svgData && svgData.length > 0) {
await editor.loadFromSVG(svgData);
} else {
await applyTemplateToEditor(editor, templateData);
// The editor expects to be saved initially (without
// unsaved changes). Save now.
saveNow();
}
// We can now edit and save safely (without data loss).
editor.setReadOnly(false);
void startAutosaveLoop(editor, callbacks.saveDrawing);
watchEditorForTemplateChanges(editor, templateData, callbacks.updateEditorTemplate);
},
onThemeUpdate: () => {
// Slightly adjusts the given editor's theme colors. This ensures that the colors chosen for
// the editor have proper contrast.
adjustEditorThemeForContrast(editor);
},
saveNow,
saveThenExit: async () => {
saveNow();
// Don't show a confirmation dialog -- it's possible that
// the code outside of the WebView still thinks changes haven't
// been saved:
const showConfirmation = false;
callbacks.closeEditor(showConfirmation);
},
};
editorControl.onThemeUpdate();
return editorControl;
};
export default createJsDrawEditor;

View File

@@ -1,26 +0,0 @@
import { Editor } from 'js-draw';
import { SaveDrawingCallback } from './types';
const startAutosaveLoop = async (
editor: Editor,
saveDrawing: SaveDrawingCallback,
) => {
// Autosave every two minutes.
const delayTime = 1000 * 60 * 2; // ms
const createAutosave = async () => {
const savedSVG = await editor.toSVGAsync();
saveDrawing(savedSVG, true);
};
while (true) {
await (new Promise<void>(resolve => {
setTimeout(() => resolve(), delayTime);
}));
await createAutosave();
}
};
export default startAutosaveLoop;

View File

@@ -1,20 +0,0 @@
export type SaveDrawingCallback = (svgElement: SVGElement, isAutosave: boolean)=> void;
export type UpdateEditorTemplateCallback = (newTemplate: string)=> void;
export interface ImageEditorCallbacks {
saveDrawing: SaveDrawingCallback;
updateEditorTemplate: UpdateEditorTemplateCallback;
closeEditor: (promptIfUnsaved: boolean)=> void;
setImageHasChanges: (hasChanges: boolean)=> void;
}
// Overrides translations in js-draw -- as of the time of this writing,
// Joplin has many common strings localized better than js-draw.
export interface LocalizedStrings {
save: string;
close: string;
undo: string;
redo: string;
}

View File

@@ -1,61 +0,0 @@
import { Editor, BackgroundComponent, EditorEventType, Vec2 } from 'js-draw';
const watchEditorForTemplateChanges = (
editor: Editor, initialTemplate: string, updateTemplate: (templateData: string)=> void,
) => {
const computeTemplate = (): string => {
let backgroundSize: Vec2|null = null;
// Only store the background size if the size isn't determined
// by the editor content. In this case, the background always
// appears to be full screen.
if (!editor.image.getAutoresizeEnabled()) {
backgroundSize = editor.getImportExportRect().size;
// Constrain the size: Don't allow an extremely small or extremely large tempalte.
// Map components to constrained components.
backgroundSize = backgroundSize.map(component => {
const minDimen = 45;
const maxDimen = 5000;
return Math.max(Math.min(component, maxDimen), minDimen);
});
}
// Find the topmost background component (if any)
let backgroundComponent: BackgroundComponent|null = null;
for (const component of editor.image.getBackgroundComponents()) {
if (component instanceof BackgroundComponent) {
backgroundComponent = component;
}
}
const templateData = {
imageSize: backgroundSize?.xy,
backgroundData: backgroundComponent?.serialize(),
autoresize: editor.image.getAutoresizeEnabled(),
};
return JSON.stringify(templateData);
};
let lastTemplate = initialTemplate;
const updateTemplateIfNecessary = () => {
const newTemplate = computeTemplate();
if (newTemplate !== lastTemplate) {
updateTemplate(newTemplate);
lastTemplate = newTemplate;
}
};
// Whenever a command is done/undone, re-calculate the template & save.
editor.notifier.on(EditorEventType.CommandDone, () => {
updateTemplateIfNecessary();
});
editor.notifier.on(EditorEventType.CommandUndone, () => {
updateTemplateIfNecessary();
});
};
export default watchEditorForTemplateChanges;

View File

@@ -1,37 +0,0 @@
import { _ } from '@joplin/lib/locale';
import shim from '@joplin/lib/shim';
import { Alert } from 'react-native';
import { clearAutosave, getAutosaveFilepath, readAutosave } from './autosave';
export type RestoreAutosaveCallback = (data: string)=> void;
const promptRestoreAutosave = async (onRestoreAutosave: RestoreAutosaveCallback) => {
const autosavePath = getAutosaveFilepath();
if (await shim.fsDriver().exists(autosavePath)) {
const title: string|null = null;
const message = _(
'An autosaved drawing was found. Attach a copy of it to the note?',
);
Alert.alert(title, message, [
{
text: _('Discard'),
onPress: async () => {
await clearAutosave();
},
},
{
text: _('Attach'),
onPress: async () => {
const autosaveData = await readAutosave();
await clearAutosave();
onRestoreAutosave(autosaveData);
},
},
]);
}
};
export default promptRestoreAutosave;

View File

@@ -14,14 +14,13 @@ import { _ } from '@joplin/lib/locale';
import time from '@joplin/lib/time';
import { useEffect } from 'react';
import { Keyboard, ViewStyle } from 'react-native';
import { EditorControl, EditorSettings } from '../types';
import { EditorControl, EditorSettings, ListType, SearchState } from '../types';
import SelectionFormatting from '../SelectionFormatting';
import { ButtonSpec, StyleSheetData } from './types';
import Toolbar from './Toolbar';
import { buttonSize } from './ToolbarButton';
import { Theme } from '@joplin/lib/themes/type';
import ToggleSpaceButton from './ToggleSpaceButton';
import { SearchState } from '@joplin/editor/types';
import SelectionFormatting from '@joplin/editor/SelectionFormatting';
type OnAttachCallback = ()=> void;
@@ -72,7 +71,9 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
),
description: _('Unordered list'),
active: selState.inUnorderedList,
onPress: editorControl.toggleUnorderedList,
onPress: useCallback(() => {
editorControl.toggleList(ListType.UnorderedList);
}, [editorControl]),
priority: -2,
disabled: readOnly,
@@ -84,7 +85,9 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
),
description: _('Ordered list'),
active: selState.inOrderedList,
onPress: editorControl.toggleOrderedList,
onPress: useCallback(() => {
editorControl.toggleList(ListType.OrderedList);
}, [editorControl]),
priority: -2,
disabled: readOnly,
@@ -96,7 +99,9 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
),
description: _('Task list'),
active: selState.inChecklist,
onPress: editorControl.toggleTaskList,
onPress: useCallback(() => {
editorControl.toggleList(ListType.CheckList);
}, [editorControl]),
priority: -2,
disabled: readOnly,

View File

@@ -5,29 +5,29 @@ import EditLinkDialog from './EditLinkDialog';
import { defaultSearchState, SearchPanel } from './SearchPanel';
import ExtendedWebView from '../ExtendedWebView';
import * as React from 'react';
const React = require('react');
import { forwardRef, RefObject, useImperativeHandle } from 'react';
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
const { editorFont } = require('../global-style');
import { EditorControl, EditorSettings, SelectionRange } from './types';
import SelectionFormatting from './SelectionFormatting';
import {
EditorSettings, EditorControl,
ChangeEvent, UndoRedoDepthChangeEvent, Selection, SelectionChangeEvent, ListType, SearchState,
} from './types';
import { _ } from '@joplin/lib/locale';
import MarkdownToolbar from './MarkdownToolbar/MarkdownToolbar';
import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
import { EditorCommandType, EditorKeymap, EditorLanguageType, PluginData, SearchState } from '@joplin/editor/types';
import supportsCommand from '@joplin/editor/CodeMirror/editorCommands/supportsCommand';
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
type ChangeEventHandler = (event: ChangeEvent)=> void;
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void;
type SelectionChangeEventHandler = (event: SelectionChangeEvent)=> void;
type OnAttachCallback = ()=> void;
interface Props {
themeId: number;
initialText: string;
initialSelection?: SelectionRange;
initialSelection?: Selection;
style: ViewStyle;
contentStyle?: ViewStyle;
toolbarEnabled: boolean;
@@ -110,11 +110,6 @@ function editorTheme(themeId: number) {
return {
...themeStyle(themeId),
// To allow accessibility font scaling, we also need to set the
// fontSize to a value in `em`s (relative scaling relative to
// parent font size).
fontSizeUnits: 'em',
fontSize: estimatedFontSizeInEm,
fontFamily: fontFamilyFromSettings(),
};
@@ -125,92 +120,48 @@ type OnSetVisibleCallback = (visible: boolean)=> void;
type OnSearchStateChangeCallback = (state: SearchState)=> void;
const useEditorControl = (
injectJS: OnInjectJSCallback, setLinkDialogVisible: OnSetVisibleCallback,
setSearchState: OnSearchStateChangeCallback,
searchStateRef: RefObject<SearchState>,
setSearchState: OnSearchStateChangeCallback, searchStateRef: RefObject<SearchState>,
): EditorControl => {
return useMemo(() => {
const execCommand = (command: EditorCommandType) => {
injectJS(`cm.execCommand(${JSON.stringify(command)})`);
};
const setSearchStateCallback = (state: SearchState) => {
injectJS(`cm.setSearchState(${JSON.stringify(state)})`);
setSearchState(state);
};
const control: EditorControl = {
supportsCommand(command: EditorCommandType) {
return supportsCommand(command);
},
execCommand,
return {
undo() {
injectJS('cm.undo()');
injectJS('cm.undo();');
},
redo() {
injectJS('cm.redo()');
injectJS('cm.redo();');
},
select(anchor: number, head: number) {
injectJS(
`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)});`,
);
},
setScrollPercent(fraction: number) {
injectJS(`cm.setScrollFraction(${JSON.stringify(fraction)})`);
},
insertText(text: string) {
injectJS(`cm.insertText(${JSON.stringify(text)});`);
},
updateBody(newBody: string) {
injectJS(`cm.updateBody(${JSON.stringify(newBody)});`);
},
updateSettings(newSettings: EditorSettings) {
injectJS(`cm.updateSettings(${JSON.stringify(newSettings)})`);
},
toggleBolded() {
execCommand(EditorCommandType.ToggleBolded);
injectJS('cm.toggleBolded();');
},
toggleItalicized() {
execCommand(EditorCommandType.ToggleItalicized);
injectJS('cm.toggleItalicized();');
},
toggleOrderedList() {
execCommand(EditorCommandType.ToggleNumberedList);
},
toggleUnorderedList() {
execCommand(EditorCommandType.ToggleBulletedList);
},
toggleTaskList() {
execCommand(EditorCommandType.ToggleCheckList);
toggleList(listType: ListType) {
injectJS(`cm.toggleList(${JSON.stringify(listType)});`);
},
toggleCode() {
execCommand(EditorCommandType.ToggleCode);
injectJS('cm.toggleCode();');
},
toggleMath() {
execCommand(EditorCommandType.ToggleMath);
injectJS('cm.toggleMath();');
},
toggleHeaderLevel(level: number) {
const levelToCommand = [
EditorCommandType.ToggleHeading1,
EditorCommandType.ToggleHeading2,
EditorCommandType.ToggleHeading3,
EditorCommandType.ToggleHeading4,
EditorCommandType.ToggleHeading5,
];
const index = level - 1;
if (index < 0 || index >= levelToCommand.length) {
throw new Error(`Unsupported header level ${level}`);
}
execCommand(levelToCommand[index]);
injectJS(`cm.toggleHeaderLevel(${level});`);
},
increaseIndent() {
execCommand(EditorCommandType.IndentMore);
injectJS('cm.increaseIndent();');
},
decreaseIndent() {
execCommand(EditorCommandType.IndentLess);
injectJS('cm.decreaseIndent();');
},
updateLink(label: string, url: string) {
injectJS(`cm.updateLink(
@@ -219,7 +170,7 @@ const useEditorControl = (
);`);
},
scrollSelectionIntoView() {
execCommand(EditorCommandType.ScrollSelectionIntoView);
injectJS('cm.scrollSelectionIntoView();');
},
showLinkDialog() {
setLinkDialogVisible(true);
@@ -230,27 +181,23 @@ const useEditorControl = (
hideKeyboard() {
injectJS('document.activeElement?.blur();');
},
setPlugins: async (plugins: PluginData[]) => {
injectJS(`cm.setPlugins(${JSON.stringify(plugins)});`);
},
setSearchState: setSearchStateCallback,
searchControl: {
findNext() {
execCommand(EditorCommandType.FindNext);
injectJS('cm.searchControl.findNext();');
},
findPrevious() {
execCommand(EditorCommandType.FindPrevious);
injectJS('cm.searchControl.findPrevious();');
},
replaceNext() {
execCommand(EditorCommandType.ReplaceNext);
replaceCurrent() {
injectJS('cm.searchControl.replaceCurrent();');
},
replaceAll() {
execCommand(EditorCommandType.ReplaceAll);
injectJS('cm.searchControl.replaceAll();');
},
setSearchState(state: SearchState) {
injectJS(`cm.searchControl.setSearchState(${JSON.stringify(state)})`);
setSearchState(state);
},
showSearch() {
setSearchState({
...searchStateRef.current,
@@ -263,12 +210,8 @@ const useEditorControl = (
dialogVisible: false,
});
},
setSearchState: setSearchStateCallback,
},
};
return control;
}, [injectJS, searchStateRef, setLinkDialogVisible, setSearchState]);
};
@@ -284,16 +227,7 @@ function NoteEditor(props: Props, ref: any) {
themeData: editorTheme(props.themeId),
katexEnabled: Setting.value('markdown.plugin.katex'),
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
language: EditorLanguageType.Markdown,
useExternalSearch: true,
readOnly: props.readOnly,
keymap: EditorKeymap.Default,
automatchBraces: false,
ignoreModifiers: false,
indentWithTabs: false,
};
const injectedJavaScript = `
@@ -318,12 +252,6 @@ function NoteEditor(props: Props, ref: any) {
);
};
window.onunhandledrejection = (event) => {
window.ReactNativeWebView.postMessage(
"error: Unhandled promise rejection: " + event
);
};
if (!window.cm) {
// This variable is not used within this script
// but is called using "injectJavaScript" from
@@ -341,7 +269,7 @@ function NoteEditor(props: Props, ref: any) {
${setInitialSelectionJS}
window.onresize = () => {
cm.execCommand('scrollSelectionIntoView');
cm.scrollSelectionIntoView();
};
} catch (e) {
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
@@ -352,7 +280,7 @@ function NoteEditor(props: Props, ref: any) {
const css = useCss(props.themeId);
const html = useHtml(css);
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
const [selectionState, setSelectionState] = useState(new SelectionFormatting());
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
const [searchState, setSearchState] = useState(defaultSearchState);
@@ -365,7 +293,7 @@ function NoteEditor(props: Props, ref: any) {
searchStateRef.current = searchState;
}, [searchState]);
// Runs [js] in the context of the CodeMirror frame.
// / Runs [js] in the context of the CodeMirror frame.
const injectJS = (js: string) => {
webviewRef.current.injectJS(js);
};
@@ -395,41 +323,36 @@ function NoteEditor(props: Props, ref: any) {
console.info('CodeMirror:', ...event.value);
},
onEditorEvent: (event: EditorEvent) => {
let exhaustivenessCheck: never;
switch (event.kind) {
case EditorEventType.Change:
props.onChange(event);
break;
case EditorEventType.UndoRedoDepthChange:
props.onUndoRedoDepthChange(event);
break;
case EditorEventType.SelectionRangeChange:
props.onSelectionChange(event);
break;
case EditorEventType.SelectionFormattingChange:
setSelectionState(event.formatting);
break;
case EditorEventType.EditLink:
editorControl.showLinkDialog();
break;
case EditorEventType.UpdateSearchDialog:
setSearchState(event.searchState);
onChange: (event: ChangeEvent) => {
props.onChange(event);
},
if (event.searchState.dialogVisible) {
editorControl.searchControl.showSearch();
} else {
editorControl.searchControl.hideSearch();
}
break;
case EditorEventType.Scroll:
// Not handled
break;
default:
exhaustivenessCheck = event;
return exhaustivenessCheck;
}
return;
onUndoRedoDepthChange: (event: UndoRedoDepthChangeEvent) => {
props.onUndoRedoDepthChange(event);
},
onSelectionChange: (event: SelectionChangeEvent) => {
props.onSelectionChange(event);
},
onSelectionFormattingChange(data: string) {
// We want a SelectionFormatting object, so are
// instantiating it from JSON.
const formatting = SelectionFormatting.fromJSON(data);
setSelectionState(formatting);
},
onRequestLinkEdit() {
editorControl.showLinkDialog();
},
onRequestShowSearch(data: SearchState) {
setSearchState(data);
editorControl.searchControl.showSearch();
},
onRequestHideSearch() {
editorControl.searchControl.hideSearch();
},
};

View File

@@ -4,13 +4,11 @@ const React = require('react');
const { useMemo, useState, useEffect } = require('react');
const MaterialCommunityIcon = require('react-native-vector-icons/MaterialCommunityIcons').default;
import { EditorSettings } from './types';
import { SearchControl, SearchState, EditorSettings } from './types';
import { _ } from '@joplin/lib/locale';
import { BackHandler, TextInput, View, Text, StyleSheet, ViewStyle } from 'react-native';
import { Theme } from '@joplin/lib/themes/type';
import CustomButton from '../CustomButton';
import { SearchState } from '@joplin/editor/types';
import { SearchControl } from './types';
const buttonSize = 48;
@@ -286,7 +284,7 @@ export const SearchPanel = (props: SearchPanelProps) => {
themeId={themeId}
styles={styles}
iconName="swap-horizontal"
onPress={control.replaceNext}
onPress={control.replaceCurrent}
title={_('Replace')}
/>
);

View File

@@ -0,0 +1,98 @@
// Stores information about the current content of the user's selection
export default class SelectionFormatting {
public bolded = false;
public italicized = false;
public inChecklist = false;
public inCode = false;
public inUnorderedList = false;
public inOrderedList = false;
public inMath = false;
public inLink = false;
public spellChecking = false;
public unspellCheckableRegion = false;
// Link data, both fields are null if not in a link.
public linkData: { linkText?: string; linkURL?: string } = {
linkText: null,
linkURL: null,
};
// If [headerLevel], [listLevel], etc. are zero, then the
// selection isn't in a header/list
public headerLevel = 0;
public listLevel = 0;
// Content of the selection
public selectedText = '';
// List of data properties (for serializing/deseralizing)
private static propNames: string[] = [
'bolded', 'italicized', 'inChecklist', 'inCode',
'inUnorderedList', 'inOrderedList', 'inMath',
'inLink', 'linkData',
'headerLevel', 'listLevel',
'selectedText',
'spellChecking',
'unspellCheckableRegion',
];
// Returns true iff [this] is equivalent to [other]
public eq(other: SelectionFormatting): boolean {
// Cast to Records to allow usage of the indexing ([])
// operator.
const selfAsRec = this as Record<string, any>;
const otherAsRec = other as Record<string, any>;
for (const prop of SelectionFormatting.propNames) {
if (selfAsRec[prop] !== otherAsRec[prop]) {
return false;
}
}
return true;
}
public static fromJSON(json: string): SelectionFormatting {
const result = new SelectionFormatting();
// Casting result to a Record<string, any> lets us use
// the indexing [] operator.
const resultRecord = result as Record<string, any>;
const obj = JSON.parse(json) as Record<string, any>;
for (const prop of SelectionFormatting.propNames) {
if (obj[prop] !== undefined) {
// Type checking!
if (typeof obj[prop] !== typeof resultRecord[prop]) {
throw new Error([
'Deserialization Error:',
`${obj[prop]} and ${resultRecord[prop]}`,
'have different types.',
].join(' '));
}
resultRecord[prop] = obj[prop];
}
}
return result;
}
public toJSON(): string {
const resultObj: Record<string, any> = {};
// Cast this to a dictionary. This allows us to use
// the indexing [] operator.
const selfAsRecord = this as Record<string, any>;
for (const prop of SelectionFormatting.propNames) {
resultObj[prop] = selfAsRecord[prop];
}
return JSON.stringify(resultObj);
}
}

View File

@@ -1,53 +1,69 @@
// Types related to the NoteEditor
import { EditorControl as EditorBodyControl, EditorSettings as EditorBodySettings, SearchState } from '@joplin/editor/types';
import { Theme } from '@joplin/lib/themes/type';
import { CodeMirrorControl } from './CodeMirror/types';
// Controls for the entire editor (including dialogs)
export interface EditorControl extends CodeMirrorControl {
showLinkDialog(): void;
hideLinkDialog(): void;
hideKeyboard(): void;
}
export interface EditorSettings {
// EditorSettings objects are deserialized within WebViews, where
// [themeStyle(themeId: number)] doesn't work. As such, we need both
// the [themeId] and [themeData].
themeId: number;
themeData: Theme;
katexEnabled: boolean;
spellcheckEnabled: boolean;
readOnly: boolean;
}
export interface ChangeEvent {
// New editor content
value: string;
}
export interface UndoRedoDepthChangeEvent {
undoDepth: number;
redoDepth: number;
}
export interface Selection {
start: number;
end: number;
}
export interface SelectionChangeEvent {
selection: Selection;
}
export interface SearchControl {
findNext(): void;
findPrevious(): void;
replaceNext(): void;
replaceCurrent(): void;
replaceAll(): void;
setSearchState(state: SearchState): void;
showSearch(): void;
hideSearch(): void;
setSearchState(state: SearchState): void;
}
// Controls for the entire editor (including dialogs)
export interface EditorControl extends EditorBodyControl {
showLinkDialog(): void;
hideLinkDialog(): void;
hideKeyboard(): void;
export interface SearchState {
useRegex: boolean;
caseSensitive: boolean;
// Additional shortcut commands (equivalent to .execCommand
// with the corresponding type).
// This reduces the need for useCallbacks in many cases.
undo(): void;
redo(): void;
increaseIndent(): void;
decreaseIndent(): void;
toggleBolded(): void;
toggleItalicized(): void;
toggleCode(): void;
toggleMath(): void;
toggleOrderedList(): void;
toggleUnorderedList(): void;
toggleTaskList(): void;
toggleHeaderLevel(level: number): void;
scrollSelectionIntoView(): void;
showLinkDialog(): void;
hideLinkDialog(): void;
hideKeyboard(): void;
searchControl: SearchControl;
searchText: string;
replaceText: string;
dialogVisible: boolean;
}
export interface EditorSettings extends EditorBodySettings {
themeId: number;
}
export interface SelectionRange {
start: number;
end: number;
// Possible types of lists in the editor
export enum ListType {
CheckList,
OrderedList,
UnorderedList,
}

View File

@@ -1,195 +0,0 @@
import * as React from 'react';
import { FlatList, View, Text, Button, StyleSheet, Platform, Alert } from 'react-native';
import { connect } from 'react-redux';
import { reg } from '@joplin/lib/registry.js';
import { ScreenHeader } from '../ScreenHeader';
import time from '@joplin/lib/time';
const { themeStyle } = require('../global-style.js');
import Logger from '@joplin/utils/Logger';
const { BaseScreenComponent } = require('../base-screen.js');
import { _ } from '@joplin/lib/locale';
import { MenuOptionType } from '../ScreenHeader';
import { AppState } from '../../utils/types';
import Share from 'react-native-share';
import { writeTextToCacheFile } from '../../utils/ShareUtils';
import shim from '@joplin/lib/shim';
const logger = Logger.create('LogScreen');
class LogScreenComponent extends BaseScreenComponent {
private readonly menuOptions: MenuOptionType[];
public static navigationOptions(): any {
return { header: null };
}
public constructor() {
super();
this.state = {
logEntries: [],
showErrorsOnly: false,
};
this.styles_ = {};
this.menuOptions = [
{
title: _('Share'),
onPress: () => {
void this.onSharePress();
},
},
];
}
private async onSharePress() {
const limit: number|null = null; // no limit
const levels = this.getLogLevels(this.state.showErrorsOnly);
const allEntries: any[] = await reg.logger().lastEntries(limit, { levels });
const logData = allEntries.map(entry => this.formatLogEntry(entry)).join('\n');
let fileToShare;
try {
// Using a .txt file extension causes a "No valid provider found from URL" error
// and blank share sheet on iOS for larger log files (around 200 KiB).
fileToShare = await writeTextToCacheFile(logData, 'mobile-log.log');
await Share.open({
type: 'text/plain',
filename: 'log.txt',
url: `file://${fileToShare}`,
failOnCancel: false,
});
} catch (e) {
logger.error('Unable to share log data:', e);
// Display a message to the user (e.g. in the case where the user is out of disk space).
Alert.alert(_('Error'), _('Unable to share log data. Reason: %s', e.toString()));
} finally {
if (fileToShare) {
await shim.fsDriver().remove(fileToShare);
}
}
}
public styles() {
const theme = themeStyle(this.props.themeId);
if (this.styles_[this.props.themeId]) return this.styles_[this.props.themeId];
this.styles_ = {};
const styles: any = {
row: {
flexDirection: 'row',
paddingLeft: 1,
paddingRight: 1,
paddingTop: 0,
paddingBottom: 0,
},
rowText: {
fontSize: 10,
color: theme.color,
},
};
if (Platform.OS !== 'ios') {
// Crashes on iOS with error "Unrecognized font family 'monospace'"
styles.rowText.fontFamily = 'monospace';
}
styles.rowTextError = { ...styles.rowText };
styles.rowTextError.color = theme.colorError;
styles.rowTextWarn = { ...styles.rowText };
styles.rowTextWarn.color = theme.colorWarn;
this.styles_[this.props.themeId] = StyleSheet.create(styles);
return this.styles_[this.props.themeId];
}
public UNSAFE_componentWillMount() {
void this.resfreshLogEntries();
}
private getLogLevels(showErrorsOnly: boolean) {
let levels = [Logger.LEVEL_DEBUG, Logger.LEVEL_INFO, Logger.LEVEL_WARN, Logger.LEVEL_ERROR];
if (showErrorsOnly) levels = [Logger.LEVEL_WARN, Logger.LEVEL_ERROR];
return levels;
}
private async resfreshLogEntries(showErrorsOnly: boolean = null) {
if (showErrorsOnly === null) showErrorsOnly = this.state.showErrorsOnly;
const levels = this.getLogLevels(showErrorsOnly);
this.setState({
logEntries: await reg.logger().lastEntries(1000, { levels: levels }),
showErrorsOnly: showErrorsOnly,
});
}
private toggleErrorsOnly() {
void this.resfreshLogEntries(!this.state.showErrorsOnly);
}
private formatLogEntry(item: any) {
return `${time.formatMsToLocal(item.timestamp, 'MM-DDTHH:mm:ss')}: ${item.message}`;
}
public render() {
const renderRow = ({ item }: any) => {
let textStyle = this.styles().rowText;
if (item.level === Logger.LEVEL_WARN) textStyle = this.styles().rowTextWarn;
if (item.level === Logger.LEVEL_ERROR) textStyle = this.styles().rowTextError;
return (
<View style={this.styles().row}>
<Text style={textStyle}>{this.formatLogEntry(item)}</Text>
</View>
);
};
// `enableEmptySections` is to fix this warning: https://github.com/FaridSafi/react-native-gifted-listview/issues/39
return (
<View style={this.rootStyle(this.props.themeId).root}>
<ScreenHeader
title={_('Log')}
menuOptions={this.menuOptions}/>
<FlatList
data={this.state.logEntries}
renderItem={renderRow}
keyExtractor={item => { return `${item.id}`; }}
/>
<View style={{ flexDirection: 'row' }}>
<View style={{ flex: 1, marginRight: 5 }}>
<Button
title={_('Refresh')}
onPress={() => {
void this.resfreshLogEntries();
}}
/>
</View>
<View style={{ flex: 1 }}>
<Button
title={this.state.showErrorsOnly ? _('Show all') : _('Errors only')}
onPress={() => {
this.toggleErrorsOnly();
}}
/>
</View>
</View>
</View>
);
}
}
const LogScreen = connect((state: AppState) => {
return {
themeId: state.settings.theme,
};
})(LogScreenComponent as any);
export default LogScreen;

View File

@@ -6,6 +6,7 @@ import UndoRedoService from '@joplin/lib/services/UndoRedoService';
import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer';
import checkPermissions from '../../utils/checkPermissions';
import NoteEditor from '../NoteEditor/NoteEditor';
import { ChangeEvent, UndoRedoDepthChangeEvent } from '../NoteEditor/types';
const FileViewer = require('react-native-file-viewer').default;
const React = require('react');
@@ -42,15 +43,11 @@ import { ImagePickerResponse, launchImageLibrary } from 'react-native-image-pick
import SelectDateTimeDialog from '../SelectDateTimeDialog';
import ShareExtension from '../../utils/ShareExtension.js';
import CameraView from '../CameraView';
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
import { NoteEntity } from '@joplin/lib/services/database/types';
import Logger from '@joplin/utils/Logger';
import ImageEditor from '../NoteEditor/ImageEditor/ImageEditor';
import promptRestoreAutosave from '../NoteEditor/ImageEditor/promptRestoreAutosave';
import isEditableResource from '../NoteEditor/ImageEditor/isEditableResource';
import VoiceTypingDialog from '../voiceTyping/VoiceTypingDialog';
import { voskEnabled } from '../../services/voiceTyping/vosk';
import { isSupportedLanguage } from '../../services/voiceTyping/vosk.android';
import { ChangeEvent as EditorChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
const urlUtils = require('@joplin/lib/urlUtils');
// import Vosk from 'react-native-vosk';
@@ -79,9 +76,6 @@ class NoteScreenComponent extends BaseScreenComponent {
noteTagDialogShown: false,
fromShare: false,
showCamera: false,
showImageEditor: false,
loadImageEditorData: null,
imageEditorResource: null,
noteResources: {},
// HACK: For reasons I can't explain, when the WebView is present, the TextInput initially does not display (It's just a white rectangle with
@@ -201,8 +195,8 @@ class NoteScreenComponent extends BaseScreenComponent {
}, 5);
} else if (item.type_ === BaseModel.TYPE_RESOURCE) {
if (!(await Resource.isReady(item))) throw new Error(_('This attachment is not downloaded or not decrypted yet.'));
const resourcePath = Resource.fullPath(item);
logger.info(`Opening resource: ${resourcePath}`);
await FileViewer.open(resourcePath);
} else {
@@ -261,7 +255,7 @@ class NoteScreenComponent extends BaseScreenComponent {
return this.props.useEditorBeta;
}
private onBodyChange(event: EditorChangeEvent) {
private onBodyChange(event: ChangeEvent) {
shared.noteComponent_change(this, 'body', event.value);
this.scheduleSave();
}
@@ -457,7 +451,7 @@ class NoteScreenComponent extends BaseScreenComponent {
void ResourceFetcher.instance().markForDownload(event.resourceId);
}
public componentDidUpdate(prevProps: any, prevState: any) {
public componentDidUpdate(prevProps: any) {
if (this.doFocusUpdate_) {
this.doFocusUpdate_ = false;
this.focusUpdate();
@@ -469,22 +463,6 @@ class NoteScreenComponent extends BaseScreenComponent {
options: this.sideMenuOptions(),
});
}
if (prevState.isLoading !== this.state.isLoading && !this.state.isLoading) {
// If there's autosave data, prompt the user to restore it.
void promptRestoreAutosave((drawingData: string) => {
void this.attachNewDrawing(drawingData);
});
}
// Disable opening/closing the side menu with touch gestures
// when the image editor is open.
if (prevState.showImageEditor !== this.state.showImageEditor) {
this.props.dispatch({
type: 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED',
disableSideMenuGestures: this.state.showImageEditor,
});
}
}
public componentWillUnmount() {
@@ -654,10 +632,10 @@ class NoteScreenComponent extends BaseScreenComponent {
return await saveOriginalImage();
}
public async attachFile(pickerResponse: any, fileType: string): Promise<ResourceEntity|null> {
public async attachFile(pickerResponse: any, fileType: string) {
if (!pickerResponse) {
// User has cancelled
return null;
return;
}
const localFilePath = Platform.select({
@@ -684,7 +662,7 @@ class NoteScreenComponent extends BaseScreenComponent {
reg.logger().info(`Got file: ${localFilePath}`);
reg.logger().info(`Got type: ${mimeType}`);
let resource: ResourceEntity = Resource.new();
let resource = Resource.new();
resource.id = uuid.create();
resource.mime = mimeType;
resource.title = pickerResponse.name ? pickerResponse.name : '';
@@ -697,11 +675,11 @@ class NoteScreenComponent extends BaseScreenComponent {
try {
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg' || mimeType === 'image/png') {
const done = await this.resizeImage(localFilePath, targetPath, mimeType);
if (!done) return null;
if (!done) return;
} else {
if (fileType === 'image' && mimeType !== 'image/svg+xml') {
if (fileType === 'image') {
dialogs.error(this, _('Unsupported image type: %s', mimeType));
return null;
return;
} else {
await shim.fsDriver().copy(localFilePath, targetPath);
const stat = await shim.fsDriver().stat(targetPath);
@@ -715,7 +693,7 @@ class NoteScreenComponent extends BaseScreenComponent {
} catch (error) {
reg.logger().warn('Could not attach file:', error);
await dialogs.error(this, error.message);
return null;
return;
}
const itDoes = await shim.fsDriver().waitTillExists(targetPath);
@@ -757,8 +735,6 @@ class NoteScreenComponent extends BaseScreenComponent {
this.refreshResource(resource, newNote.body);
this.scheduleSave();
return resource;
}
private async attachPhoto_onPress() {
@@ -800,82 +776,6 @@ class NoteScreenComponent extends BaseScreenComponent {
this.setState({ showCamera: false });
}
private async attachNewDrawing(svgData: string) {
const filePath = `${Setting.value('resourceDir')}/saved-drawing.joplin.svg`;
await shim.fsDriver().writeFile(filePath, svgData, 'utf8');
logger.info('Saved new drawing to', filePath);
return await this.attachFile({
uri: filePath,
name: _('Drawing'),
}, 'image');
}
private drawPicture_onPress = async () => {
// Create a new empty drawing and attach it now.
const resource = await this.attachNewDrawing('');
this.setState({
showImageEditor: true,
loadImageEditorData: null,
imageEditorResource: resource,
});
};
private async updateDrawing(svgData: string) {
let resource: ResourceEntity|null = this.state.imageEditorResource;
if (!resource) {
throw new Error('No resource is loaded in the editor');
}
const resourcePath = Resource.fullPath(resource);
const filePath = resourcePath;
await shim.fsDriver().writeFile(filePath, svgData, 'utf8');
logger.info('Saved drawing to', filePath);
resource = await Resource.save(resource, { isNew: false });
await this.refreshResource(resource);
}
private onSaveDrawing = async (svgData: string) => {
await this.updateDrawing(svgData);
};
private onCloseDrawing = () => {
this.setState({ showImageEditor: false });
};
private async editDrawing(item: BaseItem) {
const filePath = Resource.fullPath(item);
this.setState({
showImageEditor: true,
loadImageEditorData: async () => {
return await shim.fsDriver().readFile(filePath);
},
imageEditorResource: item,
});
}
private onEditResource = async (message: string) => {
const messageData = /^edit:(.*)$/.exec(message);
if (!messageData) {
throw new Error('onEditResource: Error: Invalid message');
}
const resourceId = messageData[1];
const resource = await BaseItem.loadItemById(resourceId);
await Resource.requireIsReady(resource);
if (isEditableResource(resource.mime)) {
await this.editDrawing(resource);
} else {
throw new Error(_('Unable to edit resource of type %s', resource.mime));
}
};
private async attachFile_onPress() {
const response = await this.pickDocuments();
for (const asset of response) {
@@ -990,10 +890,6 @@ class NoteScreenComponent extends BaseScreenComponent {
}
public async showAttachMenu() {
// If the keyboard is editing a WebView, the standard Keyboard.dismiss()
// may not work. As such, we also need to call hideKeyboard on the editorRef
this.editorRef.current?.hideKeyboard();
const buttons = [];
// On iOS, it will show "local files", which means certain files saved from the browser
@@ -1107,12 +1003,6 @@ class NoteScreenComponent extends BaseScreenComponent {
});
}
output.push({
title: _('Draw picture'),
onPress: () => this.drawPicture_onPress(),
disabled: readOnly,
});
if (isTodo) {
output.push({
title: _('Set alarm'),
@@ -1300,13 +1190,6 @@ class NoteScreenComponent extends BaseScreenComponent {
if (this.state.showCamera) {
return <CameraView themeId={this.props.themeId} style={{ flex: 1 }} onPhoto={this.cameraView_onPhoto} onCancel={this.cameraView_onCancel} />;
} else if (this.state.showImageEditor) {
return <ImageEditor
loadInitialSVGData={this.state.loadImageEditorData}
themeId={this.props.themeId}
onSave={this.onSaveDrawing}
onExit={this.onCloseDrawing}
/>;
}
// Currently keyword highlighting is supported only when FTS is available.
@@ -1332,7 +1215,6 @@ class NoteScreenComponent extends BaseScreenComponent {
noteHash={this.props.noteHash}
onCheckboxChange={this.onBodyViewerCheckboxChange}
onMarkForDownload={this.onMarkForDownload}
onRequestEditResource={this.onEditResource}
onLoadEnd={this.onBodyViewerLoadEnd}
/>
);

View File

@@ -255,18 +255,8 @@ class NotesScreenComponent extends BaseScreenComponent<any> {
const actionButtonComp = this.props.noteSelectionEnabled || !this.props.visible ? null : makeActionButtonComp();
// Ensure that screen readers can't focus the notes list when it isn't visible.
// accessibilityElementsHidden is used on iOS and importantForAccessibility is used
// on Android.
const accessibilityHidden = !this.props.visible;
return (
<View
style={rootStyle}
accessibilityElementsHidden={accessibilityHidden}
importantForAccessibility={accessibilityHidden ? 'no-hide-descendants' : undefined}
>
<View style={rootStyle}>
<ScreenHeader title={iconString + title} showBackButton={false} parentComponent={thisComp} sortButton_press={this.sortButton_press} folderPickerOptions={this.folderPickerOptions()} showSearchButton={true} showSideMenuButton={true} />
<NoteList />
{actionButtonComp}

View File

@@ -0,0 +1,135 @@
const React = require('react');
const { FlatList, View, Text, Button, StyleSheet, Platform } = require('react-native');
const { connect } = require('react-redux');
const { reg } = require('@joplin/lib/registry.js');
const { ScreenHeader } = require('../ScreenHeader');
const time = require('@joplin/lib/time').default;
const { themeStyle } = require('../global-style.js');
const Logger = require('@joplin/utils/Logger').default;
const { BaseScreenComponent } = require('../base-screen.js');
const { _ } = require('@joplin/lib/locale');
class LogScreenComponent extends BaseScreenComponent {
static navigationOptions() {
return { header: null };
}
constructor() {
super();
this.state = {
logEntries: [],
showErrorsOnly: false,
};
this.styles_ = {};
}
styles() {
const theme = themeStyle(this.props.themeId);
if (this.styles_[this.props.themeId]) return this.styles_[this.props.themeId];
this.styles_ = {};
const styles = {
row: {
flexDirection: 'row',
paddingLeft: 1,
paddingRight: 1,
paddingTop: 0,
paddingBottom: 0,
},
rowText: {
fontSize: 10,
color: theme.color,
},
};
if (Platform.OS !== 'ios') {
// Crashes on iOS with error "Unrecognized font family 'monospace'"
styles.rowText.fontFamily = 'monospace';
}
styles.rowTextError = { ...styles.rowText };
styles.rowTextError.color = theme.colorError;
styles.rowTextWarn = { ...styles.rowText };
styles.rowTextWarn.color = theme.colorWarn;
this.styles_[this.props.themeId] = StyleSheet.create(styles);
return this.styles_[this.props.themeId];
}
UNSAFE_componentWillMount() {
this.resfreshLogEntries();
}
async resfreshLogEntries(showErrorsOnly = null) {
if (showErrorsOnly === null) showErrorsOnly = this.state.showErrorsOnly;
let levels = [Logger.LEVEL_DEBUG, Logger.LEVEL_INFO, Logger.LEVEL_WARN, Logger.LEVEL_ERROR];
if (showErrorsOnly) levels = [Logger.LEVEL_WARN, Logger.LEVEL_ERROR];
this.setState({
logEntries: await reg.logger().lastEntries(1000, { levels: levels }),
showErrorsOnly: showErrorsOnly,
});
}
toggleErrorsOnly() {
this.resfreshLogEntries(!this.state.showErrorsOnly);
}
render() {
const renderRow = ({ item }) => {
let textStyle = this.styles().rowText;
if (item.level === Logger.LEVEL_WARN) textStyle = this.styles().rowTextWarn;
if (item.level === Logger.LEVEL_ERROR) textStyle = this.styles().rowTextError;
return (
<View style={this.styles().row}>
<Text style={textStyle}>{`${time.formatMsToLocal(item.timestamp, 'MM-DDTHH:mm:ss')}: ${item.message}`}</Text>
</View>
);
};
// `enableEmptySections` is to fix this warning: https://github.com/FaridSafi/react-native-gifted-listview/issues/39
return (
<View style={this.rootStyle(this.props.themeId).root}>
<ScreenHeader title={_('Log')} />
<FlatList
data={this.state.logEntries}
renderItem={renderRow}
keyExtractor={item => { return `${item.id}`; }}
/>
<View style={{ flexDirection: 'row' }}>
<View style={{ flex: 1, marginRight: 5 }}>
<Button
title={_('Refresh')}
onPress={() => {
this.resfreshLogEntries();
}}
/>
</View>
<View style={{ flex: 1 }}>
<Button
title={this.state.showErrorsOnly ? _('Show all') : _('Errors only')}
onPress={() => {
this.toggleErrorsOnly();
}}
/>
</View>
</View>
</View>
);
}
}
const LogScreen = connect(state => {
return {
themeId: state.settings.theme,
};
})(LogScreenComponent);
module.exports = { LogScreen };

View File

@@ -6,6 +6,7 @@ import ScreenHeader from '../ScreenHeader';
const Icon = require('react-native-vector-icons/Ionicons').default;
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import gotoAnythingStyleQuery from '@joplin/lib/services/searchengine/gotoAnythingStyleQuery';
const { NoteItem } = require('../note-item.js');
const { BaseScreenComponent } = require('../base-screen.js');
const { themeStyle } = require('../global-style.js');
@@ -94,6 +95,8 @@ class SearchScreenComponent extends BaseScreenComponent {
public async refreshSearch(query: string = null) {
if (!this.props.visible) return;
query = gotoAnythingStyleQuery(query);
let notes = [];
if (query) {

View File

@@ -4,7 +4,7 @@ import { Banner, ActivityIndicator } from 'react-native-paper';
import { _, languageName } from '@joplin/lib/locale';
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
import { getVosk, Recorder, startRecording, Vosk } from '../../services/voiceTyping/vosk';
import { IconSource } from 'react-native-paper/lib/typescript/components/Icon';
import { IconSource } from 'react-native-paper/lib/typescript/src/components/Icon';
import { modelIsDownloaded } from '../../services/voiceTyping/vosk.android';
interface Props {

View File

@@ -407,9 +407,6 @@
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Brands.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Regular.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Solid.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome6_Brands.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome6_Regular.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome6_Solid.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Fontisto.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Foundation.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Ionicons.ttf",
@@ -430,9 +427,6 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Brands.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Solid.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome6_Brands.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome6_Regular.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome6_Solid.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Fontisto.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Foundation.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Ionicons.ttf",

View File

@@ -298,7 +298,7 @@ PODS:
- React
- react-native-saf-x (2.13.0):
- React-Core
- react-native-safe-area-context (4.7.2):
- react-native-safe-area-context (4.7.1):
- React-Core
- react-native-slider (4.4.3):
- React-Core
@@ -306,7 +306,7 @@ PODS:
- React-Core
- react-native-version-info (1.1.1):
- React-Core
- react-native-webview (13.4.0):
- react-native-webview (13.3.1):
- React-Core
- React-perflogger (0.71.10)
- React-RCTActionSheet (0.71.10):
@@ -402,8 +402,8 @@ PODS:
- React-Core
- RNDeviceInfo (10.8.0):
- React-Core
- RNExitApp (2.0.0):
- React-Core
- RNExitApp (1.1.0):
- React
- RNFileViewer (2.1.5):
- React-Core
- RNFS (2.20.0):
@@ -414,9 +414,9 @@ PODS:
- React
- RNSecureRandom (1.0.1):
- React
- RNShare (9.2.4):
- RNShare (9.2.3):
- React-Core
- RNVectorIcons (10.0.0):
- RNVectorIcons (9.2.0):
- React-Core
- RNZipArchive (6.0.9):
- React-Core
@@ -674,11 +674,11 @@ SPEC CHECKSUMS:
react-native-netinfo: fefd4e98d75cbdd6e85fc530f7111a8afdf2b0c5
react-native-rsa-native: 12132eb627797529fdb1f0d22fd0f8f9678df64a
react-native-saf-x: b758d1b38a96a96b8179a16e96ed966a507ca5c2
react-native-safe-area-context: 7aa8e6d9d0f3100a820efb1a98af68aa747f9284
react-native-safe-area-context: 9697629f7b2cda43cf52169bb7e0767d330648c2
react-native-slider: 1cdd6ba29675df21f30544253bf7351d3c2d68c4
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
react-native-version-info: a106f23009ac0db4ee00de39574eb546682579b9
react-native-webview: 64c9bf9646e7377240fb87d70f74556af6433143
react-native-webview: c2b70afb1d910cdd8810375aecc6c2894e2ba061
React-perflogger: 217095464d5c4bb70df0742fa86bf2a363693468
React-RCTActionSheet: 8deae9b85a4cbc6a2243618ea62a374880a2c614
React-RCTAnimation: 59c62353a8b59ce206044786c5d30e4754bffa64
@@ -697,14 +697,14 @@ SPEC CHECKSUMS:
RNCPushNotificationIOS: 64218f3c776c03d7408284a819b2abfda1834bc8
RNDateTimePicker: a21bfcc694e3190f8ddb5aacb0dd16f97b6be79f
RNDeviceInfo: 5795b418ed3451ebcaf39384e6cf51f60cb931c9
RNExitApp: 00036cabe7bacbb413d276d5520bf74ba39afa6a
RNExitApp: c4e052df2568b43bec8a37c7cd61194d4cfee2c3
RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNLocalize: dbea38dcb344bf80ff18a1757b1becf11f70cae4
RNQuickAction: 6d404a869dc872cde841ad3147416a670d13fa93
RNSecureRandom: 07efbdf2cd99efe13497433668e54acd7df49fef
RNShare: 255701a138fcdef6c27dc93724548fd122e91f9e
RNVectorIcons: 8b5bb0fa61d54cd2020af4f24a51841ce365c7e9
RNShare: da6d90b6dc332f51f86498041d6e34211f96b630
RNVectorIcons: fcc2f6cb32f5735b586e66d14103a74ce6ad61f8
RNZipArchive: 68a0c6db4b1c103f846f1559622050df254a3ade
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
Yoga: e7ea9e590e27460d28911403b894722354d73479

View File

@@ -20,7 +20,7 @@ module.exports = {
// Do transform most packages in node_modules (transformations correct unrecognized
// import syntax)
transformIgnorePatterns: ['<rootDir>/node_modules/jest', '<rootDir>/node_modules/js-draw'],
transformIgnorePatterns: ['<rootDir>/node_modules/jest'],
slowTestThreshold: 40,
};

View File

@@ -14,6 +14,22 @@ import { setImmediate } from 'timers';
// so is removed by jsdom).
window.setImmediate = setImmediate;
// Prevents the CodeMirror error "getClientRects is undefined".
// See https://github.com/jsdom/jsdom/issues/3002#issue-652790925
document.createRange = () => {
const range = new Range();
range.getBoundingClientRect = jest.fn();
range.getClientRects = () => {
return {
length: 0,
item: () => null,
[Symbol.iterator]: jest.fn(),
};
};
return range;
};
shimInit({ nodeSqlite: sqlite3 });

View File

@@ -15,7 +15,6 @@ const path = require('path');
const localPackages = {
'@joplin/lib': path.resolve(__dirname, '../lib/'),
'@joplin/renderer': path.resolve(__dirname, '../renderer/'),
'@joplin/editor': path.resolve(__dirname, '../editor/'),
'@joplin/tools': path.resolve(__dirname, '../tools/'),
'@joplin/utils': path.resolve(__dirname, '../utils/'),
'@joplin/fork-htmlparser2': path.resolve(__dirname, '../fork-htmlparser2/'),

View File

@@ -19,7 +19,6 @@
},
"dependencies": {
"@bam.tech/react-native-image-resizer": "3.0.7",
"@joplin/editor": "~2.13",
"@joplin/lib": "~2.13",
"@joplin/react-native-alarm-notification": "~2.13",
"@joplin/react-native-saf-x": "~2.13",
@@ -50,28 +49,28 @@
"react-native-dialogbox": "0.6.10",
"react-native-document-picker": "9.0.1",
"react-native-dropdownalert": "4.5.1",
"react-native-exit-app": "2.0.0",
"react-native-exit-app": "1.1.0",
"react-native-file-viewer": "2.1.5",
"react-native-fingerprint-scanner": "6.0.0",
"react-native-fs": "2.20.0",
"react-native-get-random-values": "1.9.0",
"react-native-image-picker": "5.6.1",
"react-native-localize": "3.0.2",
"react-native-modal-datetime-picker": "17.1.0",
"react-native-paper": "5.10.4",
"react-native-modal-datetime-picker": "17.0.0",
"react-native-paper": "5.9.1",
"react-native-popup-menu": "0.16.1",
"react-native-quick-actions": "0.3.13",
"react-native-rsa-native": "2.0.5",
"react-native-safe-area-context": "4.7.2",
"react-native-safe-area-context": "4.7.1",
"react-native-securerandom": "1.0.1",
"react-native-share": "9.2.4",
"react-native-share": "9.2.3",
"react-native-side-menu-updated": "1.3.2",
"react-native-sqlite-storage": "6.0.1",
"react-native-url-polyfill": "2.0.0",
"react-native-vector-icons": "10.0.0",
"react-native-url-polyfill": "1.3.0",
"react-native-vector-icons": "9.2.0",
"react-native-version-info": "1.1.1",
"react-native-vosk": "0.1.12",
"react-native-webview": "13.4.0",
"react-native-webview": "13.3.1",
"react-native-zip-archive": "6.0.9",
"react-redux": "8.1.2",
"redux": "4.2.1",
@@ -81,24 +80,36 @@
"string-natural-compare": "3.0.1",
"tar-stream": "3.1.6",
"timers": "0.1.1",
"url": "0.11.3"
"url": "0.11.1"
},
"devDependencies": {
"@babel/core": "7.20.2",
"@babel/preset-env": "7.20.2",
"@babel/runtime": "7.20.0",
"@codemirror/commands": "6.2.2",
"@codemirror/lang-cpp": "6.0.2",
"@codemirror/lang-html": "6.4.3",
"@codemirror/lang-java": "6.0.1",
"@codemirror/lang-javascript": "6.1.5",
"@codemirror/lang-markdown": "6.1.0",
"@codemirror/lang-php": "6.0.1",
"@codemirror/lang-rust": "6.0.1",
"@codemirror/language": "6.6.0",
"@codemirror/legacy-modes": "6.3.2",
"@codemirror/search": "6.3.0",
"@codemirror/state": "6.2.0",
"@codemirror/view": "6.9.3",
"@joplin/tools": "~2.13",
"@js-draw/material-icons": "1.5.0",
"@lezer/highlight": "1.1.4",
"@testing-library/jest-native": "5.4.3",
"@testing-library/react-native": "12.2.2",
"@tsconfig/react-native": "2.0.2",
"@types/fs-extra": "11.0.2",
"@types/fs-extra": "11.0.1",
"@types/jest": "29.5.4",
"@types/react": "18.2.21",
"@types/react-native": "0.70.6",
"@types/react-redux": "7.1.26",
"@types/tar-stream": "2.2.3",
"@types/tar-stream": "2.2.2",
"babel-jest": "29.6.4",
"babel-plugin-module-resolver": "4.1.0",
"execa": "4.1.0",
@@ -107,11 +118,10 @@
"jest": "29.6.4",
"jest-environment-jsdom": "29.6.4",
"jetifier": "2.0.0",
"js-draw": "1.5.0",
"jsdom": "22.1.0",
"md5-file": "5.0.0",
"metro-react-native-babel-preset": "0.73.9",
"nodemon": "3.0.1",
"nodemon": "2.0.22",
"react-test-renderer": "18.2.0",
"sqlite3": "5.1.6",
"ts-jest": "29.1.1",

View File

@@ -1,5 +1,5 @@
module.exports = {
hash:"3b957b10f317a91aaea146b3445709dd", files: {
hash:"edb511738c14143467cc859e46bd6121", files: {
'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' },
'katex/fonts/KaTeX_AMS-Regular.woff2': { data: require('./katex/fonts/KaTeX_AMS-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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