You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-27 20:29:45 +02:00
Compare commits
147 Commits
note_link_
...
cli-v2.9.1
Author | SHA1 | Date | |
---|---|---|---|
|
116e2fc92e | ||
|
713c00053e | ||
|
6a0700e335 | ||
|
7961acd06f | ||
|
e9f7f106f1 | ||
|
5e944df595 | ||
|
6f6f427356 | ||
|
cac10c4e29 | ||
|
9b348fdc29 | ||
|
ec97dd8c60 | ||
|
f28c1bc6ba | ||
|
e660fafb7a | ||
|
2c49270f38 | ||
|
13c1ae3d39 | ||
|
29550ade49 | ||
|
1b9f74f674 | ||
|
0b69ae371c | ||
|
37ebd21cb3 | ||
|
c996ddaf9d | ||
|
cea1aeac4b | ||
|
13ee1c89ea | ||
|
f01ec941b7 | ||
|
0853521bc9 | ||
|
e484671a08 | ||
|
50253d00e7 | ||
|
5364965a69 | ||
|
50baad3c04 | ||
|
cf219762c9 | ||
|
9e27b0881f | ||
|
44a96f347a | ||
|
cc6620a7e1 | ||
|
29f1abb666 | ||
|
9781a33419 | ||
|
0954794195 | ||
|
a996375b88 | ||
|
129ac1829d | ||
|
44e60bdda9 | ||
|
afc34b44c8 | ||
|
e08c74ae08 | ||
|
e5c669dc7a | ||
|
f4a7f5914e | ||
|
62eee4df56 | ||
|
c16445bc2f | ||
|
e05c5598a0 | ||
|
66c9ee0a1a | ||
|
d07788607c | ||
|
907dc7601b | ||
|
4b9adcde04 | ||
|
9f3a4e0d99 | ||
|
ea14488dc3 | ||
|
f59d29f1c5 | ||
|
0a9e919ac7 | ||
|
f11b6e8fa9 | ||
|
167560ff6f | ||
|
4b4e316bf0 | ||
|
7809228bd3 | ||
|
540fbbc22c | ||
|
2983d4f1a3 | ||
|
f6a8bf9ea2 | ||
|
e3ba02281b | ||
|
295b310079 | ||
|
62346575f8 | ||
|
0a590b7de9 | ||
|
dfd95f8385 | ||
|
6efe8c171a | ||
|
a7cdcaf25f | ||
|
6277958d6a | ||
|
25bd91bed1 | ||
|
7974df98ff | ||
|
e37d980453 | ||
|
597569745c | ||
|
6e6275b1b7 | ||
|
cfba73e938 | ||
|
7e1c34b769 | ||
|
b5b281c276 | ||
|
80906cbdb3 | ||
|
1504cb71ae | ||
|
eb7083d788 | ||
|
e40d733176 | ||
|
170c669e37 | ||
|
24b4b879f2 | ||
|
3942029c90 | ||
|
01f4bb0591 | ||
|
86fbf82d36 | ||
|
1069d7d6fb | ||
|
8d67aefcd5 | ||
|
ff90166b6e | ||
|
6beaaf75bb | ||
|
ebf9a9375c | ||
|
de94c35c0b | ||
|
6a4eb33093 | ||
|
8b91427056 | ||
|
b174fcf17b | ||
|
c6b91cdc5d | ||
|
e784e8c947 | ||
|
6498f94c36 | ||
|
ae300de42f | ||
|
40e682faae | ||
|
92c24c2129 | ||
|
3ec3a37603 | ||
|
ed2a328616 | ||
|
58dc4feee7 | ||
|
0356cbbfab | ||
|
8b06cbf04e | ||
|
fd82758e74 | ||
|
c705ec682c | ||
|
a5e6491cda | ||
|
8ef9804cab | ||
|
09ec77f904 | ||
|
36871d9cb0 | ||
|
b4ece67092 | ||
|
7e8a6dfb54 | ||
|
549095f0e5 | ||
|
313c05732b | ||
|
641b0fa9a2 | ||
|
96982849ce | ||
|
4b8745c875 | ||
|
78f72f33e6 | ||
|
b4aa418276 | ||
|
8d66322c94 | ||
|
6969341745 | ||
|
488f19e3c4 | ||
|
79889facea | ||
|
74f513b082 | ||
|
ab540edacc | ||
|
9dedd88989 | ||
|
be8ebd9fc5 | ||
|
6d41814455 | ||
|
2807a32e64 | ||
|
e6b0e20f08 | ||
|
0dc92c17f5 | ||
|
308c7b11c2 | ||
|
75be518d8a | ||
|
9f97a2e910 | ||
|
03c3188a4a | ||
|
bd5ce114a1 | ||
|
d326700d32 | ||
|
5bf949fcb3 | ||
|
9d6d2f770a | ||
|
3e12313f85 | ||
|
358178f83d | ||
|
6ea40c9895 | ||
|
0191de8bb4 | ||
|
a114e1b5f7 | ||
|
cf22ec0c8b | ||
|
e074f099c4 | ||
|
c5ad2975d6 |
167
.eslintignore
167
.eslintignore
@@ -6,6 +6,7 @@ _releases/
|
||||
*.min.js
|
||||
**/commands/index.ts
|
||||
**/node_modules/
|
||||
packages/generator-joplin/generators/app/templates/api/
|
||||
Assets/
|
||||
docs/
|
||||
highlight.pack.js
|
||||
@@ -69,6 +70,7 @@ packages/tools/node_modules
|
||||
packages/tools/PortableAppsLauncher
|
||||
packages/turndown-plugin-gfm/
|
||||
packages/turndown/
|
||||
packages/pdf-viewer/dist
|
||||
plugin_types/
|
||||
readme/
|
||||
|
||||
@@ -115,6 +117,9 @@ packages/app-cli/tests/services/plugins/api/JoplinViewMenuItem.js.map
|
||||
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.d.ts
|
||||
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.js
|
||||
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.js.map
|
||||
packages/app-cli/tests/services/plugins/defaultPluginsUtils.d.ts
|
||||
packages/app-cli/tests/services/plugins/defaultPluginsUtils.js
|
||||
packages/app-cli/tests/services/plugins/defaultPluginsUtils.js.map
|
||||
packages/app-cli/tests/services/plugins/sandboxProxy.d.ts
|
||||
packages/app-cli/tests/services/plugins/sandboxProxy.js
|
||||
packages/app-cli/tests/services/plugins/sandboxProxy.js.map
|
||||
@@ -328,6 +333,9 @@ packages/app-desktop/gui/MainScreen/commands/openItem.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/openNote.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/openNote.js
|
||||
packages/app-desktop/gui/MainScreen/commands/openNote.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js
|
||||
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/openTag.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/openTag.js
|
||||
packages/app-desktop/gui/MainScreen/commands/openTag.js.map
|
||||
@@ -592,6 +600,9 @@ packages/app-desktop/gui/OneDriveLoginScreen.js.map
|
||||
packages/app-desktop/gui/PasswordInput/PasswordInput.d.ts
|
||||
packages/app-desktop/gui/PasswordInput/PasswordInput.js
|
||||
packages/app-desktop/gui/PasswordInput/PasswordInput.js.map
|
||||
packages/app-desktop/gui/PdfViewer.d.ts
|
||||
packages/app-desktop/gui/PdfViewer.js
|
||||
packages/app-desktop/gui/PdfViewer.js.map
|
||||
packages/app-desktop/gui/ResizableLayout/MoveButtons.d.ts
|
||||
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
|
||||
packages/app-desktop/gui/ResizableLayout/MoveButtons.js.map
|
||||
@@ -841,6 +852,15 @@ packages/app-mobile/components/BackButtonDialogBox.js.map
|
||||
packages/app-mobile/components/CameraView.d.ts
|
||||
packages/app-mobile/components/CameraView.js
|
||||
packages/app-mobile/components/CameraView.js.map
|
||||
packages/app-mobile/components/CustomButton.d.ts
|
||||
packages/app-mobile/components/CustomButton.js
|
||||
packages/app-mobile/components/CustomButton.js.map
|
||||
packages/app-mobile/components/Dropdown.d.ts
|
||||
packages/app-mobile/components/Dropdown.js
|
||||
packages/app-mobile/components/Dropdown.js.map
|
||||
packages/app-mobile/components/ExtendedWebView.d.ts
|
||||
packages/app-mobile/components/ExtendedWebView.js
|
||||
packages/app-mobile/components/ExtendedWebView.js.map
|
||||
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.d.ts
|
||||
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
|
||||
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js.map
|
||||
@@ -856,27 +876,99 @@ packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/types.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/types.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/types.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js.map
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.d.ts
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js.map
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.d.ts
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js.map
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.d.ts
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js.map
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.d.ts
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js.map
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.d.ts
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js.map
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.d.ts
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js.map
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.d.ts
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js.map
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.d.ts
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js.map
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.d.ts
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js.map
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.d.ts
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js.map
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.d.ts
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.js
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.js.map
|
||||
packages/app-mobile/components/NoteEditor/types.d.ts
|
||||
packages/app-mobile/components/NoteEditor/types.js
|
||||
packages/app-mobile/components/NoteEditor/types.js.map
|
||||
packages/app-mobile/components/ScreenHeader.d.ts
|
||||
packages/app-mobile/components/ScreenHeader.js
|
||||
packages/app-mobile/components/ScreenHeader.js.map
|
||||
packages/app-mobile/components/SelectDateTimeDialog.d.ts
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js.map
|
||||
packages/app-mobile/components/SideMenu.d.ts
|
||||
packages/app-mobile/components/SideMenu.js
|
||||
packages/app-mobile/components/SideMenu.js.map
|
||||
packages/app-mobile/components/getResponsiveValue.d.ts
|
||||
packages/app-mobile/components/getResponsiveValue.js
|
||||
packages/app-mobile/components/getResponsiveValue.js.map
|
||||
packages/app-mobile/components/getResponsiveValue.test.d.ts
|
||||
packages/app-mobile/components/getResponsiveValue.test.js
|
||||
packages/app-mobile/components/getResponsiveValue.test.js.map
|
||||
packages/app-mobile/components/screens/ConfigScreen.d.ts
|
||||
packages/app-mobile/components/screens/ConfigScreen.js
|
||||
packages/app-mobile/components/screens/ConfigScreen.js.map
|
||||
@@ -889,6 +981,9 @@ packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js.map
|
||||
packages/app-mobile/components/screens/encryption-config.d.ts
|
||||
packages/app-mobile/components/screens/encryption-config.js
|
||||
packages/app-mobile/components/screens/encryption-config.js.map
|
||||
packages/app-mobile/components/side-menu-content.d.ts
|
||||
packages/app-mobile/components/side-menu-content.js
|
||||
packages/app-mobile/components/side-menu-content.js.map
|
||||
packages/app-mobile/gulpfile.d.ts
|
||||
packages/app-mobile/gulpfile.js
|
||||
packages/app-mobile/gulpfile.js.map
|
||||
@@ -931,6 +1026,9 @@ packages/app-mobile/utils/setupNotifications.js.map
|
||||
packages/app-mobile/utils/shareHandler.d.ts
|
||||
packages/app-mobile/utils/shareHandler.js
|
||||
packages/app-mobile/utils/shareHandler.js.map
|
||||
packages/app-mobile/utils/types.d.ts
|
||||
packages/app-mobile/utils/types.js
|
||||
packages/app-mobile/utils/types.js.map
|
||||
packages/fork-htmlparser2/src/CollectingHandler.d.ts
|
||||
packages/fork-htmlparser2/src/CollectingHandler.js
|
||||
packages/fork-htmlparser2/src/CollectingHandler.js.map
|
||||
@@ -1495,6 +1593,9 @@ packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js.map
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.d.ts
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.js
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.js.map
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.test.d.ts
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.test.js.map
|
||||
packages/lib/services/interop/types.d.ts
|
||||
packages/lib/services/interop/types.js
|
||||
packages/lib/services/interop/types.js.map
|
||||
@@ -1603,6 +1704,12 @@ packages/lib/services/plugins/api/JoplinWorkspace.js.map
|
||||
packages/lib/services/plugins/api/types.d.ts
|
||||
packages/lib/services/plugins/api/types.js
|
||||
packages/lib/services/plugins/api/types.js.map
|
||||
packages/lib/services/plugins/defaultPlugins/defaultPluginsUtils.d.ts
|
||||
packages/lib/services/plugins/defaultPlugins/defaultPluginsUtils.js
|
||||
packages/lib/services/plugins/defaultPlugins/defaultPluginsUtils.js.map
|
||||
packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.d.ts
|
||||
packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js
|
||||
packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js.map
|
||||
packages/lib/services/plugins/reducer.d.ts
|
||||
packages/lib/services/plugins/reducer.js
|
||||
packages/lib/services/plugins/reducer.js.map
|
||||
@@ -1909,6 +2016,60 @@ packages/lib/uuid.js.map
|
||||
packages/lib/versionInfo.d.ts
|
||||
packages/lib/versionInfo.js
|
||||
packages/lib/versionInfo.js.map
|
||||
packages/pdf-viewer/FullViewer.d.ts
|
||||
packages/pdf-viewer/FullViewer.js
|
||||
packages/pdf-viewer/FullViewer.js.map
|
||||
packages/pdf-viewer/Page.d.ts
|
||||
packages/pdf-viewer/Page.js
|
||||
packages/pdf-viewer/Page.js.map
|
||||
packages/pdf-viewer/PdfDocument.d.ts
|
||||
packages/pdf-viewer/PdfDocument.js
|
||||
packages/pdf-viewer/PdfDocument.js.map
|
||||
packages/pdf-viewer/VerticalPages.d.ts
|
||||
packages/pdf-viewer/VerticalPages.js
|
||||
packages/pdf-viewer/VerticalPages.js.map
|
||||
packages/pdf-viewer/hooks/useIsFocused.d.ts
|
||||
packages/pdf-viewer/hooks/useIsFocused.js
|
||||
packages/pdf-viewer/hooks/useIsFocused.js.map
|
||||
packages/pdf-viewer/hooks/useIsVisible.d.ts
|
||||
packages/pdf-viewer/hooks/useIsVisible.js
|
||||
packages/pdf-viewer/hooks/useIsVisible.js.map
|
||||
packages/pdf-viewer/hooks/usePdfDocument.d.ts
|
||||
packages/pdf-viewer/hooks/usePdfDocument.js
|
||||
packages/pdf-viewer/hooks/usePdfDocument.js.map
|
||||
packages/pdf-viewer/hooks/useScaledSize.d.ts
|
||||
packages/pdf-viewer/hooks/useScaledSize.js
|
||||
packages/pdf-viewer/hooks/useScaledSize.js.map
|
||||
packages/pdf-viewer/hooks/useScrollSaver.d.ts
|
||||
packages/pdf-viewer/hooks/useScrollSaver.js
|
||||
packages/pdf-viewer/hooks/useScrollSaver.js.map
|
||||
packages/pdf-viewer/hooks/useVisibleOnSelect.d.ts
|
||||
packages/pdf-viewer/hooks/useVisibleOnSelect.js
|
||||
packages/pdf-viewer/hooks/useVisibleOnSelect.js.map
|
||||
packages/pdf-viewer/main.d.ts
|
||||
packages/pdf-viewer/main.js
|
||||
packages/pdf-viewer/main.js.map
|
||||
packages/pdf-viewer/messageService.d.ts
|
||||
packages/pdf-viewer/messageService.js
|
||||
packages/pdf-viewer/messageService.js.map
|
||||
packages/pdf-viewer/miniViewer.d.ts
|
||||
packages/pdf-viewer/miniViewer.js
|
||||
packages/pdf-viewer/miniViewer.js.map
|
||||
packages/pdf-viewer/pdfSource.test.d.ts
|
||||
packages/pdf-viewer/pdfSource.test.js
|
||||
packages/pdf-viewer/pdfSource.test.js.map
|
||||
packages/pdf-viewer/types.d.ts
|
||||
packages/pdf-viewer/types.js
|
||||
packages/pdf-viewer/types.js.map
|
||||
packages/pdf-viewer/ui/GotoPage.d.ts
|
||||
packages/pdf-viewer/ui/GotoPage.js
|
||||
packages/pdf-viewer/ui/GotoPage.js.map
|
||||
packages/pdf-viewer/ui/IconButtons.d.ts
|
||||
packages/pdf-viewer/ui/IconButtons.js
|
||||
packages/pdf-viewer/ui/IconButtons.js.map
|
||||
packages/pdf-viewer/ui/ZoomControls.d.ts
|
||||
packages/pdf-viewer/ui/ZoomControls.js
|
||||
packages/pdf-viewer/ui/ZoomControls.js.map
|
||||
packages/plugin-repo-cli/commands/updateRelease.d.ts
|
||||
packages/plugin-repo-cli/commands/updateRelease.js
|
||||
packages/plugin-repo-cli/commands/updateRelease.js.map
|
||||
@@ -2059,6 +2220,12 @@ packages/tools/buildServerDocker.js.map
|
||||
packages/tools/buildServerDocker.test.d.ts
|
||||
packages/tools/buildServerDocker.test.js
|
||||
packages/tools/buildServerDocker.test.js.map
|
||||
packages/tools/bundleDefaultPlugins.d.ts
|
||||
packages/tools/bundleDefaultPlugins.js
|
||||
packages/tools/bundleDefaultPlugins.js.map
|
||||
packages/tools/bundleDefaultPlugins.test.d.ts
|
||||
packages/tools/bundleDefaultPlugins.test.js
|
||||
packages/tools/bundleDefaultPlugins.test.js.map
|
||||
packages/tools/checkLibPaths.d.ts
|
||||
packages/tools/checkLibPaths.js
|
||||
packages/tools/checkLibPaths.js.map
|
||||
|
12
.eslintrc.js
12
.eslintrc.js
@@ -83,11 +83,15 @@ module.exports = {
|
||||
// 'complexity': ['warn', { max: 10 }],
|
||||
|
||||
// Checks rules of Hooks
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'@seiyab/react-hooks/rules-of-hooks': 'error',
|
||||
'@seiyab/react-hooks/exhaustive-deps': ['error', { 'ignoreThisDependency': 'props' }],
|
||||
|
||||
// Checks effect dependencies
|
||||
// Disable because of this: https://github.com/facebook/react/issues/16265
|
||||
// "react-hooks/exhaustive-deps": "warn",
|
||||
|
||||
'promise/prefer-await-to-then': 'error',
|
||||
|
||||
// -------------------------------
|
||||
// Formatting
|
||||
// -------------------------------
|
||||
@@ -134,8 +138,12 @@ module.exports = {
|
||||
'plugins': [
|
||||
'react',
|
||||
'@typescript-eslint',
|
||||
'react-hooks',
|
||||
// Need to use a fork of the official rules of hooks because of this bug:
|
||||
// https://github.com/facebook/react/issues/16265
|
||||
'@seiyab/eslint-plugin-react-hooks',
|
||||
// 'react-hooks',
|
||||
'import',
|
||||
'promise',
|
||||
],
|
||||
'overrides': [
|
||||
{
|
||||
|
8
.github/scripts/run_ci.sh
vendored
8
.github/scripts/run_ci.sh
vendored
@@ -57,6 +57,11 @@ echo "Yarn $( yarn -v )"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
yarn install
|
||||
testResult=$?
|
||||
if [ $testResult -ne 0 ]; then
|
||||
echo "Yarn installation failed. Search for 'exit code 1' in the log for more information."
|
||||
exit $testResult
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Run test units. Only do it for pull requests and dev branch because we don't
|
||||
@@ -170,6 +175,9 @@ cd "$ROOT_DIR/packages/app-desktop"
|
||||
|
||||
if [[ $GIT_TAG_NAME = v* ]]; then
|
||||
echo "Step: Building and publishing desktop application..."
|
||||
# cd "$ROOT_DIR/packages/tools"
|
||||
# node bundleDefaultPlugins.js
|
||||
cd "$ROOT_DIR/packages/app-desktop"
|
||||
USE_HARD_LINKS=false yarn run dist
|
||||
elif [[ $IS_LINUX = 1 ]] && [[ $GIT_TAG_NAME = $SERVER_TAG_PREFIX-* ]]; then
|
||||
echo "Step: Building Docker Image..."
|
||||
|
3
.github/workflows/github-actions-main.yml
vendored
3
.github/workflows/github-actions-main.yml
vendored
@@ -5,7 +5,6 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
# Removed windows-2016 for now - discontinued by GitHub
|
||||
os: [macos-latest, ubuntu-latest, windows-2019]
|
||||
steps:
|
||||
|
||||
@@ -76,6 +75,8 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
IS_CONTINUOUS_INTEGRATION: 1
|
||||
BUILD_SEQUENCIAL: 1
|
||||
SERVER_REPOSITORY: joplin/server
|
||||
SERVER_TAG_PREFIX: server
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE}/.github/scripts/run_ci.sh"
|
||||
|
||||
|
168
.gitignore
vendored
168
.gitignore
vendored
@@ -105,6 +105,9 @@ packages/app-cli/tests/services/plugins/api/JoplinViewMenuItem.js.map
|
||||
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.d.ts
|
||||
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.js
|
||||
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.js.map
|
||||
packages/app-cli/tests/services/plugins/defaultPluginsUtils.d.ts
|
||||
packages/app-cli/tests/services/plugins/defaultPluginsUtils.js
|
||||
packages/app-cli/tests/services/plugins/defaultPluginsUtils.js.map
|
||||
packages/app-cli/tests/services/plugins/sandboxProxy.d.ts
|
||||
packages/app-cli/tests/services/plugins/sandboxProxy.js
|
||||
packages/app-cli/tests/services/plugins/sandboxProxy.js.map
|
||||
@@ -318,6 +321,9 @@ packages/app-desktop/gui/MainScreen/commands/openItem.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/openNote.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/openNote.js
|
||||
packages/app-desktop/gui/MainScreen/commands/openNote.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js
|
||||
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js.map
|
||||
packages/app-desktop/gui/MainScreen/commands/openTag.d.ts
|
||||
packages/app-desktop/gui/MainScreen/commands/openTag.js
|
||||
packages/app-desktop/gui/MainScreen/commands/openTag.js.map
|
||||
@@ -582,6 +588,9 @@ packages/app-desktop/gui/OneDriveLoginScreen.js.map
|
||||
packages/app-desktop/gui/PasswordInput/PasswordInput.d.ts
|
||||
packages/app-desktop/gui/PasswordInput/PasswordInput.js
|
||||
packages/app-desktop/gui/PasswordInput/PasswordInput.js.map
|
||||
packages/app-desktop/gui/PdfViewer.d.ts
|
||||
packages/app-desktop/gui/PdfViewer.js
|
||||
packages/app-desktop/gui/PdfViewer.js.map
|
||||
packages/app-desktop/gui/ResizableLayout/MoveButtons.d.ts
|
||||
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
|
||||
packages/app-desktop/gui/ResizableLayout/MoveButtons.js.map
|
||||
@@ -831,6 +840,15 @@ packages/app-mobile/components/BackButtonDialogBox.js.map
|
||||
packages/app-mobile/components/CameraView.d.ts
|
||||
packages/app-mobile/components/CameraView.js
|
||||
packages/app-mobile/components/CameraView.js.map
|
||||
packages/app-mobile/components/CustomButton.d.ts
|
||||
packages/app-mobile/components/CustomButton.js
|
||||
packages/app-mobile/components/CustomButton.js.map
|
||||
packages/app-mobile/components/Dropdown.d.ts
|
||||
packages/app-mobile/components/Dropdown.js
|
||||
packages/app-mobile/components/Dropdown.js.map
|
||||
packages/app-mobile/components/ExtendedWebView.d.ts
|
||||
packages/app-mobile/components/ExtendedWebView.js
|
||||
packages/app-mobile/components/ExtendedWebView.js.map
|
||||
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.d.ts
|
||||
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
|
||||
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js.map
|
||||
@@ -846,27 +864,99 @@ packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/types.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/types.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/types.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js.map
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.d.ts
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js.map
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.d.ts
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js.map
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.d.ts
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js.map
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.d.ts
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js.map
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.d.ts
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js.map
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.d.ts
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js.map
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.d.ts
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js.map
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.d.ts
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js.map
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.d.ts
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js.map
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.d.ts
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js.map
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.d.ts
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.js
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.js.map
|
||||
packages/app-mobile/components/NoteEditor/types.d.ts
|
||||
packages/app-mobile/components/NoteEditor/types.js
|
||||
packages/app-mobile/components/NoteEditor/types.js.map
|
||||
packages/app-mobile/components/ScreenHeader.d.ts
|
||||
packages/app-mobile/components/ScreenHeader.js
|
||||
packages/app-mobile/components/ScreenHeader.js.map
|
||||
packages/app-mobile/components/SelectDateTimeDialog.d.ts
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js.map
|
||||
packages/app-mobile/components/SideMenu.d.ts
|
||||
packages/app-mobile/components/SideMenu.js
|
||||
packages/app-mobile/components/SideMenu.js.map
|
||||
packages/app-mobile/components/getResponsiveValue.d.ts
|
||||
packages/app-mobile/components/getResponsiveValue.js
|
||||
packages/app-mobile/components/getResponsiveValue.js.map
|
||||
packages/app-mobile/components/getResponsiveValue.test.d.ts
|
||||
packages/app-mobile/components/getResponsiveValue.test.js
|
||||
packages/app-mobile/components/getResponsiveValue.test.js.map
|
||||
packages/app-mobile/components/screens/ConfigScreen.d.ts
|
||||
packages/app-mobile/components/screens/ConfigScreen.js
|
||||
packages/app-mobile/components/screens/ConfigScreen.js.map
|
||||
@@ -879,6 +969,9 @@ packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js.map
|
||||
packages/app-mobile/components/screens/encryption-config.d.ts
|
||||
packages/app-mobile/components/screens/encryption-config.js
|
||||
packages/app-mobile/components/screens/encryption-config.js.map
|
||||
packages/app-mobile/components/side-menu-content.d.ts
|
||||
packages/app-mobile/components/side-menu-content.js
|
||||
packages/app-mobile/components/side-menu-content.js.map
|
||||
packages/app-mobile/gulpfile.d.ts
|
||||
packages/app-mobile/gulpfile.js
|
||||
packages/app-mobile/gulpfile.js.map
|
||||
@@ -921,6 +1014,9 @@ packages/app-mobile/utils/setupNotifications.js.map
|
||||
packages/app-mobile/utils/shareHandler.d.ts
|
||||
packages/app-mobile/utils/shareHandler.js
|
||||
packages/app-mobile/utils/shareHandler.js.map
|
||||
packages/app-mobile/utils/types.d.ts
|
||||
packages/app-mobile/utils/types.js
|
||||
packages/app-mobile/utils/types.js.map
|
||||
packages/fork-htmlparser2/src/CollectingHandler.d.ts
|
||||
packages/fork-htmlparser2/src/CollectingHandler.js
|
||||
packages/fork-htmlparser2/src/CollectingHandler.js.map
|
||||
@@ -1485,6 +1581,9 @@ packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js.map
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.d.ts
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.js
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.js.map
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.test.d.ts
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.test.js.map
|
||||
packages/lib/services/interop/types.d.ts
|
||||
packages/lib/services/interop/types.js
|
||||
packages/lib/services/interop/types.js.map
|
||||
@@ -1593,6 +1692,12 @@ packages/lib/services/plugins/api/JoplinWorkspace.js.map
|
||||
packages/lib/services/plugins/api/types.d.ts
|
||||
packages/lib/services/plugins/api/types.js
|
||||
packages/lib/services/plugins/api/types.js.map
|
||||
packages/lib/services/plugins/defaultPlugins/defaultPluginsUtils.d.ts
|
||||
packages/lib/services/plugins/defaultPlugins/defaultPluginsUtils.js
|
||||
packages/lib/services/plugins/defaultPlugins/defaultPluginsUtils.js.map
|
||||
packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.d.ts
|
||||
packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js
|
||||
packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js.map
|
||||
packages/lib/services/plugins/reducer.d.ts
|
||||
packages/lib/services/plugins/reducer.js
|
||||
packages/lib/services/plugins/reducer.js.map
|
||||
@@ -1899,6 +2004,60 @@ packages/lib/uuid.js.map
|
||||
packages/lib/versionInfo.d.ts
|
||||
packages/lib/versionInfo.js
|
||||
packages/lib/versionInfo.js.map
|
||||
packages/pdf-viewer/FullViewer.d.ts
|
||||
packages/pdf-viewer/FullViewer.js
|
||||
packages/pdf-viewer/FullViewer.js.map
|
||||
packages/pdf-viewer/Page.d.ts
|
||||
packages/pdf-viewer/Page.js
|
||||
packages/pdf-viewer/Page.js.map
|
||||
packages/pdf-viewer/PdfDocument.d.ts
|
||||
packages/pdf-viewer/PdfDocument.js
|
||||
packages/pdf-viewer/PdfDocument.js.map
|
||||
packages/pdf-viewer/VerticalPages.d.ts
|
||||
packages/pdf-viewer/VerticalPages.js
|
||||
packages/pdf-viewer/VerticalPages.js.map
|
||||
packages/pdf-viewer/hooks/useIsFocused.d.ts
|
||||
packages/pdf-viewer/hooks/useIsFocused.js
|
||||
packages/pdf-viewer/hooks/useIsFocused.js.map
|
||||
packages/pdf-viewer/hooks/useIsVisible.d.ts
|
||||
packages/pdf-viewer/hooks/useIsVisible.js
|
||||
packages/pdf-viewer/hooks/useIsVisible.js.map
|
||||
packages/pdf-viewer/hooks/usePdfDocument.d.ts
|
||||
packages/pdf-viewer/hooks/usePdfDocument.js
|
||||
packages/pdf-viewer/hooks/usePdfDocument.js.map
|
||||
packages/pdf-viewer/hooks/useScaledSize.d.ts
|
||||
packages/pdf-viewer/hooks/useScaledSize.js
|
||||
packages/pdf-viewer/hooks/useScaledSize.js.map
|
||||
packages/pdf-viewer/hooks/useScrollSaver.d.ts
|
||||
packages/pdf-viewer/hooks/useScrollSaver.js
|
||||
packages/pdf-viewer/hooks/useScrollSaver.js.map
|
||||
packages/pdf-viewer/hooks/useVisibleOnSelect.d.ts
|
||||
packages/pdf-viewer/hooks/useVisibleOnSelect.js
|
||||
packages/pdf-viewer/hooks/useVisibleOnSelect.js.map
|
||||
packages/pdf-viewer/main.d.ts
|
||||
packages/pdf-viewer/main.js
|
||||
packages/pdf-viewer/main.js.map
|
||||
packages/pdf-viewer/messageService.d.ts
|
||||
packages/pdf-viewer/messageService.js
|
||||
packages/pdf-viewer/messageService.js.map
|
||||
packages/pdf-viewer/miniViewer.d.ts
|
||||
packages/pdf-viewer/miniViewer.js
|
||||
packages/pdf-viewer/miniViewer.js.map
|
||||
packages/pdf-viewer/pdfSource.test.d.ts
|
||||
packages/pdf-viewer/pdfSource.test.js
|
||||
packages/pdf-viewer/pdfSource.test.js.map
|
||||
packages/pdf-viewer/types.d.ts
|
||||
packages/pdf-viewer/types.js
|
||||
packages/pdf-viewer/types.js.map
|
||||
packages/pdf-viewer/ui/GotoPage.d.ts
|
||||
packages/pdf-viewer/ui/GotoPage.js
|
||||
packages/pdf-viewer/ui/GotoPage.js.map
|
||||
packages/pdf-viewer/ui/IconButtons.d.ts
|
||||
packages/pdf-viewer/ui/IconButtons.js
|
||||
packages/pdf-viewer/ui/IconButtons.js.map
|
||||
packages/pdf-viewer/ui/ZoomControls.d.ts
|
||||
packages/pdf-viewer/ui/ZoomControls.js
|
||||
packages/pdf-viewer/ui/ZoomControls.js.map
|
||||
packages/plugin-repo-cli/commands/updateRelease.d.ts
|
||||
packages/plugin-repo-cli/commands/updateRelease.js
|
||||
packages/plugin-repo-cli/commands/updateRelease.js.map
|
||||
@@ -2049,6 +2208,12 @@ packages/tools/buildServerDocker.js.map
|
||||
packages/tools/buildServerDocker.test.d.ts
|
||||
packages/tools/buildServerDocker.test.js
|
||||
packages/tools/buildServerDocker.test.js.map
|
||||
packages/tools/bundleDefaultPlugins.d.ts
|
||||
packages/tools/bundleDefaultPlugins.js
|
||||
packages/tools/bundleDefaultPlugins.js.map
|
||||
packages/tools/bundleDefaultPlugins.test.d.ts
|
||||
packages/tools/bundleDefaultPlugins.test.js
|
||||
packages/tools/bundleDefaultPlugins.test.js.map
|
||||
packages/tools/checkLibPaths.d.ts
|
||||
packages/tools/checkLibPaths.js
|
||||
packages/tools/checkLibPaths.js.map
|
||||
@@ -2143,3 +2308,6 @@ packages/tools/website/utils/types.d.ts
|
||||
packages/tools/website/utils/types.js
|
||||
packages/tools/website/utils/types.js.map
|
||||
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
|
||||
packages/app-mobile/components/get-responsive-value.test.js
|
||||
packages/app-mobile/components/get-responsive-value.test.js
|
||||
packages/app-mobile/components/get-responsive-value.test.js
|
||||
|
@@ -1,4 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Mon, 06 Jun 2022 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 06 Jun 2022 00:00:00 GMT</pubDate><item><title><![CDATA[Joplin 2.8 is available!]]></title><description><![CDATA[<p>As always a lot of changes and new features in this new version available on both desktop and mobile.</p>
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Tue, 06 Sep 2022 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Tue, 06 Sep 2022 00:00:00 GMT</pubDate><item><title><![CDATA[Joplin interview on Website Planet]]></title><description><![CDATA[<p>Website Planet has recently conducted an interview about Joplin - it may give you some insight on the current status of the project, our priorities, and future plans! More on the article page - <a href="https://www.websiteplanet.com/blog/interview-joplin/">Organise Your Thoughts with Open Source Note-Taking App, Joplin</a></p>
|
||||
]]></description><link>https://joplinapp.org/news/20220906-interview-websiteplanet/</link><guid isPermaLink="false">20220906-interview-websiteplanet</guid><pubDate>Tue, 06 Sep 2022 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Joplin first meetup on 30 August!]]></title><description><![CDATA[<p>We are glad to announce <a href="https://www.meetup.com/joplin/events/287611873/">the first Joplin Meetup</a> that will take place on 30 August 2022 in London!</p>
|
||||
<p>This is an opportunity to meet other Joplin users as well as some of the main contributors, to discuss the apps, or to ask questions and exchange tips and tricks on how to use the app, develop plugins or contribute to the application. Everybody, technical or not, is welcome!</p>
|
||||
<p>We will meet at the Old Thameside Inn next to London Bridge. If the weather allows we will be on the terrace outside, if not inside.</p>
|
||||
<p>More information on the official Meetup page:</p>
|
||||
<p><a href="https://www.meetup.com/joplin/events/287611873/">https://www.meetup.com/joplin/events/287611873/</a></p>
|
||||
]]></description><link>https://joplinapp.org/news/20220808-first-meetup/</link><guid isPermaLink="false">20220808-first-meetup</guid><pubDate>Mon, 08 Aug 2022 00:00:00 GMT</pubDate><twitter-text>Joplin will have its first Meetup on 30 August! Come and join us at the Old Thameside Inn next to London Bridge! https://www.meetup.com/joplin/events/287611873/</twitter-text></item><item><title><![CDATA[Joplin 2.8 is available!]]></title><description><![CDATA[<p>As always a lot of changes and new features in this new version available on both desktop and mobile.</p>
|
||||
<h1>Multiple profile support<a name="multiple-profile-support" href="#multiple-profile-support" class="heading-anchor">🔗</a></h1>
|
||||
<p>Perhaps the most visible change in this version is the support for multiple profiles. You can now create as many application profile as you wish, each with their own settings, and easily switch from one to another. The main use case is to support for example a "work" profile and a "personal" profile, to allow you to keep things independent, and each profile can sync with a different sync target.</p>
|
||||
<p>To create a new profile, open <strong>File > Switch profile</strong> and select <strong>Create new profile</strong>, enter the profile name and press OK. The app will automatically switch to this new profile, which you can now configure.</p>
|
||||
@@ -253,9 +259,4 @@
|
||||
<p>Also many thanks to everyone who voted and contributed to the tagline discussion! It helped narrow down what the tagline should be, along with the equally important description below. If you have any question or notice any issue with the website let me know!</p>
|
||||
]]></description><link>https://joplinapp.org/news/20210711-095626/</link><guid isPermaLink="false">20210711-095626</guid><pubDate>Sun, 11 Jul 2021 09:56:26 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Poll: What should Joplin tagline be?]]></title><description><![CDATA[<p>Thanks everyone for your tagline suggestions - there were lots of good ideas in there. I've compiled a few of them and create a poll in the forum, so please cast your vote! And if you have any other suggestions on what would make a good tagline, feel free to post over there or here.</p>
|
||||
<p><a href="https://discourse.joplinapp.org/t/poll-what-should-joplin-tagline-be/18487">https://discourse.joplinapp.org/t/poll-what-should-joplin-tagline-be/18487</a></p>
|
||||
]]></description><link>https://joplinapp.org/news/20210706-140228/</link><guid isPermaLink="false">20210706-140228</guid><pubDate>Tue, 06 Jul 2021 14:02:28 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Any ideas for a Joplin tagline?]]></title><description><![CDATA[<p>I'm going to update the website front page to better showcase the application. I have most of the sections right, but the part I'm still not sure about is the top tagline, so I'm wondering if anyone had any suggestion about it?</p>
|
||||
<p>From what I can see on Google Keep or Evernote for example it should be something like "Use our app to get X or Y benefit", it should be a sentence that directly speaks to the user essentially.</p>
|
||||
<p>So far I have "Your notes, anywhere you are" but I'm not certain that's particularly inspiring. Any other idea about what tagline could be used?</p>
|
||||
]]></description><link>https://joplinapp.org/news/20210705-094247/</link><guid isPermaLink="false">20210705-094247</guid><pubDate>Mon, 05 Jul 2021 09:42:47 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Poll: What's the size of your note collection?]]></title><description><![CDATA[<p>Poll is on the forum:</p>
|
||||
<p><a href="https://discourse.joplinapp.org/t/poll-whats-the-size-of-your-note-collection/18191">https://discourse.joplinapp.org/t/poll-whats-the-size-of-your-note-collection/18191</a></p>
|
||||
]]></description><link>https://joplinapp.org/news/20210624-171844/</link><guid isPermaLink="false">20210624-171844</guid><pubDate>Thu, 24 Jun 2021 17:18:44 GMT</pubDate><twitter-text></twitter-text></item></channel></rss>
|
||||
]]></description><link>https://joplinapp.org/news/20210706-140228/</link><guid isPermaLink="false">20210706-140228</guid><pubDate>Tue, 06 Jul 2021 14:02:28 GMT</pubDate><twitter-text></twitter-text></item></channel></rss>
|
@@ -116,16 +116,21 @@
|
||||
});
|
||||
};
|
||||
|
||||
const applyPeriod = (period) => {
|
||||
subscriptionPeriod = period;
|
||||
$('.plan-group').removeClass(period === 'monthly' ? 'plan-prices-yearly' : 'plan-prices-monthly');
|
||||
$('.plan-group').addClass('plan-prices-' + period);
|
||||
$("#pay-" + period + '-radio').prop('checked', true);
|
||||
}
|
||||
|
||||
$(() => {
|
||||
$("input[name='pay-radio']").change(function() {
|
||||
const period = $("input[type='radio'][name='pay-radio']:checked").val();
|
||||
subscriptionPeriod = period;
|
||||
|
||||
$('.plan-group').removeClass(period === 'monthly' ? 'plan-prices-yearly' : 'plan-prices-monthly');
|
||||
$('.plan-group').addClass('plan-prices-' + period);
|
||||
applyPeriod(period);
|
||||
});
|
||||
|
||||
setupBetaHandling(urlQuery);
|
||||
applyPeriod('yearly');
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
|
@@ -31,7 +31,7 @@ Joplin is available in multiple languages thanks to the help of its users. You c
|
||||
|
||||
If you want to start contributing to the project's code, please follow these guidelines before creating a pull request:
|
||||
|
||||
- Explain WHY you want to add this change. Explain it inside the pull request and you may link to an issue for additional information, but the PR should give a clear overview of why you want to add this.
|
||||
- The top post of the pull request should contain a full, self-contained explanation of the feature: what it does, how it does it, with examples of usage and screenshots. Also explain why you want to add this - what problem does it solve. Do not simply add a text `Implement feature #4345` or link to forum posts, because the information there will most likely be outdated or confusing (multiple discussions and opinions). The pull request needs to be self-contained.
|
||||
- Bug fixes are always welcome. Start by reviewing the [list of bugs](https://github.com/laurent22/joplin/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
|
||||
- A good way to easily start contributing is to pick and work on a [good first issue](https://github.com/laurent22/joplin/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). We try to make these issues as clear as possible and provide basic info on how the code should be changed, and if something is unclear feel free to ask for more information on the issue.
|
||||
- Before adding a new feature, ask about it in the [Github Issue Tracker](https://github.com/laurent22/joplin/issues?utf8=%E2%9C%93&q=is%3Aissue) or the [Joplin Forum](https://discourse.joplinapp.org/), or check if existing discussions exist to make sure the new functionality is desired.
|
||||
|
13
README.md
13
README.md
@@ -4,6 +4,10 @@
|
||||
|
||||
* * *
|
||||
|
||||
Joplin will have [its first Meetup on 30 August 2022](https://discourse.joplinapp.org/t/joplin-first-meetup-on-30-august/26808)! Come and join us at the Old Thameside Inn next to London Bridge!
|
||||
|
||||
* * *
|
||||
|
||||
🌞 Joplin participates in **Google Summer of Code 2022**! More info on [the announcement post](https://github.com/laurent22/joplin/blob/dev/readme/news/20220308-gsoc2022-start.md). 🌞
|
||||
|
||||
* * *
|
||||
@@ -83,8 +87,9 @@ A community maintained list of these distributions can be found here: [Unofficia
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1439535?s=96&v=4"/></br>[fbloise](https://github.com/fbloise) | <img width="50" src="https://avatars2.githubusercontent.com/u/49439044?s=96&v=4"/></br>[fourstepper](https://github.com/fourstepper) | <img width="50" src="https://avatars2.githubusercontent.com/u/38898566?s=96&v=4"/></br>[h4sh5](https://github.com/h4sh5) | <img width="50" src="https://avatars2.githubusercontent.com/u/3266447?s=96&v=4"/></br>[iamwillbar](https://github.com/iamwillbar) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/37297218?s=96&v=4"/></br>[Jesssullivan](https://github.com/Jesssullivan) | <img width="50" src="https://avatars2.githubusercontent.com/u/1248504?s=96&v=4"/></br>[joesfer](https://github.com/joesfer) | <img width="50" src="https://avatars2.githubusercontent.com/u/5588131?s=96&v=4"/></br>[kianenigma](https://github.com/kianenigma) | <img width="50" src="https://avatars2.githubusercontent.com/u/24908652?s=96&v=4"/></br>[konishi-t](https://github.com/konishi-t) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/1788010?s=96&v=4"/></br>[maxtruxa](https://github.com/maxtruxa) | <img width="50" src="https://avatars2.githubusercontent.com/u/29300939?s=96&v=4"/></br>[mcejp](https://github.com/mcejp) | <img width="50" src="https://avatars2.githubusercontent.com/u/1168659?s=96&v=4"/></br>[nicholashead](https://github.com/nicholashead) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/5782817?s=96&v=4"/></br>[piccobit](https://github.com/piccobit) | <img width="50" src="https://avatars2.githubusercontent.com/u/77214738?s=96&v=4"/></br>[Polymathic-Company](https://github.com/Polymathic-Company) | <img width="50" src="https://avatars2.githubusercontent.com/u/47742?s=96&v=4"/></br>[ravenscroftj](https://github.com/ravenscroftj) | <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/73081837?s=96&v=4"/></br>[thismarty](https://github.com/thismarty) | <img width="50" src="https://avatars2.githubusercontent.com/u/15859362?s=96&v=4"/></br>[thomasbroussard](https://github.com/thomasbroussard) | | |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/5782817?s=96&v=4"/></br>[piccobit](https://github.com/piccobit) | <img width="50" src="https://avatars2.githubusercontent.com/u/77214738?s=96&v=4"/></br>[Polymathic-Company](https://github.com/Polymathic-Company) | <img width="50" src="https://avatars2.githubusercontent.com/u/47742?s=96&v=4"/></br>[ravenscroftj](https://github.com/ravenscroftj) | <img width="50" src="https://avatars2.githubusercontent.com/u/327998?s=96&v=4"/></br>[sif](https://github.com/sif) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/54626606?s=96&v=4"/></br>[skyrunner15](https://github.com/skyrunner15) | <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) | <img width="50" src="https://avatars2.githubusercontent.com/u/73081837?s=96&v=4"/></br>[thismarty](https://github.com/thismarty) | <img width="50" src="https://avatars2.githubusercontent.com/u/15859362?s=96&v=4"/></br>[thomasbroussard](https://github.com/thomasbroussard) |
|
||||
| | | | |
|
||||
<!-- SPONSORS-GITHUB -->
|
||||
|
||||
<!-- TOC -->
|
||||
@@ -341,8 +346,8 @@ If you provide a configuration and you receive "success!" on the "check config"
|
||||
- Force Path Style: unchecked
|
||||
|
||||
### Linode
|
||||
- URL: https://<region>.linodeobjects.com
|
||||
- Region: empty
|
||||
- URL: https://regionName.linodeobjects.com (regionName is the region on the URL provided by Linode; this URL is also the same as the URL provided by Linode with the bucket name removed)
|
||||
- Region: Anything you want to type, can't be left empty
|
||||
- Force Path Style: unchecked
|
||||
|
||||
### UpCloud
|
||||
|
@@ -9,3 +9,7 @@ Only the latest version is supported with security updates.
|
||||
Please [contact support](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/AdresseSupport.png) **with a proof of concept** that shows the security vulnerability. Please do not contact us without this proof of concept, as we cannot fix anything without this.
|
||||
|
||||
For general opinions on what makes an app more or less secure, please use the forum.
|
||||
|
||||
## Bounty
|
||||
|
||||
We **do not** offer a bounty for discovering vulnerabilities, please do not ask. We can however credit you and link to your website in the changelog and release announcement.
|
||||
|
22
package.json
22
package.json
@@ -12,8 +12,8 @@
|
||||
"node": ">=16"
|
||||
},
|
||||
"scripts": {
|
||||
"buildParallel": "yarn workspaces foreach --verbose --interlaced --parallel --jobs 2 run build && yarn run tsc",
|
||||
"buildSequential": "yarn workspaces foreach --verbose --interlaced run build && yarn run tsc",
|
||||
"buildParallel": "yarn workspaces foreach --verbose --interlaced --parallel --jobs 2 --topological run build && yarn run tsc",
|
||||
"buildSequential": "yarn workspaces foreach --verbose --interlaced --topological run build && yarn run tsc",
|
||||
"buildApiDoc": "yarn workspace joplin start apidoc ../../readme/api/references/rest_api.md",
|
||||
"buildCommandIndex": "gulp buildCommandIndex",
|
||||
"buildPluginDoc": "typedoc --name 'Joplin Plugin API Documentation' --mode file -theme './Assets/PluginDocTheme/' --readme './Assets/PluginDocTheme/index.md' --excludeNotExported --excludeExternals --excludePrivate --excludeProtected --out ../joplin-website/docs/api/references/plugin_api packages/lib/services/plugins/api/",
|
||||
@@ -31,6 +31,7 @@
|
||||
"linter-ci": "eslint --resolve-plugins-relative-to . --quiet --ext .js --ext .jsx --ext .ts --ext .tsx",
|
||||
"linter-precommit": "eslint --resolve-plugins-relative-to . --fix --ext .js --ext .jsx --ext .ts --ext .tsx",
|
||||
"linter": "eslint --resolve-plugins-relative-to . --fix --quiet --ext .js --ext .jsx --ext .ts --ext .tsx",
|
||||
"linter-interactive": "eslint-interactive --resolve-plugins-relative-to . --fix --quiet --ext .js --ext .jsx --ext .ts --ext .tsx",
|
||||
"postinstall": "gulp build",
|
||||
"publishAll": "git pull && yarn run buildParallel && lerna version --yes --no-private --no-git-tag-version && gulp completePublishAll",
|
||||
"releaseAndroid": "PATH=\"/usr/local/opt/openjdk@11/bin:$PATH\" node packages/tools/release-android.js",
|
||||
@@ -61,13 +62,15 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^4.6.0",
|
||||
"@typescript-eslint/parser": "^4.6.0",
|
||||
"@seiyab/eslint-plugin-react-hooks": "^4.5.1-alpha.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.33.1",
|
||||
"@typescript-eslint/parser": "^5.33.1",
|
||||
"cspell": "^5.20.0",
|
||||
"eslint": "^7.6.0",
|
||||
"eslint-plugin-import": "^2.20.2",
|
||||
"eslint-plugin-react": "^7.18.0",
|
||||
"eslint-plugin-react-hooks": "^2.4.0",
|
||||
"eslint": "^8.22.0",
|
||||
"eslint-interactive": "^10.0.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-promise": "^6.0.1",
|
||||
"eslint-plugin-react": "^7.30.1",
|
||||
"fs-extra": "^8.1.0",
|
||||
"glob": "^7.1.6",
|
||||
"gulp": "^4.0.2",
|
||||
@@ -76,9 +79,10 @@
|
||||
"lint-staged": "^9.2.1",
|
||||
"madge": "^4.0.2",
|
||||
"typedoc": "^0.17.8",
|
||||
"typescript": "4.0.5"
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"http-server": "^0.12.3",
|
||||
"node-gyp": "^8.4.1",
|
||||
"nodemon": "^2.0.9"
|
||||
|
@@ -375,6 +375,11 @@ class AppGui {
|
||||
this.showNoteMetadata(!this.widget('noteMetadata').shown);
|
||||
}
|
||||
|
||||
toggleFolderIds() {
|
||||
this.widget('folderList').toggleShowIds();
|
||||
this.widget('noteList').toggleShowIds();
|
||||
}
|
||||
|
||||
widget(name) {
|
||||
if (name === 'root') return this.rootWidget_;
|
||||
return this.rootWidget_.childByName(name);
|
||||
@@ -498,6 +503,8 @@ class AppGui {
|
||||
}
|
||||
} else if (cmd === 'toggle_metadata') {
|
||||
this.toggleNoteMetadata();
|
||||
} else if (cmd === 'toggle_ids') {
|
||||
this.toggleFolderIds();
|
||||
} else if (cmd === 'enter_command_line_mode') {
|
||||
const cmd = await this.widget('statusBar').prompt();
|
||||
if (!cmd) return;
|
||||
|
@@ -332,6 +332,7 @@ class Application extends BaseApplication {
|
||||
{ keys: [' '], command: 'todo toggle $n' },
|
||||
{ keys: ['tc'], type: 'function', command: 'toggle_console' },
|
||||
{ keys: ['tm'], type: 'function', command: 'toggle_metadata' },
|
||||
{ keys: ['ti'], type: 'function', command: 'toggle_ids' },
|
||||
{ keys: ['/'], type: 'prompt', command: 'search ""', cursorPosition: -2 },
|
||||
{ keys: ['mn'], type: 'prompt', command: 'mknote ""', cursorPosition: -2 },
|
||||
{ keys: ['mt'], type: 'prompt', command: 'mktodo ""', cursorPosition: -2 },
|
||||
|
@@ -124,6 +124,7 @@ async function handleAutocompletionPromise(line) {
|
||||
return line;
|
||||
}
|
||||
function handleAutocompletion(str, callback) {
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
handleAutocompletionPromise(str).then(function(res) {
|
||||
callback(undefined, res);
|
||||
});
|
||||
|
@@ -7,25 +7,45 @@ const Note = require('@joplin/lib/models/Note').default;
|
||||
|
||||
class Command extends BaseCommand {
|
||||
usage() {
|
||||
return 'mv <note> [notebook]';
|
||||
return 'mv <item> [notebook]';
|
||||
}
|
||||
|
||||
description() {
|
||||
return _('Moves the notes matching <note> to [notebook].');
|
||||
return _('Moves the given <item> (notes matching pattern in current notebook or one notebook) to [notebook]. If <item> is subnotebook and [notebook] is "root", will make <item> parent notebook');
|
||||
}
|
||||
|
||||
async action(args) {
|
||||
const pattern = args['note'];
|
||||
const pattern = args['item'];
|
||||
const destination = args['notebook'];
|
||||
let folder = null;
|
||||
|
||||
const folder = await Folder.loadByField('title', destination);
|
||||
if (!folder) throw new Error(_('Cannot find "%s".', destination));
|
||||
if (destination !== 'root') {
|
||||
folder = await app().loadItem(BaseModel.TYPE_FOLDER, destination);
|
||||
if (!folder) throw new Error(_('Cannot find "%s".', destination));
|
||||
}
|
||||
|
||||
const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
|
||||
if (!notes.length) throw new Error(_('Cannot find "%s".', pattern));
|
||||
const destinationDuplicates = await Folder.search({ titlePattern: destination, limit: 2 });
|
||||
if (destinationDuplicates.length > 1) {
|
||||
throw new Error(_('Ambiguous notebook "%s". Please use short notebook id instead - press "ti" to see the short notebook id' , destination));
|
||||
}
|
||||
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
await Note.moveToFolder(notes[i].id, folder.id);
|
||||
const itemFolder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern);
|
||||
if (itemFolder) {
|
||||
const sourceDuplicates = await Folder.search({ titlePattern: pattern, limit: 2 });
|
||||
if (sourceDuplicates.length > 1) {
|
||||
throw new Error(_('Ambiguous notebook "%s". Please use notebook id instead - press "ti" to see the short notebook id or use $b for current selected notebook', pattern));
|
||||
}
|
||||
if (destination === 'root') {
|
||||
await Folder.moveToFolder(itemFolder.id, '');
|
||||
} else {
|
||||
await Folder.moveToFolder(itemFolder.id, folder.id);
|
||||
}
|
||||
} else {
|
||||
const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
|
||||
if (notes.length === 0) throw new Error(_('Cannot find "%s".', pattern));
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
await Note.moveToFolder(notes[i].id, folder.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -36,6 +36,7 @@ async function createClients() {
|
||||
const client = createClient(clientId);
|
||||
promises.push(fs.remove(client.profileDir));
|
||||
promises.push(
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
execCommand(client, 'config sync.target 2').then(() => {
|
||||
return execCommand(client, `config sync.2.path ${syncDir}`);
|
||||
})
|
||||
@@ -2324,10 +2325,12 @@ async function main() {
|
||||
clients[clientId].activeCommandCount++;
|
||||
|
||||
execRandomCommand(clients[clientId])
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
.catch(error => {
|
||||
logger.info(`Client ${clientId}:`);
|
||||
logger.error(error);
|
||||
})
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
.then(r => {
|
||||
if (r) {
|
||||
logger.info(`Client ${clientId}:\n${r.trim()}`);
|
||||
|
@@ -19,13 +19,20 @@ class FolderListWidget extends ListWidget {
|
||||
this.updateIndexFromSelectedFolderId_ = false;
|
||||
this.updateItems_ = false;
|
||||
this.trimItemTitle = false;
|
||||
this.showIds = false;
|
||||
|
||||
this.itemRenderer = item => {
|
||||
const output = [];
|
||||
if (item === '-') {
|
||||
output.push('-'.repeat(this.innerWidth));
|
||||
} else if (item.type_ === Folder.modelType()) {
|
||||
output.push(' '.repeat(this.folderDepth(this.folders, item.id)) + Folder.displayTitle(item));
|
||||
output.push(' '.repeat(this.folderDepth(this.folders, item.id)));
|
||||
|
||||
if (this.showIds) {
|
||||
output.push(Folder.shortId(item.id));
|
||||
}
|
||||
output.push(Folder.displayTitle(item));
|
||||
|
||||
if (Setting.value('showNoteCounts')) {
|
||||
let noteCount = item.note_count;
|
||||
// Subtract children note_count from parent folder.
|
||||
@@ -132,6 +139,11 @@ class FolderListWidget extends ListWidget {
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
toggleShowIds() {
|
||||
this.showIds = !this.showIds;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
folderHasChildren_(folders, folderId) {
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const folder = folders[i];
|
||||
|
@@ -5,11 +5,15 @@ class NoteListWidget extends ListWidget {
|
||||
constructor() {
|
||||
super();
|
||||
this.selectedNoteId_ = 0;
|
||||
this.showIds = false;
|
||||
|
||||
this.updateIndexFromSelectedNoteId_ = false;
|
||||
|
||||
this.itemRenderer = note => {
|
||||
let label = Note.displayTitle(note); // + ' ' + note.id;
|
||||
let label = Note.displayTitle(note);
|
||||
if (this.showIds) {
|
||||
label = `${Note.shortId(note.id)} ${Note.displayTitle(note)}`;
|
||||
}
|
||||
if (note.is_todo) {
|
||||
label = `[${note.todo_completed ? 'X' : ' '}] ${label}`;
|
||||
}
|
||||
@@ -22,6 +26,11 @@ class NoteListWidget extends ListWidget {
|
||||
this.selectedNoteId_ = v;
|
||||
}
|
||||
|
||||
toggleShowIds() {
|
||||
this.showIds = !this.showIds;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.updateIndexFromSelectedNoteId_) {
|
||||
const index = this.itemIndexByKey('id', this.selectedNoteId_);
|
||||
|
@@ -50,6 +50,7 @@ export default class PluginRunner extends BasePluginRunner {
|
||||
const callId = `${pluginId}::${path}::${uuid.createNano()}`;
|
||||
this.activeSandboxCalls_[callId] = true;
|
||||
const promise = executeSandboxCall(pluginId, sandbox, `joplin.${path}`, mapEventHandlersToIds(args, this.eventHandlers_), this.eventHandler);
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
promise.finally(() => {
|
||||
delete this.activeSandboxCalls_[callId];
|
||||
});
|
||||
|
@@ -33,7 +33,7 @@
|
||||
],
|
||||
"owner": "Laurent Cozic"
|
||||
},
|
||||
"version": "2.9.0",
|
||||
"version": "2.9.1",
|
||||
"bin": "./main.js",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
269
packages/app-cli/tests/services/plugins/defaultPluginsUtils.ts
Normal file
269
packages/app-cli/tests/services/plugins/defaultPluginsUtils.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { installDefaultPlugins, getDefaultPluginsInstallState, setSettingsForDefaultPlugins, checkPreInstalledDefaultPlugins } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils';
|
||||
import PluginRunner from '../../../app/services/plugins/PluginRunner';
|
||||
import { pathExists } from 'fs-extra';
|
||||
import { checkThrow, setupDatabaseAndSynchronizer, supportDir, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import PluginService, { defaultPluginSetting, DefaultPluginsInfo, PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
const testPluginDir = `${supportDir}/plugins`;
|
||||
|
||||
function newPluginService(appVersion: string = '1.4') {
|
||||
const runner = new PluginRunner();
|
||||
const service = new PluginService();
|
||||
service.initialize(
|
||||
appVersion,
|
||||
{
|
||||
joplin: {},
|
||||
},
|
||||
runner,
|
||||
{
|
||||
dispatch: () => {},
|
||||
getState: () => {},
|
||||
}
|
||||
);
|
||||
return service;
|
||||
}
|
||||
|
||||
describe('defaultPluginsUtils', function() {
|
||||
|
||||
const pluginsId = ['joplin.plugin.ambrt.backlinksToNote', 'org.joplinapp.plugins.ToggleSidebars'];
|
||||
|
||||
beforeEach(async (done) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
done();
|
||||
});
|
||||
|
||||
it('should install default plugins with no previous default plugins installed', (async () => {
|
||||
const testPluginDir = `${supportDir}/pluginRepo/plugins`;
|
||||
Setting.setValue('installedDefaultPlugins', []);
|
||||
|
||||
const service = newPluginService('2.1');
|
||||
|
||||
const pluginSettings = service.unserializePluginSettings(Setting.value('plugins.states'));
|
||||
|
||||
const newPluginsSettings = await installDefaultPlugins(service, testPluginDir, pluginsId, pluginSettings);
|
||||
|
||||
const installedPluginPath1 = `${Setting.value('pluginDir')}/${pluginsId[0]}.jpl`;
|
||||
const installedPluginPath2 = `${Setting.value('pluginDir')}/${pluginsId[1]}.jpl`;
|
||||
|
||||
expect(await pathExists(installedPluginPath1)).toBe(true);
|
||||
expect(await pathExists(installedPluginPath2)).toBe(true);
|
||||
|
||||
expect(newPluginsSettings[pluginsId[0]]).toMatchObject(defaultPluginSetting());
|
||||
expect(newPluginsSettings[pluginsId[1]]).toMatchObject(defaultPluginSetting());
|
||||
|
||||
}));
|
||||
|
||||
it('should install default plugins with previous default plugins installed', (async () => {
|
||||
|
||||
const testPluginDir = `${supportDir}/pluginRepo/plugins`;
|
||||
Setting.setValue('installedDefaultPlugins', ['org.joplinapp.plugins.ToggleSidebars']);
|
||||
|
||||
const service = newPluginService('2.1');
|
||||
|
||||
const pluginSettings = service.unserializePluginSettings(Setting.value('plugins.states'));
|
||||
|
||||
const newPluginsSettings = await installDefaultPlugins(service, testPluginDir, pluginsId, pluginSettings);
|
||||
|
||||
const installedPluginPath1 = `${Setting.value('pluginDir')}/${pluginsId[0]}.jpl`;
|
||||
const installedPluginPath2 = `${Setting.value('pluginDir')}/${pluginsId[1]}.jpl`;
|
||||
|
||||
expect(await pathExists(installedPluginPath1)).toBe(true);
|
||||
expect(await pathExists(installedPluginPath2)).toBe(false);
|
||||
|
||||
expect(newPluginsSettings[pluginsId[0]]).toMatchObject(defaultPluginSetting());
|
||||
expect(newPluginsSettings[pluginsId[1]]).toBeUndefined();
|
||||
}));
|
||||
|
||||
it('should get default plugins install state', (async () => {
|
||||
const testCases = [
|
||||
{
|
||||
'installedDefaultPlugins': [''],
|
||||
'loadingPlugins': [`${testPluginDir}/simple`, `${testPluginDir}/jpl_test/org.joplinapp.FirstJplPlugin.jpl`],
|
||||
'plugin1DefaultState': defaultPluginSetting(),
|
||||
'plugin2DefaultState': defaultPluginSetting(),
|
||||
'installedDefaultPlugins1': true,
|
||||
'installedDefaultPlugins2': true,
|
||||
},
|
||||
{
|
||||
'installedDefaultPlugins': [''],
|
||||
'loadingPlugins': [`${testPluginDir}/simple`],
|
||||
'plugin1DefaultState': defaultPluginSetting(),
|
||||
'plugin2DefaultState': undefined,
|
||||
'installedDefaultPlugins1': true,
|
||||
'installedDefaultPlugins2': false,
|
||||
},
|
||||
{
|
||||
'installedDefaultPlugins': ['org.joplinapp.plugins.Simple'],
|
||||
'loadingPlugins': [`${testPluginDir}/simple`, `${testPluginDir}/jpl_test/org.joplinapp.FirstJplPlugin.jpl`],
|
||||
'plugin1DefaultState': undefined,
|
||||
'plugin2DefaultState': defaultPluginSetting(),
|
||||
'installedDefaultPlugins1': true,
|
||||
'installedDefaultPlugins2': true,
|
||||
},
|
||||
{
|
||||
'installedDefaultPlugins': ['org.joplinapp.plugins.Simple'],
|
||||
'loadingPlugins': [`${testPluginDir}/simple`],
|
||||
'plugin1DefaultState': undefined,
|
||||
'plugin2DefaultState': undefined,
|
||||
'installedDefaultPlugins1': true,
|
||||
'installedDefaultPlugins2': false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const service = newPluginService();
|
||||
const pluginsId = ['org.joplinapp.plugins.Simple', 'org.joplinapp.FirstJplPlugin'];
|
||||
|
||||
Setting.setValue('installedDefaultPlugins', testCase.installedDefaultPlugins);
|
||||
await service.loadAndRunPlugins(testCase.loadingPlugins, {});
|
||||
|
||||
// setting installedDefaultPlugins state
|
||||
const defaultInstallStates: PluginSettings = getDefaultPluginsInstallState(service, pluginsId);
|
||||
|
||||
expect(defaultInstallStates[pluginsId[0]]).toStrictEqual(testCase.plugin1DefaultState);
|
||||
expect(defaultInstallStates[pluginsId[1]]).toStrictEqual(testCase.plugin2DefaultState);
|
||||
|
||||
|
||||
const installedDefaultPlugins = Setting.value('installedDefaultPlugins');
|
||||
expect(installedDefaultPlugins.includes(pluginsId[0])).toBe(testCase.installedDefaultPlugins1);
|
||||
expect(installedDefaultPlugins.includes(pluginsId[1])).toBe(testCase.installedDefaultPlugins2);
|
||||
|
||||
}
|
||||
|
||||
}));
|
||||
|
||||
it('should check pre-installed default plugins', (async () => {
|
||||
// with previous pre-installed default plugins
|
||||
Setting.setValue('installedDefaultPlugins', ['']);
|
||||
let pluginSettings, installedDefaultPlugins;
|
||||
|
||||
pluginSettings = { [pluginsId[0]]: defaultPluginSetting() };
|
||||
checkPreInstalledDefaultPlugins(pluginsId, pluginSettings);
|
||||
|
||||
installedDefaultPlugins = Setting.value('installedDefaultPlugins');
|
||||
expect(installedDefaultPlugins.includes(pluginsId[0])).toBe(true);
|
||||
expect(installedDefaultPlugins.includes(pluginsId[1])).toBe(false);
|
||||
|
||||
|
||||
// with no previous pre-installed default plugins
|
||||
Setting.setValue('installedDefaultPlugins', ['not-a-default-plugin']);
|
||||
pluginSettings = {};
|
||||
checkPreInstalledDefaultPlugins(pluginsId, pluginSettings);
|
||||
|
||||
installedDefaultPlugins = Setting.value('installedDefaultPlugins');
|
||||
expect(installedDefaultPlugins.includes(pluginsId[0])).toBe(false);
|
||||
expect(installedDefaultPlugins.includes(pluginsId[1])).toBe(false);
|
||||
|
||||
}));
|
||||
|
||||
it('should set initial settings for default plugins', async () => {
|
||||
const service = newPluginService();
|
||||
|
||||
const pluginScript = `
|
||||
/* joplin-manifest:
|
||||
{
|
||||
"id": "io.github.jackgruber.backup",
|
||||
"manifest_version": 1,
|
||||
"app_min_version": "1.4",
|
||||
"name": "JS Bundle test",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
*/
|
||||
joplin.plugins.register({
|
||||
onStart: async function() {
|
||||
await joplin.settings.registerSettings({
|
||||
path: {
|
||||
value: "initial-path",
|
||||
type: 2,
|
||||
section: "backupSection",
|
||||
public: true,
|
||||
label: "Backup path",
|
||||
},
|
||||
})
|
||||
},
|
||||
});`;
|
||||
|
||||
const plugin = await service.loadPluginFromJsBundle('', pluginScript);
|
||||
await service.runPlugin(plugin);
|
||||
|
||||
const defaultPluginsInfo: DefaultPluginsInfo = {
|
||||
'io.github.jackgruber.backup': {
|
||||
version: '1.0.2',
|
||||
settings: {
|
||||
'path': `${Setting.value('profileDir')}`,
|
||||
},
|
||||
},
|
||||
'plugin.calebjohn.rich-markdown': {
|
||||
version: '0.8.3',
|
||||
},
|
||||
};
|
||||
|
||||
// with pre-installed default plugin
|
||||
Setting.setValue('installedDefaultPlugins', ['io.github.jackgruber.backup']);
|
||||
setSettingsForDefaultPlugins(defaultPluginsInfo);
|
||||
expect(Setting.value('plugin-io.github.jackgruber.backup.path')).toBe('initial-path');
|
||||
await service.destroy();
|
||||
|
||||
// with no pre-installed default plugin
|
||||
Setting.setValue('installedDefaultPlugins', ['']);
|
||||
setSettingsForDefaultPlugins(defaultPluginsInfo);
|
||||
expect(Setting.value('plugin-io.github.jackgruber.backup.path')).toBe(`${Setting.value('profileDir')}`);
|
||||
await service.destroy();
|
||||
});
|
||||
|
||||
it('should not throw error on missing setting key', async () => {
|
||||
|
||||
const service = newPluginService();
|
||||
|
||||
const pluginScript = `
|
||||
/* joplin-manifest:
|
||||
{
|
||||
"id": "io.github.jackgruber.backup",
|
||||
"manifest_version": 1,
|
||||
"app_min_version": "1.4",
|
||||
"name": "JS Bundle test",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
*/
|
||||
joplin.plugins.register({
|
||||
onStart: async function() {
|
||||
await joplin.settings.registerSettings({
|
||||
path: {
|
||||
value: "initial-path",
|
||||
type: 2,
|
||||
section: "backupSection",
|
||||
public: true,
|
||||
label: "Backup path",
|
||||
},
|
||||
})
|
||||
},
|
||||
});`;
|
||||
|
||||
const plugin = await service.loadPluginFromJsBundle('', pluginScript);
|
||||
await service.runPlugin(plugin);
|
||||
|
||||
const defaultPluginsInfo: DefaultPluginsInfo = {
|
||||
'io.github.jackgruber.backup': {
|
||||
version: '1.0.2',
|
||||
settings: {
|
||||
'path': `${Setting.value('profileDir')}`,
|
||||
'missing-key1': 'someValue',
|
||||
},
|
||||
},
|
||||
'plugin.calebjohn.rich-markdown': {
|
||||
version: '0.8.3',
|
||||
settings: {
|
||||
'missing-key2': 'someValue',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Setting.setValue('installedDefaultPlugins', ['']);
|
||||
expect(checkThrow(() => setSettingsForDefaultPlugins(defaultPluginsInfo))).toBe(false);
|
||||
expect(Setting.value('plugin-io.github.jackgruber.backup.path')).toBe(`${Setting.value('profileDir')}`);
|
||||
await service.destroy();
|
||||
});
|
||||
|
||||
});
|
BIN
packages/app-cli/tests/services/plugins/mockData/mockPlugin.tgz
Normal file
BIN
packages/app-cli/tests/services/plugins/mockData/mockPlugin.tgz
Normal file
Binary file not shown.
@@ -43,6 +43,7 @@ import sidebarCommands from './gui/Sidebar/commands/index';
|
||||
import appCommands from './commands/index';
|
||||
import libCommands from '@joplin/lib/commands/index';
|
||||
import { homedir } from 'os';
|
||||
import getDefaultPluginsInfo from '@joplin/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo';
|
||||
const electronContextMenu = require('./services/electron-context-menu');
|
||||
// import populateDatabase from '@joplin/lib/services/debug/populateDatabase';
|
||||
|
||||
@@ -63,6 +64,8 @@ import checkForUpdates from './checkForUpdates';
|
||||
import { AppState } from './app.reducer';
|
||||
import syncDebugLog from '@joplin/lib/services/synchronizer/syncDebugLog';
|
||||
import eventManager from '@joplin/lib/eventManager';
|
||||
import path = require('path');
|
||||
import { checkPreInstalledDefaultPlugins, installDefaultPlugins, setSettingsForDefaultPlugins } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils';
|
||||
// import { runIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils';
|
||||
|
||||
const pluginClasses = [
|
||||
@@ -260,9 +263,9 @@ class Application extends BaseApplication {
|
||||
const pluginRunner = new PluginRunner();
|
||||
service.initialize(packageInfo.version, PlatformImplementation.instance(), pluginRunner, this.store());
|
||||
service.isSafeMode = Setting.value('isSafeMode');
|
||||
const defaultPluginsId = Object.keys(getDefaultPluginsInfo());
|
||||
|
||||
const pluginSettings = service.unserializePluginSettings(Setting.value('plugins.states'));
|
||||
|
||||
let pluginSettings = service.unserializePluginSettings(Setting.value('plugins.states'));
|
||||
{
|
||||
// Users can add and remove plugins from the config screen at any
|
||||
// time, however we only effectively uninstall the plugin the next
|
||||
@@ -272,7 +275,11 @@ class Application extends BaseApplication {
|
||||
Setting.setValue('plugins.states', newSettings);
|
||||
}
|
||||
|
||||
checkPreInstalledDefaultPlugins(defaultPluginsId, pluginSettings);
|
||||
|
||||
try {
|
||||
const defaultPluginsDir = path.join(bridge().buildDir(), 'defaultPlugins');
|
||||
pluginSettings = await installDefaultPlugins(service, defaultPluginsDir, defaultPluginsId, pluginSettings);
|
||||
if (await shim.fsDriver().exists(Setting.value('pluginDir'))) {
|
||||
await service.loadAndRunPlugins(Setting.value('pluginDir'), pluginSettings);
|
||||
}
|
||||
@@ -320,6 +327,7 @@ class Application extends BaseApplication {
|
||||
type: 'STARTUP_PLUGINS_LOADED',
|
||||
value: true,
|
||||
});
|
||||
setSettingsForDefaultPlugins(getDefaultPluginsInfo());
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
@@ -494,6 +502,7 @@ class Application extends BaseApplication {
|
||||
if (Setting.value('env') === 'dev') {
|
||||
void AlarmService.updateAllNotifications();
|
||||
} else {
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
void reg.scheduleSync(1000).then(() => {
|
||||
// Wait for the first sync before updating the notifications, since synchronisation
|
||||
// might change the notifications.
|
||||
|
@@ -246,7 +246,7 @@ export class Bridge {
|
||||
}
|
||||
|
||||
async openItem(fullPath: string) {
|
||||
return require('electron').shell.openPath(fullPath);
|
||||
return require('electron').shell.openPath(toSystemSlashes(fullPath));
|
||||
}
|
||||
|
||||
screen() {
|
||||
|
@@ -45,6 +45,7 @@ class ClipperConfigScreenComponent extends React.Component {
|
||||
if (confirm(_('Are you sure you want to renew the authorisation token?'))) {
|
||||
void EncryptionService.instance()
|
||||
.generateApiToken()
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
.then((token) => {
|
||||
Setting.setValue('api.token', token);
|
||||
});
|
||||
|
@@ -15,6 +15,9 @@ import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
|
||||
const shared = require('@joplin/lib/components/shared/config-shared.js');
|
||||
import ClipperConfigScreen from '../ClipperConfigScreen';
|
||||
import restart from '../../services/restart';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import { getDefaultPluginsInstallState, updateDefaultPluginsInstallState } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils';
|
||||
import getDefaultPluginsInfo from '@joplin/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo';
|
||||
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
|
||||
|
||||
const settingKeyToControl: any = {
|
||||
@@ -66,6 +69,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
this.switchSection(this.props.defaultSection);
|
||||
});
|
||||
}
|
||||
updateDefaultPluginsInstallState(getDefaultPluginsInstallState(PluginService.instance(), Object.keys(getDefaultPluginsInfo())), this);
|
||||
}
|
||||
|
||||
private async handleSettingButton(key: string) {
|
||||
@@ -484,13 +488,19 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
} else {
|
||||
const paths = await bridge().showOpenDialog();
|
||||
if (!paths || !paths.length) return;
|
||||
const cmd = splitCmd(this.state.settings[key]);
|
||||
cmd[0] = paths[0];
|
||||
updateSettingValue(key, joinCmd(cmd));
|
||||
|
||||
if (md.subType === 'file_path') {
|
||||
updateSettingValue(key, paths[0]);
|
||||
} else {
|
||||
const cmd = splitCmd(this.state.settings[key]);
|
||||
cmd[0] = paths[0];
|
||||
updateSettingValue(key, joinCmd(cmd));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cmd = splitCmd(this.state.settings[key]);
|
||||
const path = md.subType === 'file_path_and_args' ? cmd[0] : this.state.settings[key];
|
||||
|
||||
const argComp = md.subType !== 'file_path_and_args' ? null : (
|
||||
<div style={{ ...rowStyle, marginBottom: 5 }}>
|
||||
@@ -526,7 +536,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
onChange={(event: any) => {
|
||||
onPathChange(event);
|
||||
}}
|
||||
value={cmd[0]}
|
||||
value={path}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
|
@@ -101,6 +101,7 @@ export default function(props: Props) {
|
||||
|
||||
const pluginSettings = useMemo(() => {
|
||||
return pluginService.unserializePluginSettings(props.value);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.value]);
|
||||
|
||||
const pluginItems = usePluginItems(pluginService.plugins, pluginSettings);
|
||||
@@ -167,6 +168,7 @@ export default function(props: Props) {
|
||||
});
|
||||
|
||||
props.onChange({ value: pluginService.serializePluginSettings(newSettings) });
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [pluginSettings, props.onChange]);
|
||||
|
||||
const onToggle = useCallback((event: ItemEvent) => {
|
||||
@@ -178,6 +180,7 @@ export default function(props: Props) {
|
||||
});
|
||||
|
||||
props.onChange({ value: pluginService.serializePluginSettings(newSettings) });
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [pluginSettings, props.onChange]);
|
||||
|
||||
const onInstall = useCallback(async () => {
|
||||
@@ -195,6 +198,7 @@ export default function(props: Props) {
|
||||
});
|
||||
|
||||
props.onChange({ value: pluginService.serializePluginSettings(newSettings) });
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [pluginSettings, props.onChange]);
|
||||
|
||||
const onBrowsePlugins = useCallback(() => {
|
||||
@@ -203,6 +207,7 @@ export default function(props: Props) {
|
||||
|
||||
const onPluginSettingsChange = useCallback((event: OnPluginSettingChangeEvent) => {
|
||||
props.onChange({ value: pluginService.serializePluginSettings(event.value) });
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
const onUpdate = useOnInstallHandler(setUpdatingPluginIds, pluginSettings, repoApi, onPluginSettingsChange, true);
|
||||
@@ -229,6 +234,7 @@ export default function(props: Props) {
|
||||
|
||||
const onSearchPluginSettingsChange = useCallback((event: any) => {
|
||||
props.onChange({ value: pluginService.serializePluginSettings(event.value) });
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.onChange]);
|
||||
|
||||
function renderCells(items: PluginItem[]) {
|
||||
|
@@ -60,6 +60,7 @@ export default function(props: Props) {
|
||||
setSearchResultCount(r.length);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.searchQuery]);
|
||||
|
||||
const onChange = useCallback((event: OnChangeEvent) => {
|
||||
@@ -70,6 +71,7 @@ export default function(props: Props) {
|
||||
const onSearchButtonClick = useCallback(() => {
|
||||
setSearchStarted(false);
|
||||
props.onSearchQueryChange({ value: '' });
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
function installState(pluginId: string): InstallState {
|
||||
|
@@ -57,5 +57,6 @@ export default function(setInstallingPluginIds: Function, pluginSettings: Plugin
|
||||
});
|
||||
|
||||
if (installError) alert(_('Could not install plugin: %s', installError.message));
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [pluginSettings, onPluginSettingsChange]);
|
||||
}
|
||||
|
@@ -16,8 +16,10 @@ export default (props: Props) => {
|
||||
globalKeydownHandlersRef.current.push(elementId);
|
||||
return () => {
|
||||
const idx = globalKeydownHandlersRef.current.findIndex(e => e === elementId);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
globalKeydownHandlersRef.current.splice(idx, 1);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
const isTopDialog = () => {
|
||||
@@ -49,6 +51,7 @@ export default (props: Props) => {
|
||||
} else if (event.keyCode === 27) {
|
||||
props.onCancelButtonClick();
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.onOkButtonClick, props.onCancelButtonClick]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@@ -81,6 +81,7 @@ export default function(props: Props) {
|
||||
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [onClose, folderTitle, folderIcon, props.folderId, props.parentId]);
|
||||
|
||||
const onFolderTitleChange = useCallback((event: any) => {
|
||||
|
@@ -2,15 +2,19 @@ import { FolderIcon, FolderIconType } from '@joplin/lib/services/database/types'
|
||||
|
||||
interface Props {
|
||||
folderIcon: FolderIcon;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export default function(props: Props) {
|
||||
const folderIcon = props.folderIcon;
|
||||
const opacity = 'opacity' in props ? props.opacity : 1;
|
||||
|
||||
if (folderIcon.type === FolderIconType.Emoji) {
|
||||
return <span style={{ fontSize: 20 }}>{folderIcon.emoji}</span>;
|
||||
return <span style={{ fontSize: 20, opacity }}>{folderIcon.emoji}</span>;
|
||||
} else if (folderIcon.type === FolderIconType.DataUrl) {
|
||||
return <img style={{ width: 20, height: 20 }} src={folderIcon.dataUrl} />;
|
||||
return <img style={{ width: 20, height: 20, opacity }} src={folderIcon.dataUrl} />;
|
||||
} else if (folderIcon.type === FolderIconType.FontAwesome) {
|
||||
return <i style={{ fontSize: 18, width: 20, opacity }} className={folderIcon.name}></i>;
|
||||
} else {
|
||||
throw new Error(`Unsupported folder icon type: ${folderIcon.type}`);
|
||||
}
|
||||
|
@@ -39,6 +39,7 @@ export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAc
|
||||
onError({ recorderError });
|
||||
setSaveAllowed(false);
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [accelerator]);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
|
@@ -15,6 +15,7 @@ import * as openFolder from './openFolder';
|
||||
import * as openFolderDialog from './openFolderDialog';
|
||||
import * as openItem from './openItem';
|
||||
import * as openNote from './openNote';
|
||||
import * as openPdfViewer from './openPdfViewer';
|
||||
import * as openTag from './openTag';
|
||||
import * as print from './print';
|
||||
import * as renameFolder from './renameFolder';
|
||||
@@ -55,6 +56,7 @@ const index:any[] = [
|
||||
openFolderDialog,
|
||||
openItem,
|
||||
openNote,
|
||||
openPdfViewer,
|
||||
openTag,
|
||||
print,
|
||||
renameFolder,
|
||||
|
@@ -0,0 +1,28 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'openPdfViewer',
|
||||
label: () => _('Open PDF viewer'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, resourceId: string, pageNo: number) => {
|
||||
|
||||
const resource = await Resource.load(resourceId);
|
||||
if (!resource) throw new Error(`No such resource: ${resourceId}`);
|
||||
if (resource.mime !== 'application/pdf') throw new Error(`Not a PDF: ${resource.mime}`);
|
||||
console.log('Opening PDF', resource);
|
||||
context.dispatch({
|
||||
type: 'DIALOG_OPEN',
|
||||
name: 'pdfViewer',
|
||||
props: {
|
||||
resource,
|
||||
pageNo: pageNo,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
@@ -14,21 +14,24 @@ export const declaration: CommandDeclaration = {
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, selectedLanguage: string = null, useSpellChecker: boolean = null) => {
|
||||
selectedLanguage = selectedLanguage === null ? context.state.settings['spellChecker.language'] : selectedLanguage;
|
||||
execute: async (context: CommandContext, selectedLanguages: string[] = null, useSpellChecker: boolean = null) => {
|
||||
selectedLanguages = selectedLanguages === null ? context.state.settings['spellChecker.languages'] : selectedLanguages;
|
||||
useSpellChecker = useSpellChecker === null ? context.state.settings['spellChecker.enabled'] : useSpellChecker;
|
||||
|
||||
const menuItems = SpellCheckerService.instance().spellCheckerConfigMenuItems(selectedLanguage, useSpellChecker);
|
||||
const menuItems = SpellCheckerService.instance().spellCheckerConfigMenuItems(selectedLanguages, useSpellChecker);
|
||||
const menu = Menu.buildFromTemplate(menuItems as any);
|
||||
menu.popup(bridge().window());
|
||||
},
|
||||
|
||||
mapStateToTitle(state: AppState): string {
|
||||
if (!state.settings['spellChecker.enabled']) return null;
|
||||
const language = state.settings['spellChecker.language'];
|
||||
if (!language) return null;
|
||||
const s = language.split('-');
|
||||
return s[0];
|
||||
const languages = state.settings['spellChecker.languages'];
|
||||
if (languages.length === 0) return null;
|
||||
const s: string[] = [];
|
||||
languages.forEach((language: string) => {
|
||||
s.push(language.split('-')[0]);
|
||||
});
|
||||
return s.join(', ');
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@@ -38,6 +38,7 @@ export default function(props: Props) {
|
||||
if ([MasterPasswordStatus.NotSet, MasterPasswordStatus.Invalid].includes(status)) return false;
|
||||
if (mode === Mode.Reset) return false;
|
||||
return true;
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [status]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
@@ -84,6 +85,7 @@ export default function(props: Props) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [currentPassword, password1, onClose, mode]);
|
||||
|
||||
const needToRepeatPassword = useMemo(() => {
|
||||
|
@@ -122,7 +122,7 @@ interface Props {
|
||||
pluginMenuItems: any[];
|
||||
pluginMenus: any[];
|
||||
['spellChecker.enabled']: boolean;
|
||||
['spellChecker.language']: string;
|
||||
['spellChecker.languages']: string[];
|
||||
plugins: PluginStates;
|
||||
customCss: string;
|
||||
locale: string;
|
||||
@@ -192,12 +192,17 @@ function useMenuStates(menu: any, props: Props) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [
|
||||
props.menuItemProps,
|
||||
props.layoutButtonSequence,
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
props['notes.sortOrder.field'],
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
props['folders.sortOrder.field'],
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
props['notes.sortOrder.reverse'],
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
props['folders.sortOrder.reverse'],
|
||||
props.showNoteCounts,
|
||||
props.uncompletedTodosOnTop,
|
||||
@@ -276,6 +281,7 @@ function useMenu(props: Props) {
|
||||
}
|
||||
|
||||
void CommandService.instance().execute('hideModalMessage');
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.selectedFolderId]);
|
||||
|
||||
const onMenuItemClickRef = useRef(null);
|
||||
@@ -292,6 +298,7 @@ function useMenu(props: Props) {
|
||||
(commandName: string) => onMenuItemClickRef.current(commandName),
|
||||
props.locale
|
||||
);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [commandNames, pluginCommandNames, props.locale]);
|
||||
|
||||
const switchProfileMenuItems: any[] = useSwitchProfileMenuItems(props.profileConfig, menuItemDic);
|
||||
@@ -471,7 +478,7 @@ function useMenu(props: Props) {
|
||||
}
|
||||
toolsItems = toolsItems.concat(toolsItemsAll);
|
||||
|
||||
toolsItems.push(SpellCheckerService.instance().spellCheckerConfigMenuItem(props['spellChecker.language'], props['spellChecker.enabled']));
|
||||
toolsItems.push(SpellCheckerService.instance().spellCheckerConfigMenuItem(props['spellChecker.languages'], props['spellChecker.enabled']));
|
||||
|
||||
function _checkForUpdates() {
|
||||
void checkForUpdates(false, bridge().window(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') });
|
||||
@@ -905,13 +912,16 @@ function useMenu(props: Props) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [
|
||||
props.routeName,
|
||||
props.pluginMenuItems,
|
||||
props.pluginMenus,
|
||||
keymapLastChangeTime,
|
||||
modulesLastChangeTime,
|
||||
props['spellChecker.language'],
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
props['spellChecker.languages'],
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
props['spellChecker.enabled'],
|
||||
props.customCss,
|
||||
props.locale,
|
||||
@@ -973,7 +983,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
showCompletedTodos: state.settings.showCompletedTodos,
|
||||
pluginMenuItems: stateUtils.selectArrayShallow({ array: pluginUtils.viewsByType(state.pluginService.plugins, 'menuItem') }, 'menuBar.pluginMenuItems'),
|
||||
pluginMenus: stateUtils.selectArrayShallow({ array: pluginUtils.viewsByType(state.pluginService.plugins, 'menu') }, 'menuBar.pluginMenus'),
|
||||
['spellChecker.language']: state.settings['spellChecker.language'],
|
||||
['spellChecker.languages']: state.settings['spellChecker.languages'],
|
||||
['spellChecker.enabled']: state.settings['spellChecker.enabled'],
|
||||
plugins: state.pluginService.plugins,
|
||||
customCss: state.customCss,
|
||||
|
@@ -70,6 +70,7 @@ export default function NoteContentPropertiesDialog(props: NoteContentProperties
|
||||
useEffect(() => {
|
||||
const strippedText: string = markupToHtml().stripMarkup(props.markupLanguage, props.text);
|
||||
countElements(strippedText, setStrippedWords, setStrippedCharacters, setStrippedCharactersNoSpace, setStrippedLines);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.text]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@@ -259,6 +259,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
return commandOutput;
|
||||
},
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.content, props.visiblePanes, addListItem, wrapSelectionWithStrings, setEditorPercentScroll, setViewerPercentScroll, resetScroll]);
|
||||
|
||||
const onEditorPaste = useCallback(async (event: any = null) => {
|
||||
@@ -565,6 +566,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
return () => {
|
||||
document.head.removeChild(element);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.themeId, props.contentMaxWidth]);
|
||||
|
||||
const webview_domReady = useCallback(() => {
|
||||
@@ -592,6 +594,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
} 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(() => {
|
||||
@@ -614,6 +617,10 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
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: true,
|
||||
noteId: props.noteId,
|
||||
vendorDir: bridge().vendorDir(),
|
||||
}));
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -633,6 +640,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
cancelled = true;
|
||||
shim.clearTimeout(timeoutId);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos, props.markupToHtml]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -658,6 +666,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
} else {
|
||||
console.error('Trying to set HTML on an undefined webview ref');
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [renderedBody, webviewReady]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -681,6 +690,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
props.setLocalSearchResultCount(matches);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.searchMarkers, previousSearchMarkers, props.setLocalSearchResultCount, props.content, previousContent, renderedBody, previousRenderedBody, renderedBody]);
|
||||
|
||||
const cellEditorStyle = useMemo(() => {
|
||||
@@ -833,10 +843,13 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
return () => {
|
||||
bridge().window().webContents.off('context-menu', onContextMenu);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.plugins]);
|
||||
|
||||
function renderEditor() {
|
||||
|
||||
const matchBracesOptions = Setting.value('editor.autoMatchingBraces') ? { override: true, pairs: '<>()[]{}\'\'""‘’“”()《》「」『』【】〔〕〖〗〘〙〚〛' } : false;
|
||||
|
||||
return (
|
||||
<div style={cellEditorStyle}>
|
||||
<Editor
|
||||
@@ -847,7 +860,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
codeMirrorTheme={styles.editor.codeMirrorTheme}
|
||||
style={styles.editor}
|
||||
readOnly={props.visiblePanes.indexOf('editor') < 0}
|
||||
autoMatchBraces={Setting.value('editor.autoMatchingBraces')}
|
||||
autoMatchBraces={matchBracesOptions}
|
||||
keyMap={props.keyboardMode}
|
||||
plugins={props.plugins}
|
||||
onChange={codeMirror_change}
|
||||
|
@@ -86,7 +86,7 @@ export interface EditorProps {
|
||||
style: any;
|
||||
codeMirrorTheme: any;
|
||||
readOnly: boolean;
|
||||
autoMatchBraces: boolean;
|
||||
autoMatchBraces: boolean | object;
|
||||
keyMap: string;
|
||||
plugins: PluginStates;
|
||||
onChange: any;
|
||||
@@ -219,9 +219,11 @@ function Editor(props: EditorProps, ref: any) {
|
||||
cm.off('dragover', editor_drag);
|
||||
cm.off('refresh', editor_resize);
|
||||
cm.off('update', editor_update);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
editorParent.current.removeChild(cm.getWrapperElement());
|
||||
setEditor(null);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -234,36 +236,42 @@ function Editor(props: EditorProps, ref: any) {
|
||||
}
|
||||
editor.setOption('screenReaderLabel', props.value);
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setOption('theme', props.codeMirrorTheme);
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.codeMirrorTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setOption('mode', props.mode);
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setOption('readOnly', props.readOnly);
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.readOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setOption('autoCloseBrackets', props.autoMatchBraces);
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.autoMatchBraces]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setOption('keyMap', props.keyMap ? props.keyMap : 'default');
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.keyMap]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@@ -7,6 +7,8 @@ import uuid from '@joplin/lib/uuid';
|
||||
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
|
||||
const loadedPluginIdSet = new Set<string>();
|
||||
|
||||
export default function useExternalPlugins(CodeMirror: any, plugins: PluginStates) {
|
||||
|
||||
const [options, setOptions] = useState({});
|
||||
@@ -17,6 +19,10 @@ export default function useExternalPlugins(CodeMirror: any, plugins: PluginState
|
||||
|
||||
for (const contentScript of contentScripts) {
|
||||
try {
|
||||
if (loadedPluginIdSet.has(contentScript.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mod = contentScript.module;
|
||||
|
||||
if (mod.codeMirrorResources) {
|
||||
@@ -64,11 +70,14 @@ export default function useExternalPlugins(CodeMirror: any, plugins: PluginState
|
||||
if (mod.plugin) {
|
||||
mod.plugin(CodeMirror);
|
||||
}
|
||||
|
||||
loadedPluginIdSet.add(contentScript.id);
|
||||
} catch (error) {
|
||||
reg.logger().error(error.toString());
|
||||
}
|
||||
}
|
||||
setOptions(newOptions);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [plugins]);
|
||||
|
||||
function addInlineCss(cssStrings: string[], id: string) {
|
||||
|
@@ -184,5 +184,6 @@ export default function useKeymap(CodeMirror: any) {
|
||||
|
||||
setupEmacs();
|
||||
setupVim();
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
}
|
||||
|
@@ -94,6 +94,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
if (editorRef.current) {
|
||||
scheduleOnScroll({ percent });
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [scheduleOnScroll]);
|
||||
|
||||
const setViewerPercentScroll = useCallback((percent: number) => {
|
||||
@@ -101,6 +102,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
webviewRef.current.wrappedInstance.send('setPercentScroll', percent);
|
||||
scheduleOnScroll({ percent });
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [scheduleOnScroll]);
|
||||
|
||||
const editor_scroll = useCallback(() => {
|
||||
@@ -126,6 +128,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
lastResizeHeight_.current = NaN;
|
||||
lastLinesHeight_.current = NaN;
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [setViewerPercentScroll]);
|
||||
|
||||
const resetScroll = useCallback(() => {
|
||||
@@ -134,6 +137,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
editorRef.current.setScrollPercent(0);
|
||||
scrollTopIsUncertain_.current = false;
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
const editor_resize = useCallback((cm) => {
|
||||
@@ -152,6 +156,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
lastResizeHeight_.current = NaN;
|
||||
lastLinesHeight_.current = NaN;
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
// When heights of lines are updated in CodeMirror, 'update' events are raised.
|
||||
@@ -173,6 +178,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
lastResizeHeight_.current = NaN;
|
||||
lastLinesHeight_.current = NaN;
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
const getLineScrollPercent = useCallback(() => {
|
||||
@@ -183,6 +189,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
|
||||
} else {
|
||||
return scrollPercent_.current;
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
@@ -280,6 +280,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [editor, props.contentMarkupLanguage, props.contentOriginalCss]);
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
@@ -512,6 +513,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
// style and re-applying it on editorReady gives our styles precedence and prevents any flashing
|
||||
//
|
||||
// tl;dr: editorReady is used here because the css needs to be re-applied after TinyMCE init
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [editorReady, props.themeId]);
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
@@ -680,6 +682,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
};
|
||||
|
||||
void loadEditor();
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [scriptLoaded]);
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
@@ -829,6 +832,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [editor, props.markupToHtml, props.allAssets, props.content, props.resourceInfos, props.contentKey]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -909,6 +913,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
return () => {
|
||||
void execOnChangeEvent();
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
const onChangeHandlerTimeoutRef = useRef<any>(null);
|
||||
@@ -1003,7 +1008,9 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, pastedText, markupRenderOptions({ bodyOnly: true }));
|
||||
editor.insertContent(result.html);
|
||||
} else { // Paste regular text
|
||||
const pastedHtml = event.clipboardData.getData('text/html');
|
||||
// event.clipboardData.getData('text/html') wraps the content with <html><body></body></html>,
|
||||
// which seems to be not supported in editor.insertContent().
|
||||
const pastedHtml = clipboard.readHTML();
|
||||
if (pastedHtml) { // Handles HTML
|
||||
const modifiedHtml = await processPastedHtml(pastedHtml);
|
||||
editor.insertContent(modifiedHtml);
|
||||
@@ -1091,6 +1098,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
console.warn('Error removing events', error);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.onWillChange, props.onChange, props.contentMarkupLanguage, props.contentOriginalCss, editor]);
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
@@ -59,6 +59,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
const formNote_beforeLoad = useCallback(async (event: OnLoadEvent) => {
|
||||
await saveNoteIfWillChange(event.formNote);
|
||||
setShowRevisions(false);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
const formNote_afterLoad = useCallback(async () => {
|
||||
@@ -177,6 +178,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
id: formNote.id,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.isProvisional, formNote.id]);
|
||||
|
||||
const previousNoteId = usePrevious(formNote.id);
|
||||
@@ -194,6 +196,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
});
|
||||
|
||||
void ResourceEditWatcher.instance().stopWatchingAll();
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [formNote.id, previousNoteId]);
|
||||
|
||||
const onFieldChange = useCallback((field: string, value: any, changeId = 0) => {
|
||||
@@ -238,6 +241,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
setFormNote(newNote);
|
||||
scheduleSaveNote(newNote);
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [handleProvisionalFlag, formNote, isNewNote, titleHasBeenManuallyChanged]);
|
||||
|
||||
useWindowCommandHandler({
|
||||
@@ -288,6 +292,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
id: formNote.id,
|
||||
status: 'saving',
|
||||
});
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [formNote, handleProvisionalFlag]);
|
||||
|
||||
const onMessage = useMessageHandler(scrollWhenReady, setScrollWhenReady, editorRef, setLocalSearchResultCount, props.dispatch, formNote);
|
||||
@@ -302,6 +307,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
|
||||
setFormNote(newFormNote);
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [formNote]);
|
||||
|
||||
const onNotePropertyChange = useCallback((event) => {
|
||||
@@ -317,6 +323,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
|
||||
return newFormNote;
|
||||
});
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -350,6 +357,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
noteId: formNoteRef.current.id,
|
||||
percent: event.percent,
|
||||
});
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.dispatch, formNote]);
|
||||
|
||||
function renderNoNotes(rootStyle: any) {
|
||||
@@ -413,6 +421,9 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
fontSize: Setting.value('style.editor.fontSize'),
|
||||
contentMaxWidth: props.contentMaxWidth,
|
||||
isSafeMode: props.isSafeMode,
|
||||
// We need it to identify the context for which media is rendered.
|
||||
// It is currently used to remember pdf scroll position for each attacments of each note uniquely.
|
||||
noteId: props.noteId,
|
||||
};
|
||||
|
||||
let editor = null;
|
||||
|
@@ -75,6 +75,7 @@ export interface NoteBodyEditorProps {
|
||||
fontSize: number;
|
||||
contentMaxWidth: number;
|
||||
isSafeMode: boolean;
|
||||
noteId: string;
|
||||
}
|
||||
|
||||
export interface FormNote {
|
||||
|
@@ -51,5 +51,6 @@ export default function useDropHandler(dependencies: HookDependencies) {
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
}
|
||||
|
@@ -138,6 +138,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [prevSyncStarted, syncStarted, formNote]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -188,6 +189,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [noteId, isProvisional, formNote]);
|
||||
|
||||
const onResourceChange = useCallback(async function(event: any = null) {
|
||||
|
@@ -20,6 +20,9 @@ export interface MarkupToHtmlOptions {
|
||||
plugins?: Record<string, any>;
|
||||
bodyOnly?: boolean;
|
||||
mapsToLine?: boolean;
|
||||
useCustomPdfViewer?: boolean;
|
||||
noteId?: string;
|
||||
vendorDir?: string;
|
||||
}
|
||||
|
||||
export default function useMarkupToHtml(deps: HookDependencies) {
|
||||
@@ -30,6 +33,7 @@ export default function useMarkupToHtml(deps: HookDependencies) {
|
||||
resourceBaseUrl: `file://${Setting.value('resourceDir')}/`,
|
||||
customCss: customCss || '',
|
||||
});
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [plugins, customCss]);
|
||||
|
||||
return useCallback(async (markupLanguage: number, md: string, options: MarkupToHtmlOptions = null): Promise<any> => {
|
||||
@@ -61,5 +65,6 @@ export default function useMarkupToHtml(deps: HookDependencies) {
|
||||
}, options));
|
||||
|
||||
return result;
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [themeId, customCss, markupToHtml]);
|
||||
}
|
||||
|
@@ -52,9 +52,12 @@ export default function useMessageHandler(scrollWhenReady: any, setScrollWhenRea
|
||||
void CommandService.instance().execute(commandName, ...commandArgs);
|
||||
} else if (msg === 'postMessageService.message') {
|
||||
void PostMessageService.instance().postMessage(arg0);
|
||||
} else if (msg === 'openPdfViewer') {
|
||||
await CommandService.instance().execute('openPdfViewer', arg0.resourceId, arg0.pageNo);
|
||||
} else {
|
||||
await CommandService.instance().execute('openItem', msg);
|
||||
// bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg));
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [dispatch, setLocalSearchResultCount, scrollWhenReady, formNote]);
|
||||
}
|
||||
|
@@ -8,5 +8,6 @@ export default function usePluginServiceRegistration(ref: any) {
|
||||
return () => {
|
||||
PlatformImplementation.instance().unregisterComponent('textEditor');
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
}
|
||||
|
@@ -32,5 +32,6 @@ export default function useSearchMarkers(showLocalSearch: boolean, localSearchMa
|
||||
output.keywords = highlightedWords;
|
||||
|
||||
return output;
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [highlightedWords, showLocalSearch, localSearchMarkerOptions, searches, selectedSearchId]);
|
||||
}
|
||||
|
@@ -96,5 +96,6 @@ export default function useWindowCommandHandler(dependencies: HookDependencies)
|
||||
CommandService.instance().unregisterRuntime(command.declaration.name);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [editorRef, setShowLocalSearch, noteSearchBarRef, titleInputRef]);
|
||||
}
|
||||
|
@@ -273,6 +273,7 @@ const NoteListComponent = (props: Props) => {
|
||||
onTitleClick={noteItem_titleClick}
|
||||
onContextMenu={itemContextMenu}
|
||||
/>;
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [style, props.themeId, width, itemHeight, dragOverTargetNoteIndex, props.provisionalNoteIds, props.selectedNoteIds, props.watchedNoteFiles,
|
||||
props.notes,
|
||||
props.notesParentType,
|
||||
@@ -305,6 +306,7 @@ const NoteListComponent = (props: Props) => {
|
||||
if (previousVisible !== props.visible) {
|
||||
updateSizeState();
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [previousSelectedNoteIds,previousNotes, previousVisible, props.selectedNoteIds, props.notes]);
|
||||
|
||||
const scrollNoteIndex_ = (keyCode: any, ctrlKey: any, metaKey: any, noteIndex: any) => {
|
||||
@@ -439,6 +441,7 @@ const NoteListComponent = (props: Props) => {
|
||||
return () => {
|
||||
props.resizableLayoutEventEmitter.off('resize', resizableLayout_resize);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.resizableLayoutEventEmitter]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -453,6 +456,7 @@ const NoteListComponent = (props: Props) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
useEffect(() => {
|
||||
// When a note list item is styled by userchrome.css, its height is reflected.
|
||||
// Ref. https://github.com/laurent22/joplin/pull/6542
|
||||
|
101
packages/app-desktop/gui/PdfViewer.tsx
Normal file
101
packages/app-desktop/gui/PdfViewer.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import bridge from '../services/bridge';
|
||||
import contextMenu from './NoteEditor/utils/contextMenu';
|
||||
import { ContextMenuItemType, ContextMenuOptions } from './NoteEditor/utils/contextMenuUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import styled from 'styled-components';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
|
||||
const Window = styled.div`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
z-index: 999;
|
||||
background-color: ${(props: any) => props.theme.backgroundColor};
|
||||
color: ${(props: any) => props.theme.color};
|
||||
`;
|
||||
|
||||
const IFrame = styled.iframe`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: none;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
dispatch: Function;
|
||||
resource: any;
|
||||
pageNo: number;
|
||||
}
|
||||
|
||||
export default function PdfViewer(props: Props) {
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
props.dispatch({
|
||||
type: 'DIALOG_CLOSE',
|
||||
name: 'pdfViewer',
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const openExternalViewer = useCallback(async () => {
|
||||
await CommandService.instance().execute('openItem', `joplin://${props.resource.id}`);
|
||||
}, [props.resource.id]);
|
||||
|
||||
const textSelected = useCallback(async (text: string) => {
|
||||
if (!text) return;
|
||||
const itemType = ContextMenuItemType.Text;
|
||||
const menu = await contextMenu({
|
||||
itemType,
|
||||
resourceId: null,
|
||||
filename: null,
|
||||
mime: 'text/plain',
|
||||
textToCopy: text,
|
||||
linkToCopy: null,
|
||||
htmlToCopy: '',
|
||||
insertContent: () => { console.warn('insertContent() not implemented'); },
|
||||
} as ContextMenuOptions, props.dispatch);
|
||||
|
||||
menu.popup(bridge().window());
|
||||
}, [props.dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage_ = async (event: any) =>{
|
||||
if (!event.data || !event.data.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.name === 'close') {
|
||||
onClose();
|
||||
} else if (event.data.name === 'externalViewer') {
|
||||
await openExternalViewer();
|
||||
} else if (event.data.name === 'textSelected') {
|
||||
await textSelected(event.data.text);
|
||||
} else {
|
||||
console.error('Unknown event received', event.data.name);
|
||||
}
|
||||
};
|
||||
const iframe = iframeRef.current;
|
||||
iframe.contentWindow.addEventListener('message', onMessage_);
|
||||
return () => {
|
||||
iframe.contentWindow.removeEventListener('message', onMessage_);
|
||||
};
|
||||
}, [onClose, openExternalViewer, textSelected]);
|
||||
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
return (
|
||||
<Window theme={theme}>
|
||||
<IFrame src="./vendor/lib/@joplin/pdf-viewer/index.html" x-url={Resource.fullPath(props.resource)}
|
||||
x-appearance={theme.appearance} ref={iframeRef}
|
||||
x-title={props.resource.title}
|
||||
x-anchorpage={props.pageNo}
|
||||
x-type="full"></IFrame>
|
||||
</Window>
|
||||
);
|
||||
}
|
@@ -13,5 +13,6 @@ export default function useWindowResizeEvent(eventEmitter: any) {
|
||||
window_resize.clear();
|
||||
window.removeEventListener('resize', window_resize);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
}
|
||||
|
@@ -174,9 +174,11 @@ class ResourceScreenComponent extends React.Component<Props, State> {
|
||||
return;
|
||||
}
|
||||
Resource.delete(resource.id)
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
.catch((error: Error) => {
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
})
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
.finally(() => {
|
||||
void this.reloadResources(this.state.sorting);
|
||||
});
|
||||
|
@@ -22,6 +22,7 @@ import Dialog from './Dialog';
|
||||
import SyncWizardDialog from './SyncWizard/Dialog';
|
||||
import MasterPasswordDialog from './MasterPasswordDialog/Dialog';
|
||||
import EditFolderDialog from './EditFolderDialog/Dialog';
|
||||
import PdfViewer from './PdfViewer';
|
||||
import StyleSheetContainer from './StyleSheets/StyleSheetContainer';
|
||||
const { ImportScreen } = require('./ImportScreen.min.js');
|
||||
const { ResourceScreen } = require('./ResourceScreen.js');
|
||||
@@ -75,6 +76,11 @@ const registeredDialogs: Record<string, RegisteredDialog> = {
|
||||
return <EditFolderDialog key={props.key} dispatch={props.dispatch} themeId={props.themeId} {...customProps}/>;
|
||||
},
|
||||
},
|
||||
pdfViewer: {
|
||||
render: (props: RegisteredDialogProps, customProps: any) => {
|
||||
return <PdfViewer key={props.key} dispatch={props.dispatch} themeId={props.themeId} {...customProps}/>;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
|
@@ -66,6 +66,7 @@ function useRestartOnDone(upgradeResult: SyncTargetUpgradeResult) {
|
||||
if (upgradeResult.done && !upgradeResult.error) {
|
||||
void restart();
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [upgradeResult.done]);
|
||||
}
|
||||
|
||||
|
@@ -55,6 +55,7 @@ function SearchBar(props: Props) {
|
||||
return () => {
|
||||
debouncedSearch.clear();
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [query, searchStarted]);
|
||||
|
||||
const onExitSearch = useCallback(async (navigateAway = true) => {
|
||||
@@ -80,6 +81,7 @@ function SearchBar(props: Props) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.selectedNoteId]);
|
||||
|
||||
function onChange(event: any) {
|
||||
@@ -129,6 +131,7 @@ function SearchBar(props: Props) {
|
||||
field: 'globalSearch',
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [onExitSearch, props.isFocused, searchStarted]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -150,6 +153,7 @@ function SearchBar(props: Props) {
|
||||
}
|
||||
void onExitSearch(true);
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
@@ -140,6 +140,7 @@ function ShareFolderDialog(props: Props) {
|
||||
useEffect(() => {
|
||||
const s = props.shares.find(s => s.folder_id === props.folderId);
|
||||
setShare(s);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.shares]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { StyledRoot, StyledAddButton, StyledShareIcon, StyledHeader, StyledHeaderIcon, StyledAllNotesIcon, StyledHeaderLabel, StyledListItem, StyledListItemAnchor, StyledExpandLink, StyledNoteCount, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton } from './styles';
|
||||
import { ButtonLevel } from '../Button/Button';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
@@ -17,12 +18,14 @@ import Folder from '@joplin/lib/models/Folder';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
import { FolderEntity, FolderIcon } from '@joplin/lib/services/database/types';
|
||||
import { FolderEntity, FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
|
||||
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
||||
import { store } from '@joplin/lib/reducer';
|
||||
import PerFolderSortOrderService from '../../services/sortOrder/PerFolderSortOrderService';
|
||||
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import FolderIconBox from '../FolderIconBox';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { RuntimeProps } from './commands/focusElementSideBar';
|
||||
const { connect } = require('react-redux');
|
||||
const shared = require('@joplin/lib/components/shared/side-menu-shared.js');
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
@@ -50,11 +53,8 @@ interface Props {
|
||||
tags: any[];
|
||||
syncStarted: boolean;
|
||||
plugins: PluginStates;
|
||||
}
|
||||
|
||||
interface State {
|
||||
tagHeaderIsExpanded: boolean;
|
||||
folderHeaderIsExpanded: boolean;
|
||||
tagHeaderIsExpanded: boolean;
|
||||
}
|
||||
|
||||
const commands = [
|
||||
@@ -79,13 +79,21 @@ function ExpandLink(props: any) {
|
||||
}
|
||||
|
||||
const renderFolderIcon = (folderIcon: FolderIcon) => {
|
||||
if (!folderIcon) return null;
|
||||
if (!folderIcon) {
|
||||
const defaultFolderIcon: FolderIcon = {
|
||||
dataUrl: '',
|
||||
emoji: '',
|
||||
name: 'far fa-folder',
|
||||
type: FolderIconType.FontAwesome,
|
||||
};
|
||||
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox opacity={0.7} folderIcon={defaultFolderIcon}/></div>;
|
||||
}
|
||||
|
||||
return <div style={{ marginRight: 5, display: 'flex' }}><FolderIconBox folderIcon={folderIcon}/></div>;
|
||||
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox folderIcon={folderIcon}/></div>;
|
||||
};
|
||||
|
||||
function FolderItem(props: any) {
|
||||
const { hasChildren, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
|
||||
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
|
||||
|
||||
const noteCountComp = noteCount ? <StyledNoteCount className="note-count-label">{noteCount}</StyledNoteCount> : null;
|
||||
|
||||
@@ -110,7 +118,7 @@ function FolderItem(props: any) {
|
||||
}}
|
||||
onDoubleClick={onFolderToggleClick_}
|
||||
>
|
||||
{renderFolderIcon(folderIcon)}<span className="title" style={{ lineHeight: 0 }}>{folderTitle}</span>
|
||||
{showFolderIcon ? renderFolderIcon(folderIcon) : null}<span className="title" style={{ lineHeight: 0 }}>{folderTitle}</span>
|
||||
{shareIcon} {noteCountComp}
|
||||
</StyledListItemAnchor>
|
||||
</StyledListItem>
|
||||
@@ -119,56 +127,88 @@ function FolderItem(props: any) {
|
||||
|
||||
const menuUtils = new MenuUtils(CommandService.instance());
|
||||
|
||||
class SidebarComponent extends React.Component<Props, State> {
|
||||
const SidebarComponent = (props: Props) => {
|
||||
|
||||
private folderItemsOrder_: any[] = [];
|
||||
private tagItemsOrder_: any[] = [];
|
||||
private rootRef: any = null;
|
||||
private anchorItemRefs: any = {};
|
||||
private pluginsRef: any;
|
||||
const folderItemsOrder_ = useRef<any[]>();
|
||||
folderItemsOrder_.current = [];
|
||||
const tagItemsOrder_ = useRef<any[]>();
|
||||
tagItemsOrder_.current = [];
|
||||
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
const rootRef = useRef(null);
|
||||
const anchorItemRefs = useRef<Record<string, any>>(null);
|
||||
anchorItemRefs.current = {};
|
||||
|
||||
CommandService.instance().componentRegisterCommands(this, commands);
|
||||
// This whole component is a bit of a mess and rather than passing
|
||||
// a plugins prop around, not knowing how it's going to affect
|
||||
// re-rendering, we just keep a ref to it. Currently that's enough
|
||||
// as plugins are only accessed from context menus. However if want
|
||||
// to do more complex things with plugins in the sidebar, it will
|
||||
// probably have to be refactored using React Hooks first.
|
||||
const pluginsRef = useRef<PluginStates>(null);
|
||||
pluginsRef.current = props.plugins;
|
||||
|
||||
this.state = {
|
||||
tagHeaderIsExpanded: Setting.value('tagHeaderIsExpanded'),
|
||||
folderHeaderIsExpanded: Setting.value('folderHeaderIsExpanded'),
|
||||
// If at least one of the folder has an icon, then we display icons for all
|
||||
// folders (those without one will get the default icon). This is so that
|
||||
// visual alignment is correct for all folders, otherwise the folder tree
|
||||
// looks messy.
|
||||
const showFolderIcons = useMemo(() => {
|
||||
return Folder.shouldShowFolderIcons(props.folders);
|
||||
}, [props.folders]);
|
||||
|
||||
const getSelectedItem = useCallback(() => {
|
||||
if (props.notesParentType === 'Folder' && props.selectedFolderId) {
|
||||
return { type: 'folder', id: props.selectedFolderId };
|
||||
} else if (props.notesParentType === 'Tag' && props.selectedTagId) {
|
||||
return { type: 'tag', id: props.selectedTagId };
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [props.notesParentType, props.selectedFolderId, props.selectedTagId]);
|
||||
|
||||
const getFirstAnchorItemRef = useCallback((type: string) => {
|
||||
const refs = anchorItemRefs.current[type];
|
||||
if (!refs) return null;
|
||||
|
||||
const p = type === 'folder' ? props.folders : props.tags;
|
||||
const item = p && p.length ? p[0] : null;
|
||||
if (!item) return null;
|
||||
|
||||
return refs[item.id];
|
||||
}, [anchorItemRefs, props.folders, props.tags]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtimeProps: RuntimeProps = {
|
||||
getSelectedItem,
|
||||
anchorItemRefs,
|
||||
getFirstAnchorItemRef,
|
||||
};
|
||||
|
||||
// This whole component is a bit of a mess and rather than passing
|
||||
// a plugins prop around, not knowing how it's going to affect
|
||||
// re-rendering, we just keep a ref to it. Currently that's enough
|
||||
// as plugins are only accessed from context menus. However if want
|
||||
// to do more complex things with plugins in the sidebar, it will
|
||||
// probably have to be refactored using React Hooks first.
|
||||
this.pluginsRef = React.createRef();
|
||||
CommandService.instance().componentRegisterCommands(runtimeProps, commands);
|
||||
|
||||
this.onFolderToggleClick_ = this.onFolderToggleClick_.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.onAllNotesClick_ = this.onAllNotesClick_.bind(this);
|
||||
this.header_contextMenu = this.header_contextMenu.bind(this);
|
||||
this.onAddFolderButtonClick = this.onAddFolderButtonClick.bind(this);
|
||||
this.folderItem_click = this.folderItem_click.bind(this);
|
||||
this.itemContextMenu = this.itemContextMenu.bind(this);
|
||||
}
|
||||
return () => {
|
||||
CommandService.instance().componentUnregisterCommands(commands);
|
||||
};
|
||||
}, [
|
||||
getSelectedItem,
|
||||
anchorItemRefs,
|
||||
getFirstAnchorItemRef,
|
||||
]);
|
||||
|
||||
onFolderDragStart_(event: any) {
|
||||
const onFolderDragStart_ = useCallback((event: any) => {
|
||||
const folderId = event.currentTarget.getAttribute('data-folder-id');
|
||||
if (!folderId) return;
|
||||
|
||||
event.dataTransfer.setDragImage(new Image(), 1, 1);
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify([folderId]));
|
||||
}
|
||||
}, []);
|
||||
|
||||
onFolderDragOver_(event: any) {
|
||||
const onFolderDragOver_ = useCallback((event: any) => {
|
||||
if (event.dataTransfer.types.indexOf('text/x-jop-note-ids') >= 0) event.preventDefault();
|
||||
if (event.dataTransfer.types.indexOf('text/x-jop-folder-ids') >= 0) event.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
async onFolderDrop_(event: any) {
|
||||
const onFolderDrop_ = useCallback(async (event: any) => {
|
||||
const folderId = event.currentTarget.getAttribute('data-folder-id');
|
||||
const dt = event.dataTransfer;
|
||||
if (!dt) return;
|
||||
@@ -199,9 +239,9 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
logger.error(error);
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
async onTagDrop_(event: any) {
|
||||
const onTagDrop_ = useCallback(async (event: any) => {
|
||||
const tagId = event.currentTarget.getAttribute('data-tag-id');
|
||||
const dt = event.dataTransfer;
|
||||
if (!dt) return;
|
||||
@@ -214,22 +254,18 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
await Tag.addNote(tagId, noteIds[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
async onFolderToggleClick_(event: any) {
|
||||
const onFolderToggleClick_ = useCallback((event: any) => {
|
||||
const folderId = event.currentTarget.getAttribute('data-folder-id');
|
||||
|
||||
this.props.dispatch({
|
||||
props.dispatch({
|
||||
type: 'FOLDER_TOGGLE',
|
||||
id: folderId,
|
||||
});
|
||||
}
|
||||
}, [props.dispatch]);
|
||||
|
||||
componentWillUnmount() {
|
||||
CommandService.instance().componentUnregisterCommands(commands);
|
||||
}
|
||||
|
||||
async header_contextMenu() {
|
||||
const header_contextMenu = useCallback(async () => {
|
||||
const menu = new Menu();
|
||||
|
||||
menu.append(
|
||||
@@ -237,9 +273,9 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
);
|
||||
|
||||
menu.popup(bridge().window());
|
||||
}
|
||||
}, []);
|
||||
|
||||
async itemContextMenu(event: any) {
|
||||
const itemContextMenu = useCallback(async (event: any) => {
|
||||
const itemId = event.currentTarget.getAttribute('data-id');
|
||||
if (itemId === Folder.conflictFolderId()) return;
|
||||
|
||||
@@ -265,7 +301,7 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
|
||||
let item = null;
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
item = BaseModel.byId(this.props.folders, itemId);
|
||||
item = BaseModel.byId(props.folders, itemId);
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
||||
@@ -289,7 +325,7 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
} else if (itemType === BaseModel.TYPE_TAG) {
|
||||
await Tag.untagAll(itemId);
|
||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
||||
this.props.dispatch({
|
||||
props.dispatch({
|
||||
type: 'SEARCH_DELETE',
|
||||
id: itemId,
|
||||
});
|
||||
@@ -314,7 +350,7 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
new MenuItem({
|
||||
label: module.fullLabel(),
|
||||
click: async () => {
|
||||
await InteropServiceHelper.export(this.props.dispatch.bind(this), module, { sourceFolderIds: [itemId], plugins: this.pluginsRef.current });
|
||||
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current });
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -374,7 +410,7 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
const pluginViews = pluginUtils.viewsByType(this.pluginsRef.current, 'menuItem');
|
||||
const pluginViews = pluginUtils.viewsByType(pluginsRef.current, 'menuItem');
|
||||
|
||||
for (const view of pluginViews) {
|
||||
const location = view.location;
|
||||
@@ -389,80 +425,79 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
menu.popup(bridge().window());
|
||||
}
|
||||
}, [props.folders, props.dispatch, pluginsRef]);
|
||||
|
||||
folderItem_click(folderId: string) {
|
||||
this.props.dispatch({
|
||||
const folderItem_click = useCallback((folderId: string) => {
|
||||
props.dispatch({
|
||||
type: 'FOLDER_SELECT',
|
||||
id: folderId ? folderId : null,
|
||||
});
|
||||
}
|
||||
}, [props.dispatch]);
|
||||
|
||||
tagItem_click(tag: any) {
|
||||
this.props.dispatch({
|
||||
const tagItem_click = useCallback((tag: any) => {
|
||||
props.dispatch({
|
||||
type: 'TAG_SELECT',
|
||||
id: tag ? tag.id : null,
|
||||
});
|
||||
}
|
||||
}, [props.dispatch]);
|
||||
|
||||
anchorItemRef(type: string, id: string) {
|
||||
if (!this.anchorItemRefs[type]) this.anchorItemRefs[type] = {};
|
||||
if (this.anchorItemRefs[type][id]) return this.anchorItemRefs[type][id];
|
||||
this.anchorItemRefs[type][id] = React.createRef();
|
||||
return this.anchorItemRefs[type][id];
|
||||
}
|
||||
const onHeaderClick_ = useCallback((key: string) => {
|
||||
const isExpanded = key === 'tag' ? props.tagHeaderIsExpanded : props.folderHeaderIsExpanded;
|
||||
Setting.setValue(key === 'tag' ? 'tagHeaderIsExpanded' : 'folderHeaderIsExpanded', !isExpanded);
|
||||
}, [props.folderHeaderIsExpanded, props.tagHeaderIsExpanded]);
|
||||
|
||||
firstAnchorItemRef(type: string) {
|
||||
const refs = this.anchorItemRefs[type];
|
||||
if (!refs) return null;
|
||||
const onAllNotesClick_ = () => {
|
||||
props.dispatch({
|
||||
type: 'SMART_FILTER_SELECT',
|
||||
id: ALL_NOTES_FILTER_ID,
|
||||
});
|
||||
};
|
||||
|
||||
const n = `${type}s`;
|
||||
const p = this.props as any;
|
||||
const item = p[n] && p[n].length ? p[n][0] : null;
|
||||
if (!item) return null;
|
||||
const anchorItemRef = (type: string, id: string) => {
|
||||
if (!anchorItemRefs.current[type]) anchorItemRefs.current[type] = {};
|
||||
if (anchorItemRefs.current[type][id]) return anchorItemRefs.current[type][id];
|
||||
anchorItemRefs.current[type][id] = React.createRef();
|
||||
return anchorItemRefs.current[type][id];
|
||||
};
|
||||
|
||||
return refs[item.id];
|
||||
}
|
||||
|
||||
renderNoteCount(count: number) {
|
||||
const renderNoteCount = (count: number) => {
|
||||
return count ? <StyledNoteCount className="note-count-label">{count}</StyledNoteCount> : null;
|
||||
}
|
||||
};
|
||||
|
||||
renderExpandIcon(isExpanded: boolean, isVisible: boolean = true) {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const renderExpandIcon = (theme: any, isExpanded: boolean, isVisible: boolean) => {
|
||||
const style: any = { width: 16, maxWidth: 16, opacity: 0.5, fontSize: Math.round(theme.toolbarIconSize * 0.8), display: 'flex', justifyContent: 'center' };
|
||||
if (!isVisible) style.visibility = 'hidden';
|
||||
return <i className={isExpanded ? 'fas fa-caret-down' : 'fas fa-caret-right'} style={style}></i>;
|
||||
}
|
||||
};
|
||||
|
||||
renderAllNotesItem(selected: boolean) {
|
||||
const renderAllNotesItem = (theme: Theme, selected: boolean) => {
|
||||
return (
|
||||
<StyledListItem key="allNotesHeader" selected={selected} className={'list-item-container list-item-depth-0 all-notes'} isSpecialItem={true}>
|
||||
<StyledExpandLink>{this.renderExpandIcon(false, false)}</StyledExpandLink>
|
||||
<StyledExpandLink>{renderExpandIcon(theme, false, false)}</StyledExpandLink>
|
||||
<StyledAllNotesIcon className="icon-notes"/>
|
||||
<StyledListItemAnchor
|
||||
className="list-item"
|
||||
isSpecialItem={true}
|
||||
href="#"
|
||||
selected={selected}
|
||||
onClick={this.onAllNotesClick_}
|
||||
onClick={onAllNotesClick_}
|
||||
>
|
||||
{_('All notes')}
|
||||
</StyledListItemAnchor>
|
||||
</StyledListItem>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderFolderItem(folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) {
|
||||
const anchorRef = this.anchorItemRef('folder', folder.id);
|
||||
const isExpanded = this.props.collapsedFolderIds.indexOf(folder.id) < 0;
|
||||
const renderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) =>{
|
||||
const anchorRef = anchorItemRef('folder', folder.id);
|
||||
const isExpanded = props.collapsedFolderIds.indexOf(folder.id) < 0;
|
||||
let noteCount = (folder as any).note_count;
|
||||
|
||||
// Thunderbird count: Subtract children note_count from parent folder if it expanded.
|
||||
if (isExpanded) {
|
||||
for (let i = 0; i < this.props.folders.length; i++) {
|
||||
if (this.props.folders[i].parent_id === folder.id) {
|
||||
noteCount -= this.props.folders[i].note_count;
|
||||
for (let i = 0; i < props.folders.length; i++) {
|
||||
if (props.folders[i].parent_id === folder.id) {
|
||||
noteCount -= props.folders[i].note_count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -472,40 +507,41 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
folderId={folder.id}
|
||||
folderTitle={Folder.displayTitle(folder)}
|
||||
folderIcon={Folder.unserializeIcon(folder.icon)}
|
||||
themeId={this.props.themeId}
|
||||
themeId={props.themeId}
|
||||
depth={depth}
|
||||
selected={selected}
|
||||
isExpanded={isExpanded}
|
||||
hasChildren={hasChildren}
|
||||
anchorRef={anchorRef}
|
||||
noteCount={noteCount}
|
||||
onFolderDragStart_={this.onFolderDragStart_}
|
||||
onFolderDragOver_={this.onFolderDragOver_}
|
||||
onFolderDrop_={this.onFolderDrop_}
|
||||
itemContextMenu={this.itemContextMenu}
|
||||
folderItem_click={this.folderItem_click}
|
||||
onFolderToggleClick_={this.onFolderToggleClick_}
|
||||
onFolderDragStart_={onFolderDragStart_}
|
||||
onFolderDragOver_={onFolderDragOver_}
|
||||
onFolderDrop_={onFolderDrop_}
|
||||
itemContextMenu={itemContextMenu}
|
||||
folderItem_click={folderItem_click}
|
||||
onFolderToggleClick_={onFolderToggleClick_}
|
||||
shareId={folder.share_id}
|
||||
parentId={folder.parent_id}
|
||||
showFolderIcon={showFolderIcons}
|
||||
/>;
|
||||
}
|
||||
};
|
||||
|
||||
renderTag(tag: any, selected: boolean) {
|
||||
const anchorRef = this.anchorItemRef('tag', tag.id);
|
||||
const renderTag = (tag: any, selected: boolean) => {
|
||||
const anchorRef = anchorItemRef('tag', tag.id);
|
||||
let noteCount = null;
|
||||
if (Setting.value('showNoteCounts')) {
|
||||
if (Setting.value('showCompletedTodos')) noteCount = this.renderNoteCount(tag.note_count);
|
||||
else noteCount = this.renderNoteCount(tag.note_count - tag.todo_completed_count);
|
||||
if (Setting.value('showCompletedTodos')) noteCount = renderNoteCount(tag.note_count);
|
||||
else noteCount = renderNoteCount(tag.note_count - tag.todo_completed_count);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledListItem selected={selected}
|
||||
className={`list-item-container ${selected ? 'selected' : ''}`}
|
||||
key={tag.id}
|
||||
onDrop={this.onTagDrop_}
|
||||
onDrop={onTagDrop_}
|
||||
data-tag-id={tag.id}
|
||||
>
|
||||
<StyledExpandLink>{this.renderExpandIcon(false, false)}</StyledExpandLink>
|
||||
<StyledExpandLink>{renderExpandIcon(theme, false, false)}</StyledExpandLink>
|
||||
<StyledListItemAnchor
|
||||
ref={anchorRef}
|
||||
className="list-item"
|
||||
@@ -513,9 +549,9 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
selected={selected}
|
||||
data-id={tag.id}
|
||||
data-type={BaseModel.TYPE_TAG}
|
||||
onContextMenu={this.itemContextMenu}
|
||||
onContextMenu={itemContextMenu}
|
||||
onClick={() => {
|
||||
this.tagItem_click(tag);
|
||||
tagItem_click(tag);
|
||||
}}
|
||||
>
|
||||
<span className="tag-label">{Tag.displayTitle(tag)}</span>
|
||||
@@ -523,16 +559,12 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
</StyledListItemAnchor>
|
||||
</StyledListItem>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
makeDivider(key: string) {
|
||||
return <div style={{ height: 2, backgroundColor: 'blue' }} key={key} />;
|
||||
}
|
||||
|
||||
renderHeader(key: string, label: string, iconName: string, contextMenuHandler: Function = null, onPlusButtonClick: Function = null, extraProps: any = {}) {
|
||||
const renderHeader = (key: string, label: string, iconName: string, contextMenuHandler: Function = null, onPlusButtonClick: Function = null, extraProps: any = {}) => {
|
||||
const headerClick = extraProps.onClick || null;
|
||||
delete extraProps.onClick;
|
||||
const ref = this.anchorItemRef('headers', key);
|
||||
const ref = anchorItemRef('headers', key);
|
||||
|
||||
return (
|
||||
<div key={key} style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||
@@ -545,7 +577,7 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
if (headerClick) {
|
||||
headerClick(key, event);
|
||||
}
|
||||
this.onHeaderClick_(key);
|
||||
onHeaderClick_(key);
|
||||
}}
|
||||
>
|
||||
<StyledHeaderIcon className={iconName}/>
|
||||
@@ -554,21 +586,11 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
{ onPlusButtonClick && <StyledAddButton onClick={onPlusButtonClick} iconName="fas fa-plus" level={ButtonLevel.SidebarSecondary}/> }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
selectedItem() {
|
||||
if (this.props.notesParentType === 'Folder' && this.props.selectedFolderId) {
|
||||
return { type: 'folder', id: this.props.selectedFolderId };
|
||||
} else if (this.props.notesParentType === 'Tag' && this.props.selectedTagId) {
|
||||
return { type: 'tag', id: this.props.selectedTagId };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
onKeyDown(event: any) {
|
||||
const onKeyDown = useCallback((event: any) => {
|
||||
const keyCode = event.keyCode;
|
||||
const selectedItem = this.selectedItem();
|
||||
const selectedItem = getSelectedItem();
|
||||
|
||||
if (keyCode === 40 || keyCode === 38) {
|
||||
// DOWN / UP
|
||||
@@ -576,14 +598,14 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
|
||||
const focusItems = [];
|
||||
|
||||
for (let i = 0; i < this.folderItemsOrder_.length; i++) {
|
||||
const id = this.folderItemsOrder_[i];
|
||||
focusItems.push({ id: id, ref: this.anchorItemRefs['folder'][id], type: 'folder' });
|
||||
for (let i = 0; i < folderItemsOrder_.current.length; i++) {
|
||||
const id = folderItemsOrder_.current[i];
|
||||
focusItems.push({ id: id, ref: anchorItemRefs.current['folder'][id], type: 'folder' });
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.tagItemsOrder_.length; i++) {
|
||||
const id = this.tagItemsOrder_[i];
|
||||
focusItems.push({ id: id, ref: this.anchorItemRefs['tag'][id], type: 'tag' });
|
||||
for (let i = 0; i < tagItemsOrder_.current.length; i++) {
|
||||
const id = tagItemsOrder_.current[i];
|
||||
focusItems.push({ id: id, ref: anchorItemRefs.current['tag'][id], type: 'tag' });
|
||||
}
|
||||
|
||||
let currentIndex = 0;
|
||||
@@ -604,7 +626,7 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
|
||||
const actionName = `${focusItem.type.toUpperCase()}_SELECT`;
|
||||
|
||||
this.props.dispatch({
|
||||
props.dispatch({
|
||||
type: actionName,
|
||||
id: focusItem.id,
|
||||
});
|
||||
@@ -627,7 +649,7 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
// SPACE
|
||||
event.preventDefault();
|
||||
|
||||
this.props.dispatch({
|
||||
props.dispatch({
|
||||
type: 'FOLDER_TOGGLE',
|
||||
id: selectedItem.id,
|
||||
});
|
||||
@@ -637,24 +659,9 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
// Ctrl+A key
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}, [getSelectedItem, props.dispatch]);
|
||||
|
||||
onHeaderClick_(key: string) {
|
||||
const toggleKey = `${key}IsExpanded`;
|
||||
const isExpanded = (this.state as any)[toggleKey];
|
||||
const newState: any = { [toggleKey]: !isExpanded };
|
||||
this.setState(newState);
|
||||
Setting.setValue(toggleKey, !isExpanded);
|
||||
}
|
||||
|
||||
onAllNotesClick_() {
|
||||
this.props.dispatch({
|
||||
type: 'SMART_FILTER_SELECT',
|
||||
id: ALL_NOTES_FILTER_ID,
|
||||
});
|
||||
}
|
||||
|
||||
renderSynchronizeButton(type: string) {
|
||||
const renderSynchronizeButton = (type: string) => {
|
||||
const label = type === 'sync' ? _('Synchronise') : _('Cancel');
|
||||
const iconAnimation = type !== 'sync' ? 'icon-infinite-rotation 1s linear infinite' : '';
|
||||
|
||||
@@ -670,116 +677,98 @@ class SidebarComponent extends React.Component<Props, State> {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onAddFolderButtonClick() {
|
||||
const onAddFolderButtonClick = useCallback(() => {
|
||||
void CommandService.instance().execute('newFolder');
|
||||
}, []);
|
||||
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const items = [];
|
||||
|
||||
items.push(
|
||||
renderHeader('folderHeader', _('Notebooks'), 'icon-notebooks', header_contextMenu, onAddFolderButtonClick, {
|
||||
onDrop: onFolderDrop_,
|
||||
['data-folder-id']: '',
|
||||
toggleblock: 1,
|
||||
})
|
||||
);
|
||||
|
||||
if (props.folders.length) {
|
||||
const allNotesSelected = props.notesParentType === 'SmartFilter' && props.selectedSmartFilterId === ALL_NOTES_FILTER_ID;
|
||||
const result = shared.renderFolders(props, renderFolderItem);
|
||||
const folderItems = [renderAllNotesItem(theme, allNotesSelected)].concat(result.items);
|
||||
folderItemsOrder_.current = result.order;
|
||||
items.push(
|
||||
<div
|
||||
className={`folders ${props.folderHeaderIsExpanded ? 'expanded' : ''}`}
|
||||
key="folder_items"
|
||||
style={{ display: props.folderHeaderIsExpanded ? 'block' : 'none', paddingBottom: 10 }}
|
||||
>
|
||||
{folderItems}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// componentDidUpdate(prevProps:any, prevState:any) {
|
||||
// for (const n in prevProps) {
|
||||
// if (prevProps[n] !== (this.props as any)[n]) {
|
||||
// console.info('CHANGED PROPS', n);
|
||||
// }
|
||||
// }
|
||||
items.push(
|
||||
renderHeader('tagHeader', _('Tags'), 'icon-tags', null, null, {
|
||||
toggleblock: 1,
|
||||
})
|
||||
);
|
||||
|
||||
// for (const n in prevState) {
|
||||
// if (prevState[n] !== (this.state as any)[n]) {
|
||||
// console.info('CHANGED STATE', n);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
render() {
|
||||
this.pluginsRef.current = this.props.plugins;
|
||||
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
|
||||
const items = [];
|
||||
if (props.tags.length) {
|
||||
const result = shared.renderTags(props, renderTag);
|
||||
const tagItems = result.items;
|
||||
tagItemsOrder_.current = result.order;
|
||||
|
||||
items.push(
|
||||
this.renderHeader('folderHeader', _('Notebooks'), 'icon-notebooks', this.header_contextMenu, this.onAddFolderButtonClick, {
|
||||
onDrop: this.onFolderDrop_,
|
||||
['data-folder-id']: '',
|
||||
toggleblock: 1,
|
||||
})
|
||||
);
|
||||
|
||||
if (this.props.folders.length) {
|
||||
const allNotesSelected = this.props.notesParentType === 'SmartFilter' && this.props.selectedSmartFilterId === ALL_NOTES_FILTER_ID;
|
||||
const result = shared.renderFolders(this.props, this.renderFolderItem.bind(this));
|
||||
const folderItems = [this.renderAllNotesItem(allNotesSelected)].concat(result.items);
|
||||
this.folderItemsOrder_ = result.order;
|
||||
items.push(
|
||||
<div
|
||||
className={`folders ${this.state.folderHeaderIsExpanded ? 'expanded' : ''}`}
|
||||
key="folder_items"
|
||||
style={{ display: this.state.folderHeaderIsExpanded ? 'block' : 'none', paddingBottom: 10 }}
|
||||
>
|
||||
{folderItems}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
this.renderHeader('tagHeader', _('Tags'), 'icon-tags', null, null, {
|
||||
toggleblock: 1,
|
||||
})
|
||||
);
|
||||
|
||||
if (this.props.tags.length) {
|
||||
const result = shared.renderTags(this.props, this.renderTag.bind(this));
|
||||
const tagItems = result.items;
|
||||
this.tagItemsOrder_ = result.order;
|
||||
|
||||
items.push(
|
||||
<div className="tags" key="tag_items" style={{ display: this.state.tagHeaderIsExpanded ? 'block' : 'none' }}>
|
||||
{tagItems}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let decryptionReportText = '';
|
||||
if (this.props.decryptionWorker && this.props.decryptionWorker.state !== 'idle' && this.props.decryptionWorker.itemCount) {
|
||||
decryptionReportText = _('Decrypting items: %d/%d', this.props.decryptionWorker.itemIndex + 1, this.props.decryptionWorker.itemCount);
|
||||
}
|
||||
|
||||
let resourceFetcherText = '';
|
||||
if (this.props.resourceFetcher && this.props.resourceFetcher.toFetchCount) {
|
||||
resourceFetcherText = _('Fetching resources: %d/%d', this.props.resourceFetcher.fetchingCount, this.props.resourceFetcher.toFetchCount);
|
||||
}
|
||||
|
||||
const lines = Synchronizer.reportToLines(this.props.syncReport);
|
||||
if (resourceFetcherText) lines.push(resourceFetcherText);
|
||||
if (decryptionReportText) lines.push(decryptionReportText);
|
||||
const syncReportText = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
syncReportText.push(
|
||||
<StyledSyncReportText key={i}>
|
||||
{lines[i]}
|
||||
</StyledSyncReportText>
|
||||
);
|
||||
}
|
||||
|
||||
const syncButton = this.renderSynchronizeButton(this.props.syncStarted ? 'cancel' : 'sync');
|
||||
|
||||
const syncReportComp = !syncReportText.length ? null : (
|
||||
<StyledSyncReport key="sync_report">
|
||||
{syncReportText}
|
||||
</StyledSyncReport>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledRoot ref={this.rootRef} onKeyDown={this.onKeyDown} className="sidebar">
|
||||
<div style={{ flex: 1, overflowX: 'hidden', overflowY: 'auto' }}>{items}</div>
|
||||
<div style={{ flex: 0, padding: theme.mainPadding }}>
|
||||
{syncReportComp}
|
||||
{syncButton}
|
||||
</div>
|
||||
</StyledRoot>
|
||||
<div className="tags" key="tag_items" style={{ display: props.tagHeaderIsExpanded ? 'block' : 'none' }}>
|
||||
{tagItems}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let decryptionReportText = '';
|
||||
if (props.decryptionWorker && props.decryptionWorker.state !== 'idle' && props.decryptionWorker.itemCount) {
|
||||
decryptionReportText = _('Decrypting items: %d/%d', props.decryptionWorker.itemIndex + 1, props.decryptionWorker.itemCount);
|
||||
}
|
||||
|
||||
let resourceFetcherText = '';
|
||||
if (props.resourceFetcher && props.resourceFetcher.toFetchCount) {
|
||||
resourceFetcherText = _('Fetching resources: %d/%d', props.resourceFetcher.fetchingCount, props.resourceFetcher.toFetchCount);
|
||||
}
|
||||
|
||||
const lines = Synchronizer.reportToLines(props.syncReport);
|
||||
if (resourceFetcherText) lines.push(resourceFetcherText);
|
||||
if (decryptionReportText) lines.push(decryptionReportText);
|
||||
const syncReportText = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
syncReportText.push(
|
||||
<StyledSyncReportText key={i}>
|
||||
{lines[i]}
|
||||
</StyledSyncReportText>
|
||||
);
|
||||
}
|
||||
|
||||
const syncButton = renderSynchronizeButton(props.syncStarted ? 'cancel' : 'sync');
|
||||
|
||||
const syncReportComp = !syncReportText.length ? null : (
|
||||
<StyledSyncReport key="sync_report">
|
||||
{syncReportText}
|
||||
</StyledSyncReport>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledRoot ref={rootRef} onKeyDown={onKeyDown} className="sidebar">
|
||||
<div style={{ flex: 1, overflowX: 'hidden', overflowY: 'auto' }}>{items}</div>
|
||||
<div style={{ flex: 0, padding: theme.mainPadding }}>
|
||||
{syncReportComp}
|
||||
{syncButton}
|
||||
</div>
|
||||
</StyledRoot>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
@@ -799,6 +788,8 @@ const mapStateToProps = (state: AppState) => {
|
||||
decryptionWorker: state.decryptionWorker,
|
||||
resourceFetcher: state.resourceFetcher,
|
||||
plugins: state.pluginService.plugins,
|
||||
tagHeaderIsExpanded: state.settings.tagHeaderIsExpanded,
|
||||
folderHeaderIsExpanded: state.settings.folderHeaderIsExpanded,
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -9,18 +9,24 @@ export const declaration: CommandDeclaration = {
|
||||
parentLabel: () => _('Focus'),
|
||||
};
|
||||
|
||||
export const runtime = (comp: any): CommandRuntime => {
|
||||
export interface RuntimeProps {
|
||||
getSelectedItem(): any;
|
||||
getFirstAnchorItemRef(type: string): any;
|
||||
anchorItemRefs: any;
|
||||
}
|
||||
|
||||
export const runtime = (props: RuntimeProps): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext) => {
|
||||
const sidebarVisible = layoutItemProp((context.state as AppState).mainLayout, 'sideBar', 'visible');
|
||||
|
||||
if (sidebarVisible) {
|
||||
const item = comp.selectedItem();
|
||||
const item = props.getSelectedItem();
|
||||
if (item) {
|
||||
const anchorRef = comp.anchorItemRefs[item.type][item.id];
|
||||
const anchorRef = props.anchorItemRefs.current[item.type][item.id];
|
||||
if (anchorRef) anchorRef.current.focus();
|
||||
} else {
|
||||
const anchorRef = comp.firstAnchorItemRef('folder');
|
||||
const anchorRef = props.getFirstAnchorItemRef('folder');
|
||||
if (anchorRef) anchorRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
@@ -23,5 +23,6 @@ export default function useEffectDebugger(effectHook: any, dependencies: any, de
|
||||
console.log('[use-effet-debugger] ', changedDeps);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
useEffect(effectHook, dependencies);
|
||||
}
|
||||
|
@@ -23,5 +23,6 @@ export default function useImperativeHandleDebugger(ref: any, effectHook: any, d
|
||||
console.log('[use-imperativeHandler-debugger] ', changedDeps);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
useImperativeHandle(ref, effectHook, dependencies);
|
||||
}
|
||||
|
@@ -579,6 +579,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
ipc.textSelected = function(event) {
|
||||
ipcProxySendToHost('contextMenu', {
|
||||
type: 'text',
|
||||
textToCopy: event.text,
|
||||
});
|
||||
}
|
||||
|
||||
ipc.openPdfViewer = function(event) {
|
||||
ipcProxySendToHost('openPdfViewer', { resourceId: event.resourceId, mime: 'application/pdf', pageNo: event.pageNo || 1 });
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', webviewLib.logEnabledEventHandler(e => {
|
||||
if (!window.location.hash) return;
|
||||
|
||||
@@ -653,6 +664,17 @@
|
||||
e.preventDefault();
|
||||
}));
|
||||
|
||||
document.addEventListener('click', webviewLib.logEnabledEventHandler(e => {
|
||||
document.querySelectorAll('.media-pdf').forEach(element => {
|
||||
if(!!element.contentWindow){
|
||||
element.contentWindow.postMessage({
|
||||
type: 'blur'
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
);
|
||||
}));
|
||||
|
||||
let lastClientWidth_ = NaN, lastClientHeight_ = NaN, lastScrollTop_ = NaN;
|
||||
|
||||
window.addEventListener('resize', webviewLib.logEnabledEventHandler(() => {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.9.1",
|
||||
"version": "2.9.8",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
@@ -31,7 +31,8 @@
|
||||
"afterSign": "./tools/notarizeMacApp.js",
|
||||
"extraResources": [
|
||||
"build/icons/**",
|
||||
"build/images/**"
|
||||
"build/images/**",
|
||||
"build/defaultPlugins/**"
|
||||
],
|
||||
"afterAllArtifactBuild": "./generateSha512.js",
|
||||
"asar": true,
|
||||
@@ -138,6 +139,7 @@
|
||||
"@fortawesome/fontawesome-free": "^5.13.0",
|
||||
"@joeattardi/emoji-button": "^4.6.0",
|
||||
"@joplin/lib": "~2.9",
|
||||
"@joplin/pdf-viewer": "~2.9",
|
||||
"@joplin/renderer": "~2.9",
|
||||
"async-mutex": "^0.1.3",
|
||||
"codemirror": "^5.56.0",
|
||||
|
@@ -69,6 +69,7 @@ function UserWebview(props: Props, ref: any) {
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && props.onReady) props.onReady();
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [isReady]);
|
||||
|
||||
function frameWindow() {
|
||||
|
@@ -33,6 +33,7 @@ export default function(frameWindow: any, htmlHash: string, minWidth: number, mi
|
||||
|
||||
useEffect(() => {
|
||||
updateContentSize(htmlHash);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [htmlHash]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -55,6 +56,7 @@ export default function(frameWindow: any, htmlHash: string, minWidth: number, mi
|
||||
return () => {
|
||||
clearInterval(updateFrameSizeIID);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [fitToContent, isReady, minWidth, minHeight, htmlHash]);
|
||||
|
||||
return contentSize;
|
||||
|
@@ -45,6 +45,7 @@ export default function(frameWindow: any, isReady: boolean, postMessage: Functio
|
||||
hash: htmlHash,
|
||||
html: html,
|
||||
});
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [html, htmlHash, isReady]);
|
||||
|
||||
return loadedHtmlHash;
|
||||
|
@@ -4,10 +4,12 @@ export default function(postMessage: Function, isReady: boolean, scripts: string
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
postMessage('setScripts', { scripts: scripts });
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [scripts, isReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || !cssFilePath) return;
|
||||
postMessage('setScript', { script: cssFilePath, key: 'themeCss' });
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [isReady, cssFilePath]);
|
||||
}
|
||||
|
@@ -43,8 +43,10 @@ export default function useViewIsReady(viewRef: any) {
|
||||
return () => {
|
||||
viewRef.current.removeEventListener('dom-ready', onIFrameReady);
|
||||
viewRef.current.removeEventListener('load', onIFrameReady);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
viewRef.current.contentWindow.removeEventListener('message', onMessage);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
return iframeReady && iframeContentReady;
|
||||
|
@@ -10,6 +10,7 @@ export default function(frameWindow: any, isReady: boolean, pluginId: string, vi
|
||||
return () => {
|
||||
PostMessageService.instance().unregisterResponder(ResponderComponentType.UserWebview, viewId);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [viewId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,5 +40,6 @@ export default function(frameWindow: any, isReady: boolean, pluginId: string, vi
|
||||
return () => {
|
||||
frameWindow.removeEventListener('message', onMessage_);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [frameWindow, isReady, pluginId, viewId]);
|
||||
}
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
import SpellCheckerServiceDriverBase from '@joplin/lib/services/spellChecker/SpellCheckerServiceDriverBase';
|
||||
import bridge from '../bridge';
|
||||
import { languageCodeOnly, localesFromLanguageCode } from '@joplin/lib/locale';
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
|
||||
const logger = Logger.create('SpellCheckerServiceDriverNative');
|
||||
@@ -17,35 +16,17 @@ export default class SpellCheckerServiceDriverNative extends SpellCheckerService
|
||||
return this.session().availableSpellCheckerLanguages;
|
||||
}
|
||||
|
||||
// Language can be set to '' to disable spell-checking
|
||||
public setLanguage(v: string) {
|
||||
// Language can be set to [] to disable spell-checking
|
||||
public setLanguages(v: string[]) {
|
||||
// If we pass an empty array, it disables spell checking
|
||||
// https://github.com/electron/electron/issues/25228
|
||||
if (!v) {
|
||||
if (v.length === 0) {
|
||||
this.session().setSpellCheckerLanguages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// The below function will throw an error if the provided language is
|
||||
// not supported, so we provide fallbacks.
|
||||
// https://github.com/laurent22/joplin/issues/4146
|
||||
const languagesToTry = [
|
||||
v,
|
||||
languageCodeOnly(v),
|
||||
].concat(localesFromLanguageCode(languageCodeOnly(v), this.availableLanguages));
|
||||
|
||||
for (const toTry of languagesToTry) {
|
||||
try {
|
||||
this.session().setSpellCheckerLanguages([toTry]);
|
||||
logger.info(`Set effective language from "${v}" to "${toTry}"`);
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to set language to "${toTry}". Will try the next one in this list: ${JSON.stringify(languagesToTry)}`);
|
||||
logger.warn('Error was:', error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`Could not set language to: ${v}`);
|
||||
this.session().setSpellCheckerLanguages(v);
|
||||
logger.info(`Set effective languages to "${v}"`);
|
||||
}
|
||||
|
||||
public get language(): string {
|
||||
|
@@ -13,46 +13,49 @@ function fileIsNewerThan(path1, path2) {
|
||||
return stat1.mtime > stat2.mtime;
|
||||
}
|
||||
|
||||
function convertJsx(path) {
|
||||
function convertJsx(paths) {
|
||||
chdir(`${__dirname}/..`);
|
||||
|
||||
fs.readdirSync(path).forEach((filename) => {
|
||||
const jsxPath = `${path}/${filename}`;
|
||||
const p = jsxPath.split('.');
|
||||
if (p.length <= 1) return;
|
||||
const ext = p[p.length - 1];
|
||||
if (ext !== 'jsx') return;
|
||||
p.pop();
|
||||
paths.forEach(path => {
|
||||
fs.readdirSync(path).forEach((filename) => {
|
||||
const jsxPath = `${path}/${filename}`;
|
||||
const p = jsxPath.split('.');
|
||||
if (p.length <= 1) return;
|
||||
const ext = p[p.length - 1];
|
||||
if (ext !== 'jsx') return;
|
||||
p.pop();
|
||||
|
||||
const basePath = p.join('.');
|
||||
const basePath = p.join('.');
|
||||
|
||||
const jsPath = `${basePath}.min.js`;
|
||||
const jsPath = `${basePath}.min.js`;
|
||||
|
||||
if (fileIsNewerThan(jsxPath, jsPath)) {
|
||||
console.info(`Compiling ${jsxPath}...`);
|
||||
if (fileIsNewerThan(jsxPath, jsPath)) {
|
||||
console.info(`Compiling ${jsxPath}...`);
|
||||
|
||||
// { shell: true } is needed to get it working on Windows:
|
||||
// https://discourse.joplinapp.org/t/attempting-to-build-on-windows/22559/12
|
||||
const result = spawnSync('yarn', ['run', 'babel', '--presets', 'react', '--out-file', jsPath, jsxPath], { shell: true });
|
||||
if (result.status !== 0) {
|
||||
const msg = [];
|
||||
if (result.stdout) msg.push(result.stdout.toString());
|
||||
if (result.stderr) msg.push(result.stderr.toString());
|
||||
console.error(msg.join('\n'));
|
||||
if (result.error) console.error(result.error);
|
||||
process.exit(result.status);
|
||||
// { shell: true } is needed to get it working on Windows:
|
||||
// https://discourse.joplinapp.org/t/attempting-to-build-on-windows/22559/12
|
||||
const result = spawnSync('yarn', ['run', 'babel', '--presets', 'react', '--out-file', jsPath, jsxPath], { shell: true });
|
||||
if (result.status !== 0) {
|
||||
const msg = [];
|
||||
if (result.stdout) msg.push(result.stdout.toString());
|
||||
if (result.stderr) msg.push(result.stderr.toString());
|
||||
console.error(msg.join('\n'));
|
||||
if (result.error) console.error(result.error);
|
||||
process.exit(result.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = function() {
|
||||
convertJsx(`${__dirname}/../gui`);
|
||||
convertJsx(`${__dirname}/../gui/MainScreen`);
|
||||
convertJsx(`${__dirname}/../gui/NoteList`);
|
||||
convertJsx(`${__dirname}/../plugins`);
|
||||
convertJsx([
|
||||
`${__dirname}/../gui`,
|
||||
`${__dirname}/../gui/MainScreen`,
|
||||
`${__dirname}/../gui/NoteList`,
|
||||
`${__dirname}/../plugins`,
|
||||
]);
|
||||
|
||||
// TODO: should get from node_modules @joplin/lib
|
||||
const libContent = [
|
||||
fs.readFileSync(`${basePath}/packages/lib/string-utils-common.js`, 'utf8'),
|
||||
fs.readFileSync(`${basePath}/packages/lib/markJsUtils.js`, 'utf8'),
|
||||
|
@@ -72,6 +72,10 @@ async function main() {
|
||||
src: langSourceDir,
|
||||
dest: `${buildLibDir}/tinymce/langs`,
|
||||
},
|
||||
{
|
||||
src: resolve(__dirname, '../../pdf-viewer/dist'),
|
||||
dest: `${buildLibDir}/@joplin/pdf-viewer`,
|
||||
},
|
||||
];
|
||||
|
||||
const files = [
|
||||
@@ -87,6 +91,10 @@ async function main() {
|
||||
src: resolve(__dirname, '../../lib/services/plugins/sandboxProxy.js'),
|
||||
dest: `${buildLibDir}/@joplin/lib/services/plugins/sandboxProxy.js`,
|
||||
},
|
||||
{
|
||||
src: resolve(__dirname, '../../pdf-viewer/index.html'),
|
||||
dest: `${buildLibDir}/@joplin/pdf-viewer/index.html`,
|
||||
},
|
||||
];
|
||||
|
||||
// First we delete all the destination directories, then we copy the files.
|
||||
|
@@ -146,8 +146,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097668
|
||||
versionName "2.9.0"
|
||||
versionCode 2097673
|
||||
versionName "2.9.5"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="net.cozic.joplin"
|
||||
android:installLocation="auto">
|
||||
package="net.cozic.joplin"
|
||||
android:installLocation="auto">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
@@ -8,7 +8,7 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
|
||||
<!-- Make these features optional to enable Chromebooks -->
|
||||
<!-- Make these features optional to enable Chromebooks -->
|
||||
<!-- https://github.com/laurent22/joplin/issues/37 -->
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||
@@ -58,14 +58,19 @@
|
||||
This is recommended in the React docs: https://reactnavigation.org/docs/deep-linking. In practice, "singleTask" and "singleTop" are
|
||||
largely similar, but "singleTask" is more strict in preventing multiple instances of the app from being created if another app
|
||||
explicitly requests it.
|
||||
|
||||
2022-08-12: Added `screenLayout` and `smallestScreenSize` to `android:configChanges`.
|
||||
This prevents the application from being re-constructed on
|
||||
screen orientation change/window resizes on some devices.
|
||||
See https://github.com/laurent22/joplin/pull/6737.
|
||||
-->
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/app_name"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -93,6 +98,6 @@
|
||||
</activity>
|
||||
<!-- /SHARE EXTENSION -->
|
||||
|
||||
</application>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
190
packages/app-mobile/components/CustomButton.tsx
Normal file
190
packages/app-mobile/components/CustomButton.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
//
|
||||
// A button with a long-press action. Long-pressing the button displays a tooltip
|
||||
//
|
||||
|
||||
const React = require('react');
|
||||
import { ReactNode } from 'react';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { View, Text, Pressable, ViewStyle, PressableStateCallbackType, StyleProp, StyleSheet, LayoutChangeEvent, LayoutRectangle, Animated, AccessibilityState, AccessibilityRole } from 'react-native';
|
||||
import { Menu, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu';
|
||||
|
||||
type ButtonClickListener = ()=> void;
|
||||
interface ButtonProps {
|
||||
onPress: ButtonClickListener;
|
||||
|
||||
// Accessibility label and text shown in a tooltip
|
||||
description?: string;
|
||||
|
||||
children: ReactNode;
|
||||
|
||||
themeId: number;
|
||||
|
||||
style?: ViewStyle;
|
||||
pressedStyle?: ViewStyle;
|
||||
contentStyle?: ViewStyle;
|
||||
|
||||
// Additional accessibility information. See View.accessibilityHint
|
||||
accessibilityHint?: string;
|
||||
|
||||
// Role of the button. Defaults to 'button'.
|
||||
accessibilityRole?: AccessibilityRole;
|
||||
accessibilityState?: AccessibilityState;
|
||||
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CustomButton = (props: ButtonProps) => {
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
||||
const [buttonLayout, setButtonLayout] = useState<LayoutRectangle|null>(null);
|
||||
const tooltipStyles = useTooltipStyles(props.themeId);
|
||||
|
||||
// See https://blog.logrocket.com/react-native-touchable-vs-pressable-components/
|
||||
// for more about animating Pressable buttons.
|
||||
const fadeAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animationDuration = 100; // ms
|
||||
const onPressIn = useCallback(() => {
|
||||
// Fade out.
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0.5,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [fadeAnim]);
|
||||
const onPressOut = useCallback(() => {
|
||||
// Fade in.
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
setTooltipVisible(false);
|
||||
}, [fadeAnim]);
|
||||
const onLongPress = useCallback(() => {
|
||||
setTooltipVisible(true);
|
||||
}, []);
|
||||
|
||||
// Select different user-specified styles if selected/unselected.
|
||||
const onStyleChange = useCallback((state: PressableStateCallbackType): StyleProp<ViewStyle> => {
|
||||
let result = { ...props.style };
|
||||
|
||||
if (state.pressed) {
|
||||
result = {
|
||||
...result,
|
||||
...props.pressedStyle,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}, [props.pressedStyle, props.style]);
|
||||
|
||||
const onButtonLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const layoutEvt = event.nativeEvent.layout;
|
||||
|
||||
// Copy the layout event
|
||||
setButtonLayout({ ...layoutEvt });
|
||||
}, []);
|
||||
|
||||
|
||||
const button = (
|
||||
<Pressable
|
||||
onPress={props.onPress}
|
||||
onLongPress={onLongPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
|
||||
style={ onStyleChange }
|
||||
|
||||
disabled={ props.disabled ?? false }
|
||||
onLayout={ onButtonLayout }
|
||||
|
||||
accessibilityLabel={props.description}
|
||||
accessibilityHint={props.accessibilityHint}
|
||||
accessibilityRole={props.accessibilityRole ?? 'button'}
|
||||
accessibilityState={props.accessibilityState}
|
||||
>
|
||||
<Animated.View style={{
|
||||
opacity: fadeAnim,
|
||||
...props.contentStyle,
|
||||
}}>
|
||||
{ props.children }
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const tooltip = (
|
||||
<View
|
||||
// Any information given by the tooltip should also be provided via
|
||||
// [accessibilityLabel]/[accessibilityHint]. As such, we can hide the tooltip
|
||||
// from the screen reader.
|
||||
// On Android:
|
||||
importantForAccessibility='no-hide-descendants'
|
||||
// On iOS:
|
||||
accessibilityElementsHidden={true}
|
||||
|
||||
// Position the menu beneath the button so the tooltip appears in the
|
||||
// correct location.
|
||||
style={{
|
||||
left: buttonLayout?.x,
|
||||
top: buttonLayout?.y,
|
||||
position: 'absolute',
|
||||
zIndex: -1,
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
opened={tooltipVisible}
|
||||
renderer={renderers.Popover}
|
||||
rendererProps={{
|
||||
preferredPlacement: 'bottom',
|
||||
anchorStyle: tooltipStyles.anchor,
|
||||
}}>
|
||||
<MenuTrigger
|
||||
// Don't show/hide when pressed (let the Pressable handle opening/closing)
|
||||
disabled={true}
|
||||
style={{
|
||||
// Ensure that the trigger region has the same size as the button.
|
||||
width: buttonLayout?.width ?? 0,
|
||||
height: buttonLayout?.height ?? 0,
|
||||
}}
|
||||
/>
|
||||
<MenuOptions
|
||||
customStyles={{ optionsContainer: tooltipStyles.optionsContainer }}
|
||||
>
|
||||
<Text style={tooltipStyles.text}>
|
||||
{props.description}
|
||||
</Text>
|
||||
</MenuOptions>
|
||||
</Menu>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.description ? tooltip : null}
|
||||
{button}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const useTooltipStyles = (themeId: number) => {
|
||||
return useMemo(() => {
|
||||
const themeData: Theme = themeStyle(themeId);
|
||||
|
||||
return StyleSheet.create({
|
||||
text: {
|
||||
color: themeData.raisedColor,
|
||||
padding: 4,
|
||||
},
|
||||
anchor: {
|
||||
backgroundColor: themeData.raisedBackgroundColor,
|
||||
},
|
||||
optionsContainer: {
|
||||
backgroundColor: themeData.raisedBackgroundColor,
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
};
|
||||
|
||||
export default CustomButton;
|
@@ -1,31 +1,60 @@
|
||||
const React = require('react');
|
||||
const { TouchableOpacity, TouchableWithoutFeedback, Dimensions, Text, Modal, View } = require('react-native');
|
||||
import { TouchableOpacity, TouchableWithoutFeedback, Dimensions, Text, Modal, View, LayoutRectangle, ViewStyle, TextStyle } from 'react-native';
|
||||
import { Component } from 'react';
|
||||
const { ItemList } = require('./ItemList.js');
|
||||
|
||||
class Dropdown extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
type ValueType = string;
|
||||
export interface DropdownListItem {
|
||||
label: string;
|
||||
value: ValueType;
|
||||
}
|
||||
|
||||
this.headerRef_ = null;
|
||||
}
|
||||
export type OnValueChangedListener = (newValue: ValueType)=> void;
|
||||
|
||||
UNSAFE_componentWillMount() {
|
||||
this.setState({
|
||||
interface DropdownProps {
|
||||
listItemStyle?: ViewStyle;
|
||||
itemListStyle?: ViewStyle;
|
||||
itemWrapperStyle?: ViewStyle;
|
||||
headerWrapperStyle?: ViewStyle;
|
||||
headerStyle?: TextStyle;
|
||||
itemStyle?: TextStyle;
|
||||
disabled?: boolean;
|
||||
|
||||
labelTransform?: 'trim';
|
||||
items: DropdownListItem[];
|
||||
|
||||
selectedValue: ValueType|null;
|
||||
onValueChange?: OnValueChangedListener;
|
||||
}
|
||||
|
||||
interface DropdownState {
|
||||
headerSize: LayoutRectangle;
|
||||
listVisible: boolean;
|
||||
}
|
||||
|
||||
class Dropdown extends Component<DropdownProps, DropdownState> {
|
||||
private headerRef: TouchableOpacity;
|
||||
|
||||
public constructor(props: DropdownProps) {
|
||||
super(props);
|
||||
|
||||
this.headerRef = null;
|
||||
this.state = {
|
||||
headerSize: { x: 0, y: 0, width: 0, height: 0 },
|
||||
listVisible: false,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
updateHeaderCoordinates() {
|
||||
private updateHeaderCoordinates() {
|
||||
// https://stackoverflow.com/questions/30096038/react-native-getting-the-position-of-an-element
|
||||
this.headerRef_.measure((fx, fy, width, height, px, py) => {
|
||||
this.headerRef.measure((_fx, _fy, width, height, px, py) => {
|
||||
this.setState({
|
||||
headerSize: { x: px, y: py, width: width, height: height },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
public render() {
|
||||
const items = this.props.items;
|
||||
const itemHeight = 60;
|
||||
const windowHeight = Dimensions.get('window').height - 50;
|
||||
@@ -84,23 +113,26 @@ class Dropdown extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.labelTransform && this.props.labelTransform === 'trim') headerLabel = headerLabel.trim();
|
||||
if (this.props.labelTransform && this.props.labelTransform === 'trim') {
|
||||
headerLabel = headerLabel.trim();
|
||||
}
|
||||
|
||||
const closeList = () => {
|
||||
this.setState({ listVisible: false });
|
||||
};
|
||||
|
||||
const itemRenderer = item => {
|
||||
const itemRenderer = (item: DropdownListItem) => {
|
||||
const key = item.value ? item.value.toString() : '__null'; // The top item ("Move item to notebook...") has a null value.
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={itemWrapperStyle}
|
||||
key={item.value}
|
||||
key={key}
|
||||
onPress={() => {
|
||||
closeList();
|
||||
if (this.props.onValueChange) this.props.onValueChange(item.value);
|
||||
}}
|
||||
>
|
||||
<Text ellipsizeMode="tail" numberOfLines={1} style={itemStyle} key={item.value}>
|
||||
<Text ellipsizeMode="tail" numberOfLines={1} style={itemStyle} key={key}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -111,7 +143,7 @@ class Dropdown extends React.Component {
|
||||
<View style={{ flex: 1, flexDirection: 'column' }}>
|
||||
<TouchableOpacity
|
||||
style={headerWrapperStyle}
|
||||
ref={ref => (this.headerRef_ = ref)}
|
||||
ref={ref => (this.headerRef = ref)}
|
||||
disabled={this.props.disabled}
|
||||
onPress={() => {
|
||||
this.updateHeaderCoordinates();
|
||||
@@ -141,9 +173,7 @@ class Dropdown extends React.Component {
|
||||
style={itemListStyle}
|
||||
items={this.props.items}
|
||||
itemHeight={itemHeight}
|
||||
itemRenderer={item => {
|
||||
return itemRenderer(item);
|
||||
}}
|
||||
itemRenderer={itemRenderer}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -154,4 +184,5 @@ class Dropdown extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Dropdown };
|
||||
export default Dropdown;
|
||||
export { Dropdown };
|
147
packages/app-mobile/components/ExtendedWebView.tsx
Normal file
147
packages/app-mobile/components/ExtendedWebView.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
// Wraps react-native-webview. Allows loading HTML directly.
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
forwardRef, Ref, useEffect, useImperativeHandle, useRef, useState,
|
||||
} from 'react';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import { WebViewErrorEvent, WebViewEvent, WebViewSource } from 'react-native-webview/lib/WebViewTypes';
|
||||
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
|
||||
export interface WebViewControl {
|
||||
// Evaluate the given [script] in the context of the page.
|
||||
// Unlike react-native-webview/WebView, this does not need to return true.
|
||||
injectJS(script: string): void;
|
||||
}
|
||||
|
||||
interface SourceFileUpdateEvent {
|
||||
uri: string;
|
||||
baseUrl: string;
|
||||
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
type OnMessageCallback = (event: WebViewMessageEvent)=> void;
|
||||
type OnErrorCallback = (event: WebViewErrorEvent)=> void;
|
||||
type OnLoadEndCallback = (event: WebViewEvent)=> void;
|
||||
type OnFileUpdateCallback = (event: SourceFileUpdateEvent)=> void;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
|
||||
// A name to be associated with the WebView (e.g. NoteEditor)
|
||||
// This name should be unique.
|
||||
webviewInstanceId: string;
|
||||
|
||||
// If HTML is still being loaded, [html] should be an empty string.
|
||||
html: string;
|
||||
|
||||
// Allow a secure origin to load content from any other origin.
|
||||
// Defaults to 'never'.
|
||||
// See react-native-webview's prop with the same name.
|
||||
mixedContentMode?: 'never' | 'always';
|
||||
|
||||
// Initial javascript. Must evaluate to true.
|
||||
injectedJavaScript: string;
|
||||
|
||||
style?: StyleProp<ViewStyle>;
|
||||
onMessage: OnMessageCallback;
|
||||
onError: OnErrorCallback;
|
||||
onLoadEnd?: OnLoadEndCallback;
|
||||
|
||||
// Triggered when the file containing [html] is overwritten with new content.
|
||||
onFileUpdate?: OnFileUpdateCallback;
|
||||
}
|
||||
|
||||
const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
const theme: Theme = themeStyle(props.themeId);
|
||||
const webviewRef = useRef(null);
|
||||
const [source, setSource] = useState<WebViewSource|undefined>(undefined);
|
||||
|
||||
useImperativeHandle(ref, (): WebViewControl => {
|
||||
return {
|
||||
injectJS(js: string) {
|
||||
webviewRef.current.injectJavaScript(`
|
||||
try {
|
||||
${js}
|
||||
}
|
||||
catch(e) {
|
||||
logMessage('Error in injected JS:' + e, e);
|
||||
throw e;
|
||||
};
|
||||
|
||||
true;`);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function createHtmlFile() {
|
||||
const tempFile = `${Setting.value('resourceDir')}/${props.webviewInstanceId}.html`;
|
||||
await shim.fsDriver().writeFile(tempFile, props.html, 'utf8');
|
||||
if (cancelled) return;
|
||||
|
||||
// Now that we are sending back a file instead of an HTML string, we're always sending back the
|
||||
// same file. So we add a cache busting query parameter to it, to make sure that the WebView re-renders.
|
||||
//
|
||||
// `baseUrl` is where the images will be loaded from. So images must use a path relative to resourceDir.
|
||||
const newSource = {
|
||||
uri: `file://${tempFile}?r=${Math.round(Math.random() * 100000000)}`,
|
||||
baseUrl: `file://${Setting.value('resourceDir')}/`,
|
||||
};
|
||||
setSource(newSource);
|
||||
|
||||
props.onFileUpdate?.({
|
||||
...newSource,
|
||||
filePath: tempFile,
|
||||
});
|
||||
}
|
||||
|
||||
if (props.html && props.html.length > 0) {
|
||||
void createHtmlFile();
|
||||
} else {
|
||||
setSource(undefined);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [props.html, props.webviewInstanceId, props.onFileUpdate]);
|
||||
|
||||
// - `setSupportMultipleWindows` must be `true` for security reasons:
|
||||
// https://github.com/react-native-webview/react-native-webview/releases/tag/v11.0.0
|
||||
// - `scrollEnabled` prevents iOS from scrolling the document (has no effect on Android)
|
||||
// when an editable region (e.g. a the full-screen NoteEditor) is focused.
|
||||
return (
|
||||
<WebView
|
||||
style={{
|
||||
backgroundColor: theme.backgroundColor,
|
||||
...(props.style as any),
|
||||
}}
|
||||
ref={webviewRef}
|
||||
scrollEnabled={false}
|
||||
useWebKit={true}
|
||||
source={source}
|
||||
setSupportMultipleWindows={true}
|
||||
hideKeyboardAccessoryView={true}
|
||||
allowingReadAccessToURL={`file://${Setting.value('resourceDir')}`}
|
||||
originWhitelist={['file://*', './*', 'http://*', 'https://*']}
|
||||
mixedContentMode={props.mixedContentMode}
|
||||
allowFileAccess={true}
|
||||
injectedJavaScript={props.injectedJavaScript}
|
||||
onMessage={props.onMessage}
|
||||
onError={props.onError}
|
||||
onLoadEnd={props.onLoadEnd}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(ExtendedWebView);
|
@@ -1,16 +1,14 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import useSource from './hooks/useSource';
|
||||
import useOnMessage from './hooks/useOnMessage';
|
||||
import useOnResourceLongPress from './hooks/useOnResourceLongPress';
|
||||
|
||||
const React = require('react');
|
||||
const { View } = require('react-native');
|
||||
const { WebView } = require('react-native-webview');
|
||||
const { themeStyle } = require('../global-style.js');
|
||||
import { View } from 'react-native';
|
||||
import BackButtonDialogBox from '../BackButtonDialogBox';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import ExtendedWebView from '../ExtendedWebView';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -32,11 +30,9 @@ const webViewStyle = {
|
||||
};
|
||||
|
||||
export default function NoteBodyViewer(props: Props) {
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const dialogBoxRef = useRef(null);
|
||||
|
||||
const { source, injectedJs } = useSource(
|
||||
const { html, injectedJs } = useSource(
|
||||
props.noteBody,
|
||||
props.noteMarkupLanguage,
|
||||
props.themeId,
|
||||
@@ -67,6 +63,8 @@ export default function NoteBodyViewer(props: Props) {
|
||||
reg.logger().error('WebView error');
|
||||
}
|
||||
|
||||
const BackButtonDialogBox_ = BackButtonDialogBox as any;
|
||||
|
||||
// On iOS scalesPageToFit work like this:
|
||||
//
|
||||
// Find the widest image, resize it *and everything else* by x% so that
|
||||
@@ -88,21 +86,15 @@ export default function NoteBodyViewer(props: Props) {
|
||||
// 2020-10-15: As we've now fully switched to WebKit for iOS (useWebKit=true) and
|
||||
// since the WebView package went through many versions it's possible that
|
||||
// the above no longer applies.
|
||||
|
||||
const BackButtonDialogBox_ = BackButtonDialogBox as any;
|
||||
|
||||
return (
|
||||
<View style={props.style}>
|
||||
<WebView
|
||||
theme={theme}
|
||||
useWebKit={true}
|
||||
allowingReadAccessToURL={`file://${Setting.value('resourceDir')}`}
|
||||
<ExtendedWebView
|
||||
webviewInstanceId='NoteBodyViewer'
|
||||
themeId={props.themeId}
|
||||
style={webViewStyle}
|
||||
source={source}
|
||||
html={html}
|
||||
injectedJavaScript={injectedJs.join('\n')}
|
||||
originWhitelist={['file://*', './*', 'http://*', 'https://*']}
|
||||
mixedContentMode="always"
|
||||
allowFileAccess={true}
|
||||
onLoadEnd={onLoadEnd}
|
||||
onError={onError}
|
||||
onMessage={onMessage}
|
||||
|
@@ -36,5 +36,6 @@ export default function useOnResourceLongPress(onJoplinLinkClick: Function, dial
|
||||
reg.logger().error('Could not handle link long press', e);
|
||||
ToastAndroid.show('An error occurred, check log for details', ToastAndroid.SHORT);
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [onJoplinLinkClick]);
|
||||
}
|
||||
|
@@ -5,13 +5,9 @@ const { themeStyle } = require('../../global-style.js');
|
||||
import markupLanguageUtils from '@joplin/lib/markupLanguageUtils';
|
||||
const { assetsToHeaders } = require('@joplin/renderer');
|
||||
|
||||
interface Source {
|
||||
uri: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface UseSourceResult {
|
||||
source: Source;
|
||||
// [html] can be null if the note is still being rendered.
|
||||
html: string|null;
|
||||
injectedJs: string[];
|
||||
}
|
||||
|
||||
@@ -24,7 +20,7 @@ function usePrevious(value: any, initialValue: any = null): any {
|
||||
}
|
||||
|
||||
export default function useSource(noteBody: string, noteMarkupLanguage: number, themeId: number, highlightedKeywords: string[], noteResources: any, paddingBottom: number, noteHash: string): UseSourceResult {
|
||||
const [source, setSource] = useState<Source>(undefined);
|
||||
const [html, setHtml] = useState<string>('');
|
||||
const [injectedJs, setInjectedJs] = useState<string[]>([]);
|
||||
const [resourceLoadedTime, setResourceLoadedTime] = useState(0);
|
||||
const [isFirstRender, setIsFirstRender] = useState(true);
|
||||
@@ -39,6 +35,7 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
|
||||
|
||||
const markupToHtml = useMemo(() => {
|
||||
return markupLanguageUtils.newMarkupToHtml();
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [isFirstRender]);
|
||||
|
||||
// To address https://github.com/laurent22/joplin/issues/433
|
||||
@@ -82,7 +79,7 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
|
||||
resources: noteResources,
|
||||
codeTheme: theme.codeThemeCss,
|
||||
postMessageSyntax: 'window.joplinPostMessage_',
|
||||
enableLongPress: shim.mobilePlatform() === 'android', // On iOS, there's already a built-on open/share menu
|
||||
enableLongPress: true,
|
||||
};
|
||||
|
||||
// Whenever a resource state changes, for example when it goes from "not downloaded" to "downloaded", the "noteResources"
|
||||
@@ -168,20 +165,7 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
|
||||
</html>
|
||||
`;
|
||||
|
||||
const tempFile = `${Setting.value('resourceDir')}/NoteBodyViewer.html`;
|
||||
await shim.fsDriver().writeFile(tempFile, html, 'utf8');
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// Now that we are sending back a file instead of an HTML string, we're always sending back the
|
||||
// same file. So we add a cache busting query parameter to it, to make sure that the WebView re-renders.
|
||||
//
|
||||
// `baseUrl` is where the images will be loaded from. So images must use a path relative to resourceDir.
|
||||
setSource({
|
||||
uri: `file://${tempFile}?r=${Math.round(Math.random() * 100000000)}`,
|
||||
baseUrl: `file://${Setting.value('resourceDir')}/`,
|
||||
});
|
||||
|
||||
setHtml(html);
|
||||
setInjectedJs(js);
|
||||
}
|
||||
|
||||
@@ -193,7 +177,7 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
|
||||
|
||||
if (isFirstRender) {
|
||||
setIsFirstRender(false);
|
||||
setSource(undefined);
|
||||
setHtml('');
|
||||
setInjectedJs([]);
|
||||
} else {
|
||||
void renderNote();
|
||||
@@ -202,7 +186,8 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, effectDependencies);
|
||||
|
||||
return { source, injectedJs };
|
||||
return { html, injectedJs };
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
// This contains the CodeMirror instance, which needs to be built into a bundle
|
||||
// using `npm run buildInjectedJs`. This bundle is then loaded from
|
||||
// using `yarn run buildInjectedJs`. This bundle is then loaded from
|
||||
// NoteEditor.tsx into the webview.
|
||||
//
|
||||
// In general, since this file is harder to debug due to the intermediate built
|
||||
@@ -9,48 +9,52 @@
|
||||
// wrapper to access CodeMirror functionalities. Anything else should be done
|
||||
// from NoteEditor.tsx.
|
||||
|
||||
import { MarkdownMathExtension } from './markdownMathParser';
|
||||
import createTheme from './theme';
|
||||
import decoratorExtension from './decoratorExtension';
|
||||
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { highlightSelectionMatches, search } from '@codemirror/search';
|
||||
import { EditorView, drawSelection, highlightSpecialChars, ViewUpdate } from '@codemirror/view';
|
||||
import { undo, redo, history, undoDepth, redoDepth } from '@codemirror/commands';
|
||||
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { indentOnInput } from '@codemirror/language';
|
||||
import { searchKeymap } from '@codemirror/search';
|
||||
import { historyKeymap, defaultKeymap } from '@codemirror/commands';
|
||||
import { MarkdownMathExtension } from './markdownMathParser';
|
||||
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
|
||||
import syntaxHighlightingLanguages from './syntaxHighlightingLanguages';
|
||||
|
||||
interface CodeMirrorResult {
|
||||
editor: EditorView;
|
||||
undo: Function;
|
||||
redo: Function;
|
||||
select(anchor: number, head: number): void;
|
||||
scrollSelectionIntoView(): void;
|
||||
insertText(text: string): void;
|
||||
}
|
||||
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';
|
||||
|
||||
function postMessage(name: string, data: any) {
|
||||
(window as any).ReactNativeWebView.postMessage(JSON.stringify({
|
||||
data,
|
||||
name,
|
||||
}));
|
||||
}
|
||||
import {
|
||||
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command,
|
||||
} from '@codemirror/view';
|
||||
import { undo, redo, history, undoDepth, redoDepth, indentWithTab } from '@codemirror/commands';
|
||||
|
||||
function logMessage(...msg: any[]) {
|
||||
postMessage('onLog', { value: msg });
|
||||
}
|
||||
import { keymap, KeyBinding } from '@codemirror/view';
|
||||
import { searchKeymap } from '@codemirror/search';
|
||||
import { historyKeymap, defaultKeymap } from '@codemirror/commands';
|
||||
|
||||
export function initCodeMirror(parentElement: any, initialText: string, theme: any): CodeMirrorResult {
|
||||
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 {
|
||||
decreaseIndent, increaseIndent,
|
||||
toggleBolded, toggleCode,
|
||||
toggleHeaderLevel, toggleItalicized,
|
||||
toggleList, toggleMath, updateLink,
|
||||
} from './markdownCommands';
|
||||
|
||||
export function initCodeMirror(
|
||||
parentElement: any, initialText: string, settings: EditorSettings
|
||||
): CodeMirrorControl {
|
||||
logMessage('Initializing CodeMirror...');
|
||||
const theme = settings.themeData;
|
||||
|
||||
let searchVisible = false;
|
||||
|
||||
let schedulePostUndoRedoDepthChangeId_: any = 0;
|
||||
function schedulePostUndoRedoDepthChange(editor: EditorView, doItNow: boolean = false) {
|
||||
const schedulePostUndoRedoDepthChange = (editor: EditorView, doItNow: boolean = false) => {
|
||||
if (schedulePostUndoRedoDepthChangeId_) {
|
||||
if (doItNow) {
|
||||
clearTimeout(schedulePostUndoRedoDepthChangeId_);
|
||||
@@ -66,7 +70,193 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
||||
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({
|
||||
@@ -75,37 +265,76 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
||||
extensions: [
|
||||
markdown({
|
||||
extensions: [
|
||||
MarkdownMathExtension,
|
||||
GitHubFlavoredMarkdownExtension,
|
||||
|
||||
// Don't highlight KaTeX if the user disabled it
|
||||
settings.katexEnabled ? MarkdownMathExtension : [],
|
||||
],
|
||||
codeLanguages: syntaxHighlightingLanguages,
|
||||
}),
|
||||
...createTheme(theme),
|
||||
history(),
|
||||
search(),
|
||||
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(),
|
||||
// highlightSelectionMatches(),
|
||||
indentOnInput(),
|
||||
|
||||
decoratorExtension,
|
||||
EditorView.lineWrapping,
|
||||
EditorView.contentAttributes.of({ autocapitalize: 'sentence' }),
|
||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||
if (viewUpdate.docChanged) {
|
||||
postMessage('onChange', { value: editor.state.doc.toString() });
|
||||
schedulePostUndoRedoDepthChange(editor);
|
||||
}
|
||||
// By default, indent with four spaces
|
||||
indentUnit.of(' '),
|
||||
EditorState.tabSize.of(4),
|
||||
|
||||
if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
|
||||
const mainRange = viewUpdate.state.selection.main;
|
||||
const selStart = mainRange.from;
|
||||
const selEnd = mainRange.to;
|
||||
postMessage('onSelectionChange', { selection: { start: selStart, end: selEnd } });
|
||||
}
|
||||
// Apply styles to entire lines (block-display decorations)
|
||||
decoratorExtension,
|
||||
|
||||
EditorView.lineWrapping,
|
||||
EditorView.contentAttributes.of({
|
||||
autocapitalize: 'sentence',
|
||||
spellcheck: settings.spellcheckEnabled ? 'true' : 'false',
|
||||
}),
|
||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||
notifyDocChanged(viewUpdate);
|
||||
notifySelectionChange(viewUpdate);
|
||||
notifySelectionFormattingChange(viewUpdate);
|
||||
}),
|
||||
keymap.of([
|
||||
...defaultKeymap, ...historyKeymap, ...searchKeymap,
|
||||
// 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,
|
||||
]),
|
||||
],
|
||||
doc: initialText,
|
||||
@@ -113,7 +342,40 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
||||
parent: parentElement,
|
||||
});
|
||||
|
||||
return {
|
||||
// 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);
|
||||
@@ -137,5 +399,50 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
||||
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;
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,26 @@
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { GFM as GithubFlavoredMarkdownExt } from '@lezer/markdown';
|
||||
import { forceParsing, indentUnit } from '@codemirror/language';
|
||||
import { SelectionRange, EditorSelection, EditorState } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { MarkdownMathExtension } from './markdownMathParser';
|
||||
|
||||
// Creates and returns a minimal editor with markdown extensions
|
||||
const createEditor = (initialText: string, initialSelection: SelectionRange): EditorView => {
|
||||
const editor = new EditorView({
|
||||
doc: initialText,
|
||||
selection: EditorSelection.create([initialSelection]),
|
||||
extensions: [
|
||||
markdown({
|
||||
extensions: [MarkdownMathExtension, GithubFlavoredMarkdownExt],
|
||||
}),
|
||||
indentUnit.of('\t'),
|
||||
EditorState.tabSize.of(4),
|
||||
],
|
||||
});
|
||||
|
||||
forceParsing(editor);
|
||||
return editor;
|
||||
};
|
||||
|
||||
export default createEditor;
|
48
packages/app-mobile/components/NoteEditor/CodeMirror/demo.html
vendored
Normal file
48
packages/app-mobile/components/NoteEditor/CodeMirror/demo.html
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
<!--
|
||||
Open this file in a web browser to more easily debug the CodeMirror editor.
|
||||
Messages will show up in the console when posted.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<meta charset="utf-8"/>
|
||||
<title>CodeMirror test</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="CodeMirror"></div>
|
||||
<script>
|
||||
// Override the default postMessage — codeMirrorBundle expects
|
||||
// this to be present.
|
||||
window.ReactNativeWebView = {
|
||||
postMessage: message => {
|
||||
console.log('postMessage:', message);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<script src="./CodeMirror.bundle.js"></script>
|
||||
<script>
|
||||
const parent = document.querySelector('.CodeMirror');
|
||||
const initialText = 'Testing...';
|
||||
|
||||
const settings = {
|
||||
katexEnabled: true,
|
||||
themeData: {
|
||||
fontSize: 1, // em
|
||||
fontFamily: 'serif',
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
backgroundColor2: '#330',
|
||||
color2: '#ff0',
|
||||
backgroundColor3: '#404',
|
||||
color3: '#f0f',
|
||||
backgroundColor4: '#555',
|
||||
color4: '#0ff',
|
||||
appearance: 'dark',
|
||||
},
|
||||
};
|
||||
|
||||
window.cm = codeMirrorBundle.initCodeMirror(parent, initialText, settings);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import { ListType } from '../types';
|
||||
import createEditor from './createEditor';
|
||||
import { toggleList } from './markdownCommands';
|
||||
|
||||
describe('markdownCommands.bulletedVsChecklist', () => {
|
||||
const bulletedListPart = '- Test\n- This is a test.\n- 3\n- 4\n- 5';
|
||||
const checklistPart = '- [ ] This is a checklist\n- [ ] with multiple items.\n- [ ] ☑';
|
||||
const initialDocText = `${bulletedListPart}\n\n${checklistPart}`;
|
||||
|
||||
it('should remove a checklist following a bulleted list without modifying the bulleted list', () => {
|
||||
const editor = createEditor(
|
||||
initialDocText, EditorSelection.cursor(bulletedListPart.length + 5)
|
||||
);
|
||||
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
`${bulletedListPart}\n\nThis is a checklist\nwith multiple items.\n☑`
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove an unordered list following a checklist without modifying the checklist', () => {
|
||||
const editor = createEditor(
|
||||
initialDocText, EditorSelection.cursor(bulletedListPart.length - 5)
|
||||
);
|
||||
|
||||
toggleList(ListType.UnorderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
`Test\nThis is a test.\n3\n4\n5\n\n${checklistPart}`
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace a selection of unordered and task lists with a correctly-numbered list', () => {
|
||||
const editor = createEditor(
|
||||
initialDocText, EditorSelection.range(0, initialDocText.length)
|
||||
);
|
||||
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'1. Test\n2. This is a test.\n3. 3\n4. 4\n5. 5'
|
||||
+ '\n\n6. This is a checklist\n7. with multiple items.\n8. ☑'
|
||||
);
|
||||
});
|
||||
});
|
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import {
|
||||
toggleBolded, toggleCode, toggleHeaderLevel, toggleItalicized, toggleMath, updateLink,
|
||||
} from './markdownCommands';
|
||||
import { GFM as GithubFlavoredMarkdownExt } from '@lezer/markdown';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { MarkdownMathExtension } from './markdownMathParser';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
|
||||
// Creates and returns a minimal editor with markdown extensions
|
||||
const createEditor = (initialText: string, initialSelection: SelectionRange): EditorView => {
|
||||
return new EditorView({
|
||||
doc: initialText,
|
||||
selection: EditorSelection.create([initialSelection]),
|
||||
extensions: [
|
||||
markdown({
|
||||
extensions: [MarkdownMathExtension, GithubFlavoredMarkdownExt],
|
||||
}),
|
||||
indentUnit.of('\t'),
|
||||
EditorState.tabSize.of(4),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
describe('markdownCommands', () => {
|
||||
it('should bold/italicize everything selected', () => {
|
||||
const initialDocText = 'Testing...';
|
||||
const editor = createEditor(
|
||||
initialDocText, EditorSelection.range(0, initialDocText.length)
|
||||
);
|
||||
|
||||
toggleBolded(editor);
|
||||
|
||||
let mainSel = editor.state.selection.main;
|
||||
const boldedText = '**Testing...**';
|
||||
expect(editor.state.doc.toString()).toBe(boldedText);
|
||||
expect(mainSel.from).toBe(0);
|
||||
expect(mainSel.to).toBe(boldedText.length);
|
||||
|
||||
toggleBolded(editor);
|
||||
mainSel = editor.state.selection.main;
|
||||
expect(editor.state.doc.toString()).toBe(initialDocText);
|
||||
expect(mainSel.from).toBe(0);
|
||||
expect(mainSel.to).toBe(initialDocText.length);
|
||||
|
||||
toggleItalicized(editor);
|
||||
expect(editor.state.doc.toString()).toBe('*Testing...*');
|
||||
|
||||
toggleItalicized(editor);
|
||||
expect(editor.state.doc.toString()).toBe('Testing...');
|
||||
});
|
||||
|
||||
it('for a cursor, bolding, then italicizing, should produce a bold-italic region', () => {
|
||||
const initialDocText = '';
|
||||
const editor = createEditor(
|
||||
initialDocText, EditorSelection.cursor(0)
|
||||
);
|
||||
|
||||
toggleBolded(editor);
|
||||
toggleItalicized(editor);
|
||||
expect(editor.state.doc.toString()).toBe('******');
|
||||
|
||||
editor.dispatch(editor.state.replaceSelection('Test'));
|
||||
expect(editor.state.doc.toString()).toBe('***Test***');
|
||||
|
||||
toggleItalicized(editor);
|
||||
editor.dispatch(editor.state.replaceSelection(' Test'));
|
||||
expect(editor.state.doc.toString()).toBe('***Test*** Test');
|
||||
});
|
||||
|
||||
it('toggling math should both create and navigate out of math regions', () => {
|
||||
const initialDocText = 'Testing... ';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
|
||||
|
||||
toggleMath(editor);
|
||||
expect(editor.state.doc.toString()).toBe('Testing... $$');
|
||||
expect(editor.state.selection.main.empty).toBe(true);
|
||||
|
||||
editor.dispatch(editor.state.replaceSelection('3 + 3 \\neq 5'));
|
||||
expect(editor.state.doc.toString()).toBe('Testing... $3 + 3 \\neq 5$');
|
||||
|
||||
toggleMath(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('...'));
|
||||
expect(editor.state.doc.toString()).toBe('Testing... $3 + 3 \\neq 5$...');
|
||||
});
|
||||
|
||||
it('toggling inline code should both create and navigate out of an inline code region', () => {
|
||||
const initialDocText = 'Testing...\n\n';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
|
||||
|
||||
toggleCode(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
|
||||
toggleCode(editor);
|
||||
|
||||
editor.dispatch(editor.state.replaceSelection(' is a function.'));
|
||||
expect(editor.state.doc.toString()).toBe('Testing...\n\n`f(x) = ...` is a function.');
|
||||
});
|
||||
|
||||
it('should set headers to the proper levels (when toggling)', () => {
|
||||
const initialDocText = 'Testing...\nThis is a test.';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(3));
|
||||
|
||||
toggleHeaderLevel(1)(editor);
|
||||
|
||||
let mainSel = editor.state.selection.main;
|
||||
expect(editor.state.doc.toString()).toBe('# Testing...\nThis is a test.');
|
||||
expect(mainSel.empty).toBe(true);
|
||||
expect(mainSel.from).toBe('# Testing...'.length);
|
||||
|
||||
toggleHeaderLevel(2)(editor);
|
||||
|
||||
mainSel = editor.state.selection.main;
|
||||
expect(editor.state.doc.toString()).toBe('## Testing...\nThis is a test.');
|
||||
expect(mainSel.empty).toBe(true);
|
||||
expect(mainSel.from).toBe('## Testing...'.length);
|
||||
|
||||
toggleHeaderLevel(2)(editor);
|
||||
|
||||
mainSel = editor.state.selection.main;
|
||||
expect(editor.state.doc.toString()).toEqual(initialDocText);
|
||||
expect(mainSel.empty).toBe(true);
|
||||
expect(mainSel.from).toBe('Testing...'.length);
|
||||
});
|
||||
|
||||
it('headers should toggle properly within block quotes', () => {
|
||||
const initialDocText = 'Testing...\n\n> This is a test.\n> ...a test';
|
||||
const editor = createEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor('Testing...\n\n> This'.length)
|
||||
);
|
||||
|
||||
toggleHeaderLevel(1)(editor);
|
||||
|
||||
const mainSel = editor.state.selection.main;
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'Testing...\n\n> # This is a test.\n> ...a test'
|
||||
);
|
||||
expect(mainSel.empty).toBe(true);
|
||||
expect(mainSel.from).toBe('Testing...\n\n> # This is a test.'.length);
|
||||
|
||||
toggleHeaderLevel(3)(editor);
|
||||
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'Testing...\n\n> ### This is a test.\n> ...a test'
|
||||
);
|
||||
});
|
||||
|
||||
it('block math should properly toggle within block quotes', () => {
|
||||
const initialDocText = 'Testing...\n\n> This is a test.\n> y = mx + b\n> ...a test';
|
||||
const editor = createEditor(
|
||||
initialDocText,
|
||||
EditorSelection.range(
|
||||
'Testing...\n\n> This'.length,
|
||||
'Testing...\n\n> This is a test.\n> y = mx + b'.length
|
||||
)
|
||||
);
|
||||
|
||||
toggleMath(editor);
|
||||
|
||||
// Toggling math should surround the content in '$$'s
|
||||
let mainSel = editor.state.selection.main;
|
||||
expect(editor.state.doc.toString()).toEqual(
|
||||
'Testing...\n\n> $$\n> This is a test.\n> y = mx + b\n> $$\n> ...a test'
|
||||
);
|
||||
expect(mainSel.from).toBe('Testing...\n\n'.length);
|
||||
expect(mainSel.to).toBe('Testing...\n\n> $$\n> This is a test.\n> y = mx + b\n> $$'.length);
|
||||
|
||||
// Change to a cursor --- test cursor expansion
|
||||
editor.dispatch({
|
||||
selection: EditorSelection.cursor('Testing...\n\n> $$\n> This is'.length),
|
||||
});
|
||||
|
||||
// Toggling math again should remove the '$$'s
|
||||
toggleMath(editor);
|
||||
mainSel = editor.state.selection.main;
|
||||
expect(editor.state.doc.toString()).toEqual(initialDocText);
|
||||
expect(mainSel.from).toBe('Testing...\n\n'.length);
|
||||
expect(mainSel.to).toBe('Testing...\n\n> This is a test.\n> y = mx + b'.length);
|
||||
});
|
||||
|
||||
it('updateLink should replace link titles and isolate URLs if no title is given', () => {
|
||||
const initialDocText = '[foo](http://example.com/)';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor('[f'.length));
|
||||
|
||||
updateLink('bar', 'https://example.com/')(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'[bar](https://example.com/)'
|
||||
);
|
||||
|
||||
updateLink('', 'https://example.com/')(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'https://example.com/'
|
||||
);
|
||||
});
|
||||
|
||||
it('toggling math twice, starting on a line with content, should a math block', () => {
|
||||
const initialDocText = 'Testing... ';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
|
||||
|
||||
toggleMath(editor);
|
||||
toggleMath(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
|
||||
expect(editor.state.doc.toString()).toBe('Testing... \n$$\nf(x) = ...\n$$');
|
||||
});
|
||||
|
||||
it('toggling math twice on an empty line should create an empty math block', () => {
|
||||
const initialDocText = 'Testing...\n\n';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
|
||||
|
||||
toggleMath(editor);
|
||||
toggleMath(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
|
||||
expect(editor.state.doc.toString()).toBe('Testing...\n\n$$\nf(x) = ...\n$$');
|
||||
});
|
||||
|
||||
it('toggling code twice on an empty line should create an empty code block', () => {
|
||||
const initialDocText = 'Testing...\n\n';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
|
||||
|
||||
// Toggling code twice should create a block code region
|
||||
toggleCode(editor);
|
||||
toggleCode(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
|
||||
expect(editor.state.doc.toString()).toBe('Testing...\n\n```\nf(x) = ...\n```');
|
||||
|
||||
toggleCode(editor);
|
||||
expect(editor.state.doc.toString()).toBe('Testing...\n\nf(x) = ...\n');
|
||||
});
|
||||
|
||||
it('toggling math twice inside a block quote should produce an empty math block', () => {
|
||||
const initialDocText = '> Testing...> \n> ';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
|
||||
|
||||
toggleMath(editor);
|
||||
toggleMath(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'> Testing...> \n> \n> $$\n> f(x) = ...\n> $$'
|
||||
);
|
||||
|
||||
// If we toggle math again, everything from the start of the line with the first
|
||||
// $$ to the end of the document should be selected.
|
||||
toggleMath(editor);
|
||||
const sel = editor.state.selection.main;
|
||||
expect(sel.from).toBe('> Testing...> \n> \n'.length);
|
||||
expect(sel.to).toBe(editor.state.doc.length);
|
||||
});
|
||||
|
||||
it('toggling inline code should both create and navigate out of an inline code region', () => {
|
||||
const initialDocText = 'Testing...\n\n';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
|
||||
|
||||
toggleCode(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
|
||||
toggleCode(editor);
|
||||
|
||||
editor.dispatch(editor.state.replaceSelection(' is a function.'));
|
||||
expect(editor.state.doc.toString()).toBe('Testing...\n\n`f(x) = ...` is a function.');
|
||||
});
|
||||
});
|
||||
|
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { EditorSelection, EditorState } from '@codemirror/state';
|
||||
import {
|
||||
increaseIndent, toggleList,
|
||||
} from './markdownCommands';
|
||||
import { ListType } from '../types';
|
||||
import createEditor from './createEditor';
|
||||
|
||||
describe('markdownCommands.toggleList', () => {
|
||||
it('should remove the same type of list', () => {
|
||||
const initialDocText = '- testing\n- this is a test';
|
||||
|
||||
const editor = createEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor(5)
|
||||
);
|
||||
|
||||
toggleList(ListType.UnorderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'testing\nthis is a test'
|
||||
);
|
||||
});
|
||||
|
||||
it('should insert a numbered list with correct numbering', () => {
|
||||
const initialDocText = 'Testing...\nThis is a test\nof list toggling...';
|
||||
const editor = createEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor('Testing...\nThis is a'.length)
|
||||
);
|
||||
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'Testing...\n1. This is a test\nof list toggling...'
|
||||
);
|
||||
|
||||
editor.setState(EditorState.create({
|
||||
doc: initialDocText,
|
||||
selection: EditorSelection.range(4, initialDocText.length),
|
||||
}));
|
||||
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'1. Testing...\n2. This is a test\n3. of list toggling...'
|
||||
);
|
||||
});
|
||||
|
||||
const numberedListText = '- 1\n- 2\n- 3\n- 4\n- 5\n- 6\n- 7';
|
||||
|
||||
it('should correctly replace an unordered list with a numbered list', () => {
|
||||
const editor = createEditor(
|
||||
numberedListText,
|
||||
EditorSelection.cursor(numberedListText.length)
|
||||
);
|
||||
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'1. 1\n2. 2\n3. 3\n4. 4\n5. 5\n6. 6\n7. 7'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it('should correctly replace an unordered list with a checklist', () => {
|
||||
const editor = createEditor(
|
||||
numberedListText,
|
||||
EditorSelection.cursor(numberedListText.length)
|
||||
);
|
||||
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'- [ ] 1\n- [ ] 2\n- [ ] 3\n- [ ] 4\n- [ ] 5\n- [ ] 6\n- [ ] 7'
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly toggle a sublist of a bulleted list', () => {
|
||||
const preSubListText = '# List test\n * This\n * is\n';
|
||||
const initialDocText = `${preSubListText}\t* a\n\t* test\n * of list toggling`;
|
||||
|
||||
const editor = createEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor(preSubListText.length + '\t* a'.length)
|
||||
);
|
||||
|
||||
// Indentation should be preserved when changing list types
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'# List test\n * This\n * is\n\t1. a\n\t2. test\n * of list toggling'
|
||||
);
|
||||
|
||||
// The changed region should be selected
|
||||
expect(editor.state.selection.main.from).toBe(preSubListText.length);
|
||||
expect(editor.state.selection.main.to).toBe(
|
||||
`${preSubListText}\t1. a\n\t2. test`.length
|
||||
);
|
||||
|
||||
// Indentation should not be preserved when removing lists
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.selection.main.from).toBe(preSubListText.length);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'# List test\n * This\n * is\na\ntest\n * of list toggling'
|
||||
);
|
||||
|
||||
|
||||
// Put the cursor in the middle of the list
|
||||
editor.dispatch({ selection: EditorSelection.cursor(preSubListText.length) });
|
||||
|
||||
// Sublists should be changed
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
const expectedChecklistPart =
|
||||
'# List test\n - [ ] This\n - [ ] is\n - [ ] a\n - [ ] test\n - [ ] of list toggling';
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
expectedChecklistPart
|
||||
);
|
||||
|
||||
editor.dispatch({ selection: EditorSelection.cursor(editor.state.doc.length) });
|
||||
editor.dispatch(editor.state.replaceSelection('\n\n\n'));
|
||||
|
||||
// toggleList should also create a new list if the cursor is on an empty line.
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('Test.\n2. Test2\n3. Test3'));
|
||||
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
`${expectedChecklistPart}\n\n\n1. Test.\n2. Test2\n3. Test3`
|
||||
);
|
||||
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
`${expectedChecklistPart}\n\n\n- [ ] Test.\n- [ ] Test2\n- [ ] Test3`
|
||||
);
|
||||
|
||||
// The entire checklist should have been selected (and thus will now be indented)
|
||||
increaseIndent(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
`${expectedChecklistPart}\n\n\n\t- [ ] Test.\n\t- [ ] Test2\n\t- [ ] Test3`
|
||||
);
|
||||
});
|
||||
|
||||
it('should toggle a numbered list without changing its sublists', () => {
|
||||
const initialDocText = '1. Foo\n2. Bar\n3. Baz\n\t- Test\n\t- of\n\t- sublists\n4. Foo';
|
||||
|
||||
const editor = createEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor(0)
|
||||
);
|
||||
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'- [ ] Foo\n- [ ] Bar\n- [ ] Baz\n\t- Test\n\t- of\n\t- sublists\n- [ ] Foo'
|
||||
);
|
||||
});
|
||||
|
||||
it('should toggle a sublist without changing the parent list', () => {
|
||||
const initialDocText = '1. This\n2. is\n3. ';
|
||||
|
||||
const editor = createEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor(initialDocText.length)
|
||||
);
|
||||
|
||||
increaseIndent(editor);
|
||||
expect(editor.state.selection.main.empty).toBe(true);
|
||||
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'1. This\n2. is\n\t- [ ] '
|
||||
);
|
||||
|
||||
editor.dispatch(editor.state.replaceSelection('a test.'));
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'1. This\n2. is\n\t- [ ] a test.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should toggle lists properly within block quotes', () => {
|
||||
const preSubListText = '> # List test\n> * This\n> * is\n';
|
||||
const initialDocText = `${preSubListText}> \t* a\n> \t* test\n> * of list toggling`;
|
||||
const editor = createEditor(
|
||||
initialDocText, EditorSelection.cursor(preSubListText.length + 3)
|
||||
);
|
||||
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'> # List test\n> * This\n> * is\n> \t1. a\n> \t2. test\n> * of list toggling'
|
||||
);
|
||||
expect(editor.state.selection.main.from).toBe(preSubListText.length);
|
||||
});
|
||||
});
|
@@ -0,0 +1,485 @@
|
||||
// CodeMirror 6 commands that modify markdown formatting (e.g. toggleBold).
|
||||
|
||||
import { EditorView, Command } from '@codemirror/view';
|
||||
|
||||
import { ListType } from '../types';
|
||||
import {
|
||||
SelectionRange, EditorSelection, ChangeSpec, Line, TransactionSpec,
|
||||
} from '@codemirror/state';
|
||||
import { getIndentUnit, indentString, syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
RegionSpec, growSelectionToNode, renumberList,
|
||||
toggleInlineFormatGlobally, toggleRegionFormatGlobally, toggleSelectedLinesStartWith,
|
||||
isIndentationEquivalent, stripBlockquote, tabsToSpaces,
|
||||
} from './markdownReformatter';
|
||||
|
||||
const startingSpaceRegex = /^(\s*)/;
|
||||
|
||||
export const toggleBolded: Command = (view: EditorView): boolean => {
|
||||
const spec = RegionSpec.of({ template: '**', nodeName: 'StrongEmphasis' });
|
||||
const changes = toggleInlineFormatGlobally(view.state, spec);
|
||||
|
||||
view.dispatch(changes);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const toggleItalicized: Command = (view: EditorView): boolean => {
|
||||
let handledBoldItalicRegion = false;
|
||||
|
||||
// Bold-italic regions' starting and ending patterns are similar to italicized regions.
|
||||
// Thus, we need additional logic to convert bold regions to bold-italic regions.
|
||||
view.dispatch(view.state.changeByRange((sel: SelectionRange) => {
|
||||
const changes: ChangeSpec[] = [];
|
||||
|
||||
// Only handle cursors (empty selections)
|
||||
if (sel.empty) {
|
||||
const doc = view.state.doc;
|
||||
const selLine = doc.lineAt(sel.from);
|
||||
|
||||
const selStartLineIdx = sel.from - selLine.from;
|
||||
const selEndLineIdx = sel.to - selLine.from;
|
||||
const beforeSel = selLine.text.substring(0, selStartLineIdx);
|
||||
const afterSel = selLine.text.substring(selEndLineIdx);
|
||||
|
||||
const isBolded = beforeSel.endsWith('**') && afterSel.startsWith('**');
|
||||
|
||||
// If at the end of a bold-italic region, exit the region.
|
||||
if (afterSel.startsWith('***')) {
|
||||
sel = EditorSelection.cursor(sel.to + 3);
|
||||
handledBoldItalicRegion = true;
|
||||
} else if (isBolded) {
|
||||
// Create a bold-italic region.
|
||||
changes.push({
|
||||
from: sel.from,
|
||||
to: sel.to,
|
||||
insert: '**',
|
||||
});
|
||||
|
||||
// Move to the center of the bold-italic region (**|**** -> ***|***)
|
||||
sel = EditorSelection.cursor(sel.to + 1);
|
||||
handledBoldItalicRegion = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
range: sel,
|
||||
};
|
||||
}));
|
||||
|
||||
if (!handledBoldItalicRegion) {
|
||||
const changes = toggleInlineFormatGlobally(view.state, {
|
||||
nodeName: 'Emphasis',
|
||||
|
||||
template: { start: '*', end: '*' },
|
||||
matcher: { start: /[_*]/g, end: /[_*]/g },
|
||||
});
|
||||
view.dispatch(changes);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// If the selected region is an empty inline code block, it will be converted to
|
||||
// a block (fenced) code block.
|
||||
export const toggleCode: Command = (view: EditorView): boolean => {
|
||||
const codeFenceRegex = /^```\w*\s*$/;
|
||||
const inlineRegionSpec = RegionSpec.of({ template: '`', nodeName: 'InlineCode' });
|
||||
const blockRegionSpec: RegionSpec = {
|
||||
nodeName: 'FencedCode',
|
||||
template: { start: '```', end: '```' },
|
||||
matcher: { start: codeFenceRegex, end: codeFenceRegex },
|
||||
};
|
||||
|
||||
const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec);
|
||||
view.dispatch(changes);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const toggleMath: Command = (view: EditorView): boolean => {
|
||||
const blockStartRegex = /^\$\$/;
|
||||
const blockEndRegex = /\$\$\s*$/;
|
||||
const inlineRegionSpec = RegionSpec.of({ nodeName: 'InlineMath', template: '$' });
|
||||
const blockRegionSpec = RegionSpec.of({
|
||||
nodeName: 'BlockMath',
|
||||
template: '$$',
|
||||
matcher: {
|
||||
start: blockStartRegex,
|
||||
end: blockEndRegex,
|
||||
},
|
||||
});
|
||||
|
||||
const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec);
|
||||
view.dispatch(changes);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const toggleList = (listType: ListType): Command => {
|
||||
return (view: EditorView): boolean => {
|
||||
let state = view.state;
|
||||
let doc = state.doc;
|
||||
|
||||
const orderedListTag = 'OrderedList';
|
||||
const unorderedListTag = 'BulletList';
|
||||
|
||||
// RegExps for different list types. The regular expressions MUST
|
||||
// be mutually exclusive.
|
||||
// `(?!\[[ xX]+\]\s?)` means "not followed by [x] or [ ]".
|
||||
const bulletedRegex = /^\s*([-*])(?!\s\[[ xX]+\])\s?/;
|
||||
const checklistRegex = /^\s*[-*]\s\[[ xX]+\]\s?/;
|
||||
const numberedRegex = /^\s*\d+\.\s?/;
|
||||
|
||||
const listRegexes: Record<ListType, RegExp> = {
|
||||
[ListType.OrderedList]: numberedRegex,
|
||||
[ListType.CheckList]: checklistRegex,
|
||||
[ListType.UnorderedList]: bulletedRegex,
|
||||
};
|
||||
|
||||
const getContainerType = (line: Line): ListType|null => {
|
||||
const lineContent = stripBlockquote(line);
|
||||
|
||||
// Determine the container's type.
|
||||
const checklistMatch = lineContent.match(checklistRegex);
|
||||
const bulletListMatch = lineContent.match(bulletedRegex);
|
||||
const orderedListMatch = lineContent.match(numberedRegex);
|
||||
|
||||
if (checklistMatch) {
|
||||
return ListType.CheckList;
|
||||
} else if (bulletListMatch) {
|
||||
return ListType.UnorderedList;
|
||||
} else if (orderedListMatch) {
|
||||
return ListType.OrderedList;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const changes: TransactionSpec = state.changeByRange((sel: SelectionRange) => {
|
||||
const changes: ChangeSpec[] = [];
|
||||
let containerType: ListType|null = null;
|
||||
|
||||
// Total number of characters added (deleted if negative)
|
||||
let charsAdded = 0;
|
||||
|
||||
const originalSel = sel;
|
||||
let fromLine: Line;
|
||||
let toLine: Line;
|
||||
let firstLineIndentation: string;
|
||||
let firstLineInBlockQuote: boolean;
|
||||
let fromLineContent: string;
|
||||
const computeSelectionProps = () => {
|
||||
fromLine = doc.lineAt(sel.from);
|
||||
toLine = doc.lineAt(sel.to);
|
||||
fromLineContent = stripBlockquote(fromLine);
|
||||
firstLineIndentation = fromLineContent.match(startingSpaceRegex)[0];
|
||||
firstLineInBlockQuote = (fromLineContent !== fromLine.text);
|
||||
|
||||
containerType = getContainerType(fromLine);
|
||||
};
|
||||
computeSelectionProps();
|
||||
|
||||
const origFirstLineIndentation = firstLineIndentation;
|
||||
const origContainerType = containerType;
|
||||
|
||||
// Grow [sel] to the smallest containing list
|
||||
if (sel.empty) {
|
||||
sel = growSelectionToNode(state, sel, [orderedListTag, unorderedListTag]);
|
||||
computeSelectionProps();
|
||||
}
|
||||
|
||||
// Reset the selection if it seems likely the user didn't want the selection
|
||||
// to be expanded
|
||||
const isIndentationDiff =
|
||||
!isIndentationEquivalent(state, firstLineIndentation, origFirstLineIndentation);
|
||||
if (isIndentationDiff) {
|
||||
const expandedRegionIndentation = firstLineIndentation;
|
||||
sel = originalSel;
|
||||
computeSelectionProps();
|
||||
|
||||
// Use the indentation level of the expanded region if it's greater.
|
||||
// This makes sense in the case where unindented text is being converted to
|
||||
// the same type of list as its container. For example,
|
||||
// 1. Foobar
|
||||
// unindented text
|
||||
// that should be made a part of the above list.
|
||||
//
|
||||
// becoming
|
||||
//
|
||||
// 1. Foobar
|
||||
// 2. unindented text
|
||||
// 3. that should be made a part of the above list.
|
||||
const wasGreaterIndentation = (
|
||||
tabsToSpaces(state, expandedRegionIndentation).length
|
||||
> tabsToSpaces(state, firstLineIndentation).length
|
||||
);
|
||||
if (wasGreaterIndentation) {
|
||||
firstLineIndentation = expandedRegionIndentation;
|
||||
}
|
||||
} else if (
|
||||
(origContainerType !== containerType && (origContainerType ?? null) !== null)
|
||||
|| containerType !== getContainerType(toLine)
|
||||
) {
|
||||
// If the container type changed, this could be an artifact of checklists/bulleted
|
||||
// lists sharing the same node type.
|
||||
// Find the closest range of the same type of list to the original selection
|
||||
let newFromLineNo = doc.lineAt(originalSel.from).number;
|
||||
let newToLineNo = doc.lineAt(originalSel.to).number;
|
||||
let lastFromLineNo;
|
||||
let lastToLineNo;
|
||||
|
||||
while (newFromLineNo !== lastFromLineNo || newToLineNo !== lastToLineNo) {
|
||||
lastFromLineNo = newFromLineNo;
|
||||
lastToLineNo = newToLineNo;
|
||||
|
||||
if (lastFromLineNo - 1 >= 1) {
|
||||
const testFromLine = doc.line(lastFromLineNo - 1);
|
||||
if (getContainerType(testFromLine) === origContainerType) {
|
||||
newFromLineNo --;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastToLineNo + 1 <= doc.lines) {
|
||||
const testToLine = doc.line(lastToLineNo + 1);
|
||||
if (getContainerType(testToLine) === origContainerType) {
|
||||
newToLineNo ++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sel = EditorSelection.range(
|
||||
doc.line(newFromLineNo).from,
|
||||
doc.line(newToLineNo).to
|
||||
);
|
||||
computeSelectionProps();
|
||||
}
|
||||
|
||||
// Determine whether the expanded selection should be empty
|
||||
if (originalSel.empty && fromLine.number === toLine.number) {
|
||||
sel = EditorSelection.cursor(toLine.to);
|
||||
}
|
||||
|
||||
// Select entire lines (if not just a cursor)
|
||||
if (!sel.empty) {
|
||||
sel = EditorSelection.range(fromLine.from, toLine.to);
|
||||
}
|
||||
|
||||
// Number of the item in the list (e.g. 2 for the 2nd item in the list)
|
||||
let listItemCounter = 1;
|
||||
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum ++) {
|
||||
const line = doc.line(lineNum);
|
||||
const lineContent = stripBlockquote(line);
|
||||
const lineContentFrom = line.to - lineContent.length;
|
||||
const inBlockQuote = (lineContent !== line.text);
|
||||
const indentation = lineContent.match(startingSpaceRegex)[0];
|
||||
|
||||
const wrongIndentaton = !isIndentationEquivalent(state, indentation, firstLineIndentation);
|
||||
|
||||
// If not the right list level,
|
||||
if (inBlockQuote !== firstLineInBlockQuote || wrongIndentaton) {
|
||||
// We'll be starting a new list
|
||||
listItemCounter = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't add list numbers to otherwise empty lines (unless it's the first line)
|
||||
if (lineNum !== fromLine.number && line.text.trim().length === 0) {
|
||||
// Do not reset the counter -- the markdown renderer doesn't!
|
||||
continue;
|
||||
}
|
||||
|
||||
const deleteFrom = lineContentFrom;
|
||||
let deleteTo = deleteFrom + indentation.length;
|
||||
|
||||
// If we need to remove an existing list,
|
||||
const currentContainer = getContainerType(line);
|
||||
if (currentContainer !== null) {
|
||||
const containerRegex = listRegexes[currentContainer];
|
||||
const containerMatch = lineContent.match(containerRegex);
|
||||
if (!containerMatch) {
|
||||
throw new Error(
|
||||
'Assertion failed: container regex does not match line content.'
|
||||
);
|
||||
}
|
||||
|
||||
deleteTo = lineContentFrom + containerMatch[0].length;
|
||||
}
|
||||
|
||||
let replacementString;
|
||||
|
||||
if (listType === containerType) {
|
||||
// Delete the existing list if it's the same type as the current
|
||||
replacementString = '';
|
||||
} else if (listType === ListType.OrderedList) {
|
||||
replacementString = `${firstLineIndentation}${listItemCounter}. `;
|
||||
} else if (listType === ListType.CheckList) {
|
||||
replacementString = `${firstLineIndentation}- [ ] `;
|
||||
} else {
|
||||
replacementString = `${firstLineIndentation}- `;
|
||||
}
|
||||
|
||||
changes.push({
|
||||
from: deleteFrom,
|
||||
to: deleteTo,
|
||||
insert: replacementString,
|
||||
});
|
||||
charsAdded -= deleteTo - deleteFrom;
|
||||
charsAdded += replacementString.length;
|
||||
listItemCounter++;
|
||||
}
|
||||
|
||||
// Don't change cursors to selections
|
||||
if (sel.empty) {
|
||||
// Position the cursor at the end of the last line modified
|
||||
sel = EditorSelection.cursor(toLine.to + charsAdded);
|
||||
} else {
|
||||
sel = EditorSelection.range(
|
||||
sel.from,
|
||||
sel.to + charsAdded
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
range: sel,
|
||||
};
|
||||
});
|
||||
view.dispatch(changes);
|
||||
state = view.state;
|
||||
doc = state.doc;
|
||||
|
||||
// Renumber the list
|
||||
view.dispatch(state.changeByRange((sel: SelectionRange) => {
|
||||
return renumberList(state, sel);
|
||||
}));
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
export const toggleHeaderLevel = (level: number): Command => {
|
||||
return (view: EditorView): boolean => {
|
||||
let headerStr = '';
|
||||
for (let i = 0; i < level; i++) {
|
||||
headerStr += '#';
|
||||
}
|
||||
|
||||
const matchEmpty = true;
|
||||
// Remove header formatting for any other level
|
||||
let changes = toggleSelectedLinesStartWith(
|
||||
view.state,
|
||||
new RegExp(
|
||||
// Check all numbers of #s lower than [level]
|
||||
`${level - 1 >= 1 ? `(?:^[#]{1,${level - 1}}\\s)|` : ''
|
||||
|
||||
// Check all number of #s higher than [level]
|
||||
}(?:^[#]{${level + 1},}\\s)`
|
||||
),
|
||||
'',
|
||||
matchEmpty
|
||||
);
|
||||
view.dispatch(changes);
|
||||
|
||||
// Set to the proper header level
|
||||
changes = toggleSelectedLinesStartWith(
|
||||
view.state,
|
||||
// We want exactly [level] '#' characters.
|
||||
new RegExp(`^[#]{${level}} `),
|
||||
`${headerStr} `,
|
||||
matchEmpty
|
||||
);
|
||||
view.dispatch(changes);
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
// Prepends the given editor's indentUnit to all lines of the current selection
|
||||
// and re-numbers modified ordered lists (if any).
|
||||
export const increaseIndent: Command = (view: EditorView): boolean => {
|
||||
const matchEmpty = true;
|
||||
const matchNothing = /$ ^/;
|
||||
const indentUnit = indentString(view.state, getIndentUnit(view.state));
|
||||
|
||||
const changes = toggleSelectedLinesStartWith(
|
||||
view.state,
|
||||
// Delete nothing
|
||||
matchNothing,
|
||||
// ...and thus always add indentUnit.
|
||||
indentUnit,
|
||||
matchEmpty
|
||||
);
|
||||
view.dispatch(changes);
|
||||
|
||||
// Fix any lists
|
||||
view.dispatch(view.state.changeByRange((sel: SelectionRange) => {
|
||||
return renumberList(view.state, sel);
|
||||
}));
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const decreaseIndent: Command = (view: EditorView): boolean => {
|
||||
const matchEmpty = true;
|
||||
const changes = toggleSelectedLinesStartWith(
|
||||
view.state,
|
||||
// Assume indentation is either a tab or in units
|
||||
// of n spaces.
|
||||
new RegExp(`^(?:[\\t]|[ ]{1,${getIndentUnit(view.state)}})`),
|
||||
// Don't add new text
|
||||
'',
|
||||
matchEmpty
|
||||
);
|
||||
|
||||
view.dispatch(changes);
|
||||
|
||||
// Fix any lists
|
||||
view.dispatch(view.state.changeByRange((sel: SelectionRange) => {
|
||||
return renumberList(view.state, sel);
|
||||
}));
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const updateLink = (label: string, url: string): Command => {
|
||||
// Empty label? Just include the URL.
|
||||
const linkText = label === '' ? url : `[${label}](${url})`;
|
||||
|
||||
return (editor: EditorView): boolean => {
|
||||
const transaction = editor.state.changeByRange((sel: SelectionRange) => {
|
||||
const changes = [];
|
||||
|
||||
// Search for a link that overlaps [sel]
|
||||
let linkFrom: number | null = null;
|
||||
let linkTo: number | null = null;
|
||||
syntaxTree(editor.state).iterate({
|
||||
from: sel.from, to: sel.to,
|
||||
enter: node => {
|
||||
const haveFoundLink = (linkFrom !== null && linkTo !== null);
|
||||
|
||||
if (node.name === 'Link' || (node.name === 'URL' && !haveFoundLink)) {
|
||||
linkFrom = node.from;
|
||||
linkTo = node.to;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
linkFrom ??= sel.from;
|
||||
linkTo ??= sel.to;
|
||||
|
||||
changes.push({
|
||||
from: linkFrom, to: linkTo,
|
||||
insert: linkText,
|
||||
});
|
||||
|
||||
return {
|
||||
changes,
|
||||
range: EditorSelection.range(linkFrom, linkFrom + linkText.length),
|
||||
};
|
||||
});
|
||||
|
||||
editor.dispatch(transaction);
|
||||
return true;
|
||||
};
|
||||
};
|
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
findInlineMatch, MatchSide, RegionSpec, tabsToSpaces, toggleRegionFormatGlobally,
|
||||
} from './markdownReformatter';
|
||||
import { Text as DocumentText, EditorSelection, EditorState } from '@codemirror/state';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
|
||||
describe('markdownReformatter', () => {
|
||||
const boldSpec: RegionSpec = RegionSpec.of({
|
||||
template: '**',
|
||||
});
|
||||
|
||||
it('matching a bolded region: should return the length of the match', () => {
|
||||
const doc = DocumentText.of(['**test**']);
|
||||
const sel = EditorSelection.range(0, 5);
|
||||
|
||||
// matchStart returns the length of the match
|
||||
expect(findInlineMatch(doc, boldSpec, sel, MatchSide.Start)).toBe(2);
|
||||
});
|
||||
|
||||
it('matching a bolded region: should match the end of a region, if next to the cursor', () => {
|
||||
const doc = DocumentText.of(['**...** test.']);
|
||||
const sel = EditorSelection.range(5, 5);
|
||||
expect(findInlineMatch(doc, boldSpec, sel, MatchSide.End)).toBe(2);
|
||||
});
|
||||
|
||||
it('matching a bolded region: should return -1 if no match is found', () => {
|
||||
const doc = DocumentText.of(['**...** test.']);
|
||||
const sel = EditorSelection.range(3, 3);
|
||||
expect(findInlineMatch(doc, boldSpec, sel, MatchSide.Start)).toBe(-1);
|
||||
});
|
||||
|
||||
it('should match a custom specification of italicized regions', () => {
|
||||
const spec: RegionSpec = {
|
||||
template: { start: '*', end: '*' },
|
||||
matcher: { start: /[*_]/g, end: /[*_]/g },
|
||||
};
|
||||
const testString = 'This is a _test_';
|
||||
const testDoc = DocumentText.of([testString]);
|
||||
const fullSel = EditorSelection.range('This is a '.length, testString.length);
|
||||
|
||||
// should match the start of the region
|
||||
expect(findInlineMatch(testDoc, spec, fullSel, MatchSide.Start)).toBe(1);
|
||||
|
||||
// should match the end of the region
|
||||
expect(findInlineMatch(testDoc, spec, fullSel, MatchSide.End)).toBe(1);
|
||||
});
|
||||
|
||||
const listSpec: RegionSpec = {
|
||||
template: { start: ' - ', end: '' },
|
||||
matcher: {
|
||||
start: /^\s*[-*]\s/g,
|
||||
end: /$/g,
|
||||
},
|
||||
};
|
||||
|
||||
it('matching a custom list: should not match a list if not within the selection', () => {
|
||||
const doc = DocumentText.of(['- Test...']);
|
||||
const sel = EditorSelection.range(1, 6);
|
||||
|
||||
// Beginning of list not selected: no match
|
||||
expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(-1);
|
||||
});
|
||||
|
||||
it('matching a custom list: should match start of selected, unindented list', () => {
|
||||
const doc = DocumentText.of(['- Test...']);
|
||||
const sel = EditorSelection.range(0, 6);
|
||||
|
||||
expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(2);
|
||||
});
|
||||
|
||||
it('matching a custom list: should match start of indented list', () => {
|
||||
const doc = DocumentText.of([' - Test...']);
|
||||
const sel = EditorSelection.range(0, 6);
|
||||
|
||||
expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(5);
|
||||
});
|
||||
|
||||
it('matching a custom list: should match the end of an item in an indented list', () => {
|
||||
const doc = DocumentText.of([' - Test...']);
|
||||
const sel = EditorSelection.range(0, 6);
|
||||
|
||||
// Zero-length, but found, selection
|
||||
expect(findInlineMatch(doc, listSpec, sel, MatchSide.End)).toBe(0);
|
||||
});
|
||||
|
||||
const multiLineTestText = `Internal text manipulation
|
||||
This is a test...
|
||||
of block and inline region toggling.`;
|
||||
const codeFenceRegex = /^``````\w*\s*$/;
|
||||
const inlineCodeRegionSpec = RegionSpec.of({
|
||||
template: '`',
|
||||
nodeName: 'InlineCode',
|
||||
});
|
||||
const blockCodeRegionSpec: RegionSpec = {
|
||||
template: { start: '``````', end: '``````' },
|
||||
matcher: { start: codeFenceRegex, end: codeFenceRegex },
|
||||
};
|
||||
|
||||
it('should create an empty inline region around the cursor, if given an empty selection', () => {
|
||||
const initialState: EditorState = EditorState.create({
|
||||
doc: multiLineTestText,
|
||||
selection: EditorSelection.cursor(0),
|
||||
});
|
||||
|
||||
const changes = toggleRegionFormatGlobally(
|
||||
initialState, inlineCodeRegionSpec, blockCodeRegionSpec
|
||||
);
|
||||
|
||||
const newState = initialState.update(changes).state;
|
||||
expect(newState.doc.toString()).toEqual(`\`\`${multiLineTestText}`);
|
||||
});
|
||||
|
||||
it('should wrap multiple selected lines in block formatting', () => {
|
||||
const initialState: EditorState = EditorState.create({
|
||||
doc: multiLineTestText,
|
||||
selection: EditorSelection.range(0, multiLineTestText.length),
|
||||
});
|
||||
|
||||
const changes = toggleRegionFormatGlobally(
|
||||
initialState, inlineCodeRegionSpec, blockCodeRegionSpec
|
||||
);
|
||||
|
||||
const newState = initialState.update(changes).state;
|
||||
const editorText = newState.doc.toString();
|
||||
expect(editorText).toBe(`\`\`\`\`\`\`\n${multiLineTestText}\n\`\`\`\`\`\``);
|
||||
expect(newState.selection.main.from).toBe(0);
|
||||
expect(newState.selection.main.to).toBe(editorText.length);
|
||||
});
|
||||
|
||||
it('should convert tabs to spaces based on indentUnit', () => {
|
||||
const state: EditorState = EditorState.create({
|
||||
doc: multiLineTestText,
|
||||
selection: EditorSelection.cursor(0),
|
||||
extensions: [
|
||||
indentUnit.of(' '),
|
||||
],
|
||||
});
|
||||
expect(tabsToSpaces(state, '\t')).toBe(' ');
|
||||
expect(tabsToSpaces(state, '\t ')).toBe(' ');
|
||||
expect(tabsToSpaces(state, ' \t ')).toBe(' ');
|
||||
});
|
||||
});
|
@@ -0,0 +1,712 @@
|
||||
import {
|
||||
Text as DocumentText, EditorSelection, SelectionRange, ChangeSpec, EditorState, Line, TransactionSpec,
|
||||
} from '@codemirror/state';
|
||||
import { getIndentUnit, syntaxTree } from '@codemirror/language';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
|
||||
// pregQuote escapes text for usage in regular expressions
|
||||
const { pregQuote } = require('@joplin/lib/string-utils-common');
|
||||
|
||||
// Length of the symbol that starts a block quote
|
||||
const blockQuoteStartLen = '> '.length;
|
||||
const blockQuoteRegex = /^>\s/;
|
||||
|
||||
// Specifies the update of a single selection region and its contents
|
||||
type SelectionUpdate = { range: SelectionRange; changes?: ChangeSpec };
|
||||
|
||||
// Specifies how a to find the start/stop of a type of formatting
|
||||
interface RegionMatchSpec {
|
||||
start: RegExp;
|
||||
end: RegExp;
|
||||
}
|
||||
|
||||
// Describes a region's formatting
|
||||
export interface RegionSpec {
|
||||
// The name of the node corresponding to the region in the syntax tree
|
||||
nodeName?: string;
|
||||
|
||||
// Text to be inserted before and after the region when toggling.
|
||||
template: { start: string; end: string };
|
||||
|
||||
// How to identify the region
|
||||
matcher: RegionMatchSpec;
|
||||
}
|
||||
|
||||
export namespace RegionSpec { // eslint-disable-line no-redeclare
|
||||
interface RegionSpecConfig {
|
||||
nodeName?: string;
|
||||
template: string | { start: string; end: string };
|
||||
matcher?: RegionMatchSpec;
|
||||
}
|
||||
|
||||
// Creates a new RegionSpec, given a simplified set of options.
|
||||
// If [config.template] is a string, it is used as both the starting and ending
|
||||
// templates.
|
||||
// Similarly, if [config.matcher] is not given, a matcher is created based on
|
||||
// [config.template].
|
||||
export const of = (config: RegionSpecConfig): RegionSpec => {
|
||||
let templateStart: string, templateEnd: string;
|
||||
if (typeof config.template === 'string') {
|
||||
templateStart = config.template;
|
||||
templateEnd = config.template;
|
||||
} else {
|
||||
templateStart = config.template.start;
|
||||
templateEnd = config.template.end;
|
||||
}
|
||||
|
||||
const matcher: RegionMatchSpec =
|
||||
config.matcher ?? matcherFromTemplate(templateStart, templateEnd);
|
||||
|
||||
return {
|
||||
nodeName: config.nodeName,
|
||||
template: { start: templateStart, end: templateEnd },
|
||||
matcher,
|
||||
};
|
||||
};
|
||||
|
||||
const matcherFromTemplate = (start: string, end: string): RegionMatchSpec => {
|
||||
// See https://stackoverflow.com/a/30851002
|
||||
const escapedStart = pregQuote(start);
|
||||
const escapedEnd = pregQuote(end);
|
||||
|
||||
return {
|
||||
start: new RegExp(escapedStart, 'g'),
|
||||
end: new RegExp(escapedEnd, 'g'),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export enum MatchSide {
|
||||
Start,
|
||||
End,
|
||||
}
|
||||
|
||||
// Returns the length of a match for this in the given selection,
|
||||
// -1 if no match is found.
|
||||
export const findInlineMatch = (
|
||||
doc: DocumentText, spec: RegionSpec, sel: SelectionRange, side: MatchSide
|
||||
): number => {
|
||||
const [regex, template] = (() => {
|
||||
if (side === MatchSide.Start) {
|
||||
return [spec.matcher.start, spec.template.start];
|
||||
} else {
|
||||
return [spec.matcher.end, spec.template.end];
|
||||
}
|
||||
})();
|
||||
const [startIndex, endIndex] = (() => {
|
||||
if (!sel.empty) {
|
||||
return [sel.from, sel.to];
|
||||
}
|
||||
|
||||
const bufferSize = template.length;
|
||||
if (side === MatchSide.Start) {
|
||||
return [sel.from - bufferSize, sel.to];
|
||||
} else {
|
||||
return [sel.from, sel.to + bufferSize];
|
||||
}
|
||||
})();
|
||||
const searchText = doc.sliceString(startIndex, endIndex);
|
||||
|
||||
// Returns true if [idx] is in the right place (the match is at
|
||||
// the end of the string or the beginning based on startIndex/endIndex).
|
||||
const indexSatisfies = (idx: number, len: number): boolean => {
|
||||
idx += startIndex;
|
||||
if (side === MatchSide.Start) {
|
||||
return idx === startIndex;
|
||||
} else {
|
||||
return idx + len === endIndex;
|
||||
}
|
||||
};
|
||||
|
||||
// Enforce 'g' flag.
|
||||
if (!regex.global) {
|
||||
throw new Error('Regular expressions used by RegionSpec must have the global flag!');
|
||||
}
|
||||
|
||||
// Search from the beginning.
|
||||
regex.lastIndex = 0;
|
||||
|
||||
let foundMatch = null;
|
||||
let match;
|
||||
while ((match = regex.exec(searchText)) !== null) {
|
||||
if (indexSatisfies(match.index, match[0].length)) {
|
||||
foundMatch = match;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundMatch) {
|
||||
const matchLength = foundMatch[0].length;
|
||||
const matchIndex = foundMatch.index;
|
||||
|
||||
// If the match isn't in the right place,
|
||||
if (indexSatisfies(matchIndex, matchLength)) {
|
||||
return matchLength;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
};
|
||||
|
||||
export const stripBlockquote = (line: Line): string => {
|
||||
const match = line.text.match(blockQuoteRegex);
|
||||
|
||||
if (match) {
|
||||
return line.text.substring(match[0].length);
|
||||
}
|
||||
|
||||
return line.text;
|
||||
};
|
||||
|
||||
export const tabsToSpaces = (state: EditorState, text: string): string => {
|
||||
const chunks = text.split('\t');
|
||||
const spaceLen = getIndentUnit(state);
|
||||
let result = chunks[0];
|
||||
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
for (let j = result.length % spaceLen; j < spaceLen; j++) {
|
||||
result += ' ';
|
||||
}
|
||||
|
||||
result += chunks[i];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Returns true iff [a] (an indentation string) is roughly equivalent to [b].
|
||||
export const isIndentationEquivalent = (state: EditorState, a: string, b: string): boolean => {
|
||||
// Consider sublists to be the same as their parent list if they have the same
|
||||
// label plus or minus 1 space.
|
||||
return Math.abs(tabsToSpaces(state, a).length - tabsToSpaces(state, b).length) <= 1;
|
||||
};
|
||||
|
||||
// Expands and returns a copy of [sel] to the smallest container node with name in [nodeNames].
|
||||
export const growSelectionToNode = (
|
||||
state: EditorState, sel: SelectionRange, nodeNames: string|string[]|null
|
||||
): SelectionRange => {
|
||||
if (!nodeNames) {
|
||||
return sel;
|
||||
}
|
||||
|
||||
const isAcceptableNode = (name: string): boolean => {
|
||||
if (typeof nodeNames === 'string') {
|
||||
return name === nodeNames;
|
||||
}
|
||||
|
||||
for (const otherName of nodeNames) {
|
||||
if (otherName === name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
let newFrom = null;
|
||||
let newTo = null;
|
||||
let smallestLen = Infinity;
|
||||
|
||||
// Find the smallest range.
|
||||
syntaxTree(state).iterate({
|
||||
from: sel.from, to: sel.to,
|
||||
enter: node => {
|
||||
if (isAcceptableNode(node.name)) {
|
||||
if (node.to - node.from < smallestLen) {
|
||||
newFrom = node.from;
|
||||
newTo = node.to;
|
||||
smallestLen = newTo - newFrom;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// If it's in such a node,
|
||||
if (newFrom !== null && newTo !== null) {
|
||||
return EditorSelection.range(newFrom, newTo);
|
||||
} else {
|
||||
return sel;
|
||||
}
|
||||
};
|
||||
|
||||
// Toggles whether the given selection matches the inline region specified by [spec].
|
||||
//
|
||||
// For example, something similar to toggleSurrounded('**', '**') would surround
|
||||
// every selection range with asterisks (including the caret).
|
||||
// If the selection is already surrounded by these characters, they are
|
||||
// removed.
|
||||
const toggleInlineRegionSurrounded = (
|
||||
doc: DocumentText, sel: SelectionRange, spec: RegionSpec
|
||||
): SelectionUpdate => {
|
||||
let content = doc.sliceString(sel.from, sel.to);
|
||||
const startMatchLen = findInlineMatch(doc, spec, sel, MatchSide.Start);
|
||||
const endMatchLen = findInlineMatch(doc, spec, sel, MatchSide.End);
|
||||
|
||||
const startsWithBefore = startMatchLen >= 0;
|
||||
const endsWithAfter = endMatchLen >= 0;
|
||||
|
||||
const changes = [];
|
||||
let finalSelStart = sel.from;
|
||||
let finalSelEnd = sel.to;
|
||||
|
||||
if (startsWithBefore && endsWithAfter) {
|
||||
// Remove the before and after.
|
||||
content = content.substring(startMatchLen);
|
||||
content = content.substring(0, content.length - endMatchLen);
|
||||
|
||||
finalSelEnd -= startMatchLen + endMatchLen;
|
||||
|
||||
changes.push({
|
||||
from: sel.from,
|
||||
to: sel.to,
|
||||
insert: content,
|
||||
});
|
||||
} else {
|
||||
changes.push({
|
||||
from: sel.from,
|
||||
insert: spec.template.start,
|
||||
});
|
||||
|
||||
changes.push({
|
||||
from: sel.to,
|
||||
insert: spec.template.start,
|
||||
});
|
||||
|
||||
// If not a caret,
|
||||
if (!sel.empty) {
|
||||
// Select the surrounding chars.
|
||||
finalSelEnd += spec.template.start.length + spec.template.end.length;
|
||||
} else {
|
||||
// Position the caret within the added content.
|
||||
finalSelStart = sel.from + spec.template.start.length;
|
||||
finalSelEnd = finalSelStart;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
range: EditorSelection.range(finalSelStart, finalSelEnd),
|
||||
};
|
||||
};
|
||||
|
||||
// Returns updated selections: For all selections in the given `EditorState`, toggles
|
||||
// whether each is contained in an inline region of type [spec].
|
||||
export const toggleInlineSelectionFormat = (
|
||||
state: EditorState, spec: RegionSpec, sel: SelectionRange
|
||||
): SelectionUpdate => {
|
||||
const endMatchLen = findInlineMatch(state.doc, spec, sel, MatchSide.End);
|
||||
|
||||
// If at the end of the region, move the
|
||||
// caret to the end.
|
||||
// E.g.
|
||||
// **foobar|**
|
||||
// **foobar**|
|
||||
if (sel.empty && endMatchLen > -1) {
|
||||
const newCursorPos = sel.from + endMatchLen;
|
||||
|
||||
return {
|
||||
range: EditorSelection.cursor(newCursorPos),
|
||||
};
|
||||
}
|
||||
|
||||
// Grow the selection to encompass the entire node.
|
||||
const newRange = growSelectionToNode(state, sel, spec.nodeName);
|
||||
return toggleInlineRegionSurrounded(state.doc, newRange, spec);
|
||||
};
|
||||
|
||||
// Like toggleInlineSelectionFormat, but for all selections in [state].
|
||||
export const toggleInlineFormatGlobally = (
|
||||
state: EditorState, spec: RegionSpec
|
||||
): TransactionSpec => {
|
||||
const changes = state.changeByRange((sel: SelectionRange) => {
|
||||
return toggleInlineSelectionFormat(state, spec, sel);
|
||||
});
|
||||
return changes;
|
||||
};
|
||||
|
||||
// Toggle formatting in a region, applying block formatting
|
||||
export const toggleRegionFormatGlobally = (
|
||||
state: EditorState,
|
||||
|
||||
inlineSpec: RegionSpec,
|
||||
blockSpec: RegionSpec
|
||||
): TransactionSpec => {
|
||||
const doc = state.doc;
|
||||
const preserveBlockQuotes = true;
|
||||
|
||||
const getMatchEndPoints = (
|
||||
match: RegExpMatchArray, line: Line, inBlockQuote: boolean
|
||||
): [startIdx: number, stopIdx: number] => {
|
||||
const startIdx = line.from + match.index;
|
||||
let stopIdx;
|
||||
|
||||
// Don't treat '> ' as part of the line's content if we're in a blockquote.
|
||||
let contentLength = line.text.length;
|
||||
if (inBlockQuote && preserveBlockQuotes) {
|
||||
contentLength -= blockQuoteStartLen;
|
||||
}
|
||||
|
||||
// If it matches the entire line, remove the newline character.
|
||||
if (match[0].length === contentLength) {
|
||||
stopIdx = line.to + 1;
|
||||
} else {
|
||||
stopIdx = startIdx + match[0].length;
|
||||
|
||||
// Take into account the extra '> ' characters, if necessary
|
||||
if (inBlockQuote && preserveBlockQuotes) {
|
||||
stopIdx += blockQuoteStartLen;
|
||||
}
|
||||
}
|
||||
|
||||
stopIdx = Math.min(stopIdx, doc.length);
|
||||
return [startIdx, stopIdx];
|
||||
};
|
||||
|
||||
// Returns a change spec that converts an inline region to a block region
|
||||
// only if the user's cursor is in an empty inline region.
|
||||
// For example,
|
||||
// $|$ -> $$\n|\n$$ where | represents the cursor.
|
||||
const handleInlineToBlockConversion = (sel: SelectionRange) => {
|
||||
if (!sel.empty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startMatchLen = findInlineMatch(doc, inlineSpec, sel, MatchSide.Start);
|
||||
const stopMatchLen = findInlineMatch(doc, inlineSpec, sel, MatchSide.End);
|
||||
|
||||
if (startMatchLen >= 0 && stopMatchLen >= 0) {
|
||||
const fromLine = doc.lineAt(sel.from);
|
||||
const inBlockQuote = fromLine.text.match(blockQuoteRegex);
|
||||
|
||||
let lineStartStr = '\n';
|
||||
if (inBlockQuote && preserveBlockQuotes) {
|
||||
lineStartStr = '\n> ';
|
||||
}
|
||||
|
||||
|
||||
const inlineStart = sel.from - startMatchLen;
|
||||
const inlineStop = sel.from + stopMatchLen;
|
||||
|
||||
// Determine the text that starts the new block (e.g. \n$$\n for
|
||||
// a math block).
|
||||
let blockStart = `${blockSpec.template.start}${lineStartStr}`;
|
||||
if (fromLine.from !== inlineStart) {
|
||||
// Add a line before to put the start of the block
|
||||
// on its own line.
|
||||
blockStart = lineStartStr + blockStart;
|
||||
}
|
||||
|
||||
return {
|
||||
changes: [
|
||||
{
|
||||
from: inlineStart,
|
||||
to: inlineStop,
|
||||
insert: `${blockStart}${lineStartStr}${blockSpec.template.end}`,
|
||||
},
|
||||
],
|
||||
|
||||
range: EditorSelection.cursor(inlineStart + blockStart.length),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const changes = state.changeByRange((sel: SelectionRange) => {
|
||||
const blockConversion = handleInlineToBlockConversion(sel);
|
||||
if (blockConversion) {
|
||||
return blockConversion;
|
||||
}
|
||||
|
||||
// If we're in the block version, grow the selection to cover the entire region.
|
||||
sel = growSelectionToNode(state, sel, blockSpec.nodeName);
|
||||
|
||||
const fromLine = doc.lineAt(sel.from);
|
||||
const toLine = doc.lineAt(sel.to);
|
||||
let fromLineText = fromLine.text;
|
||||
let toLineText = toLine.text;
|
||||
|
||||
let charsAdded = 0;
|
||||
const changes = [];
|
||||
|
||||
// Single line: Inline toggle.
|
||||
if (fromLine.number === toLine.number) {
|
||||
return toggleInlineSelectionFormat(state, inlineSpec, sel);
|
||||
}
|
||||
|
||||
// Are all lines in a block quote?
|
||||
let inBlockQuote = true;
|
||||
for (let i = fromLine.number; i <= toLine.number; i++) {
|
||||
const line = doc.line(i);
|
||||
|
||||
if (!line.text.match(blockQuoteRegex)) {
|
||||
inBlockQuote = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore block quote characters if in a block quote.
|
||||
if (inBlockQuote && preserveBlockQuotes) {
|
||||
fromLineText = fromLineText.substring(blockQuoteStartLen);
|
||||
toLineText = toLineText.substring(blockQuoteStartLen);
|
||||
}
|
||||
|
||||
// Otherwise, we're toggling the block version
|
||||
const startMatch = blockSpec.matcher.start.exec(fromLineText);
|
||||
const stopMatch = blockSpec.matcher.end.exec(toLineText);
|
||||
if (startMatch && stopMatch) {
|
||||
// Get start and stop indicies for the starting and ending matches
|
||||
const [fromMatchFrom, fromMatchTo] = getMatchEndPoints(startMatch, fromLine, inBlockQuote);
|
||||
const [toMatchFrom, toMatchTo] = getMatchEndPoints(stopMatch, toLine, inBlockQuote);
|
||||
|
||||
// Delete content of the first line
|
||||
changes.push({
|
||||
from: fromMatchFrom,
|
||||
to: fromMatchTo,
|
||||
});
|
||||
charsAdded -= fromMatchTo - fromMatchFrom;
|
||||
|
||||
// Delete content of the last line
|
||||
changes.push({
|
||||
from: toMatchFrom,
|
||||
to: toMatchTo,
|
||||
});
|
||||
charsAdded -= toMatchTo - toMatchFrom;
|
||||
} else {
|
||||
let insertBefore, insertAfter;
|
||||
|
||||
if (inBlockQuote && preserveBlockQuotes) {
|
||||
insertBefore = `> ${blockSpec.template.start}\n`;
|
||||
insertAfter = `\n> ${blockSpec.template.end}`;
|
||||
} else {
|
||||
insertBefore = `${blockSpec.template.start}\n`;
|
||||
insertAfter = `\n${blockSpec.template.end}`;
|
||||
}
|
||||
|
||||
changes.push({
|
||||
from: fromLine.from,
|
||||
insert: insertBefore,
|
||||
});
|
||||
|
||||
changes.push({
|
||||
from: toLine.to,
|
||||
insert: insertAfter,
|
||||
});
|
||||
charsAdded += insertBefore.length + insertAfter.length;
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
|
||||
// Selection should now encompass all lines that were changed.
|
||||
range: EditorSelection.range(
|
||||
fromLine.from, toLine.to + charsAdded
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
// Toggles whether all lines in the user's selection start with [regex].
|
||||
export const toggleSelectedLinesStartWith = (
|
||||
state: EditorState,
|
||||
regex: RegExp,
|
||||
template: string,
|
||||
matchEmpty: boolean,
|
||||
|
||||
// Name associated with what [regex] matches (e.g. FencedCode)
|
||||
nodeName?: string
|
||||
): TransactionSpec => {
|
||||
const ignoreBlockQuotes = true;
|
||||
const getLineContentStart = (line: Line): number => {
|
||||
if (!ignoreBlockQuotes) {
|
||||
return line.from;
|
||||
}
|
||||
|
||||
const blockQuoteMatch = line.text.match(blockQuoteRegex);
|
||||
if (blockQuoteMatch) {
|
||||
return line.from + blockQuoteMatch[0].length;
|
||||
}
|
||||
|
||||
return line.from;
|
||||
};
|
||||
|
||||
const getLineContent = (line: Line): string => {
|
||||
const contentStart = getLineContentStart(line);
|
||||
return line.text.substring(contentStart - line.from);
|
||||
};
|
||||
|
||||
const changes = state.changeByRange((sel: SelectionRange) => {
|
||||
// Attempt to select all lines in the region
|
||||
if (nodeName && sel.empty) {
|
||||
sel = growSelectionToNode(state, sel, nodeName);
|
||||
}
|
||||
|
||||
const doc = state.doc;
|
||||
const fromLine = doc.lineAt(sel.from);
|
||||
const toLine = doc.lineAt(sel.to);
|
||||
let hasProp = false;
|
||||
let charsAdded = 0;
|
||||
|
||||
const changes = [];
|
||||
const lines = [];
|
||||
|
||||
for (let i = fromLine.number; i <= toLine.number; i++) {
|
||||
const line = doc.line(i);
|
||||
const text = getLineContent(line);
|
||||
|
||||
// If already matching [regex],
|
||||
if (text.search(regex) === 0) {
|
||||
hasProp = true;
|
||||
}
|
||||
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const text = getLineContent(line);
|
||||
const contentFrom = getLineContentStart(line);
|
||||
|
||||
// Only process if the line is non-empty.
|
||||
if (!matchEmpty && text.trim().length === 0
|
||||
// Treat the first line differently
|
||||
&& fromLine.number < line.number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasProp) {
|
||||
const match = text.match(regex);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
changes.push({
|
||||
from: contentFrom,
|
||||
to: contentFrom + match[0].length,
|
||||
insert: '',
|
||||
});
|
||||
|
||||
charsAdded -= match[0].length;
|
||||
} else {
|
||||
changes.push({
|
||||
from: contentFrom,
|
||||
insert: template,
|
||||
});
|
||||
|
||||
charsAdded += template.length;
|
||||
}
|
||||
}
|
||||
|
||||
// If the selection is empty and a single line was changed, don't grow it.
|
||||
// (user might be adding a list/header, in which case, selecting the just
|
||||
// added text isn't helpful)
|
||||
let newSel;
|
||||
if (sel.empty && fromLine.number === toLine.number) {
|
||||
const regionEnd = toLine.to + charsAdded;
|
||||
newSel = EditorSelection.cursor(regionEnd);
|
||||
} else {
|
||||
newSel = EditorSelection.range(fromLine.from, toLine.to + charsAdded);
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
|
||||
// Selection should now encompass all lines that were changed.
|
||||
range: newSel,
|
||||
};
|
||||
});
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
// Ensures that ordered lists within [sel] are numbered in ascending order.
|
||||
export const renumberList = (state: EditorState, sel: SelectionRange): SelectionUpdate => {
|
||||
const doc = state.doc;
|
||||
|
||||
const listItemRegex = /^(\s*)(\d+)\.\s?/;
|
||||
const changes: ChangeSpec[] = [];
|
||||
const fromLine = doc.lineAt(sel.from);
|
||||
const toLine = doc.lineAt(sel.to);
|
||||
let charsAdded = 0;
|
||||
|
||||
// Re-numbers ordered lists and sublists with numbers on each line in [linesToHandle]
|
||||
const handleLines = (linesToHandle: Line[]) => {
|
||||
let currentGroupIndentation = '';
|
||||
let nextListNumber = 1;
|
||||
const listNumberStack: number[] = [];
|
||||
let prevLineNumber;
|
||||
|
||||
for (const line of linesToHandle) {
|
||||
// Don't re-handle lines.
|
||||
if (line.number === prevLineNumber) {
|
||||
continue;
|
||||
}
|
||||
prevLineNumber = line.number;
|
||||
|
||||
const filteredText = stripBlockquote(line);
|
||||
const match = filteredText.match(listItemRegex);
|
||||
const indentation = match[1];
|
||||
|
||||
const indentationLen = tabsToSpaces(state, indentation).length;
|
||||
const targetIndentLen = tabsToSpaces(state, currentGroupIndentation).length;
|
||||
if (targetIndentLen < indentationLen) {
|
||||
listNumberStack.push(nextListNumber);
|
||||
nextListNumber = 1;
|
||||
} else if (targetIndentLen > indentationLen) {
|
||||
nextListNumber = listNumberStack.pop() ?? parseInt(match[2], 10);
|
||||
}
|
||||
|
||||
if (targetIndentLen !== indentationLen) {
|
||||
currentGroupIndentation = indentation;
|
||||
}
|
||||
|
||||
const from = line.to - filteredText.length;
|
||||
const to = from + match[0].length;
|
||||
const inserted = `${indentation}${nextListNumber}. `;
|
||||
nextListNumber++;
|
||||
|
||||
changes.push({
|
||||
from,
|
||||
to,
|
||||
insert: inserted,
|
||||
});
|
||||
charsAdded -= to - from;
|
||||
charsAdded += inserted.length;
|
||||
}
|
||||
};
|
||||
|
||||
const linesToHandle: Line[] = [];
|
||||
syntaxTree(state).iterate({
|
||||
from: sel.from,
|
||||
to: sel.to,
|
||||
enter: (nodeRef: SyntaxNodeRef) => {
|
||||
if (nodeRef.name === 'ListItem') {
|
||||
for (const node of nodeRef.node.parent.getChildren('ListItem')) {
|
||||
const line = doc.lineAt(node.from);
|
||||
const filteredText = stripBlockquote(line);
|
||||
const match = filteredText.match(listItemRegex);
|
||||
if (match) {
|
||||
linesToHandle.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
linesToHandle.sort((a, b) => a.number - b.number);
|
||||
handleLines(linesToHandle);
|
||||
|
||||
// Re-position the selection in a way that makes sense
|
||||
if (sel.empty) {
|
||||
sel = EditorSelection.cursor(toLine.to + charsAdded);
|
||||
} else {
|
||||
sel = EditorSelection.range(
|
||||
fromLine.from,
|
||||
toLine.to + charsAdded
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
range: sel,
|
||||
changes,
|
||||
};
|
||||
};
|
@@ -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;
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
// Handle logging strings when running in a WebView.
|
||||
|
||||
// Because this will be running both in a WebView and in nodeJS, we need to use
|
||||
// globalThis in place of window. We need to tell ESLint that we're doing this:
|
||||
/* global globalThis*/
|
||||
|
||||
export function postMessage(name: string, data: any) {
|
||||
// Only call postMessage if we're running in a WebView (this code may be called
|
||||
// in integration tests).
|
||||
(globalThis as any).ReactNativeWebView?.postMessage(JSON.stringify({
|
||||
data,
|
||||
name,
|
||||
}));
|
||||
}
|
||||
|
||||
export function logMessage(...msg: any[]) {
|
||||
postMessage('onLog', { value: msg });
|
||||
}
|
||||
|
156
packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx
Normal file
156
packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
// Dialog allowing the user to update/create links
|
||||
|
||||
const React = require('react');
|
||||
const { useState, useEffect, useMemo, useRef } = require('react');
|
||||
const { StyleSheet } = require('react-native');
|
||||
const { View, Modal, Text, TextInput, Button } = require('react-native');
|
||||
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { EditorControl } from './types';
|
||||
import SelectionFormatting from './SelectionFormatting';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
interface LinkDialogProps {
|
||||
editorControl: EditorControl;
|
||||
selectionState: SelectionFormatting;
|
||||
visible: boolean;
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
const EditLinkDialog = (props: LinkDialogProps) => {
|
||||
// The content of the link selected in the editor (if any)
|
||||
const editorLinkData = props.selectionState.linkData ?? {};
|
||||
const [linkLabel, setLinkLabel] = useState('');
|
||||
const [linkURL, setLinkURL] = useState('');
|
||||
|
||||
const linkInputRef = useRef();
|
||||
|
||||
// Reset the label and URL when shown/hidden
|
||||
useEffect(() => {
|
||||
setLinkLabel(editorLinkData.linkText ?? props.selectionState.selectedText);
|
||||
setLinkURL(editorLinkData.linkURL ?? '');
|
||||
}, [
|
||||
props.visible, editorLinkData.linkText, props.selectionState.selectedText,
|
||||
editorLinkData.linkURL,
|
||||
]);
|
||||
|
||||
const [styles, placeholderColor] = useMemo(() => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const styleSheet = StyleSheet.create({
|
||||
modalContent: {
|
||||
margin: 15,
|
||||
padding: 30,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
|
||||
elevation: 5,
|
||||
shadowOffset: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 1,
|
||||
},
|
||||
button: {
|
||||
color: theme.color2,
|
||||
backgroundColor: theme.backgroundColor2,
|
||||
},
|
||||
text: {
|
||||
color: theme.color,
|
||||
},
|
||||
header: {
|
||||
color: theme.color,
|
||||
fontSize: 22,
|
||||
},
|
||||
input: {
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
|
||||
minHeight: 48,
|
||||
borderBottomColor: theme.backgroundColor3,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'column',
|
||||
paddingBottom: 10,
|
||||
},
|
||||
});
|
||||
const placeholderColor = theme.colorFaded;
|
||||
return [styleSheet, placeholderColor];
|
||||
}, [props.themeId]);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
props.editorControl.updateLink(linkLabel, linkURL);
|
||||
props.editorControl.hideLinkDialog();
|
||||
}, [props.editorControl, linkLabel, linkURL]);
|
||||
|
||||
// See https://www.hingehealth.com/engineering-blog/accessible-react-native-textinput/
|
||||
// for more about creating accessible RN inputs.
|
||||
const linkTextInput = (
|
||||
<View style={styles.inputContainer} accessible>
|
||||
<Text style={styles.text}>{_('Link Text')}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={_('Description of the link')}
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={linkLabel}
|
||||
|
||||
returnKeyType="next"
|
||||
autoFocus
|
||||
|
||||
onSubmitEditing={() => {
|
||||
linkInputRef.current.focus();
|
||||
}}
|
||||
onChangeText={(text: string) => setLinkLabel(text)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const linkURLInput = (
|
||||
<View style={styles.inputContainer} accessible>
|
||||
<Text style={styles.text}>{_('URL')}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={_('URL')}
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={linkURL}
|
||||
ref={linkInputRef}
|
||||
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
textContentType="URL"
|
||||
returnKeyType="done"
|
||||
|
||||
onSubmitEditing={onSubmit}
|
||||
onChangeText={(text: string) => setLinkURL(text)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
visible={props.visible}
|
||||
onRequestClose={() => {
|
||||
props.editorControl.hideLinkDialog();
|
||||
}}>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.header}>{_('Edit Link')}</Text>
|
||||
<View>
|
||||
{linkTextInput}
|
||||
{linkURLInput}
|
||||
</View>
|
||||
<Button
|
||||
style={styles.button}
|
||||
onPress={onSubmit}
|
||||
title={_('Done')}
|
||||
/>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditLinkDialog;
|
@@ -0,0 +1,363 @@
|
||||
// A toolbar for the markdown editor.
|
||||
|
||||
const React = require('react');
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
|
||||
// See https://oblador.github.io/react-native-vector-icons/ for a list of
|
||||
// available icons.
|
||||
const AntIcon = require('react-native-vector-icons/AntDesign').default;
|
||||
const FontAwesomeIcon = require('react-native-vector-icons/FontAwesome5').default;
|
||||
const MaterialIcon = require('react-native-vector-icons/MaterialIcons').default;
|
||||
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import time from '@joplin/lib/time';
|
||||
import { useEffect } from 'react';
|
||||
import { Keyboard, ViewStyle } from 'react-native';
|
||||
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';
|
||||
|
||||
type OnAttachCallback = ()=> void;
|
||||
|
||||
interface MarkdownToolbarProps {
|
||||
editorControl: EditorControl;
|
||||
selectionState: SelectionFormatting;
|
||||
searchState: SearchState;
|
||||
editorSettings: EditorSettings;
|
||||
onAttach: OnAttachCallback;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||
const themeData = props.editorSettings.themeData;
|
||||
const styles = useStyles(props.style, themeData);
|
||||
const selState = props.selectionState;
|
||||
const editorControl = props.editorControl;
|
||||
|
||||
const headerButtons: ButtonSpec[] = [];
|
||||
for (let level = 1; level <= 5; level++) {
|
||||
const active = selState.headerLevel === level;
|
||||
let label;
|
||||
if (!active) {
|
||||
label = _('Create header level %d', level);
|
||||
} else {
|
||||
label = _('Remove level %d header', level);
|
||||
}
|
||||
|
||||
headerButtons.push({
|
||||
icon: `H${level}`,
|
||||
description: label,
|
||||
active,
|
||||
|
||||
// We only call addHeaderButton 5 times and in the same order, so
|
||||
// the linter error is safe to ignore.
|
||||
// eslint-disable-next-line @seiyab/react-hooks/rules-of-hooks
|
||||
onPress: useCallback(() => {
|
||||
editorControl.toggleHeaderLevel(level);
|
||||
}, [editorControl, level]),
|
||||
|
||||
// Make it likely for the first three header buttons to show, less likely for
|
||||
// the others.
|
||||
priority: level < 3 ? 2 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
const listButtons: ButtonSpec[] = [];
|
||||
listButtons.push({
|
||||
icon: (
|
||||
<FontAwesomeIcon name="list-ul" style={styles.text}/>
|
||||
),
|
||||
description:
|
||||
selState.inUnorderedList ? _('Remove unordered list') : _('Create unordered list'),
|
||||
active: selState.inUnorderedList,
|
||||
onPress: useCallback(() => {
|
||||
editorControl.toggleList(ListType.UnorderedList);
|
||||
}, [editorControl]),
|
||||
|
||||
priority: -2,
|
||||
});
|
||||
|
||||
listButtons.push({
|
||||
icon: (
|
||||
<FontAwesomeIcon name="list-ol" style={styles.text}/>
|
||||
),
|
||||
description:
|
||||
selState.inOrderedList ? _('Remove ordered list') : _('Create ordered list'),
|
||||
active: selState.inOrderedList,
|
||||
onPress: useCallback(() => {
|
||||
editorControl.toggleList(ListType.OrderedList);
|
||||
}, [editorControl]),
|
||||
|
||||
priority: -2,
|
||||
});
|
||||
|
||||
listButtons.push({
|
||||
icon: (
|
||||
<FontAwesomeIcon name="tasks" style={styles.text}/>
|
||||
),
|
||||
description:
|
||||
selState.inChecklist ? _('Remove task list') : _('Create task list'),
|
||||
active: selState.inChecklist,
|
||||
onPress: useCallback(() => {
|
||||
editorControl.toggleList(ListType.CheckList);
|
||||
}, [editorControl]),
|
||||
|
||||
priority: -2,
|
||||
});
|
||||
|
||||
|
||||
listButtons.push({
|
||||
icon: (
|
||||
<AntIcon name="indent-left" style={styles.text}/>
|
||||
),
|
||||
description: _('Decrease indent level'),
|
||||
onPress: editorControl.decreaseIndent,
|
||||
|
||||
priority: -1,
|
||||
});
|
||||
|
||||
listButtons.push({
|
||||
icon: (
|
||||
<AntIcon name="indent-right" style={styles.text}/>
|
||||
),
|
||||
description: _('Increase indent level'),
|
||||
onPress: editorControl.increaseIndent,
|
||||
|
||||
priority: -1,
|
||||
});
|
||||
|
||||
|
||||
// Inline formatting
|
||||
const inlineFormattingBtns: ButtonSpec[] = [];
|
||||
inlineFormattingBtns.push({
|
||||
icon: (
|
||||
<FontAwesomeIcon name="bold" style={styles.text}/>
|
||||
),
|
||||
description:
|
||||
selState.bolded ? _('Unbold') : _('Bold text'),
|
||||
active: selState.bolded,
|
||||
onPress: editorControl.toggleBolded,
|
||||
|
||||
priority: 3,
|
||||
});
|
||||
|
||||
inlineFormattingBtns.push({
|
||||
icon: (
|
||||
<FontAwesomeIcon name="italic" style={styles.text}/>
|
||||
),
|
||||
description:
|
||||
selState.italicized ? _('Unitalicize') : _('Italicize'),
|
||||
active: selState.italicized,
|
||||
onPress: editorControl.toggleItalicized,
|
||||
|
||||
priority: 2,
|
||||
});
|
||||
|
||||
inlineFormattingBtns.push({
|
||||
icon: '{;}',
|
||||
description:
|
||||
selState.inCode ? _('Remove code formatting') : _('Format as code'),
|
||||
active: selState.inCode,
|
||||
onPress: editorControl.toggleCode,
|
||||
|
||||
priority: 2,
|
||||
});
|
||||
|
||||
if (props.editorSettings.katexEnabled) {
|
||||
inlineFormattingBtns.push({
|
||||
icon: '∑',
|
||||
description:
|
||||
selState.inMath ? _('Remove TeX region') : _('Create TeX region'),
|
||||
active: selState.inMath,
|
||||
onPress: editorControl.toggleMath,
|
||||
|
||||
priority: 1,
|
||||
});
|
||||
}
|
||||
|
||||
inlineFormattingBtns.push({
|
||||
icon: (
|
||||
<FontAwesomeIcon name="link" style={styles.text}/>
|
||||
),
|
||||
description:
|
||||
selState.inLink ? _('Edit link') : _('Create link'),
|
||||
active: selState.inLink,
|
||||
onPress: editorControl.showLinkDialog,
|
||||
|
||||
priority: -3,
|
||||
});
|
||||
|
||||
|
||||
// Actions
|
||||
const actionButtons: ButtonSpec[] = [];
|
||||
actionButtons.push({
|
||||
icon: (
|
||||
<FontAwesomeIcon name="calendar-plus" style={styles.text}/>
|
||||
),
|
||||
description: _('Insert time'),
|
||||
onPress: useCallback(() => {
|
||||
editorControl.insertText(time.formatDateToLocal(new Date()));
|
||||
}, [editorControl]),
|
||||
});
|
||||
|
||||
const onDismissKeyboard = useCallback(() => {
|
||||
// Keyboard.dismiss() doesn't dismiss the keyboard if it's editing the WebView.
|
||||
Keyboard.dismiss();
|
||||
|
||||
// As such, dismiss the keyboard by sending a message to the View.
|
||||
editorControl.hideKeyboard();
|
||||
}, [editorControl]);
|
||||
|
||||
actionButtons.push({
|
||||
icon: (
|
||||
<MaterialIcon name="attachment" style={styles.text}/>
|
||||
),
|
||||
description: _('Attach'),
|
||||
onPress: useCallback(() => {
|
||||
onDismissKeyboard();
|
||||
props.onAttach();
|
||||
}, [props.onAttach, onDismissKeyboard]),
|
||||
});
|
||||
|
||||
actionButtons.push({
|
||||
icon: (
|
||||
<MaterialIcon name="search" style={styles.text}/>
|
||||
),
|
||||
description: (
|
||||
props.searchState.dialogVisible ? _('Close find and replace') : _('Find and replace')
|
||||
),
|
||||
active: props.searchState.dialogVisible,
|
||||
onPress: useCallback(() => {
|
||||
if (props.searchState.dialogVisible) {
|
||||
editorControl.searchControl.hideSearch();
|
||||
} else {
|
||||
editorControl.searchControl.showSearch();
|
||||
}
|
||||
}, [editorControl, props.searchState.dialogVisible]),
|
||||
|
||||
priority: -3,
|
||||
});
|
||||
|
||||
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
||||
const [hasSoftwareKeyboard, setHasSoftwareKeyboard] = useState(false);
|
||||
useEffect(() => {
|
||||
const showListener = Keyboard.addListener('keyboardDidShow', () => {
|
||||
setKeyboardVisible(true);
|
||||
setHasSoftwareKeyboard(true);
|
||||
});
|
||||
const hideListener = Keyboard.addListener('keyboardDidHide', () => {
|
||||
setKeyboardVisible(false);
|
||||
});
|
||||
|
||||
return (() => {
|
||||
showListener.remove();
|
||||
hideListener.remove();
|
||||
});
|
||||
});
|
||||
|
||||
actionButtons.push({
|
||||
icon: (
|
||||
<MaterialIcon name="keyboard-hide" style={styles.text}/>
|
||||
),
|
||||
description: _('Hide keyboard'),
|
||||
disabled: !keyboardVisible,
|
||||
visible: hasSoftwareKeyboard && Platform.OS === 'ios',
|
||||
onPress: onDismissKeyboard,
|
||||
|
||||
priority: -3,
|
||||
});
|
||||
|
||||
const styleData: StyleSheetData = {
|
||||
styles: styles,
|
||||
themeId: props.editorSettings.themeId,
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleSpaceButton
|
||||
spaceApplicable={ Platform.OS === 'ios' && keyboardVisible }
|
||||
themeId={props.editorSettings.themeId}
|
||||
style={styles.container}
|
||||
>
|
||||
<Toolbar
|
||||
styleSheet={styleData}
|
||||
buttons={[
|
||||
{
|
||||
title: _('Formatting'),
|
||||
items: inlineFormattingBtns,
|
||||
},
|
||||
{
|
||||
title: _('Headers'),
|
||||
items: headerButtons,
|
||||
},
|
||||
{
|
||||
title: _('Lists'),
|
||||
items: listButtons,
|
||||
},
|
||||
{
|
||||
title: _('Actions'),
|
||||
items: actionButtons,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ToggleSpaceButton>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = (styleProps: any, theme: Theme) => {
|
||||
return useMemo(() => {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
...styleProps,
|
||||
},
|
||||
button: {
|
||||
width: buttonSize,
|
||||
height: buttonSize,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: theme.backgroundColor,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
buttonDisabledContent: {
|
||||
},
|
||||
buttonActive: {
|
||||
backgroundColor: theme.backgroundColor3,
|
||||
color: theme.color3,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.color3,
|
||||
borderRadius: 6,
|
||||
},
|
||||
buttonActiveContent: {
|
||||
color: theme.color3,
|
||||
},
|
||||
text: {
|
||||
fontSize: 22,
|
||||
color: theme.color,
|
||||
},
|
||||
toolbarRow: {
|
||||
flex: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'center',
|
||||
|
||||
// Add a small amount of additional padding for button borders
|
||||
height: buttonSize + 6,
|
||||
},
|
||||
toolbarContainer: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
toolbarContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
}, [styleProps, theme]);
|
||||
};
|
||||
|
||||
export default MarkdownToolbar;
|
@@ -0,0 +1,34 @@
|
||||
const React = require('react');
|
||||
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
import { ButtonSpec, StyleSheetData } from './types';
|
||||
const MaterialIcon = require('react-native-vector-icons/MaterialIcons').default;
|
||||
|
||||
type OnToggleOverflowCallback = ()=> void;
|
||||
interface ToggleOverflowButtonProps {
|
||||
overflowVisible: boolean;
|
||||
onToggleOverflowVisible: OnToggleOverflowCallback;
|
||||
styleSheet: StyleSheetData;
|
||||
}
|
||||
|
||||
// Button that shows/hides the overflow menu.
|
||||
const ToggleOverflowButton = (props: ToggleOverflowButtonProps) => {
|
||||
const spec: ButtonSpec = {
|
||||
icon: (
|
||||
<MaterialIcon name="more-horiz" style={props.styleSheet.styles.text}/>
|
||||
),
|
||||
description:
|
||||
props.overflowVisible ? _('Hide more actions') : _('Show more actions'),
|
||||
active: props.overflowVisible,
|
||||
onPress: props.onToggleOverflowVisible,
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
styleSheet={props.styleSheet}
|
||||
spec={spec}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default ToggleOverflowButton;
|
@@ -0,0 +1,96 @@
|
||||
|
||||
// On some devices, the SafeAreaView conflicts with the KeyboardAvoidingView, creating
|
||||
// additional (or a lack of additional) space at the bottom of the screen. Because this
|
||||
// is different on different devices, this button allows toggling additional space a the bottom
|
||||
// of the screen to compensate.
|
||||
|
||||
// Works around https://github.com/facebook/react-native/issues/13393 by adding additional
|
||||
// space below the given component when the keyboard is visible unless a button is pressed.
|
||||
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ReactNode, useCallback, useState, useEffect } from 'react';
|
||||
import { View, ViewStyle } from 'react-native';
|
||||
import CustomButton from '../../CustomButton';
|
||||
|
||||
const AntIcon = require('react-native-vector-icons/AntDesign').default;
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
spaceApplicable: boolean;
|
||||
themeId: number;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const ToggleSpaceButton = (props: Props) => {
|
||||
const [additionalSpace, setAdditionalSpace] = useState(0);
|
||||
const [decreaseSpaceBtnVisible, setDecreaseSpaceBtnVisible] = useState(true);
|
||||
|
||||
// Some devices need space added, others need space removed.
|
||||
const additionalPositiveSpace = 14;
|
||||
const additionalNegativeSpace = -14;
|
||||
|
||||
// Switch from adding +14px to -14px.
|
||||
const onDecreaseSpace = useCallback(() => {
|
||||
setAdditionalSpace(additionalNegativeSpace);
|
||||
setDecreaseSpaceBtnVisible(false);
|
||||
Setting.setValue('editor.mobile.removeSpaceBelowToolbar', true);
|
||||
}, [setAdditionalSpace, setDecreaseSpaceBtnVisible, additionalNegativeSpace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Setting.value('editor.mobile.removeSpaceBelowToolbar')) {
|
||||
onDecreaseSpace();
|
||||
}
|
||||
}, [onDecreaseSpace]);
|
||||
|
||||
const theme: Theme = themeStyle(props.themeId);
|
||||
|
||||
const decreaseSpaceButton = (
|
||||
<>
|
||||
<View style={{
|
||||
height: additionalPositiveSpace,
|
||||
zIndex: -2,
|
||||
}} />
|
||||
<CustomButton
|
||||
themeId={props.themeId}
|
||||
description={'Move toolbar to bottom of screen'}
|
||||
style={{
|
||||
height: additionalPositiveSpace,
|
||||
width: '100%',
|
||||
|
||||
// Ensure that the icon is near the bottom of the screen,
|
||||
// and thus invisible on devices where it isn't necessary.
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
|
||||
// Don't show the button on top of views with content.
|
||||
zIndex: -1,
|
||||
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onPress={onDecreaseSpace}
|
||||
>
|
||||
<AntIcon name='down' style={{
|
||||
color: theme.color,
|
||||
}}/>
|
||||
</CustomButton>
|
||||
</>
|
||||
);
|
||||
|
||||
const style: ViewStyle = {
|
||||
marginBottom: props.spaceApplicable ? additionalSpace : 0,
|
||||
...props.style,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
{props.children}
|
||||
{ decreaseSpaceBtnVisible && props.spaceApplicable ? decreaseSpaceButton : null }
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToggleSpaceButton;
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user