You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-26 23:38:08 +02:00
Compare commits
56 Commits
note_list_
...
random_ci_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15287f7ffe | ||
|
|
061ec7902c | ||
|
|
e94b20d57a | ||
|
|
04b52d7c1e | ||
|
|
63695637b9 | ||
|
|
a1821d607e | ||
|
|
cc459a68d5 | ||
|
|
89b3c41d65 | ||
|
|
62c4fd4b7d | ||
|
|
c24fe372fc | ||
|
|
fa0cd2e2a4 | ||
|
|
5078d975c8 | ||
|
|
b742e36ea3 | ||
|
|
2da5b6d848 | ||
|
|
662fc08cb1 | ||
|
|
1fe5fd17b8 | ||
|
|
aff1bf501f | ||
|
|
2f3d207096 | ||
|
|
fc515313cf | ||
|
|
a0a4a81cc7 | ||
|
|
01f37dfc32 | ||
|
|
54966adbbb | ||
|
|
003d47fb72 | ||
|
|
84c6de9b56 | ||
|
|
c3971ff226 | ||
|
|
874b39fe95 | ||
|
|
41a78c9602 | ||
|
|
d43482a962 | ||
|
|
1b00445452 | ||
|
|
fad4f6d1dd | ||
|
|
58ab087d39 | ||
|
|
05e9cac111 | ||
|
|
a8efad9887 | ||
|
|
2abc13b1c4 | ||
|
|
82bd65b622 | ||
|
|
6962ba115e | ||
|
|
6cdb1a714a | ||
|
|
8d4a1818c5 | ||
|
|
9a2d8b19f4 | ||
|
|
847213eb8c | ||
|
|
728105f936 | ||
|
|
e566f404a7 | ||
|
|
bb5befc8bf | ||
|
|
ec7f5aaf9b | ||
|
|
06130a8e9a | ||
|
|
d9054ada91 | ||
|
|
324c9ad907 | ||
|
|
a04654a5a0 | ||
|
|
fa0740338d | ||
|
|
c3c5612dc5 | ||
|
|
1b3c5db0c9 | ||
|
|
e94d684048 | ||
|
|
331d101f94 | ||
|
|
04ef13cdc5 | ||
|
|
b9cb9e42ff | ||
|
|
7882237e24 |
@@ -215,12 +215,11 @@ packages/app-desktop/gui/MenuBar.js
|
||||
packages/app-desktop/gui/MultiNoteActions.js
|
||||
packages/app-desktop/gui/Navigator.js
|
||||
packages/app-desktop/gui/NoteContentPropertiesDialog.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/styles/index.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/setupVim.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js
|
||||
@@ -232,6 +231,13 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useStyles.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useWebviewIpcMessage.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands.js
|
||||
@@ -410,23 +416,7 @@ packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/createEditor.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/forceFullParse.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/loadLanguages.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/types.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
|
||||
@@ -439,7 +429,6 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.js
|
||||
packages/app-mobile/components/NoteEditor/types.js
|
||||
packages/app-mobile/components/NoteList.js
|
||||
packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js
|
||||
@@ -489,6 +478,38 @@ packages/app-mobile/utils/fs-driver-rn.js
|
||||
packages/app-mobile/utils/setupNotifications.js
|
||||
packages/app-mobile/utils/shareHandler.js
|
||||
packages/app-mobile/utils/types.js
|
||||
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js
|
||||
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js
|
||||
packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.js
|
||||
packages/editor/CodeMirror/CodeMirrorControl.test.js
|
||||
packages/editor/CodeMirror/CodeMirrorControl.js
|
||||
packages/editor/CodeMirror/PluginLoader.js
|
||||
packages/editor/CodeMirror/configFromSettings.js
|
||||
packages/editor/CodeMirror/createEditor.test.js
|
||||
packages/editor/CodeMirror/createEditor.js
|
||||
packages/editor/CodeMirror/editorCommands/editorCommands.js
|
||||
packages/editor/CodeMirror/editorCommands/supportsCommand.js
|
||||
packages/editor/CodeMirror/editorCommands/swapLine.js
|
||||
packages/editor/CodeMirror/getScrollFraction.js
|
||||
packages/editor/CodeMirror/markdown/computeSelectionFormatting.js
|
||||
packages/editor/CodeMirror/markdown/decoratorExtension.js
|
||||
packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.js
|
||||
packages/editor/CodeMirror/markdown/markdownCommands.test.js
|
||||
packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js
|
||||
packages/editor/CodeMirror/markdown/markdownCommands.js
|
||||
packages/editor/CodeMirror/markdown/markdownMathParser.test.js
|
||||
packages/editor/CodeMirror/markdown/markdownMathParser.js
|
||||
packages/editor/CodeMirror/markdown/markdownReformatter.test.js
|
||||
packages/editor/CodeMirror/markdown/markdownReformatter.js
|
||||
packages/editor/CodeMirror/markdown/syntaxHighlightingLanguages.js
|
||||
packages/editor/CodeMirror/testUtil/createEditorSettings.js
|
||||
packages/editor/CodeMirror/testUtil/createTestEditor.js
|
||||
packages/editor/CodeMirror/testUtil/forceFullParse.js
|
||||
packages/editor/CodeMirror/testUtil/loadLanguages.js
|
||||
packages/editor/CodeMirror/theme.js
|
||||
packages/editor/SelectionFormatting.js
|
||||
packages/editor/events.js
|
||||
packages/editor/types.js
|
||||
packages/fork-htmlparser2/src/CollectingHandler.js
|
||||
packages/fork-htmlparser2/src/FeedHandler.spec.js
|
||||
packages/fork-htmlparser2/src/FeedHandler.js
|
||||
|
||||
6
.github/workflows/github-actions-main.yml
vendored
6
.github/workflows/github-actions-main.yml
vendored
@@ -3,7 +3,7 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
pre_job:
|
||||
if: github.repository == 'laurent22/joplin'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-20.04
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip }}
|
||||
steps:
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest, windows-2019]
|
||||
os: [macos-latest, ubuntu-20.04, windows-2019]
|
||||
steps:
|
||||
|
||||
# Trying to fix random networking issues on Windows
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
os: [ubuntu-20.04]
|
||||
steps:
|
||||
|
||||
- name: Install Docker Engine
|
||||
|
||||
61
.gitignore
vendored
61
.gitignore
vendored
@@ -201,12 +201,11 @@ packages/app-desktop/gui/MenuBar.js
|
||||
packages/app-desktop/gui/MultiNoteActions.js
|
||||
packages/app-desktop/gui/Navigator.js
|
||||
packages/app-desktop/gui/NoteContentPropertiesDialog.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/styles/index.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/setupVim.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js
|
||||
@@ -218,6 +217,13 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useStyles.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useWebviewIpcMessage.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands.js
|
||||
@@ -396,23 +402,7 @@ packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/createEditor.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/forceFullParse.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/loadLanguages.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/types.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
|
||||
@@ -425,7 +415,6 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.js
|
||||
packages/app-mobile/components/NoteEditor/types.js
|
||||
packages/app-mobile/components/NoteList.js
|
||||
packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js
|
||||
@@ -475,6 +464,38 @@ packages/app-mobile/utils/fs-driver-rn.js
|
||||
packages/app-mobile/utils/setupNotifications.js
|
||||
packages/app-mobile/utils/shareHandler.js
|
||||
packages/app-mobile/utils/types.js
|
||||
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js
|
||||
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js
|
||||
packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.js
|
||||
packages/editor/CodeMirror/CodeMirrorControl.test.js
|
||||
packages/editor/CodeMirror/CodeMirrorControl.js
|
||||
packages/editor/CodeMirror/PluginLoader.js
|
||||
packages/editor/CodeMirror/configFromSettings.js
|
||||
packages/editor/CodeMirror/createEditor.test.js
|
||||
packages/editor/CodeMirror/createEditor.js
|
||||
packages/editor/CodeMirror/editorCommands/editorCommands.js
|
||||
packages/editor/CodeMirror/editorCommands/supportsCommand.js
|
||||
packages/editor/CodeMirror/editorCommands/swapLine.js
|
||||
packages/editor/CodeMirror/getScrollFraction.js
|
||||
packages/editor/CodeMirror/markdown/computeSelectionFormatting.js
|
||||
packages/editor/CodeMirror/markdown/decoratorExtension.js
|
||||
packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.js
|
||||
packages/editor/CodeMirror/markdown/markdownCommands.test.js
|
||||
packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js
|
||||
packages/editor/CodeMirror/markdown/markdownCommands.js
|
||||
packages/editor/CodeMirror/markdown/markdownMathParser.test.js
|
||||
packages/editor/CodeMirror/markdown/markdownMathParser.js
|
||||
packages/editor/CodeMirror/markdown/markdownReformatter.test.js
|
||||
packages/editor/CodeMirror/markdown/markdownReformatter.js
|
||||
packages/editor/CodeMirror/markdown/syntaxHighlightingLanguages.js
|
||||
packages/editor/CodeMirror/testUtil/createEditorSettings.js
|
||||
packages/editor/CodeMirror/testUtil/createTestEditor.js
|
||||
packages/editor/CodeMirror/testUtil/forceFullParse.js
|
||||
packages/editor/CodeMirror/testUtil/loadLanguages.js
|
||||
packages/editor/CodeMirror/theme.js
|
||||
packages/editor/SelectionFormatting.js
|
||||
packages/editor/events.js
|
||||
packages/editor/types.js
|
||||
packages/fork-htmlparser2/src/CollectingHandler.js
|
||||
packages/fork-htmlparser2/src/FeedHandler.spec.js
|
||||
packages/fork-htmlparser2/src/FeedHandler.js
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"exceptions": [
|
||||
"@joplin/lib",
|
||||
"@joplin/renderer",
|
||||
"@joplin/editor",
|
||||
"@joplin/pdf-viewer",
|
||||
"@joplin/fork-htmlparser2",
|
||||
"@joplin/fork-sax",
|
||||
|
||||
15
Assets/ImageSources/JoplinCloudIcon.svg
Normal file
15
Assets/ImageSources/JoplinCloudIcon.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="84" height="84" viewBox="0 0 84 84" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_52_28)">
|
||||
<rect width="84" height="84" fill="url(#paint0_linear_52_28)"/>
|
||||
<path d="M66 48.5424C66 51.4195 64.8173 54.0531 62.9108 55.9371C61.0108 57.8274 58.3546 59 55.4529 59H49.1324V53.5533H55.4529C56.8553 53.5533 58.1026 52.9959 59.0268 52.086C59.9445 51.1696 60.5067 49.9393 60.5067 48.5424C60.5067 47.1519 59.9445 45.9152 59.0268 44.9989C58.1026 44.089 56.8618 43.5315 55.4529 43.5315H50.1083L50.4056 40.539C50.4379 40.2314 50.4508 39.9303 50.4508 39.6163C50.4508 37.0724 49.4168 34.7976 47.7429 33.1316C46.0626 31.4655 43.7684 30.4467 41.2027 30.4467C38.637 30.4467 36.3428 31.4719 34.6625 33.1316C32.9822 34.7976 31.9546 37.0724 31.9546 39.6163C31.9546 39.9239 31.9675 40.2314 31.9999 40.539L32.2971 43.5315H27.5535C26.1511 43.5315 24.9039 44.089 23.9797 44.9989C23.062 45.9152 22.4997 47.1455 22.4997 48.5424C22.4997 49.9329 23.062 51.1696 23.9797 52.086C24.9039 52.9959 26.1447 53.5533 27.5535 53.5533H35.2118C36.6077 53.3291 37.9003 52.6755 38.9278 51.663C40.2139 50.3815 40.9248 48.6834 40.9248 46.8828V41.0837H35.3605V38.9819C35.3605 37.1941 36.8275 35.7331 38.637 35.7331H46.3211V46.8828C46.3211 50.0739 45.0222 53.1945 42.7473 55.4437C40.7439 57.4365 38.0619 58.6732 35.2441 58.9359L34.5332 59H27.5471C24.6453 59 21.9892 57.8274 20.0892 55.9371C18.1827 54.0531 17 51.4195 17 48.5424C17 45.6653 18.1827 43.0317 20.0892 41.1478C21.763 39.4817 24.0249 38.3732 26.526 38.1361C26.875 34.6951 28.4389 31.5937 30.772 29.2805C33.4346 26.6404 37.1377 24.9936 41.1963 25C45.2613 25 48.9644 26.6404 51.6205 29.2805C53.9471 31.5809 55.5046 34.6759 55.8601 38.1041C58.6002 38.213 61.0884 39.3472 62.8979 41.1478C64.8173 43.0317 66 45.6653 66 48.5424Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_52_28" x1="3" y1="4" x2="78" y2="79" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.14" stop-color="#3873DB"/>
|
||||
<stop offset="0.946667" stop-color="#163467"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_52_28">
|
||||
<rect width="84" height="84" rx="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
75
Assets/ImageSources/JoplinCloudIcon2.svg
Normal file
75
Assets/ImageSources/JoplinCloudIcon2.svg
Normal file
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="84"
|
||||
height="84"
|
||||
viewBox="0 0 84 84"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg938"
|
||||
sodipodi:docname="JoplinCloudIcon copie.svg"
|
||||
inkscape:version="1.1.1 (c3084ef, 2021-09-22)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview940"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
showgrid="false"
|
||||
inkscape:zoom="3.5021331"
|
||||
inkscape:cx="-6.1391156"
|
||||
inkscape:cy="29.838957"
|
||||
inkscape:window-width="1310"
|
||||
inkscape:window-height="969"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="g926" />
|
||||
<g
|
||||
clip-path="url(#clip0_52_28)"
|
||||
id="g926">
|
||||
<rect
|
||||
width="84"
|
||||
height="84"
|
||||
fill="url(#paint0_linear_52_28)"
|
||||
id="rect922" />
|
||||
<path
|
||||
d="m 73.705932,49.824628 c 0,3.73216 -1.534192,7.148453 -4.007294,9.592368 -2.464671,2.452087 -5.91028,3.973178 -9.674351,3.973178 h -8.198921 v -7.065432 h 8.198921 c 1.819186,0 3.437178,-0.723056 4.636045,-1.903374 1.190436,-1.188749 1.919719,-2.784688 1.919719,-4.59674 0,-1.803749 -0.729283,-3.40799 -1.919719,-4.59661 -1.198867,-1.180318 -2.808427,-1.903504 -4.636045,-1.903504 h -6.932988 l 0.385656,-3.881856 c 0.0419,-0.399017 0.05863,-0.789603 0.05863,-1.196922 0,-3.299934 -1.341299,-6.250793 -3.512674,-8.41192 -2.179677,-2.161256 -5.155701,-3.482839 -8.483914,-3.482839 -3.328214,0 -6.304238,1.329885 -8.483915,3.482839 -2.179677,2.161127 -3.512674,5.111986 -3.512674,8.41192 0,0.399017 0.01673,0.797905 0.05876,1.196922 l 0.385526,3.881856 H 23.83332 c -1.819186,0 -3.437048,0.723186 -4.635915,1.903504 -1.190436,1.18862 -1.919849,2.784559 -1.919849,4.59661 0,1.80375 0.729413,3.407991 1.919849,4.59674 1.198867,1.180318 2.808427,1.903374 4.635915,1.903374 h 9.934308 c 1.810755,-0.290831 3.487509,-1.138677 4.820377,-2.452087 1.668322,-1.662356 2.590498,-3.865122 2.590498,-6.200852 V 40.14924 h -7.217982 v -2.726444 c 0,-2.319125 1.902985,-4.214327 4.250259,-4.214327 h 9.967776 v 14.463334 c 0,4.139479 -1.684926,8.187506 -4.635915,11.105157 -2.598801,2.58505 -6.077877,4.189291 -9.733113,4.530064 l -0.922176,0.08315 h -9.062334 c -3.7642,0 -7.20968,-1.521091 -9.67435,-3.973178 -2.473097,-2.443915 -4.007289,-5.860208 -4.007289,-9.592368 0,-3.732159 1.534192,-7.148452 4.007295,-9.592238 2.171245,-2.161256 5.10537,-3.599197 8.349784,-3.906762 0.452721,-4.463648 2.481404,-8.486768 5.50789,-11.487439 3.453911,-3.424725 8.257554,-5.560946 13.522349,-5.552643 5.273098,0 10.076741,2.127918 13.52222,5.552643 3.018054,2.984067 5.038435,6.998885 5.499588,11.445929 3.554444,0.141265 6.782124,1.612543 9.129399,3.948272 2.489836,2.443786 4.024028,5.860079 4.024028,9.592238 z"
|
||||
fill="#ffffff"
|
||||
id="path924"
|
||||
style="stroke-width:1.29719" />
|
||||
</g>
|
||||
<defs
|
||||
id="defs936">
|
||||
<linearGradient
|
||||
id="paint0_linear_52_28"
|
||||
x1="3"
|
||||
y1="4"
|
||||
x2="78"
|
||||
y2="79"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
offset="0.14"
|
||||
stop-color="#3873DB"
|
||||
id="stop928" />
|
||||
<stop
|
||||
offset="0.946667"
|
||||
stop-color="#163467"
|
||||
id="stop930" />
|
||||
</linearGradient>
|
||||
<clipPath
|
||||
id="clip0_52_28">
|
||||
<rect
|
||||
width="84"
|
||||
height="84"
|
||||
rx="20"
|
||||
fill="white"
|
||||
id="rect933" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
16
Assets/ImageSources/JoplinServerIcon.svg
Normal file
16
Assets/ImageSources/JoplinServerIcon.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg width="84" height="84" viewBox="0 0 84 84" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_309_806)">
|
||||
<rect width="84" height="84" fill="url(#paint0_linear_309_806)"/>
|
||||
<path d="M42.5797 45.6916H33.6235V36.276C33.6235 33.3538 34.7629 30.6033 36.8396 28.5204C38.4998 26.8725 40.5826 25.8127 42.8431 25.4513V16.7033C38.2854 17.1138 33.9359 19.1354 30.6891 22.3822C27.0258 26.0333 24.9307 31.0995 24.9307 36.2821V54.3845H37.3052C40.2151 54.3845 42.5797 52.0198 42.5797 49.11V45.6916Z" fill="#1883E6"/>
|
||||
<path d="M42.4204 35.89V39.3022H51.3767V48.7179C51.3767 51.64 50.2372 54.3906 48.1605 56.4735C46.5003 58.1214 44.4175 59.1812 42.157 59.5426V68.2906C46.7147 67.8801 51.0642 65.8585 54.311 62.6117C57.9744 58.9606 60.0695 53.8944 60.0695 48.7118V30.6155H47.6949C44.7851 30.6155 42.4204 32.9863 42.4204 35.89Z" fill="#1883E6"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_309_806" x1="8.5" y1="3" x2="76.5" y2="81.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#B6D3F9"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_309_806">
|
||||
<rect width="84" height="84" rx="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -667,6 +667,30 @@ footer .bottom-links-row p {
|
||||
color: #0557ba;
|
||||
}
|
||||
|
||||
.joplin-cloud-feature-list .feature-description {
|
||||
max-width: 600px;
|
||||
font-size: .8em;
|
||||
color: #555555;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.joplin-cloud-feature-list .feature-title {
|
||||
text-decoration: none;
|
||||
color: #000000;
|
||||
margin-left: 10px;
|
||||
border: 2px solid black;
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 100px;
|
||||
height: 20px;
|
||||
font-weight: bold;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/*****************************************************************
|
||||
WHAT'S NEW PAGE
|
||||
*****************************************************************/
|
||||
|
||||
@@ -86,11 +86,7 @@ https://github.com/laurent22/joplin/blob/dev/{{{sourceMarkdownFile}}}
|
||||
{{> footer}}
|
||||
</div>
|
||||
|
||||
<script
|
||||
src="{{jsBaseUrl}}/bootstrap5.0.2.bundle.min.js"
|
||||
rel="preload"
|
||||
as="script"
|
||||
></script>
|
||||
<script src="{{jsBaseUrl}}/bootstrap5.0.2.bundle.min.js" rel="preload" as="script"></script>
|
||||
<script src="{{{assetUrls.js.script}}}"></script>
|
||||
|
||||
{{> analytics}}
|
||||
|
||||
@@ -138,6 +138,14 @@
|
||||
} else {
|
||||
applyPeriod('yearly');
|
||||
}
|
||||
|
||||
$('.feature-description').hide();
|
||||
|
||||
$('.feature-title').click((event) => {
|
||||
event.preventDefault();
|
||||
const featureId = event.currentTarget.getAttribute('data-id');
|
||||
$('.feature-description-' + featureId).toggle(200);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@@ -22,11 +22,11 @@ Three types of applications are available: for **desktop** (Windows, macOS and L
|
||||
|
||||
Operating System | Download
|
||||
---|---
|
||||
Windows (32 and 64-bit) | <a href='https://objects.joplinusercontent.com/v2.12.17/Joplin-Setup-2.12.17.exe?source=JoplinWebsite&type=New'><img alt='Get it on Windows' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeWindows.png'/></a>
|
||||
macOS | <a href='https://objects.joplinusercontent.com/v2.12.17/Joplin-2.12.17.dmg?source=JoplinWebsite&type=New'><img alt='Get it on macOS' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeMacOS.png'/></a>
|
||||
Linux | <a href='https://objects.joplinusercontent.com/v2.12.17/Joplin-2.12.17.AppImage?source=JoplinWebsite&type=New'><img alt='Get it on Linux' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeLinux.png'/></a>
|
||||
Windows (32 and 64-bit) | <a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-Setup-2.12.18.exe?source=JoplinWebsite&type=New'><img alt='Get it on Windows' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeWindows.png'/></a>
|
||||
macOS | <a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-2.12.18.dmg?source=JoplinWebsite&type=New'><img alt='Get it on macOS' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeMacOS.png'/></a>
|
||||
Linux | <a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-2.12.18.AppImage?source=JoplinWebsite&type=New'><img alt='Get it on Linux' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeLinux.png'/></a>
|
||||
|
||||
**On Windows**, you may also use the <a href='https://objects.joplinusercontent.com/v2.12.17/JoplinPortable.exe?source=JoplinWebsite&type=New'>Portable version</a>. The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
|
||||
**On Windows**, you may also use the <a href='https://objects.joplinusercontent.com/v2.12.18/JoplinPortable.exe?source=JoplinWebsite&type=New'>Portable version</a>. The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
|
||||
|
||||
**On Linux**, the recommended way is to use the following installation script as it will handle the desktop icon too:
|
||||
|
||||
|
||||
10
package.json
10
package.json
@@ -71,7 +71,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "5.62.0",
|
||||
"@typescript-eslint/parser": "5.62.0",
|
||||
"cspell": "5.21.2",
|
||||
"eslint": "8.46.0",
|
||||
"eslint": "8.47.0",
|
||||
"eslint-interactive": "10.8.0",
|
||||
"eslint-plugin-import": "2.28.1",
|
||||
"eslint-plugin-jest": "27.2.3",
|
||||
@@ -79,13 +79,13 @@
|
||||
"eslint-plugin-react": "7.33.2",
|
||||
"execa": "5.1.1",
|
||||
"fs-extra": "11.1.1",
|
||||
"glob": "10.3.3",
|
||||
"glob": "10.3.4",
|
||||
"gulp": "4.0.2",
|
||||
"husky": "3.1.0",
|
||||
"lerna": "3.22.1",
|
||||
"lint-staged": "13.2.3",
|
||||
"lint-staged": "13.3.0",
|
||||
"madge": "6.1.0",
|
||||
"npm-package-json-lint": "6.4.0",
|
||||
"npm-package-json-lint": "7.0.0",
|
||||
"typescript": "5.1.6"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -99,7 +99,7 @@
|
||||
"resolutions": {
|
||||
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
|
||||
"react-native-vosk@0.1.12": "patch:react-native-vosk@npm%3A0.1.12#./.yarn/patches/react-native-vosk-npm-0.1.12-76b1caaae8.patch",
|
||||
"eslint": "patch:eslint@npm%3A8.39.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
|
||||
"eslint": "patch:eslint@8.47.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
|
||||
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
|
||||
"react-native@0.71.10": "patch:react-native@npm%3A0.71.10#./.yarn/patches/react-native-animation-fix/react-native-npm-0.71.10-f9c32562d8.patch"
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"@joplin/tools": "~2.13",
|
||||
"@types/fs-extra": "11.0.1",
|
||||
"@types/jest": "29.5.4",
|
||||
"@types/node": "18.17.12",
|
||||
"@types/node": "18.17.14",
|
||||
"@types/proper-lockfile": "^4.1.2",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.6.4",
|
||||
|
||||
@@ -2,5 +2,20 @@ joplin.plugins.register({
|
||||
onStart: async function() {
|
||||
const folder = await joplin.data.post(['folders'], null, { title: "my plugin folder" });
|
||||
await joplin.data.post(['notes'], null, { parent_id: folder.id, title: "testing plugin!" });
|
||||
|
||||
// await joplin.commands.register({
|
||||
// name: 'updateCurrentNote',
|
||||
// label: 'Update current note via the data API',
|
||||
// iconName: 'fas fa-music',
|
||||
// execute: async () => {
|
||||
// const noteIds = await joplin.workspace.selectedNoteIds();
|
||||
// const noteId = noteIds.length === 1 ? noteIds[0] : null;
|
||||
// if (!noteId) return;
|
||||
// console.info('Modifying current note...');
|
||||
// await joplin.data.put(['notes', noteId], null, { body: "New note body " + Date.now() });
|
||||
// },
|
||||
// });
|
||||
|
||||
// await joplin.views.toolbarButtons.create('updateCurrentNoteButton', 'updateCurrentNote', 'editorToolbar');
|
||||
},
|
||||
});
|
||||
@@ -192,7 +192,7 @@ class Application extends BaseApplication {
|
||||
// The '*' and '!important' parts are necessary to make sure Russian text is displayed properly
|
||||
// https://github.com/laurent22/joplin/issues/155
|
||||
|
||||
const css = `.CodeMirror * { font-family: ${fontFamilies.join(', ')} !important; }`;
|
||||
const css = `.CodeMirror *, .cm-editor .cm-content { font-family: ${fontFamilies.join(', ')} !important; }`;
|
||||
const styleTag = document.createElement('style');
|
||||
styleTag.type = 'text/css';
|
||||
styleTag.appendChild(document.createTextNode(css));
|
||||
|
||||
@@ -79,6 +79,7 @@ interface Props {
|
||||
startupPluginsLoaded: boolean;
|
||||
shareInvitations: ShareInvitation[];
|
||||
isSafeMode: boolean;
|
||||
enableBetaMarkdownEditor: boolean;
|
||||
needApiAuth: boolean;
|
||||
processingShareInvitationResponse: boolean;
|
||||
isResettingLayout: boolean;
|
||||
@@ -737,7 +738,11 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
},
|
||||
|
||||
editor: () => {
|
||||
const bodyEditor = this.props.settingEditorCodeView ? 'CodeMirror' : 'TinyMCE';
|
||||
let bodyEditor = this.props.settingEditorCodeView ? 'CodeMirror' : 'TinyMCE';
|
||||
|
||||
if (this.props.settingEditorCodeView && this.props.enableBetaMarkdownEditor) {
|
||||
bodyEditor = 'CodeMirror6';
|
||||
}
|
||||
return <NoteEditor key={key} bodyEditor={bodyEditor} />;
|
||||
},
|
||||
};
|
||||
@@ -909,6 +914,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
shareInvitations: state.shareService.shareInvitations,
|
||||
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
|
||||
isSafeMode: state.settings.isSafeMode,
|
||||
enableBetaMarkdownEditor: state.settings['editor.beta'],
|
||||
needApiAuth: state.needApiAuth,
|
||||
showInstallTemplatesPlugin: state.hasLegacyTemplates && !state.pluginService.plugins['joplin.plugin.templates'],
|
||||
isResettingLayout: state.isResettingLayout,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { NoteBodyEditorProps } from '../../../utils/types';
|
||||
const { buildStyle } = require('@joplin/lib/theme');
|
||||
|
||||
export default function styles(props: NoteBodyEditorProps) {
|
||||
return buildStyle(['CodeMirror', props.fontSize], props.themeId, (theme: any) => {
|
||||
return {
|
||||
root: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
...props.style,
|
||||
},
|
||||
rowToolbar: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
rowEditorViewer: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
paddingTop: 10,
|
||||
},
|
||||
cellEditor: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
},
|
||||
cellViewer: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: theme.dividerColor,
|
||||
borderLeftStyle: 'solid',
|
||||
},
|
||||
viewer: {
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
verticalAlign: 'top',
|
||||
boxSizing: 'border-box',
|
||||
width: '100%',
|
||||
},
|
||||
editor: {
|
||||
display: 'flex',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
flex: 1,
|
||||
overflowY: 'hidden',
|
||||
paddingTop: 0,
|
||||
lineHeight: `${Math.round(17 * props.fontSize / 12)}px`,
|
||||
fontSize: `${props.fontSize}px`,
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
codeMirrorTheme: theme.codeMirrorTheme, // Defined in theme.js
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
|
||||
|
||||
const setupVim = (CodeMirror: CodeMirrorControl) => {
|
||||
CodeMirror.Vim.defineAction('swapLineDown', CodeMirror.commands.swapLineDown);
|
||||
CodeMirror.Vim.mapCommand('<A-j>', 'action', 'swapLineDown', {}, { context: 'normal', isEdit: true });
|
||||
CodeMirror.Vim.defineAction('swapLineUp', CodeMirror.commands.swapLineUp);
|
||||
CodeMirror.Vim.mapCommand('<A-k>', 'action', 'swapLineUp', {}, { context: 'normal', isEdit: true });
|
||||
CodeMirror.Vim.defineAction('insertListElement', CodeMirror.commands.vimInsertListElement);
|
||||
CodeMirror.Vim.mapCommand('o', 'action', 'insertListElement', { after: true }, { context: 'normal', isEdit: true, interlaceInsertRepeat: true });
|
||||
};
|
||||
|
||||
export default setupVim;
|
||||
@@ -0,0 +1,175 @@
|
||||
|
||||
import { ContextMenuEvent, ContextMenuParams } from 'electron';
|
||||
import { useEffect, RefObject } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import convertToScreenCoordinates from '../../../../utils/convertToScreenCoordinates';
|
||||
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
|
||||
import { EditContextMenuFilterObject } from '@joplin/lib/services/plugins/api/JoplinWorkspace';
|
||||
import type CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
|
||||
import eventManager from '@joplin/lib/eventManager';
|
||||
import bridge from '../../../../../services/bridge';
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
const menuUtils = new MenuUtils(CommandService.instance());
|
||||
|
||||
|
||||
interface ContextMenuProps {
|
||||
plugins: PluginStates;
|
||||
editorCutText: ()=> void;
|
||||
editorCopyText: ()=> void;
|
||||
editorPaste: ()=> void;
|
||||
editorRef: RefObject<CodeMirrorControl>;
|
||||
editorClassName: string;
|
||||
}
|
||||
|
||||
const useContextMenu = (props: ContextMenuProps) => {
|
||||
const editorRef = props.editorRef;
|
||||
|
||||
// The below code adds support for spellchecking when it is enabled
|
||||
// It might be buggy, refer to the below issue
|
||||
// https://github.com/laurent22/joplin/pull/3974#issuecomment-718936703
|
||||
useEffect(() => {
|
||||
const isAncestorOfCodeMirrorEditor = (elem: HTMLElement) => {
|
||||
for (; elem.parentElement; elem = elem.parentElement) {
|
||||
if (elem.classList.contains(props.editorClassName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
let lastInCodeMirrorContextMenuTimestamp = 0;
|
||||
|
||||
// The browser's contextmenu event provides additional information about the
|
||||
// target of the event, not provided by the Electron context-menu event.
|
||||
const onBrowserContextMenu = (event: Event) => {
|
||||
if (isAncestorOfCodeMirrorEditor(event.target as HTMLElement)) {
|
||||
lastInCodeMirrorContextMenuTimestamp = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
function pointerInsideEditor(params: ContextMenuParams) {
|
||||
const x = params.x, y = params.y, isEditable = params.isEditable;
|
||||
const elements = document.getElementsByClassName(props.editorClassName);
|
||||
|
||||
// Note: We can't check inputFieldType here. When spellcheck is enabled,
|
||||
// params.inputFieldType is "none". When spellcheck is disabled,
|
||||
// params.inputFieldType is "plainText". Thus, such a check would be inconsistent.
|
||||
if (!elements.length || !isEditable) return false;
|
||||
|
||||
const maximumMsSinceBrowserEvent = 100;
|
||||
if (Date.now() - lastInCodeMirrorContextMenuTimestamp > maximumMsSinceBrowserEvent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = convertToScreenCoordinates(Setting.value('windowContentZoomFactor'), elements[0].getBoundingClientRect());
|
||||
return rect.x < x && rect.y < y && rect.right > x && rect.bottom > y;
|
||||
}
|
||||
|
||||
async function onContextMenu(event: ContextMenuEvent, params: ContextMenuParams) {
|
||||
if (!pointerInsideEditor(params)) return;
|
||||
|
||||
// Don't show the default menu.
|
||||
event.preventDefault();
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ;
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Cut'),
|
||||
enabled: hasSelectedText,
|
||||
click: async () => {
|
||||
props.editorCutText();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Copy'),
|
||||
enabled: hasSelectedText,
|
||||
click: async () => {
|
||||
props.editorCopyText();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Paste'),
|
||||
enabled: true,
|
||||
click: async () => {
|
||||
props.editorPaste();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
|
||||
|
||||
for (const item of spellCheckerMenuItems) {
|
||||
menu.append(new MenuItem(item));
|
||||
}
|
||||
|
||||
// CodeMirror 5 only:
|
||||
// Typically CodeMirror handles all interactions itself (highlighting etc.)
|
||||
// But in the case of clicking a mispelled word, we need electron to handle the click
|
||||
// The result is that CodeMirror doesn't know what's been selected and doesn't
|
||||
// move the cursor into the correct location.
|
||||
// and when the user selects a new spelling it will be inserted in the wrong location
|
||||
// So in this situation, we use must manually align the internal codemirror selection
|
||||
// to the contextmenu selection
|
||||
if (editorRef.current && !editorRef.current.cm6 && spellCheckerMenuItems.length > 0) {
|
||||
(editorRef.current as any).alignSelection(params);
|
||||
}
|
||||
|
||||
let filterObject: EditContextMenuFilterObject = {
|
||||
items: [],
|
||||
};
|
||||
|
||||
filterObject = await eventManager.filterEmit('editorContextMenu', filterObject);
|
||||
|
||||
for (const item of filterObject.items) {
|
||||
menu.append(new MenuItem({
|
||||
label: item.label,
|
||||
click: async () => {
|
||||
const args = item.commandArgs || [];
|
||||
void CommandService.instance().execute(item.commandName, ...args);
|
||||
},
|
||||
type: item.type,
|
||||
}));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line github/array-foreach -- Old code before rule was applied
|
||||
menuUtils.pluginContextMenuItems(props.plugins, MenuItemLocation.EditorContextMenu).forEach((item: any) => {
|
||||
menu.append(new MenuItem(item));
|
||||
});
|
||||
|
||||
menu.popup();
|
||||
}
|
||||
|
||||
// Prepend the event listener so that it gets called before
|
||||
// the listener that shows the default menu.
|
||||
bridge().window().webContents.prependListener('context-menu', onContextMenu);
|
||||
|
||||
window.addEventListener('contextmenu', onBrowserContextMenu);
|
||||
|
||||
return () => {
|
||||
bridge().window().webContents.off('context-menu', onContextMenu);
|
||||
window.removeEventListener('contextmenu', onBrowserContextMenu);
|
||||
};
|
||||
}, [
|
||||
props.plugins, props.editorClassName, editorRef,
|
||||
props.editorCutText, props.editorCopyText, props.editorPaste,
|
||||
]);
|
||||
};
|
||||
|
||||
export default useContextMenu;
|
||||
@@ -4,6 +4,7 @@ import KeymapService, { KeymapItem } from '@joplin/lib/services/KeymapService';
|
||||
import { EditorCommand } from '../../../utils/types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import setupVim from './setupVim';
|
||||
|
||||
export default function useKeymap(CodeMirror: any) {
|
||||
|
||||
@@ -17,14 +18,6 @@ export default function useKeymap(CodeMirror: any) {
|
||||
CodeMirror.keyMap.emacs['Shift-Tab'] = 'smartListUnindent';
|
||||
}
|
||||
|
||||
function setupVim() {
|
||||
CodeMirror.Vim.defineAction('swapLineDown', CodeMirror.commands.swapLineDown);
|
||||
CodeMirror.Vim.mapCommand('<A-j>', 'action', 'swapLineDown', {}, { context: 'normal', isEdit: true });
|
||||
CodeMirror.Vim.defineAction('swapLineUp', CodeMirror.commands.swapLineUp);
|
||||
CodeMirror.Vim.mapCommand('<A-k>', 'action', 'swapLineUp', {}, { context: 'normal', isEdit: true });
|
||||
CodeMirror.Vim.defineAction('insertListElement', CodeMirror.commands.vimInsertListElement);
|
||||
CodeMirror.Vim.mapCommand('o', 'action', 'insertListElement', { after: true }, { context: 'normal', isEdit: true, interlaceInsertRepeat: true });
|
||||
}
|
||||
function isEditorCommand(command: string) {
|
||||
return command.startsWith('editor.');
|
||||
}
|
||||
@@ -184,7 +177,7 @@ export default function useKeymap(CodeMirror: any) {
|
||||
keymapService.on('keymapChange', registerKeymap);
|
||||
|
||||
setupEmacs();
|
||||
setupVim();
|
||||
setupVim(CodeMirror);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { NoteBodyEditorProps } from '../../../utils/types';
|
||||
import { buildStyle } from '@joplin/lib/theme';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
|
||||
|
||||
const useStyles = (props: NoteBodyEditorProps) => {
|
||||
return useMemo(() => {
|
||||
return buildStyle(['CodeMirror', props.fontSize], props.themeId, (theme: Theme) => {
|
||||
return {
|
||||
root: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
...props.style,
|
||||
},
|
||||
rowToolbar: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
rowEditorViewer: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
paddingTop: 10,
|
||||
|
||||
// Allow the editor container to shrink (allowing the editor to scroll)
|
||||
minHeight: 0,
|
||||
},
|
||||
cellEditor: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
},
|
||||
cellViewer: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: theme.dividerColor,
|
||||
borderLeftStyle: 'solid',
|
||||
},
|
||||
viewer: {
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
verticalAlign: 'top',
|
||||
boxSizing: 'border-box',
|
||||
width: '100%',
|
||||
},
|
||||
editor: {
|
||||
display: 'flex',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
flex: 1,
|
||||
overflowY: 'hidden',
|
||||
paddingTop: 0,
|
||||
lineHeight: `${Math.round(17 * props.fontSize / 12)}px`,
|
||||
fontSize: `${props.fontSize}px`,
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
|
||||
// CM5 only
|
||||
codeMirrorTheme: theme.codeMirrorTheme, // Defined in theme.js
|
||||
},
|
||||
|
||||
// CM6 only
|
||||
globalTheme: {
|
||||
...theme,
|
||||
fontFamily: 'inherit',
|
||||
fontSize: props.fontSize,
|
||||
fontSizeUnits: 'px',
|
||||
isDesktop: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [props.style, props.themeId, props.fontSize]);
|
||||
};
|
||||
export default useStyles;
|
||||
@@ -0,0 +1,46 @@
|
||||
import type CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation';
|
||||
import shared from '@joplin/lib/components/shared/note-screen-shared';
|
||||
import { useCallback, RefObject } from 'react';
|
||||
|
||||
interface Props {
|
||||
onMessage(event: any): void;
|
||||
getLineScrollPercent(): number;
|
||||
setEditorPercentScroll(fraction: number): void;
|
||||
editorRef: RefObject<CodeMirror5Emulation>;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const useWebviewIpcMessage = (props: Props) => {
|
||||
const editorRef = props.editorRef;
|
||||
|
||||
return useCallback((event: any) => {
|
||||
const msg = event.channel ? event.channel : '';
|
||||
const args = event.args;
|
||||
const arg0 = args && args.length >= 1 ? args[0] : null;
|
||||
|
||||
if (msg.indexOf('checkboxclick:') === 0) {
|
||||
const { line, from, to } = shared.toggleCheckboxRange(msg, props.content);
|
||||
if (editorRef.current) {
|
||||
// To cancel CodeMirror's layout drift, the scroll position
|
||||
// is recorded before updated, and then it is restored.
|
||||
// Ref. https://github.com/laurent22/joplin/issues/5890
|
||||
const percent = props.getLineScrollPercent();
|
||||
editorRef.current.replaceRange(line, from, to);
|
||||
props.setEditorPercentScroll(percent);
|
||||
}
|
||||
} else if (msg === 'percentScroll') {
|
||||
const percent = arg0;
|
||||
props.setEditorPercentScroll(percent);
|
||||
} else {
|
||||
props.onMessage(event);
|
||||
}
|
||||
}, [
|
||||
props.onMessage,
|
||||
props.content,
|
||||
editorRef,
|
||||
props.getLineScrollPercent,
|
||||
props.setEditorPercentScroll,
|
||||
]);
|
||||
};
|
||||
|
||||
export default useWebviewIpcMessage;
|
||||
@@ -1,55 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo } from 'react';
|
||||
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef } from 'react';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { EditorCommand, NoteBodyEditorProps } from '../../utils/types';
|
||||
import { commandAttachFileToBody, getResourcesFromPasteEvent } from '../../utils/resourceHandling';
|
||||
import { ScrollOptions, ScrollOptionTypes } from '../../utils/types';
|
||||
import { CommandValue } from '../../utils/types';
|
||||
import { usePrevious, cursorPositionToTextOffset } from './utils';
|
||||
import useScrollHandler from './utils/useScrollHandler';
|
||||
import { EditorCommand, NoteBodyEditorProps, NoteBodyEditorRef } from '../../../utils/types';
|
||||
import { commandAttachFileToBody, getResourcesFromPasteEvent } from '../../../utils/resourceHandling';
|
||||
import { ScrollOptions, ScrollOptionTypes } from '../../../utils/types';
|
||||
import { CommandValue } from '../../../utils/types';
|
||||
import { usePrevious, cursorPositionToTextOffset } from '../utils';
|
||||
import useScrollHandler from '../utils/useScrollHandler';
|
||||
import useElementSize from '@joplin/lib/hooks/useElementSize';
|
||||
import Toolbar from './Toolbar';
|
||||
import styles_ from './styles';
|
||||
import { RenderedBody, defaultRenderedBody } from './utils/types';
|
||||
import NoteTextViewer from '../../../NoteTextViewer';
|
||||
import Toolbar from '../Toolbar';
|
||||
import { RenderedBody, defaultRenderedBody } from '../utils/types';
|
||||
import NoteTextViewer from '../../../../NoteTextViewer';
|
||||
import Editor from './Editor';
|
||||
import usePluginServiceRegistration from '../../utils/usePluginServiceRegistration';
|
||||
import usePluginServiceRegistration from '../../../utils/usePluginServiceRegistration';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../../../../services/bridge';
|
||||
import bridge from '../../../../../services/bridge';
|
||||
import markdownUtils from '@joplin/lib/markdownUtils';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { ThemeAppearance } from '@joplin/lib/themes/type';
|
||||
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
|
||||
import dialogs from '../../../dialogs';
|
||||
import convertToScreenCoordinates from '../../../utils/convertToScreenCoordinates';
|
||||
import dialogs from '../../../../dialogs';
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
const { clipboard } = require('electron');
|
||||
const debounce = require('debounce');
|
||||
import shared from '@joplin/lib/components/shared/note-screen-shared';
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import ErrorBoundary from '../../../ErrorBoundary';
|
||||
import { MarkupToHtmlOptions } from '../../utils/useMarkupToHtml';
|
||||
import eventManager from '@joplin/lib/eventManager';
|
||||
import { EditContextMenuFilterObject } from '@joplin/lib/services/plugins/api/JoplinWorkspace';
|
||||
import type { ContextMenuEvent, ContextMenuParams } from 'electron';
|
||||
|
||||
const menuUtils = new MenuUtils(CommandService.instance());
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import ErrorBoundary from '../../../../ErrorBoundary';
|
||||
import { MarkupToHtmlOptions } from '../../../utils/useMarkupToHtml';
|
||||
import useStyles from '../utils/useStyles';
|
||||
import useContextMenu from '../utils/useContextMenu';
|
||||
import useWebviewIpcMessage from '../utils/useWebviewIpcMessage';
|
||||
|
||||
function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions {
|
||||
return { ...override };
|
||||
}
|
||||
|
||||
function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
const styles = styles_(props);
|
||||
function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditorRef>) {
|
||||
const styles = useStyles(props);
|
||||
|
||||
const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content
|
||||
const [renderedBodyContentKey, setRenderedBodyContentKey] = useState<string>(null);
|
||||
@@ -599,29 +589,13 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
setWebviewReady(true);
|
||||
}, []);
|
||||
|
||||
const webview_ipcMessage = useCallback((event: any) => {
|
||||
const msg = event.channel ? event.channel : '';
|
||||
const args = event.args;
|
||||
const arg0 = args && args.length >= 1 ? args[0] : null;
|
||||
|
||||
if (msg.indexOf('checkboxclick:') === 0) {
|
||||
const { line, from, to } = shared.toggleCheckboxRange(msg, props.content);
|
||||
if (editorRef.current) {
|
||||
// To cancel CodeMirror's layout drift, the scroll position
|
||||
// is recorded before updated, and then it is restored.
|
||||
// Ref. https://github.com/laurent22/joplin/issues/5890
|
||||
const percent = getLineScrollPercent();
|
||||
editorRef.current.replaceRange(line, from, to);
|
||||
setEditorPercentScroll(percent);
|
||||
}
|
||||
} else if (msg === 'percentScroll') {
|
||||
const percent = arg0;
|
||||
setEditorPercentScroll(percent);
|
||||
} else {
|
||||
props.onMessage(event);
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.onMessage, props.content, setEditorPercentScroll]);
|
||||
const webview_ipcMessage = useWebviewIpcMessage({
|
||||
editorRef,
|
||||
setEditorPercentScroll,
|
||||
getLineScrollPercent,
|
||||
content: props.content,
|
||||
onMessage: props.onMessage,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -779,142 +753,12 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
editorRef.current.refresh();
|
||||
}, [rootSize, styles.editor, props.visiblePanes]);
|
||||
|
||||
// The below code adds support for spellchecking when it is enabled
|
||||
// It might be buggy, refer to the below issue
|
||||
// https://github.com/laurent22/joplin/pull/3974#issuecomment-718936703
|
||||
useEffect(() => {
|
||||
const isAncestorOfCodeMirrorEditor = (elem: HTMLElement) => {
|
||||
for (; elem.parentElement; elem = elem.parentElement) {
|
||||
if (elem.classList.contains('codeMirrorEditor')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
let lastInCodeMirrorContextMenuTimestamp = 0;
|
||||
|
||||
// The browser's contextmenu event provides additional information about the
|
||||
// target of the event, not provided by the Electron context-menu event.
|
||||
const onBrowserContextMenu = (event: Event) => {
|
||||
if (isAncestorOfCodeMirrorEditor(event.target as HTMLElement)) {
|
||||
lastInCodeMirrorContextMenuTimestamp = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
function pointerInsideEditor(params: ContextMenuParams) {
|
||||
const x = params.x, y = params.y, isEditable = params.isEditable;
|
||||
const elements = document.getElementsByClassName('codeMirrorEditor');
|
||||
|
||||
// Note: We can't check inputFieldType here. When spellcheck is enabled,
|
||||
// params.inputFieldType is "none". When spellcheck is disabled,
|
||||
// params.inputFieldType is "plainText". Thus, such a check would be inconsistent.
|
||||
if (!elements.length || !isEditable) return false;
|
||||
|
||||
const maximumMsSinceBrowserEvent = 100;
|
||||
if (Date.now() - lastInCodeMirrorContextMenuTimestamp > maximumMsSinceBrowserEvent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = convertToScreenCoordinates(Setting.value('windowContentZoomFactor'), elements[0].getBoundingClientRect());
|
||||
return rect.x < x && rect.y < y && rect.right > x && rect.bottom > y;
|
||||
}
|
||||
|
||||
async function onContextMenu(event: ContextMenuEvent, params: ContextMenuParams) {
|
||||
if (!pointerInsideEditor(params)) return;
|
||||
|
||||
// Don't show the default menu.
|
||||
event.preventDefault();
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ;
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Cut'),
|
||||
enabled: hasSelectedText,
|
||||
click: async () => {
|
||||
editorCutText();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Copy'),
|
||||
enabled: hasSelectedText,
|
||||
click: async () => {
|
||||
editorCopyText();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Paste'),
|
||||
enabled: true,
|
||||
click: async () => {
|
||||
editorPaste();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
|
||||
|
||||
for (const item of spellCheckerMenuItems) {
|
||||
menu.append(new MenuItem(item));
|
||||
}
|
||||
|
||||
// Typically CodeMirror handles all interactions itself (highlighting etc.)
|
||||
// But in the case of clicking a mispelled word, we need electron to handle the click
|
||||
// The result is that CodeMirror doesn't know what's been selected and doesn't
|
||||
// move the cursor into the correct location.
|
||||
// and when the user selects a new spelling it will be inserted in the wrong location
|
||||
// So in this situation, we use must manually align the internal codemirror selection
|
||||
// to the contextmenu selection
|
||||
if (editorRef.current && spellCheckerMenuItems.length > 0) {
|
||||
editorRef.current.alignSelection(params);
|
||||
}
|
||||
|
||||
let filterObject: EditContextMenuFilterObject = {
|
||||
items: [],
|
||||
};
|
||||
|
||||
filterObject = await eventManager.filterEmit('editorContextMenu', filterObject);
|
||||
|
||||
for (const item of filterObject.items) {
|
||||
menu.append(new MenuItem({
|
||||
label: item.label,
|
||||
click: async () => {
|
||||
const args = item.commandArgs || [];
|
||||
void CommandService.instance().execute(item.commandName, ...args);
|
||||
},
|
||||
type: item.type,
|
||||
}));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line github/array-foreach -- Old code before rule was applied
|
||||
menuUtils.pluginContextMenuItems(props.plugins, MenuItemLocation.EditorContextMenu).forEach((item: any) => {
|
||||
menu.append(new MenuItem(item));
|
||||
});
|
||||
|
||||
menu.popup();
|
||||
}
|
||||
|
||||
// Prepend the event listener so that it gets called before
|
||||
// the listener that shows the default menu.
|
||||
bridge().window().webContents.prependListener('context-menu', onContextMenu);
|
||||
|
||||
window.addEventListener('contextmenu', onBrowserContextMenu);
|
||||
|
||||
return () => {
|
||||
bridge().window().webContents.off('context-menu', onContextMenu);
|
||||
window.removeEventListener('contextmenu', onBrowserContextMenu);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.plugins]);
|
||||
useContextMenu({
|
||||
plugins: props.plugins,
|
||||
editorCutText, editorCopyText, editorPaste,
|
||||
editorRef,
|
||||
editorClassName: 'codeMirrorEditor',
|
||||
});
|
||||
|
||||
function renderEditor() {
|
||||
|
||||
@@ -12,15 +12,15 @@ import 'codemirror/addon/scroll/annotatescrollbar';
|
||||
import 'codemirror/addon/search/matchesonscrollbar';
|
||||
import 'codemirror/addon/search/searchcursor';
|
||||
|
||||
import useListIdent from './utils/useListIdent';
|
||||
import useScrollUtils from './utils/useScrollUtils';
|
||||
import useCursorUtils from './utils/useCursorUtils';
|
||||
import useLineSorting from './utils/useLineSorting';
|
||||
import useEditorSearch from './utils/useEditorSearch';
|
||||
import useJoplinMode from './utils/useJoplinMode';
|
||||
import useKeymap from './utils/useKeymap';
|
||||
import useExternalPlugins from './utils/useExternalPlugins';
|
||||
import useJoplinCommands from './utils/useJoplinCommands';
|
||||
import useListIdent from '../utils/useListIdent';
|
||||
import useScrollUtils from '../utils/useScrollUtils';
|
||||
import useCursorUtils from '../utils/useCursorUtils';
|
||||
import useLineSorting from '../utils/useLineSorting';
|
||||
import useEditorSearch from '../utils/useEditorSearch';
|
||||
import useJoplinMode from '../utils/useJoplinMode';
|
||||
import useKeymap from '../utils/useKeymap';
|
||||
import useExternalPlugins from '../utils/useExternalPlugins';
|
||||
import useJoplinCommands from '../utils/useJoplinCommands';
|
||||
|
||||
import 'codemirror/keymap/emacs';
|
||||
import 'codemirror/keymap/vim';
|
||||
@@ -0,0 +1,429 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef } from 'react';
|
||||
|
||||
import { EditorCommand, NoteBodyEditorProps, NoteBodyEditorRef, OnChangeEvent } from '../../../utils/types';
|
||||
import { getResourcesFromPasteEvent } from '../../../utils/resourceHandling';
|
||||
import { ScrollOptions, ScrollOptionTypes } from '../../../utils/types';
|
||||
import NoteTextViewer from '../../../../NoteTextViewer';
|
||||
import Editor from './Editor';
|
||||
import usePluginServiceRegistration from '../../../utils/usePluginServiceRegistration';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../../../../../services/bridge';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
const { clipboard } = require('electron');
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import ErrorBoundary from '../../../../ErrorBoundary';
|
||||
import { MarkupToHtmlOptions } from '../../../utils/useMarkupToHtml';
|
||||
import { EditorKeymap, EditorLanguageType, EditorSettings } from '@joplin/editor/types';
|
||||
import useStyles from '../utils/useStyles';
|
||||
import { EditorEvent, EditorEventType } from '@joplin/editor/events';
|
||||
import useScrollHandler from '../utils/useScrollHandler';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import useEditorCommands from './useEditorCommands';
|
||||
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
|
||||
import useContextMenu from '../utils/useContextMenu';
|
||||
import useWebviewIpcMessage from '../utils/useWebviewIpcMessage';
|
||||
import Toolbar from '../Toolbar';
|
||||
|
||||
const logger = Logger.create('CodeMirror6');
|
||||
const logDebug = (message: string) => logger.debug(message);
|
||||
|
||||
interface RenderedBody {
|
||||
html: string;
|
||||
pluginAssets: any[];
|
||||
}
|
||||
|
||||
function defaultRenderedBody(): RenderedBody {
|
||||
return {
|
||||
html: '',
|
||||
pluginAssets: [],
|
||||
};
|
||||
}
|
||||
|
||||
function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions {
|
||||
return { ...override };
|
||||
}
|
||||
|
||||
const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditorRef>) => {
|
||||
const styles = useStyles(props);
|
||||
|
||||
const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content
|
||||
const [renderedBodyContentKey, setRenderedBodyContentKey] = useState<string>(null);
|
||||
|
||||
const [webviewReady, setWebviewReady] = useState(false);
|
||||
|
||||
const editorRef = useRef<CodeMirrorControl>(null);
|
||||
const rootRef = useRef(null);
|
||||
const webviewRef = useRef(null);
|
||||
|
||||
type OnChangeCallback = (event: OnChangeEvent)=> void;
|
||||
const props_onChangeRef = useRef<OnChangeCallback>(null);
|
||||
props_onChangeRef.current = props.onChange;
|
||||
|
||||
const [selectionRange, setSelectionRange] = useState({ from: 0, to: 0 });
|
||||
|
||||
const {
|
||||
resetScroll, editor_scroll, setEditorPercentScroll, setViewerPercentScroll, getLineScrollPercent,
|
||||
} = useScrollHandler(editorRef, webviewRef, props.onScroll);
|
||||
|
||||
usePluginServiceRegistration(ref);
|
||||
|
||||
const codeMirror_change = useCallback((newBody: string) => {
|
||||
if (newBody !== props.content) {
|
||||
props_onChangeRef.current({ changeId: null, content: newBody });
|
||||
}
|
||||
}, [props.content]);
|
||||
|
||||
const onEditorPaste = useCallback(async (event: any = null) => {
|
||||
const resourceMds = await getResourcesFromPasteEvent(event);
|
||||
if (!resourceMds.length) return;
|
||||
if (editorRef.current) {
|
||||
editorRef.current.insertText(resourceMds.join('\n'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editorCutText = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
const selections = editorRef.current.getSelections();
|
||||
if (selections.length > 0 && selections[0]) {
|
||||
clipboard.writeText(selections[0]);
|
||||
// Easy way to wipe out just the first selection
|
||||
selections[0] = '';
|
||||
editorRef.current.replaceSelections(selections);
|
||||
} else {
|
||||
const cursor = editorRef.current.getCursor();
|
||||
const line = editorRef.current.getLine(cursor.line);
|
||||
clipboard.writeText(`${line}\n`);
|
||||
const startLine = editorRef.current.getCursor('head');
|
||||
startLine.ch = 0;
|
||||
const endLine = {
|
||||
line: startLine.line + 1,
|
||||
ch: 0,
|
||||
};
|
||||
editorRef.current.replaceRange('', startLine, endLine);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editorCopyText = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
const selections = editorRef.current.getSelections();
|
||||
|
||||
// Handle the case when there is a selection - copy the selection to the clipboard
|
||||
// When there is no selection, the selection array contains an empty string.
|
||||
if (selections.length > 0 && selections[0]) {
|
||||
clipboard.writeText(selections[0]);
|
||||
} else {
|
||||
// This is the case when there is no selection - copy the current line to the clipboard
|
||||
const cursor = editorRef.current.getCursor();
|
||||
const line = editorRef.current.getLine(cursor.line);
|
||||
clipboard.writeText(line);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editorPasteText = useCallback(async () => {
|
||||
if (editorRef.current) {
|
||||
const modifiedMd = await Note.replaceResourceExternalToInternalLinks(clipboard.readText(), { useAbsolutePaths: true });
|
||||
editorRef.current.insertText(modifiedMd);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editorPaste = useCallback(() => {
|
||||
const clipboardText = clipboard.readText();
|
||||
|
||||
if (clipboardText) {
|
||||
void editorPasteText();
|
||||
} else {
|
||||
// To handle pasting images
|
||||
void onEditorPaste();
|
||||
}
|
||||
}, [editorPasteText, onEditorPaste]);
|
||||
|
||||
const commands = useEditorCommands({
|
||||
webviewRef,
|
||||
editorRef,
|
||||
selectionRange,
|
||||
|
||||
editorCopyText, editorCutText, editorPaste,
|
||||
editorContent: props.content,
|
||||
visiblePanes: props.visiblePanes,
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
content: () => props.content,
|
||||
resetScroll: () => {
|
||||
resetScroll();
|
||||
},
|
||||
scrollTo: (options: ScrollOptions) => {
|
||||
if (options.type === ScrollOptionTypes.Hash) {
|
||||
if (!webviewRef.current) return;
|
||||
webviewRef.current.send('scrollToHash', options.value as string);
|
||||
} else if (options.type === ScrollOptionTypes.Percent) {
|
||||
const percent = options.value as number;
|
||||
setEditorPercentScroll(percent);
|
||||
setViewerPercentScroll(percent);
|
||||
|
||||
} else {
|
||||
throw new Error(`Unsupported scroll options: ${options.type}`);
|
||||
}
|
||||
},
|
||||
supportsCommand: (name: string) => {
|
||||
return name in commands || editorRef.current.supportsCommand(name);
|
||||
},
|
||||
execCommand: async (cmd: EditorCommand) => {
|
||||
if (!editorRef.current) return false;
|
||||
|
||||
logger.debug('execCommand', cmd);
|
||||
|
||||
let commandOutput = null;
|
||||
if (cmd.name in commands) {
|
||||
commandOutput = (commands as any)[cmd.name](cmd.value);
|
||||
} else if (editorRef.current.supportsCommand(cmd.name)) {
|
||||
commandOutput = editorRef.current.execCommand(cmd.name);
|
||||
} else if (editorRef.current.supportsJoplinCommand(cmd.name)) {
|
||||
commandOutput = editorRef.current.execJoplinCommand(cmd.name);
|
||||
} else {
|
||||
reg.logger().warn('CodeMirror: unsupported Joplin command: ', cmd);
|
||||
}
|
||||
|
||||
return commandOutput;
|
||||
},
|
||||
};
|
||||
}, [props.content, commands, resetScroll, setEditorPercentScroll, setViewerPercentScroll]);
|
||||
|
||||
const webview_domReady = useCallback(() => {
|
||||
setWebviewReady(true);
|
||||
}, []);
|
||||
|
||||
const webview_ipcMessage = useWebviewIpcMessage({
|
||||
editorRef,
|
||||
setEditorPercentScroll,
|
||||
getLineScrollPercent,
|
||||
content: props.content,
|
||||
onMessage: props.onMessage,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
// When a new note is loaded (contentKey is different), we want the note to be displayed
|
||||
// right away. However once that's done, we put a small delay so that the view is not
|
||||
// being constantly updated while the user changes the note.
|
||||
const interval = renderedBodyContentKey !== props.contentKey ? 0 : 500;
|
||||
|
||||
const timeoutId = shim.setTimeout(async () => {
|
||||
let bodyToRender = props.content;
|
||||
|
||||
if (!bodyToRender.trim() && props.visiblePanes.indexOf('viewer') >= 0 && props.visiblePanes.indexOf('editor') < 0) {
|
||||
// Fixes https://github.com/laurent22/joplin/issues/217
|
||||
bodyToRender = `<i>${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}</i>`;
|
||||
}
|
||||
|
||||
const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({
|
||||
resourceInfos: props.resourceInfos,
|
||||
contentMaxWidth: props.contentMaxWidth,
|
||||
mapsToLine: true,
|
||||
// Always using useCustomPdfViewer for now, we can add a new setting for it in future if we need to.
|
||||
useCustomPdfViewer: props.useCustomPdfViewer,
|
||||
noteId: props.noteId,
|
||||
vendorDir: bridge().vendorDir(),
|
||||
}));
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setRenderedBody(result);
|
||||
|
||||
// Since we set `renderedBodyContentKey` here, it means this effect is going to
|
||||
// be triggered again, but that's hard to avoid and the second call would be cheap
|
||||
// anyway since the renderered markdown is cached by MdToHtml. We could use a ref
|
||||
// to avoid this, but a second rendering might still happens anyway to render images,
|
||||
// resources, or for other reasons. So it's best to focus on making any second call
|
||||
// to this effect as cheap as possible with caching, etc.
|
||||
setRenderedBodyContentKey(props.contentKey);
|
||||
}, interval);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
shim.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [
|
||||
props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage,
|
||||
props.visiblePanes, props.resourceInfos, props.markupToHtml, props.contentMaxWidth,
|
||||
props.noteId, props.useCustomPdfViewer,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webviewReady) return;
|
||||
|
||||
let lineCount = 0;
|
||||
if (editorRef.current) {
|
||||
lineCount = editorRef.current.editor.state.doc.lines;
|
||||
}
|
||||
|
||||
const options: any = {
|
||||
pluginAssets: renderedBody.pluginAssets,
|
||||
downloadResources: Setting.value('sync.resourceDownloadMode'),
|
||||
markupLineCount: lineCount,
|
||||
};
|
||||
|
||||
// It seems when there's an error immediately when the component is
|
||||
// mounted, webviewReady might be true, but webviewRef.current will be
|
||||
// undefined. Maybe due to the error boundary that unmount components.
|
||||
// Since we can't do much about it we just print an error.
|
||||
if (webviewRef.current) {
|
||||
// To keep consistency among CodeMirror's editing and scroll percents
|
||||
// of Editor and Viewer.
|
||||
const percent = getLineScrollPercent();
|
||||
setEditorPercentScroll(percent);
|
||||
options.percent = percent;
|
||||
webviewRef.current.send('setHtml', renderedBody.html, options);
|
||||
} else {
|
||||
console.error('Trying to set HTML on an undefined webview ref');
|
||||
}
|
||||
}, [renderedBody, webviewReady, getLineScrollPercent, setEditorPercentScroll]);
|
||||
|
||||
const cellEditorStyle = useMemo(() => {
|
||||
const output = { ...styles.cellEditor };
|
||||
if (!props.visiblePanes.includes('editor')) {
|
||||
output.display = 'none'; // Seems to work fine since the refactoring
|
||||
}
|
||||
|
||||
return output;
|
||||
}, [styles.cellEditor, props.visiblePanes]);
|
||||
|
||||
const cellViewerStyle = useMemo(() => {
|
||||
const output = { ...styles.cellViewer };
|
||||
if (!props.visiblePanes.includes('viewer')) {
|
||||
// Note: setting webview.display to "none" is currently not supported due
|
||||
// to this bug: https://github.com/electron/electron/issues/8277
|
||||
// So instead setting the width 0.
|
||||
output.width = 1;
|
||||
output.maxWidth = 1;
|
||||
} else if (!props.visiblePanes.includes('editor')) {
|
||||
output.borderLeftStyle = 'none';
|
||||
}
|
||||
return output;
|
||||
}, [styles.cellViewer, props.visiblePanes]);
|
||||
|
||||
const editorPaneVisible = props.visiblePanes.indexOf('editor') >= 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
// Anytime the user toggles the visible panes AND the editor is visible as a result
|
||||
// we should focus the editor
|
||||
// The intuition is that a panel toggle (with editor in view) is the equivalent of
|
||||
// an editor interaction so users should expect the editor to be focused
|
||||
if (editorPaneVisible) {
|
||||
editorRef.current.focus();
|
||||
}
|
||||
}, [editorPaneVisible]);
|
||||
|
||||
useContextMenu({
|
||||
plugins: props.plugins,
|
||||
editorCutText, editorCopyText, editorPaste,
|
||||
editorRef,
|
||||
editorClassName: 'cm-editor',
|
||||
});
|
||||
|
||||
const onEditorEvent = useCallback((event: EditorEvent) => {
|
||||
if (event.kind === EditorEventType.Scroll) {
|
||||
editor_scroll();
|
||||
} else if (event.kind === EditorEventType.Change) {
|
||||
codeMirror_change(event.value);
|
||||
} else if (event.kind === EditorEventType.SelectionRangeChange) {
|
||||
setSelectionRange({ from: event.from, to: event.to });
|
||||
}
|
||||
}, [editor_scroll, codeMirror_change]);
|
||||
|
||||
const editorSettings = useMemo((): EditorSettings => {
|
||||
const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML;
|
||||
|
||||
let keyboardMode = EditorKeymap.Default;
|
||||
if (props.keyboardMode === 'vim') {
|
||||
keyboardMode = EditorKeymap.Vim;
|
||||
} else if (props.keyboardMode === 'emacs') {
|
||||
keyboardMode = EditorKeymap.Emacs;
|
||||
}
|
||||
|
||||
return {
|
||||
language: isHTMLNote ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
readOnly: props.disabled || props.visiblePanes.indexOf('editor') < 0,
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
themeData: {
|
||||
...styles.globalTheme,
|
||||
monospaceFont: Setting.value('style.editor.monospaceFontFamily'),
|
||||
},
|
||||
automatchBraces: Setting.value('editor.autoMatchingBraces'),
|
||||
useExternalSearch: false,
|
||||
ignoreModifiers: true,
|
||||
spellcheckEnabled: Setting.value('editor.spellcheckBeta'),
|
||||
keymap: keyboardMode,
|
||||
indentWithTabs: true,
|
||||
};
|
||||
}, [
|
||||
props.contentMarkupLanguage, props.disabled, props.visiblePanes,
|
||||
props.keyboardMode, styles.globalTheme,
|
||||
]);
|
||||
|
||||
// Update the editor's value
|
||||
useEffect(() => {
|
||||
if (editorRef.current?.updateBody(props.content)) {
|
||||
editorRef.current?.clearHistory();
|
||||
}
|
||||
}, [props.content]);
|
||||
|
||||
const renderEditor = () => {
|
||||
return (
|
||||
<div style={cellEditorStyle}>
|
||||
<Editor
|
||||
style={styles.editor}
|
||||
initialText={props.content}
|
||||
ref={editorRef}
|
||||
settings={editorSettings}
|
||||
pluginStates={props.plugins}
|
||||
onEvent={onEditorEvent}
|
||||
onLogMessage={logDebug}
|
||||
onEditorPaste={onEditorPaste}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderViewer = () => {
|
||||
return (
|
||||
<div style={cellViewerStyle}>
|
||||
<NoteTextViewer
|
||||
ref={webviewRef}
|
||||
themeId={props.themeId}
|
||||
viewerStyle={styles.viewer}
|
||||
onIpcMessage={webview_ipcMessage}
|
||||
onDomReady={webview_domReady}
|
||||
contentMaxWidth={props.contentMaxWidth}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary message="The text editor encountered a fatal error and could not continue. The error might be due to a plugin, so please try to disable some of them and try again.">
|
||||
<div style={styles.root} ref={rootRef}>
|
||||
<div style={styles.rowToolbar}>
|
||||
<Toolbar themeId={props.themeId}/>
|
||||
{props.noteToolbar}
|
||||
</div>
|
||||
<div style={styles.rowEditorViewer}>
|
||||
{renderEditor()}
|
||||
{renderViewer()}
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(CodeMirror);
|
||||
@@ -0,0 +1,122 @@
|
||||
import * as React from 'react';
|
||||
import { ForwardedRef } from 'react';
|
||||
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import { EditorProps, LogMessageCallback, OnEventCallback, PluginData } from '@joplin/editor/types';
|
||||
import createEditor from '@joplin/editor/CodeMirror/createEditor';
|
||||
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import setupVim from '../utils/setupVim';
|
||||
|
||||
interface Props extends EditorProps {
|
||||
style: React.CSSProperties;
|
||||
pluginStates: PluginStates;
|
||||
|
||||
onEditorPaste: ()=> void;
|
||||
}
|
||||
|
||||
const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
|
||||
const editorContainerRef = useRef<HTMLDivElement>();
|
||||
const [editor, setEditor] = useState<CodeMirrorControl|null>(null);
|
||||
|
||||
// The editor will only be created once, so callbacks that could
|
||||
// change need to be stored as references.
|
||||
const onEventRef = useRef<OnEventCallback>(props.onEvent);
|
||||
const onLogMessageRef = useRef<LogMessageCallback>(props.onLogMessage);
|
||||
|
||||
useEffect(() => {
|
||||
onEventRef.current = props.onEvent;
|
||||
onLogMessageRef.current = props.onLogMessage;
|
||||
}, [props.onEvent, props.onLogMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const pasteEventHandler = (_editor: any, event: Event) => {
|
||||
event.preventDefault();
|
||||
props.onEditorPaste();
|
||||
};
|
||||
|
||||
editor.on('paste', pasteEventHandler);
|
||||
|
||||
return () => {
|
||||
editor.off('paste', pasteEventHandler);
|
||||
};
|
||||
}, [editor, props.onEditorPaste]);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return editor;
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plugins: PluginData[] = [];
|
||||
for (const pluginId in props.pluginStates) {
|
||||
const pluginState = props.pluginStates[pluginId];
|
||||
const codeMirrorContentScripts = pluginState.contentScripts[ContentScriptType.CodeMirrorPlugin] ?? [];
|
||||
|
||||
for (const contentScript of codeMirrorContentScripts) {
|
||||
plugins.push({
|
||||
pluginId,
|
||||
contentScriptId: contentScript.id,
|
||||
contentScriptJs: () => shim.fsDriver().readFile(contentScript.path),
|
||||
postMessageHandler: (message: any) => {
|
||||
const plugin = PluginService.instance().pluginById(pluginId);
|
||||
return plugin.emitContentScriptMessage(contentScript.id, message);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void editor.setPlugins(plugins);
|
||||
}, [editor, props.pluginStates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorContainerRef.current) return () => {};
|
||||
|
||||
const editorProps: EditorProps = {
|
||||
...props,
|
||||
onEvent: event => onEventRef.current(event),
|
||||
onLogMessage: message => onLogMessageRef.current(message),
|
||||
};
|
||||
|
||||
const editor = createEditor(editorContainerRef.current, editorProps);
|
||||
editor.addStyles({
|
||||
'.cm-scroller': { overflow: 'auto' },
|
||||
});
|
||||
setEditor(editor);
|
||||
|
||||
return () => {
|
||||
editor.remove();
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Should run just once
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
editor?.updateSettings(props.settings);
|
||||
}, [props.settings, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
setupVim(editor);
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={props.style}
|
||||
ref={editorContainerRef}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(Editor);
|
||||
@@ -0,0 +1,136 @@
|
||||
|
||||
import { RefObject, useMemo } from 'react';
|
||||
import { CommandValue } from '../../../utils/types';
|
||||
import { commandAttachFileToBody } from '../../../utils/resourceHandling';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import dialogs from '../../../../dialogs';
|
||||
import { EditorCommandType } from '@joplin/editor/types';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
|
||||
|
||||
const logger = Logger.create('CodeMirror 6 commands');
|
||||
|
||||
const wrapSelectionWithStrings = (editor: CodeMirrorControl, string1: string, string2 = '', defaultText = '') => {
|
||||
if (editor.somethingSelected()) {
|
||||
editor.wrapSelections(string1, string2);
|
||||
} else {
|
||||
editor.wrapSelections(string1 + defaultText, string2);
|
||||
|
||||
// Now select the default text so the user can replace it
|
||||
const selections = editor.listSelections();
|
||||
const newSelections = [];
|
||||
for (let i = 0; i < selections.length; i++) {
|
||||
const s = selections[i];
|
||||
const anchor = { line: s.anchor.line, ch: s.anchor.ch + string1.length };
|
||||
const head = { line: s.head.line, ch: s.head.ch - string2.length };
|
||||
newSelections.push({ anchor: anchor, head: head });
|
||||
}
|
||||
editor.setSelections(newSelections);
|
||||
}
|
||||
};
|
||||
|
||||
interface Props {
|
||||
webviewRef: RefObject<any>;
|
||||
editorRef: RefObject<CodeMirrorControl>;
|
||||
editorContent: string;
|
||||
|
||||
editorCutText(): void;
|
||||
editorCopyText(): void;
|
||||
editorPaste(): void;
|
||||
selectionRange: { from: number; to: number };
|
||||
|
||||
visiblePanes: string[];
|
||||
}
|
||||
|
||||
const useEditorCommands = (props: Props) => {
|
||||
const editorRef = props.editorRef;
|
||||
|
||||
return useMemo(() => {
|
||||
const selectedText = () => {
|
||||
if (!editorRef.current) return '';
|
||||
return editorRef.current.getSelection();
|
||||
};
|
||||
|
||||
return {
|
||||
dropItems: async (cmd: any) => {
|
||||
if (cmd.type === 'notes') {
|
||||
editorRef.current.insertText(cmd.markdownTags.join('\n'));
|
||||
} else if (cmd.type === 'files') {
|
||||
const pos = props.selectionRange.from;
|
||||
const newBody = await commandAttachFileToBody(props.editorContent, cmd.paths, { createFileURL: !!cmd.createFileURL, position: pos });
|
||||
editorRef.current.updateBody(newBody);
|
||||
} else {
|
||||
logger.warn('CodeMirror: unsupported drop item: ', cmd);
|
||||
}
|
||||
},
|
||||
selectedText: () => {
|
||||
return selectedText();
|
||||
},
|
||||
selectedHtml: () => {
|
||||
return selectedText();
|
||||
},
|
||||
replaceSelection: (value: string) => {
|
||||
return editorRef.current.insertText(value);
|
||||
},
|
||||
textCopy: () => {
|
||||
props.editorCopyText();
|
||||
},
|
||||
textCut: () => {
|
||||
props.editorCutText();
|
||||
},
|
||||
textPaste: () => {
|
||||
props.editorPaste();
|
||||
},
|
||||
textSelectAll: () => {
|
||||
return editorRef.current.execCommand(EditorCommandType.SelectAll);
|
||||
},
|
||||
textLink: async () => {
|
||||
const url = await dialogs.prompt(_('Insert Hyperlink'));
|
||||
editorRef.current.focus();
|
||||
if (url) wrapSelectionWithStrings(editorRef.current, '[', `](${url})`);
|
||||
},
|
||||
insertText: (value: any) => editorRef.current.insertText(value),
|
||||
attachFile: async () => {
|
||||
const newBody = await commandAttachFileToBody(
|
||||
props.editorContent, null, { position: props.selectionRange.from },
|
||||
);
|
||||
if (newBody) {
|
||||
editorRef.current.updateBody(newBody);
|
||||
}
|
||||
},
|
||||
textHorizontalRule: () => editorRef.current.insertText('* * *'),
|
||||
'editor.execCommand': (value: CommandValue) => {
|
||||
if (!('args' in value)) value.args = [];
|
||||
|
||||
if ((editorRef.current as any)[value.name]) {
|
||||
const result = (editorRef.current as any)[value.name](...value.args);
|
||||
return result;
|
||||
} else if (editorRef.current.commandExists(value.name)) {
|
||||
const result = editorRef.current.execCommand(value.name);
|
||||
return result;
|
||||
} else {
|
||||
logger.warn('CodeMirror execCommand: unsupported command: ', value.name);
|
||||
}
|
||||
},
|
||||
'editor.focus': () => {
|
||||
if (props.visiblePanes.indexOf('editor') >= 0) {
|
||||
editorRef.current.editor.focus();
|
||||
} else {
|
||||
// If we just call focus() then the iframe is focused,
|
||||
// but not its content, such that scrolling up / down
|
||||
// with arrow keys fails
|
||||
props.webviewRef.current.send('focus');
|
||||
}
|
||||
},
|
||||
search: () => {
|
||||
editorRef.current.execCommand(EditorCommandType.ShowSearch);
|
||||
},
|
||||
};
|
||||
}, [
|
||||
props.visiblePanes, props.editorContent, props.editorCopyText, props.editorCutText, props.editorPaste,
|
||||
props.selectionRange,
|
||||
|
||||
props.webviewRef, editorRef,
|
||||
]);
|
||||
};
|
||||
export default useEditorCommands;
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import TinyMCE from './NoteBody/TinyMCE/TinyMCE';
|
||||
import CodeMirror from './NoteBody/CodeMirror/CodeMirror';
|
||||
import { connect } from 'react-redux';
|
||||
import MultiNoteActions from '../MultiNoteActions';
|
||||
import { htmlToMarkdown, formNoteToNote } from './utils';
|
||||
@@ -46,6 +45,8 @@ import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import { ErrorCode } from '@joplin/lib/errors';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
import CodeMirror6 from './NoteBody/CodeMirror/v6/CodeMirror';
|
||||
import CodeMirror5 from './NoteBody/CodeMirror/v5/CodeMirror';
|
||||
|
||||
const commands = [
|
||||
require('./commands/showRevisions'),
|
||||
@@ -75,11 +76,18 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
}, []);
|
||||
|
||||
const effectiveNoteId = useEffectiveNoteId(props);
|
||||
const effectiveNote = props.notes.find(n => n.id === effectiveNoteId);
|
||||
|
||||
const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({
|
||||
syncStarted: props.syncStarted,
|
||||
decryptionStarted: props.decryptionStarted,
|
||||
noteId: effectiveNoteId,
|
||||
|
||||
// The effective updated_time property of the note. It may be different
|
||||
// from the last time the note was saved, if it was modified outside the
|
||||
// editor (eg. via API).
|
||||
dbNote: effectiveNote ? { id: effectiveNote.id, updated_time: effectiveNote.updated_time } : { id: '', updated_time: 0 },
|
||||
|
||||
isProvisional: props.isProvisional,
|
||||
titleInputRef: titleInputRef,
|
||||
editorRef: editorRef,
|
||||
@@ -119,12 +127,32 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
return async function() {
|
||||
const note = await formNoteToNote(formNote);
|
||||
reg.logger().debug('Saving note...', note);
|
||||
const savedNote: any = await Note.save(note);
|
||||
const noteUpdatedTime = Date.now();
|
||||
|
||||
// First we set the formNote object, then we save the note. We
|
||||
// do it in that order, otherwise `useFormNote` will be rendered
|
||||
// with the newly saved note and the timestamp of that note will
|
||||
// be more recent that the one in the editor, which will trigger
|
||||
// an update. We do not want this since we already have the
|
||||
// latest changes.
|
||||
//
|
||||
// It also means that we manually set the timestamp, so that we
|
||||
// have it before the note is saved.
|
||||
|
||||
setFormNote((prev: FormNote) => {
|
||||
return { ...prev, user_updated_time: savedNote.user_updated_time, hasChanged: false };
|
||||
return {
|
||||
...prev,
|
||||
user_updated_time: noteUpdatedTime,
|
||||
updated_time: noteUpdatedTime,
|
||||
hasChanged: false,
|
||||
};
|
||||
});
|
||||
|
||||
const savedNote = await Note.save({
|
||||
...note,
|
||||
updated_time: noteUpdatedTime,
|
||||
}, { autoTimestamp: false });
|
||||
|
||||
void ExternalEditWatcher.instance().updateNoteFile(savedNote);
|
||||
|
||||
props.dispatch({
|
||||
@@ -380,7 +408,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
};
|
||||
}, [setShowRevisions]);
|
||||
|
||||
const onScroll = useCallback((event: any) => {
|
||||
const onScroll = useCallback((event: { percent: number }) => {
|
||||
props.dispatch({
|
||||
type: 'EDITOR_SCROLL_PERCENT_SET',
|
||||
// In callbacks of setTimeout()/setInterval(), props/state cannot be used
|
||||
@@ -462,7 +490,9 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
if (props.bodyEditor === 'TinyMCE') {
|
||||
editor = <TinyMCE {...editorProps}/>;
|
||||
} else if (props.bodyEditor === 'CodeMirror') {
|
||||
editor = <CodeMirror {...editorProps}/>;
|
||||
editor = <CodeMirror5 {...editorProps}/>;
|
||||
} else if (props.bodyEditor === 'CodeMirror6') {
|
||||
editor = <CodeMirror6 {...editorProps}/>;
|
||||
} else {
|
||||
throw new Error(`Invalid editor: ${props.bodyEditor}`);
|
||||
}
|
||||
@@ -602,7 +632,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{renderSearchInfo()}
|
||||
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%' }}>
|
||||
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%', minHeight: '0' }}>
|
||||
{editor}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function NoteTitleBar(props: Props) {
|
||||
className="title-input"
|
||||
type="text"
|
||||
ref={props.titleInputRef}
|
||||
placeholder={props.isProvisional ? _('Creating new %s...', props.noteIsTodo ? _('to-do') : _('note')) : ''}
|
||||
placeholder={props.isProvisional ? (props.noteIsTodo ? _('Creating new to-do...') : _('Creating new note...')) : ''}
|
||||
style={styles.titleInput}
|
||||
readOnly={props.disabled}
|
||||
onChange={props.onTitleChange}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/MarkupToHtml';
|
||||
import { MarkupToHtmlOptions } from './useMarkupToHtml';
|
||||
import { Dispatch } from 'redux';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
|
||||
export interface AllAssetsOptions {
|
||||
contentMaxWidthTarget?: string;
|
||||
@@ -20,7 +21,7 @@ export interface NoteEditorProps {
|
||||
dispatch: Dispatch;
|
||||
selectedNoteIds: string[];
|
||||
selectedFolderId: string;
|
||||
notes: any[];
|
||||
notes: NoteEntity[];
|
||||
watchedNoteFiles: string[];
|
||||
isProvisional: boolean;
|
||||
editorNoteStatuses: any;
|
||||
@@ -48,6 +49,15 @@ export interface NoteEditorProps {
|
||||
syncUserId: string;
|
||||
}
|
||||
|
||||
export interface NoteBodyEditorRef {
|
||||
content(): string|Promise<string>;
|
||||
resetScroll(): void;
|
||||
scrollTo(options: ScrollOptions): void;
|
||||
|
||||
supportsCommand(name: string): boolean;
|
||||
execCommand(command: CommandValue): Promise<void>;
|
||||
}
|
||||
|
||||
export interface NoteBodyEditorProps {
|
||||
style: any;
|
||||
ref: any;
|
||||
@@ -59,7 +69,7 @@ export interface NoteBodyEditorProps {
|
||||
onChange(event: OnChangeEvent): void;
|
||||
onWillChange(event: any): void;
|
||||
onMessage(event: any): void;
|
||||
onScroll(event: any): void;
|
||||
onScroll(event: { percent: number }): void;
|
||||
markupToHtml: (markupLanguage: MarkupLanguage, markup: string, options: MarkupToHtmlOptions)=> Promise<RenderResult>;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
htmlToMarkdown: Function;
|
||||
@@ -95,6 +105,7 @@ export interface FormNote {
|
||||
markup_language: number;
|
||||
user_updated_time: number;
|
||||
encryption_applied: number;
|
||||
updated_time: number;
|
||||
|
||||
hasChanged: boolean;
|
||||
|
||||
@@ -145,6 +156,7 @@ export function defaultFormNote(): FormNote {
|
||||
hasChanged: false,
|
||||
user_updated_time: 0,
|
||||
encryption_applied: 0,
|
||||
updated_time: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import useFormNote, { HookDependencies } from './useFormNote';
|
||||
import useFormNote, { DbNote, HookDependencies } from './useFormNote';
|
||||
|
||||
const defaultFormNoteProps: HookDependencies = {
|
||||
syncStarted: false,
|
||||
decryptionStarted: false,
|
||||
noteId: '',
|
||||
isProvisional: false,
|
||||
titleInputRef: null,
|
||||
editorRef: null,
|
||||
onBeforeLoad: ()=>{},
|
||||
onAfterLoad: ()=>{},
|
||||
dbNote: { id: '', updated_time: 0 },
|
||||
};
|
||||
|
||||
describe('useFormNote', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -15,14 +26,10 @@ describe('useFormNote', () => {
|
||||
|
||||
const makeFormNoteProps = (syncStarted: boolean, decryptionStarted: boolean): HookDependencies => {
|
||||
return {
|
||||
...defaultFormNoteProps,
|
||||
syncStarted,
|
||||
decryptionStarted,
|
||||
noteId: testNote.id,
|
||||
isProvisional: false,
|
||||
titleInputRef: null,
|
||||
editorRef: null,
|
||||
onBeforeLoad: ()=>{},
|
||||
onAfterLoad: ()=>{},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -70,4 +77,35 @@ describe('useFormNote', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should reload the note when it is changed outside of the editor', async () => {
|
||||
const note = await Note.save({ title: 'Test Note!' });
|
||||
|
||||
const makeFormNoteProps = (dbNote: DbNote): HookDependencies => {
|
||||
return {
|
||||
...defaultFormNoteProps,
|
||||
noteId: note.id,
|
||||
dbNote,
|
||||
};
|
||||
};
|
||||
|
||||
const formNote = renderHook(props => useFormNote(props), {
|
||||
initialProps: makeFormNoteProps({ id: note.id, updated_time: note.updated_time }),
|
||||
});
|
||||
|
||||
await formNote.waitFor(() => {
|
||||
expect(formNote.result.current.formNote.title).toBe('Test Note!');
|
||||
});
|
||||
|
||||
// Simulate the note being modified outside the editor
|
||||
const modifiedNote = await Note.save({ id: note.id, title: 'Modified' });
|
||||
|
||||
// NoteEditor then would update `dbNote`
|
||||
formNote.rerender(makeFormNoteProps({ id: note.id, updated_time: modifiedNote.updated_time }));
|
||||
|
||||
await formNote.waitFor(() => {
|
||||
expect(formNote.result.current.formNote.title).toBe('Modified');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -7,21 +7,30 @@ import { splitHtml } from '@joplin/renderer/HtmlToHtml';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import usePrevious from '../../hooks/usePrevious';
|
||||
import ResourceEditWatcher from '@joplin/lib/services/ResourceEditWatcher/index';
|
||||
|
||||
const { MarkupToHtml } = require('@joplin/renderer');
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
||||
import DecryptionWorker from '@joplin/lib/services/DecryptionWorker';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('useFormNote');
|
||||
|
||||
export interface OnLoadEvent {
|
||||
formNote: FormNote;
|
||||
updated_time: number;
|
||||
}
|
||||
|
||||
export interface DbNote {
|
||||
id: string;
|
||||
updated_time: number;
|
||||
}
|
||||
|
||||
export interface HookDependencies {
|
||||
syncStarted: boolean;
|
||||
decryptionStarted: boolean;
|
||||
noteId: string;
|
||||
dbNote: DbNote;
|
||||
isProvisional: boolean;
|
||||
titleInputRef: any;
|
||||
editorRef: any;
|
||||
@@ -63,7 +72,7 @@ function resourceInfosChanged(a: ResourceInfos, b: ResourceInfos): boolean {
|
||||
|
||||
export default function useFormNote(dependencies: HookDependencies) {
|
||||
const {
|
||||
syncStarted, decryptionStarted, noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad,
|
||||
syncStarted, decryptionStarted, noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad, dbNote,
|
||||
} = dependencies;
|
||||
|
||||
const [formNote, setFormNote] = useState<FormNote>(defaultFormNote());
|
||||
@@ -77,7 +86,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
// a new refresh.
|
||||
const [formNoteRefeshScheduled, setFormNoteRefreshScheduled] = useState<number>(0);
|
||||
|
||||
async function initNoteState(n: any) {
|
||||
async function initNoteState(n: NoteEntity) {
|
||||
let originalCss = '';
|
||||
|
||||
if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
|
||||
@@ -85,7 +94,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
originalCss = splitted.css;
|
||||
}
|
||||
|
||||
const newFormNote = {
|
||||
const newFormNote: FormNote = {
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
@@ -99,6 +108,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
hasChanged: false,
|
||||
user_updated_time: n.user_updated_time,
|
||||
encryption_applied: n.encryption_applied,
|
||||
updated_time: n.updated_time,
|
||||
};
|
||||
|
||||
// Note that for performance reason,the call to setResourceInfos should
|
||||
@@ -116,7 +126,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
useEffect(() => {
|
||||
if (formNoteRefeshScheduled <= 0) return () => {};
|
||||
|
||||
reg.logger().info('Sync has finished and note has never been changed - reloading it');
|
||||
logger.info('Sync has finished and note has never been changed - reloading it');
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -128,7 +138,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
// it would not have been loaded in the editor (due to note selection changing
|
||||
// on delete)
|
||||
if (!n) {
|
||||
reg.logger().warn('Trying to reload note that has been deleted:', noteId);
|
||||
logger.warn('Trying to reload note that has been deleted:', noteId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -150,19 +160,19 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
}, [formNoteRefeshScheduled]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check that synchronisation has just finished - and
|
||||
// if the note has never been changed, we reload it.
|
||||
// If the note has already been changed, it's a conflict
|
||||
// that's already been handled by the synchronizer.
|
||||
// Check that synchronisation has just finished - and if the note has
|
||||
// never been changed, we reload it. If the note has already been
|
||||
// changed, it's a conflict that's already been handled by the
|
||||
// synchronizer.
|
||||
const decryptionJustEnded = prevDecryptionStarted && !decryptionStarted;
|
||||
const syncJustEnded = prevSyncStarted && !syncStarted;
|
||||
|
||||
if (!decryptionJustEnded && !syncJustEnded) return;
|
||||
if (formNote.hasChanged) return;
|
||||
|
||||
// Refresh the form note.
|
||||
// This is kept separate from the above logic so that when prevSyncStarted is changed
|
||||
// from true to false, it doesn't cancel the note from loading.
|
||||
// Refresh the form note. This is kept separate from the above logic so
|
||||
// that when prevSyncStarted is changed from true to false, it doesn't
|
||||
// cancel the note from loading.
|
||||
refreshFormNote();
|
||||
}, [
|
||||
prevSyncStarted, syncStarted,
|
||||
@@ -170,6 +180,18 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
formNote.hasChanged, refreshFormNote,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Something's not fully initialised - we skip the check
|
||||
if (!dbNote.id || !dbNote.updated_time || !formNote.updated_time) return;
|
||||
|
||||
// If the note in the database is more recent that the note in editor,
|
||||
// it was modified outside the editor, so we refresh it.
|
||||
if (dbNote.id === formNote.id && dbNote.updated_time > formNote.updated_time) {
|
||||
logger.info('Note has been changed outside the editor - reloading it');
|
||||
refreshFormNote();
|
||||
}
|
||||
}, [dbNote.id, dbNote.updated_time, formNote.updated_time, formNote.id, refreshFormNote]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteId) {
|
||||
if (formNote.id) setFormNote(defaultFormNote());
|
||||
@@ -180,7 +202,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
reg.logger().debug('Loading existing note', noteId);
|
||||
logger.debug('Loading existing note', noteId);
|
||||
|
||||
function handleAutoFocus(noteIsTodo: boolean) {
|
||||
if (!isProvisional) return;
|
||||
@@ -200,15 +222,15 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
const n = await Note.load(noteId);
|
||||
if (cancelled) return;
|
||||
if (!n) throw new Error(`Cannot find note with ID: ${noteId}`);
|
||||
reg.logger().debug('Loaded note:', n);
|
||||
logger.debug('Loaded note:', n);
|
||||
|
||||
await onBeforeLoad({ formNote });
|
||||
await onBeforeLoad({ formNote, updated_time: 0 });
|
||||
|
||||
const newFormNote = await initNoteState(n);
|
||||
|
||||
setIsNewNote(isProvisional);
|
||||
|
||||
await onAfterLoad({ formNote: newFormNote });
|
||||
await onAfterLoad({ formNote: newFormNote, updated_time: n.updated_time });
|
||||
|
||||
handleAutoFocus(!!n.is_todo);
|
||||
}
|
||||
|
||||
@@ -118,13 +118,13 @@
|
||||
"@joplin/tools": "~2.13",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.4",
|
||||
"@types/node": "18.17.12",
|
||||
"@types/node": "18.17.14",
|
||||
"@types/react": "18.2.21",
|
||||
"@types/react-redux": "7.1.26",
|
||||
"@types/styled-components": "5.1.26",
|
||||
"electron": "25.3.1",
|
||||
"electron": "25.8.1",
|
||||
"electron-builder": "24.4.0",
|
||||
"glob": "10.3.3",
|
||||
"glob": "10.3.4",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.6.4",
|
||||
"jest-environment-jsdom": "29.6.4",
|
||||
@@ -140,9 +140,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron/notarize": "2.1.0",
|
||||
"@electron/remote": "2.0.10",
|
||||
"@electron/remote": "2.0.11",
|
||||
"@fortawesome/fontawesome-free": "5.15.4",
|
||||
"@joeattardi/emoji-button": "4.6.4",
|
||||
"@joplin/editor": "~2.13",
|
||||
"@joplin/lib": "~2.13",
|
||||
"@joplin/renderer": "~2.13",
|
||||
"@joplin/utils": "~2.13",
|
||||
|
||||
@@ -5,17 +5,22 @@
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
TEMP_PATH=~/src/plugin-tests
|
||||
PLUGIN_PATH=~/src/joplin/packages/app-cli/tests/support/plugins/note_list_renderer
|
||||
NEED_COMPILING=0
|
||||
PLUGIN_PATH=~/src/joplin/packages/app-cli/tests/support/plugins/simple
|
||||
|
||||
mkdir -p "$TEMP_PATH"
|
||||
PLUGIN_NAME=$(echo "$PLUGIN_PATH" | awk -F/ '{print $NF}')
|
||||
TEMP_PLUGIN_PATH="$TEMP_PATH/$PLUGIN_NAME"
|
||||
if [[ $NEED_COMPILING == 1 ]]; then
|
||||
mkdir -p "$TEMP_PATH"
|
||||
PLUGIN_NAME=$(echo "$PLUGIN_PATH" | awk -F/ '{print $NF}')
|
||||
TEMP_PLUGIN_PATH="$TEMP_PATH/$PLUGIN_NAME"
|
||||
|
||||
echo "Copying from: $PLUGIN_PATH"
|
||||
echo "To: $TEMP_PLUGIN_PATH"
|
||||
echo "Copying from: $PLUGIN_PATH"
|
||||
echo "To: $TEMP_PLUGIN_PATH"
|
||||
|
||||
rsync -a --delete "$PLUGIN_PATH/" "$TEMP_PLUGIN_PATH/"
|
||||
rsync -a --delete "$PLUGIN_PATH/" "$TEMP_PLUGIN_PATH/"
|
||||
|
||||
npm install --prefix="$TEMP_PLUGIN_PATH" && yarn start --dev-plugins "$TEMP_PLUGIN_PATH"
|
||||
npm install --prefix="$TEMP_PLUGIN_PATH" && yarn start --dev-plugins "$TEMP_PLUGIN_PATH"
|
||||
else
|
||||
yarn start --dev-plugins "$PLUGIN_PATH"
|
||||
fi
|
||||
|
||||
# Add eg "--profile $HOME/.config/joplindev-desktop-1" to test with a different profile
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { EditorSettings } from '../types';
|
||||
import { initCodeMirror } from './CodeMirror';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { forceParsing } from '@codemirror/language';
|
||||
import loadLangauges from './testUtil/loadLanguages';
|
||||
|
||||
import { expect, describe, it } from '@jest/globals';
|
||||
|
||||
|
||||
const createEditorSettings = (themeId: number) => {
|
||||
const themeData = themeStyle(themeId);
|
||||
const editorSettings: EditorSettings = {
|
||||
katexEnabled: true,
|
||||
spellcheckEnabled: true,
|
||||
readOnly: false,
|
||||
themeId,
|
||||
themeData,
|
||||
};
|
||||
|
||||
return editorSettings;
|
||||
};
|
||||
|
||||
describe('CodeMirror', () => {
|
||||
// This checks for a regression -- occasionally, when updating packages,
|
||||
// syntax highlighting in the CodeMirror editor stops working. This is usually
|
||||
// fixed by
|
||||
// 1. removing all `@codemirror/` and `@lezer/` dependencies from yarn.lock,
|
||||
// 2. upgrading all CodeMirror packages to the latest versions in package.json, and
|
||||
// 3. re-running `yarn install`.
|
||||
//
|
||||
// See https://github.com/laurent22/joplin/issues/7253
|
||||
it('should give headings a different style', async () => {
|
||||
const headerLineText = '# Testing...';
|
||||
const initialText = `${headerLineText}\nThis is a test.`;
|
||||
const editorSettings = createEditorSettings(Setting.THEME_LIGHT);
|
||||
|
||||
await loadLangauges();
|
||||
const editor = initCodeMirror(document.body, initialText, editorSettings);
|
||||
|
||||
// Force the generation of the syntax tree now.
|
||||
forceParsing(editor.editor);
|
||||
|
||||
// CodeMirror nests the tag that styles the header within .cm-headerLine:
|
||||
// <div class='cm-headerLine'><span class='someclass'>Testing...</span></div>
|
||||
const headerLineContent = document.body.querySelector('.cm-headerLine > span')!;
|
||||
|
||||
|
||||
expect(headerLineContent.textContent).toBe(headerLineText);
|
||||
|
||||
const style = getComputedStyle(headerLineContent);
|
||||
expect(style.borderBottom).not.toBe('');
|
||||
expect(style.fontSize).toBe('1.6em');
|
||||
});
|
||||
});
|
||||
@@ -9,448 +9,24 @@
|
||||
// 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 syntaxHighlightingLanguages from './syntaxHighlightingLanguages';
|
||||
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
|
||||
import { indentOnInput, indentUnit, syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
openSearchPanel, closeSearchPanel, SearchQuery, setSearchQuery, getSearchQuery,
|
||||
/* highlightSelectionMatches, */ search, findNext, findPrevious, replaceAll, replaceNext,
|
||||
} from '@codemirror/search';
|
||||
|
||||
import {
|
||||
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command,
|
||||
} from '@codemirror/view';
|
||||
import { undo, redo, history, undoDepth, redoDepth, indentWithTab } from '@codemirror/commands';
|
||||
|
||||
import { keymap, KeyBinding } from '@codemirror/view';
|
||||
import { searchKeymap } from '@codemirror/search';
|
||||
import { historyKeymap, defaultKeymap } from '@codemirror/commands';
|
||||
|
||||
import { CodeMirrorControl } from './types';
|
||||
import { EditorSettings, ListType, SearchState } from '../types';
|
||||
import { ChangeEvent, SelectionChangeEvent, Selection } from '../types';
|
||||
import SelectionFormatting from '../SelectionFormatting';
|
||||
import { EditorSettings } from '@joplin/editor/types';
|
||||
import createEditor from '@joplin/editor/CodeMirror/createEditor';
|
||||
import { logMessage, postMessage } from './webviewLogger';
|
||||
import {
|
||||
decreaseIndent, increaseIndent,
|
||||
toggleBolded, toggleCode,
|
||||
toggleHeaderLevel, toggleItalicized,
|
||||
toggleList, toggleMath, updateLink,
|
||||
} from './markdownCommands';
|
||||
|
||||
|
||||
interface CodeMirrorResult extends CodeMirrorControl {
|
||||
editor: EditorView;
|
||||
}
|
||||
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
|
||||
|
||||
export function initCodeMirror(
|
||||
parentElement: any, initialText: string, settings: EditorSettings,
|
||||
): CodeMirrorResult {
|
||||
logMessage('Initializing CodeMirror...');
|
||||
const theme = settings.themeData;
|
||||
parentElement: HTMLElement, initialText: string, settings: EditorSettings,
|
||||
): CodeMirrorControl {
|
||||
return createEditor(parentElement, {
|
||||
initialText,
|
||||
settings,
|
||||
|
||||
let searchVisible = false;
|
||||
|
||||
let schedulePostUndoRedoDepthChangeId_: any = 0;
|
||||
const schedulePostUndoRedoDepthChange = (editor: EditorView, doItNow = false) => {
|
||||
if (schedulePostUndoRedoDepthChangeId_) {
|
||||
if (doItNow) {
|
||||
clearTimeout(schedulePostUndoRedoDepthChangeId_);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
schedulePostUndoRedoDepthChangeId_ = setTimeout(() => {
|
||||
schedulePostUndoRedoDepthChangeId_ = null;
|
||||
postMessage('onUndoRedoDepthChange', {
|
||||
undoDepth: undoDepth(editor.state),
|
||||
redoDepth: redoDepth(editor.state),
|
||||
});
|
||||
}, doItNow ? 0 : 1000);
|
||||
};
|
||||
|
||||
const notifyDocChanged = (viewUpdate: ViewUpdate) => {
|
||||
if (viewUpdate.docChanged) {
|
||||
const event: ChangeEvent = {
|
||||
value: editor.state.doc.toString(),
|
||||
};
|
||||
|
||||
postMessage('onChange', event);
|
||||
schedulePostUndoRedoDepthChange(editor);
|
||||
}
|
||||
};
|
||||
|
||||
const notifyLinkEditRequest = () => {
|
||||
postMessage('onRequestLinkEdit', null);
|
||||
};
|
||||
|
||||
const showSearchDialog = () => {
|
||||
const query = getSearchQuery(editor.state);
|
||||
const searchState: SearchState = {
|
||||
searchText: query.search,
|
||||
replaceText: query.replace,
|
||||
useRegex: query.regexp,
|
||||
caseSensitive: query.caseSensitive,
|
||||
dialogVisible: true,
|
||||
};
|
||||
|
||||
postMessage('onRequestShowSearch', searchState);
|
||||
searchVisible = true;
|
||||
};
|
||||
|
||||
const hideSearchDialog = () => {
|
||||
postMessage('onRequestHideSearch', null);
|
||||
searchVisible = false;
|
||||
};
|
||||
|
||||
const notifySelectionChange = (viewUpdate: ViewUpdate) => {
|
||||
if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
|
||||
const mainRange = viewUpdate.state.selection.main;
|
||||
const selection: Selection = {
|
||||
start: mainRange.from,
|
||||
end: mainRange.to,
|
||||
};
|
||||
const event: SelectionChangeEvent = {
|
||||
selection,
|
||||
};
|
||||
postMessage('onSelectionChange', event);
|
||||
}
|
||||
};
|
||||
|
||||
const notifySelectionFormattingChange = (viewUpdate?: ViewUpdate) => {
|
||||
// If we can't determine the previous formatting, post the update regardless
|
||||
if (!viewUpdate) {
|
||||
const formatting = computeSelectionFormatting(editor.state);
|
||||
postMessage('onSelectionFormattingChange', formatting.toJSON());
|
||||
} else if (viewUpdate.docChanged || !viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
|
||||
// Only post the update if something changed
|
||||
const oldFormatting = computeSelectionFormatting(viewUpdate.startState);
|
||||
const newFormatting = computeSelectionFormatting(viewUpdate.state);
|
||||
|
||||
if (!oldFormatting.eq(newFormatting)) {
|
||||
postMessage('onSelectionFormattingChange', newFormatting.toJSON());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const computeSelectionFormatting = (state: EditorState): SelectionFormatting => {
|
||||
const range = state.selection.main;
|
||||
const formatting: SelectionFormatting = new SelectionFormatting();
|
||||
formatting.selectedText = state.doc.sliceString(range.from, range.to);
|
||||
formatting.spellChecking = editor.contentDOM.spellcheck;
|
||||
|
||||
const parseLinkData = (nodeText: string) => {
|
||||
const linkMatch = nodeText.match(/\[([^\]]*)\]\(([^)]*)\)/);
|
||||
|
||||
if (linkMatch) {
|
||||
return {
|
||||
linkText: linkMatch[1],
|
||||
linkURL: linkMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Find nodes that overlap/are within the selected region
|
||||
syntaxTree(state).iterate({
|
||||
from: range.from, to: range.to,
|
||||
enter: node => {
|
||||
// Checklists don't have a specific containing node. As such,
|
||||
// we're in a checklist if we've selected a 'Task' node.
|
||||
if (node.name === 'Task') {
|
||||
formatting.inChecklist = true;
|
||||
}
|
||||
|
||||
// Only handle notes that contain the entire range.
|
||||
if (node.from > range.from || node.to < range.to) {
|
||||
return;
|
||||
}
|
||||
// Lazily compute the node's text
|
||||
const nodeText = () => state.doc.sliceString(node.from, node.to);
|
||||
|
||||
switch (node.name) {
|
||||
case 'StrongEmphasis':
|
||||
formatting.bolded = true;
|
||||
break;
|
||||
case 'Emphasis':
|
||||
formatting.italicized = true;
|
||||
break;
|
||||
case 'ListItem':
|
||||
formatting.listLevel += 1;
|
||||
break;
|
||||
case 'BulletList':
|
||||
formatting.inUnorderedList = true;
|
||||
break;
|
||||
case 'OrderedList':
|
||||
formatting.inOrderedList = true;
|
||||
break;
|
||||
case 'TaskList':
|
||||
formatting.inChecklist = true;
|
||||
break;
|
||||
case 'InlineCode':
|
||||
case 'FencedCode':
|
||||
formatting.inCode = true;
|
||||
formatting.unspellCheckableRegion = true;
|
||||
break;
|
||||
case 'InlineMath':
|
||||
case 'BlockMath':
|
||||
formatting.inMath = true;
|
||||
formatting.unspellCheckableRegion = true;
|
||||
break;
|
||||
case 'ATXHeading1':
|
||||
formatting.headerLevel = 1;
|
||||
break;
|
||||
case 'ATXHeading2':
|
||||
formatting.headerLevel = 2;
|
||||
break;
|
||||
case 'ATXHeading3':
|
||||
formatting.headerLevel = 3;
|
||||
break;
|
||||
case 'ATXHeading4':
|
||||
formatting.headerLevel = 4;
|
||||
break;
|
||||
case 'ATXHeading5':
|
||||
formatting.headerLevel = 5;
|
||||
break;
|
||||
case 'URL':
|
||||
formatting.inLink = true;
|
||||
formatting.linkData.linkURL = nodeText();
|
||||
formatting.unspellCheckableRegion = true;
|
||||
break;
|
||||
case 'Link':
|
||||
formatting.inLink = true;
|
||||
formatting.linkData = parseLinkData(nodeText());
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// The markdown parser marks checklists as unordered lists. Ensure
|
||||
// that they aren't marked as such.
|
||||
if (formatting.inChecklist) {
|
||||
if (!formatting.inUnorderedList) {
|
||||
// Even if the selection contains a Task, because an unordered list node
|
||||
// must contain a valid Task node, we're only in a checklist if we're also in
|
||||
// an unordered list.
|
||||
formatting.inChecklist = false;
|
||||
} else {
|
||||
formatting.inUnorderedList = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (formatting.unspellCheckableRegion) {
|
||||
formatting.spellChecking = false;
|
||||
}
|
||||
|
||||
return formatting;
|
||||
};
|
||||
|
||||
// Returns a keyboard command that returns true (so accepts the keybind)
|
||||
const keyCommand = (key: string, run: Command): KeyBinding => {
|
||||
return {
|
||||
key,
|
||||
run,
|
||||
preventDefault: true,
|
||||
};
|
||||
};
|
||||
|
||||
const editor = new EditorView({
|
||||
state: EditorState.create({
|
||||
// See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
|
||||
// for a sample configuration.
|
||||
extensions: [
|
||||
markdown({
|
||||
extensions: [
|
||||
GitHubFlavoredMarkdownExtension,
|
||||
|
||||
// Don't highlight KaTeX if the user disabled it
|
||||
settings.katexEnabled ? MarkdownMathExtension : [],
|
||||
],
|
||||
codeLanguages: syntaxHighlightingLanguages,
|
||||
}),
|
||||
...createTheme(theme),
|
||||
history(),
|
||||
search({
|
||||
createPanel(_: EditorView) {
|
||||
return {
|
||||
// The actual search dialog is implemented with react native,
|
||||
// use a dummy element.
|
||||
dom: document.createElement('div'),
|
||||
mount() {
|
||||
showSearchDialog();
|
||||
},
|
||||
destroy() {
|
||||
hideSearchDialog();
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
drawSelection(),
|
||||
highlightSpecialChars(),
|
||||
// highlightSelectionMatches(),
|
||||
indentOnInput(),
|
||||
|
||||
// By default, indent with four spaces
|
||||
indentUnit.of(' '),
|
||||
EditorState.tabSize.of(4),
|
||||
|
||||
// Apply styles to entire lines (block-display decorations)
|
||||
decoratorExtension,
|
||||
|
||||
EditorView.lineWrapping,
|
||||
EditorView.contentAttributes.of({
|
||||
autocapitalize: 'sentence',
|
||||
autocorrect: settings.spellcheckEnabled ? 'true' : 'false',
|
||||
spellcheck: settings.spellcheckEnabled ? 'true' : 'false',
|
||||
}),
|
||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||
notifyDocChanged(viewUpdate);
|
||||
notifySelectionChange(viewUpdate);
|
||||
notifySelectionFormattingChange(viewUpdate);
|
||||
}),
|
||||
keymap.of([
|
||||
// Custom mod-f binding: Toggle the external dialog implementation
|
||||
// (don't show/hide the Panel dialog).
|
||||
keyCommand('Mod-f', (_: EditorView) => {
|
||||
if (searchVisible) {
|
||||
hideSearchDialog();
|
||||
} else {
|
||||
showSearchDialog();
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
// Markdown formatting keyboard shortcuts
|
||||
keyCommand('Mod-b', toggleBolded),
|
||||
keyCommand('Mod-i', toggleItalicized),
|
||||
keyCommand('Mod-$', toggleMath),
|
||||
keyCommand('Mod-`', toggleCode),
|
||||
keyCommand('Mod-[', decreaseIndent),
|
||||
keyCommand('Mod-]', increaseIndent),
|
||||
keyCommand('Mod-k', (_: EditorView) => {
|
||||
notifyLinkEditRequest();
|
||||
return true;
|
||||
}),
|
||||
|
||||
...defaultKeymap, ...historyKeymap, indentWithTab, ...searchKeymap,
|
||||
]),
|
||||
|
||||
EditorState.readOnly.of(settings.readOnly),
|
||||
],
|
||||
doc: initialText,
|
||||
}),
|
||||
parent: parentElement,
|
||||
onLogMessage: message => {
|
||||
logMessage(message);
|
||||
},
|
||||
onEvent: (event): void => {
|
||||
postMessage('onEditorEvent', event);
|
||||
},
|
||||
});
|
||||
|
||||
// HACK: 09/02/22: Work around https://github.com/laurent22/joplin/issues/6802 by creating a copy mousedown
|
||||
// event to prevent the Editor's .preventDefault from making the context menu not appear.
|
||||
// TODO: Track the upstream issue at https://github.com/codemirror/dev/issues/935 and remove this workaround
|
||||
// when the upstream bug is fixed.
|
||||
document.body.addEventListener('mousedown', (evt) => {
|
||||
if (!evt.isTrusted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk up the tree -- is evt.target or any of its parent nodes the editor's input region?
|
||||
for (let current: Record<string, any> = evt.target; current; current = current.parentElement) {
|
||||
if (current === editor.contentDOM) {
|
||||
evt.stopPropagation();
|
||||
|
||||
const copyEvent = new Event('mousedown', evt);
|
||||
editor.contentDOM.dispatchEvent(copyEvent);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
const updateSearchQuery = (newState: SearchState) => {
|
||||
const query = new SearchQuery({
|
||||
search: newState.searchText,
|
||||
caseSensitive: newState.caseSensitive,
|
||||
regexp: newState.useRegex,
|
||||
replace: newState.replaceText,
|
||||
});
|
||||
editor.dispatch({
|
||||
effects: setSearchQuery.of(query),
|
||||
});
|
||||
};
|
||||
|
||||
const editorControls = {
|
||||
editor,
|
||||
undo: () => {
|
||||
undo(editor);
|
||||
schedulePostUndoRedoDepthChange(editor, true);
|
||||
},
|
||||
redo: () => {
|
||||
redo(editor);
|
||||
schedulePostUndoRedoDepthChange(editor, true);
|
||||
},
|
||||
select: (anchor: number, head: number) => {
|
||||
editor.dispatch(editor.state.update({
|
||||
selection: { anchor, head },
|
||||
scrollIntoView: true,
|
||||
}));
|
||||
},
|
||||
scrollSelectionIntoView: () => {
|
||||
editor.dispatch(editor.state.update({
|
||||
scrollIntoView: true,
|
||||
}));
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
editor.dispatch(editor.state.replaceSelection(text));
|
||||
},
|
||||
toggleFindDialog: () => {
|
||||
const opened = openSearchPanel(editor);
|
||||
if (!opened) {
|
||||
closeSearchPanel(editor);
|
||||
}
|
||||
},
|
||||
|
||||
// Formatting
|
||||
toggleBolded: () => { toggleBolded(editor); },
|
||||
toggleItalicized: () => { toggleItalicized(editor); },
|
||||
toggleCode: () => { toggleCode(editor); },
|
||||
toggleMath: () => { toggleMath(editor); },
|
||||
increaseIndent: () => { increaseIndent(editor); },
|
||||
decreaseIndent: () => { decreaseIndent(editor); },
|
||||
toggleList: (kind: ListType) => { toggleList(kind)(editor); },
|
||||
toggleHeaderLevel: (level: number) => { toggleHeaderLevel(level)(editor); },
|
||||
updateLink: (label: string, url: string) => { updateLink(label, url)(editor); },
|
||||
|
||||
// Search
|
||||
searchControl: {
|
||||
findNext: () => {
|
||||
findNext(editor);
|
||||
},
|
||||
findPrevious: () => {
|
||||
findPrevious(editor);
|
||||
},
|
||||
replaceCurrent: () => {
|
||||
replaceNext(editor);
|
||||
},
|
||||
replaceAll: () => {
|
||||
replaceAll(editor);
|
||||
},
|
||||
setSearchState: (state: SearchState) => {
|
||||
updateSearchQuery(state);
|
||||
},
|
||||
showSearch: () => {
|
||||
showSearchDialog();
|
||||
},
|
||||
hideSearch: () => {
|
||||
hideSearchDialog();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return editorControls;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import Modal from '../Modal';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { EditorControl } from './types';
|
||||
import SelectionFormatting from './SelectionFormatting';
|
||||
import { useCallback } from 'react';
|
||||
import SelectionFormatting from '@joplin/editor/SelectionFormatting';
|
||||
|
||||
interface LinkDialogProps {
|
||||
editorControl: EditorControl;
|
||||
@@ -21,7 +21,7 @@ interface LinkDialogProps {
|
||||
|
||||
const EditLinkDialog = (props: LinkDialogProps) => {
|
||||
// The content of the link selected in the editor (if any)
|
||||
const editorLinkData = props.selectionState.linkData ?? {};
|
||||
const editorLinkData = props.selectionState.linkData;
|
||||
const [linkLabel, setLinkLabel] = useState('');
|
||||
const [linkURL, setLinkURL] = useState('');
|
||||
|
||||
|
||||
@@ -14,13 +14,14 @@ 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 { EditorControl, EditorSettings } from '../types';
|
||||
import { ButtonSpec, StyleSheetData } from './types';
|
||||
import Toolbar from './Toolbar';
|
||||
import { buttonSize } from './ToolbarButton';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import ToggleSpaceButton from './ToggleSpaceButton';
|
||||
import { SearchState } from '@joplin/editor/types';
|
||||
import SelectionFormatting from '@joplin/editor/SelectionFormatting';
|
||||
|
||||
type OnAttachCallback = ()=> void;
|
||||
|
||||
@@ -71,9 +72,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||
),
|
||||
description: _('Unordered list'),
|
||||
active: selState.inUnorderedList,
|
||||
onPress: useCallback(() => {
|
||||
editorControl.toggleList(ListType.UnorderedList);
|
||||
}, [editorControl]),
|
||||
onPress: editorControl.toggleUnorderedList,
|
||||
|
||||
priority: -2,
|
||||
disabled: readOnly,
|
||||
@@ -85,9 +84,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||
),
|
||||
description: _('Ordered list'),
|
||||
active: selState.inOrderedList,
|
||||
onPress: useCallback(() => {
|
||||
editorControl.toggleList(ListType.OrderedList);
|
||||
}, [editorControl]),
|
||||
onPress: editorControl.toggleOrderedList,
|
||||
|
||||
priority: -2,
|
||||
disabled: readOnly,
|
||||
@@ -99,9 +96,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||
),
|
||||
description: _('Task list'),
|
||||
active: selState.inChecklist,
|
||||
onPress: useCallback(() => {
|
||||
editorControl.toggleList(ListType.CheckList);
|
||||
}, [editorControl]),
|
||||
onPress: editorControl.toggleTaskList,
|
||||
|
||||
priority: -2,
|
||||
disabled: readOnly,
|
||||
|
||||
@@ -5,29 +5,29 @@ import EditLinkDialog from './EditLinkDialog';
|
||||
import { defaultSearchState, SearchPanel } from './SearchPanel';
|
||||
import ExtendedWebView from '../ExtendedWebView';
|
||||
|
||||
const React = require('react');
|
||||
import * as React from 'react';
|
||||
import { forwardRef, RefObject, useImperativeHandle } from 'react';
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
|
||||
const { editorFont } = require('../global-style');
|
||||
|
||||
import SelectionFormatting from './SelectionFormatting';
|
||||
import {
|
||||
EditorSettings, EditorControl,
|
||||
ChangeEvent, UndoRedoDepthChangeEvent, Selection, SelectionChangeEvent, ListType, SearchState,
|
||||
} from './types';
|
||||
import { EditorControl, EditorSettings, SelectionRange } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import MarkdownToolbar from './MarkdownToolbar/MarkdownToolbar';
|
||||
import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
|
||||
import { EditorCommandType, EditorKeymap, EditorLanguageType, PluginData, SearchState } from '@joplin/editor/types';
|
||||
import supportsCommand from '@joplin/editor/CodeMirror/editorCommands/supportsCommand';
|
||||
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
|
||||
|
||||
type ChangeEventHandler = (event: ChangeEvent)=> void;
|
||||
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
|
||||
type SelectionChangeEventHandler = (event: SelectionChangeEvent)=> void;
|
||||
type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void;
|
||||
type OnAttachCallback = ()=> void;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
initialText: string;
|
||||
initialSelection?: Selection;
|
||||
initialSelection?: SelectionRange;
|
||||
style: ViewStyle;
|
||||
contentStyle?: ViewStyle;
|
||||
toolbarEnabled: boolean;
|
||||
@@ -110,6 +110,11 @@ function editorTheme(themeId: number) {
|
||||
|
||||
return {
|
||||
...themeStyle(themeId),
|
||||
|
||||
// To allow accessibility font scaling, we also need to set the
|
||||
// fontSize to a value in `em`s (relative scaling relative to
|
||||
// parent font size).
|
||||
fontSizeUnits: 'em',
|
||||
fontSize: estimatedFontSizeInEm,
|
||||
fontFamily: fontFamilyFromSettings(),
|
||||
};
|
||||
@@ -120,48 +125,92 @@ type OnSetVisibleCallback = (visible: boolean)=> void;
|
||||
type OnSearchStateChangeCallback = (state: SearchState)=> void;
|
||||
const useEditorControl = (
|
||||
injectJS: OnInjectJSCallback, setLinkDialogVisible: OnSetVisibleCallback,
|
||||
setSearchState: OnSearchStateChangeCallback, searchStateRef: RefObject<SearchState>,
|
||||
setSearchState: OnSearchStateChangeCallback,
|
||||
searchStateRef: RefObject<SearchState>,
|
||||
): EditorControl => {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
const execCommand = (command: EditorCommandType) => {
|
||||
injectJS(`cm.execCommand(${JSON.stringify(command)})`);
|
||||
};
|
||||
|
||||
const setSearchStateCallback = (state: SearchState) => {
|
||||
injectJS(`cm.setSearchState(${JSON.stringify(state)})`);
|
||||
setSearchState(state);
|
||||
};
|
||||
|
||||
const control: EditorControl = {
|
||||
supportsCommand(command: EditorCommandType) {
|
||||
return supportsCommand(command);
|
||||
},
|
||||
execCommand,
|
||||
|
||||
undo() {
|
||||
injectJS('cm.undo();');
|
||||
injectJS('cm.undo()');
|
||||
},
|
||||
redo() {
|
||||
injectJS('cm.redo();');
|
||||
injectJS('cm.redo()');
|
||||
},
|
||||
select(anchor: number, head: number) {
|
||||
injectJS(
|
||||
`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)});`,
|
||||
);
|
||||
},
|
||||
setScrollPercent(fraction: number) {
|
||||
injectJS(`cm.setScrollFraction(${JSON.stringify(fraction)})`);
|
||||
},
|
||||
insertText(text: string) {
|
||||
injectJS(`cm.insertText(${JSON.stringify(text)});`);
|
||||
},
|
||||
updateBody(newBody: string) {
|
||||
injectJS(`cm.updateBody(${JSON.stringify(newBody)});`);
|
||||
},
|
||||
updateSettings(newSettings: EditorSettings) {
|
||||
injectJS(`cm.updateSettings(${JSON.stringify(newSettings)})`);
|
||||
},
|
||||
|
||||
toggleBolded() {
|
||||
injectJS('cm.toggleBolded();');
|
||||
execCommand(EditorCommandType.ToggleBolded);
|
||||
},
|
||||
toggleItalicized() {
|
||||
injectJS('cm.toggleItalicized();');
|
||||
execCommand(EditorCommandType.ToggleItalicized);
|
||||
},
|
||||
toggleList(listType: ListType) {
|
||||
injectJS(`cm.toggleList(${JSON.stringify(listType)});`);
|
||||
toggleOrderedList() {
|
||||
execCommand(EditorCommandType.ToggleNumberedList);
|
||||
},
|
||||
toggleUnorderedList() {
|
||||
execCommand(EditorCommandType.ToggleCheckList);
|
||||
},
|
||||
toggleTaskList() {
|
||||
execCommand(EditorCommandType.ToggleCheckList);
|
||||
},
|
||||
toggleCode() {
|
||||
injectJS('cm.toggleCode();');
|
||||
execCommand(EditorCommandType.ToggleCode);
|
||||
},
|
||||
toggleMath() {
|
||||
injectJS('cm.toggleMath();');
|
||||
execCommand(EditorCommandType.ToggleMath);
|
||||
},
|
||||
toggleHeaderLevel(level: number) {
|
||||
injectJS(`cm.toggleHeaderLevel(${level});`);
|
||||
const levelToCommand = [
|
||||
EditorCommandType.ToggleHeading1,
|
||||
EditorCommandType.ToggleHeading2,
|
||||
EditorCommandType.ToggleHeading3,
|
||||
EditorCommandType.ToggleHeading4,
|
||||
EditorCommandType.ToggleHeading5,
|
||||
];
|
||||
|
||||
const index = level - 1;
|
||||
|
||||
if (index < 0 || index >= levelToCommand.length) {
|
||||
throw new Error(`Unsupported header level ${level}`);
|
||||
}
|
||||
|
||||
execCommand(levelToCommand[index]);
|
||||
},
|
||||
increaseIndent() {
|
||||
injectJS('cm.increaseIndent();');
|
||||
execCommand(EditorCommandType.IndentMore);
|
||||
},
|
||||
decreaseIndent() {
|
||||
injectJS('cm.decreaseIndent();');
|
||||
execCommand(EditorCommandType.IndentLess);
|
||||
},
|
||||
updateLink(label: string, url: string) {
|
||||
injectJS(`cm.updateLink(
|
||||
@@ -170,7 +219,7 @@ const useEditorControl = (
|
||||
);`);
|
||||
},
|
||||
scrollSelectionIntoView() {
|
||||
injectJS('cm.scrollSelectionIntoView();');
|
||||
execCommand(EditorCommandType.ScrollSelectionIntoView);
|
||||
},
|
||||
showLinkDialog() {
|
||||
setLinkDialogVisible(true);
|
||||
@@ -181,23 +230,27 @@ const useEditorControl = (
|
||||
hideKeyboard() {
|
||||
injectJS('document.activeElement?.blur();');
|
||||
},
|
||||
|
||||
setPlugins: async (plugins: PluginData[]) => {
|
||||
injectJS(`cm.setPlugins(${JSON.stringify(plugins)});`);
|
||||
},
|
||||
|
||||
setSearchState: setSearchStateCallback,
|
||||
|
||||
searchControl: {
|
||||
findNext() {
|
||||
injectJS('cm.searchControl.findNext();');
|
||||
execCommand(EditorCommandType.FindNext);
|
||||
},
|
||||
findPrevious() {
|
||||
injectJS('cm.searchControl.findPrevious();');
|
||||
execCommand(EditorCommandType.FindPrevious);
|
||||
},
|
||||
replaceCurrent() {
|
||||
injectJS('cm.searchControl.replaceCurrent();');
|
||||
replaceNext() {
|
||||
execCommand(EditorCommandType.ReplaceNext);
|
||||
},
|
||||
replaceAll() {
|
||||
injectJS('cm.searchControl.replaceAll();');
|
||||
},
|
||||
setSearchState(state: SearchState) {
|
||||
injectJS(`cm.searchControl.setSearchState(${JSON.stringify(state)})`);
|
||||
setSearchState(state);
|
||||
execCommand(EditorCommandType.ReplaceAll);
|
||||
},
|
||||
|
||||
showSearch() {
|
||||
setSearchState({
|
||||
...searchStateRef.current,
|
||||
@@ -210,8 +263,12 @@ const useEditorControl = (
|
||||
dialogVisible: false,
|
||||
});
|
||||
},
|
||||
|
||||
setSearchState: setSearchStateCallback,
|
||||
},
|
||||
};
|
||||
|
||||
return control;
|
||||
}, [injectJS, searchStateRef, setLinkDialogVisible, setSearchState]);
|
||||
};
|
||||
|
||||
@@ -227,7 +284,16 @@ function NoteEditor(props: Props, ref: any) {
|
||||
themeData: editorTheme(props.themeId),
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
|
||||
language: EditorLanguageType.Markdown,
|
||||
useExternalSearch: true,
|
||||
readOnly: props.readOnly,
|
||||
|
||||
keymap: EditorKeymap.Default,
|
||||
|
||||
automatchBraces: false,
|
||||
ignoreModifiers: false,
|
||||
|
||||
indentWithTabs: false,
|
||||
};
|
||||
|
||||
const injectedJavaScript = `
|
||||
@@ -252,6 +318,12 @@ function NoteEditor(props: Props, ref: any) {
|
||||
);
|
||||
};
|
||||
|
||||
window.onunhandledrejection = (event) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: Unhandled promise rejection: " + event
|
||||
);
|
||||
};
|
||||
|
||||
if (!window.cm) {
|
||||
// This variable is not used within this script
|
||||
// but is called using "injectJavaScript" from
|
||||
@@ -269,7 +341,7 @@ function NoteEditor(props: Props, ref: any) {
|
||||
${setInitialSelectionJS}
|
||||
|
||||
window.onresize = () => {
|
||||
cm.scrollSelectionIntoView();
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
};
|
||||
} catch (e) {
|
||||
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
|
||||
@@ -280,7 +352,7 @@ function NoteEditor(props: Props, ref: any) {
|
||||
|
||||
const css = useCss(props.themeId);
|
||||
const html = useHtml(css);
|
||||
const [selectionState, setSelectionState] = useState(new SelectionFormatting());
|
||||
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
|
||||
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
|
||||
const [searchState, setSearchState] = useState(defaultSearchState);
|
||||
|
||||
@@ -293,7 +365,7 @@ function NoteEditor(props: Props, ref: any) {
|
||||
searchStateRef.current = searchState;
|
||||
}, [searchState]);
|
||||
|
||||
// / Runs [js] in the context of the CodeMirror frame.
|
||||
// Runs [js] in the context of the CodeMirror frame.
|
||||
const injectJS = (js: string) => {
|
||||
webviewRef.current.injectJS(js);
|
||||
};
|
||||
@@ -323,36 +395,41 @@ function NoteEditor(props: Props, ref: any) {
|
||||
console.info('CodeMirror:', ...event.value);
|
||||
},
|
||||
|
||||
onChange: (event: ChangeEvent) => {
|
||||
props.onChange(event);
|
||||
},
|
||||
onEditorEvent: (event: EditorEvent) => {
|
||||
let exhaustivenessCheck: never;
|
||||
switch (event.kind) {
|
||||
case EditorEventType.Change:
|
||||
props.onChange(event);
|
||||
break;
|
||||
case EditorEventType.UndoRedoDepthChange:
|
||||
props.onUndoRedoDepthChange(event);
|
||||
break;
|
||||
case EditorEventType.SelectionRangeChange:
|
||||
props.onSelectionChange(event);
|
||||
break;
|
||||
case EditorEventType.SelectionFormattingChange:
|
||||
setSelectionState(event.formatting);
|
||||
break;
|
||||
case EditorEventType.EditLink:
|
||||
editorControl.showLinkDialog();
|
||||
break;
|
||||
case EditorEventType.UpdateSearchDialog:
|
||||
setSearchState(event.searchState);
|
||||
|
||||
onUndoRedoDepthChange: (event: UndoRedoDepthChangeEvent) => {
|
||||
props.onUndoRedoDepthChange(event);
|
||||
},
|
||||
|
||||
onSelectionChange: (event: SelectionChangeEvent) => {
|
||||
props.onSelectionChange(event);
|
||||
},
|
||||
|
||||
onSelectionFormattingChange(data: string) {
|
||||
// We want a SelectionFormatting object, so are
|
||||
// instantiating it from JSON.
|
||||
const formatting = SelectionFormatting.fromJSON(data);
|
||||
setSelectionState(formatting);
|
||||
},
|
||||
|
||||
onRequestLinkEdit() {
|
||||
editorControl.showLinkDialog();
|
||||
},
|
||||
|
||||
onRequestShowSearch(data: SearchState) {
|
||||
setSearchState(data);
|
||||
editorControl.searchControl.showSearch();
|
||||
},
|
||||
|
||||
onRequestHideSearch() {
|
||||
editorControl.searchControl.hideSearch();
|
||||
if (event.searchState.dialogVisible) {
|
||||
editorControl.searchControl.showSearch();
|
||||
} else {
|
||||
editorControl.searchControl.hideSearch();
|
||||
}
|
||||
break;
|
||||
case EditorEventType.Scroll:
|
||||
// Not handled
|
||||
break;
|
||||
default:
|
||||
exhaustivenessCheck = event;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ const React = require('react');
|
||||
const { useMemo, useState, useEffect } = require('react');
|
||||
const MaterialCommunityIcon = require('react-native-vector-icons/MaterialCommunityIcons').default;
|
||||
|
||||
import { SearchControl, SearchState, EditorSettings } from './types';
|
||||
import { EditorSettings } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { BackHandler, TextInput, View, Text, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import CustomButton from '../CustomButton';
|
||||
import { SearchState } from '@joplin/editor/types';
|
||||
import { SearchControl } from './types';
|
||||
|
||||
const buttonSize = 48;
|
||||
|
||||
@@ -284,7 +286,7 @@ export const SearchPanel = (props: SearchPanelProps) => {
|
||||
themeId={themeId}
|
||||
styles={styles}
|
||||
iconName="swap-horizontal"
|
||||
onPress={control.replaceCurrent}
|
||||
onPress={control.replaceNext}
|
||||
title={_('Replace')}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
// Stores information about the current content of the user's selection
|
||||
|
||||
export default class SelectionFormatting {
|
||||
public bolded = false;
|
||||
public italicized = false;
|
||||
public inChecklist = false;
|
||||
public inCode = false;
|
||||
public inUnorderedList = false;
|
||||
public inOrderedList = false;
|
||||
public inMath = false;
|
||||
public inLink = false;
|
||||
public spellChecking = false;
|
||||
public unspellCheckableRegion = false;
|
||||
|
||||
// Link data, both fields are null if not in a link.
|
||||
public linkData: { linkText?: string; linkURL?: string } = {
|
||||
linkText: null,
|
||||
linkURL: null,
|
||||
};
|
||||
|
||||
// If [headerLevel], [listLevel], etc. are zero, then the
|
||||
// selection isn't in a header/list
|
||||
public headerLevel = 0;
|
||||
public listLevel = 0;
|
||||
|
||||
// Content of the selection
|
||||
public selectedText = '';
|
||||
|
||||
// List of data properties (for serializing/deseralizing)
|
||||
private static propNames: string[] = [
|
||||
'bolded', 'italicized', 'inChecklist', 'inCode',
|
||||
'inUnorderedList', 'inOrderedList', 'inMath',
|
||||
'inLink', 'linkData',
|
||||
|
||||
'headerLevel', 'listLevel',
|
||||
|
||||
'selectedText',
|
||||
|
||||
'spellChecking',
|
||||
'unspellCheckableRegion',
|
||||
];
|
||||
|
||||
// Returns true iff [this] is equivalent to [other]
|
||||
public eq(other: SelectionFormatting): boolean {
|
||||
// Cast to Records to allow usage of the indexing ([])
|
||||
// operator.
|
||||
const selfAsRec = this as Record<string, any>;
|
||||
const otherAsRec = other as Record<string, any>;
|
||||
|
||||
for (const prop of SelectionFormatting.propNames) {
|
||||
if (selfAsRec[prop] !== otherAsRec[prop]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static fromJSON(json: string): SelectionFormatting {
|
||||
const result = new SelectionFormatting();
|
||||
|
||||
// Casting result to a Record<string, any> lets us use
|
||||
// the indexing [] operator.
|
||||
const resultRecord = result as Record<string, any>;
|
||||
const obj = JSON.parse(json) as Record<string, any>;
|
||||
|
||||
for (const prop of SelectionFormatting.propNames) {
|
||||
if (obj[prop] !== undefined) {
|
||||
// Type checking!
|
||||
if (typeof obj[prop] !== typeof resultRecord[prop]) {
|
||||
throw new Error([
|
||||
'Deserialization Error:',
|
||||
`${obj[prop]} and ${resultRecord[prop]}`,
|
||||
'have different types.',
|
||||
].join(' '));
|
||||
}
|
||||
|
||||
resultRecord[prop] = obj[prop];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public toJSON(): string {
|
||||
const resultObj: Record<string, any> = {};
|
||||
|
||||
// Cast this to a dictionary. This allows us to use
|
||||
// the indexing [] operator.
|
||||
const selfAsRecord = this as Record<string, any>;
|
||||
|
||||
for (const prop of SelectionFormatting.propNames) {
|
||||
resultObj[prop] = selfAsRecord[prop];
|
||||
}
|
||||
|
||||
return JSON.stringify(resultObj);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,53 @@
|
||||
// Types related to the NoteEditor
|
||||
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { CodeMirrorControl } from './CodeMirror/types';
|
||||
|
||||
// Controls for the entire editor (including dialogs)
|
||||
export interface EditorControl extends CodeMirrorControl {
|
||||
showLinkDialog(): void;
|
||||
hideLinkDialog(): void;
|
||||
hideKeyboard(): void;
|
||||
}
|
||||
|
||||
export interface EditorSettings {
|
||||
// EditorSettings objects are deserialized within WebViews, where
|
||||
// [themeStyle(themeId: number)] doesn't work. As such, we need both
|
||||
// the [themeId] and [themeData].
|
||||
themeId: number;
|
||||
themeData: Theme;
|
||||
|
||||
katexEnabled: boolean;
|
||||
spellcheckEnabled: boolean;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export interface ChangeEvent {
|
||||
// New editor content
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface UndoRedoDepthChangeEvent {
|
||||
undoDepth: number;
|
||||
redoDepth: number;
|
||||
}
|
||||
|
||||
export interface Selection {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface SelectionChangeEvent {
|
||||
selection: Selection;
|
||||
}
|
||||
import { EditorControl as EditorBodyControl, EditorSettings as EditorBodySettings, SearchState } from '@joplin/editor/types';
|
||||
|
||||
export interface SearchControl {
|
||||
findNext(): void;
|
||||
findPrevious(): void;
|
||||
replaceCurrent(): void;
|
||||
replaceNext(): void;
|
||||
replaceAll(): void;
|
||||
setSearchState(state: SearchState): void;
|
||||
|
||||
showSearch(): void;
|
||||
hideSearch(): void;
|
||||
setSearchState(state: SearchState): void;
|
||||
}
|
||||
|
||||
export interface SearchState {
|
||||
useRegex: boolean;
|
||||
caseSensitive: boolean;
|
||||
// Controls for the entire editor (including dialogs)
|
||||
export interface EditorControl extends EditorBodyControl {
|
||||
showLinkDialog(): void;
|
||||
hideLinkDialog(): void;
|
||||
hideKeyboard(): void;
|
||||
|
||||
searchText: string;
|
||||
replaceText: string;
|
||||
dialogVisible: boolean;
|
||||
// Additional shortcut commands (equivalent to .execCommand
|
||||
// with the corresponding type).
|
||||
// This reduces the need for useCallbacks in many cases.
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
|
||||
increaseIndent(): void;
|
||||
decreaseIndent(): void;
|
||||
toggleBolded(): void;
|
||||
toggleItalicized(): void;
|
||||
toggleCode(): void;
|
||||
toggleMath(): void;
|
||||
toggleOrderedList(): void;
|
||||
toggleUnorderedList(): void;
|
||||
toggleTaskList(): void;
|
||||
toggleHeaderLevel(level: number): void;
|
||||
|
||||
scrollSelectionIntoView(): void;
|
||||
showLinkDialog(): void;
|
||||
hideLinkDialog(): void;
|
||||
hideKeyboard(): void;
|
||||
|
||||
searchControl: SearchControl;
|
||||
}
|
||||
|
||||
// Possible types of lists in the editor
|
||||
export enum ListType {
|
||||
CheckList,
|
||||
OrderedList,
|
||||
UnorderedList,
|
||||
export interface EditorSettings extends EditorBodySettings {
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
export interface SelectionRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import UndoRedoService from '@joplin/lib/services/UndoRedoService';
|
||||
import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer';
|
||||
import checkPermissions from '../../utils/checkPermissions';
|
||||
import NoteEditor from '../NoteEditor/NoteEditor';
|
||||
import { ChangeEvent, UndoRedoDepthChangeEvent } from '../NoteEditor/types';
|
||||
|
||||
const FileViewer = require('react-native-file-viewer').default;
|
||||
const React = require('react');
|
||||
@@ -48,6 +47,7 @@ import Logger from '@joplin/utils/Logger';
|
||||
import VoiceTypingDialog from '../voiceTyping/VoiceTypingDialog';
|
||||
import { voskEnabled } from '../../services/voiceTyping/vosk';
|
||||
import { isSupportedLanguage } from '../../services/voiceTyping/vosk.android';
|
||||
import { ChangeEvent as EditorChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
|
||||
const urlUtils = require('@joplin/lib/urlUtils');
|
||||
|
||||
// import Vosk from 'react-native-vosk';
|
||||
@@ -255,7 +255,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
return this.props.useEditorBeta;
|
||||
}
|
||||
|
||||
private onBodyChange(event: ChangeEvent) {
|
||||
private onBodyChange(event: EditorChangeEvent) {
|
||||
shared.noteComponent_change(this, 'body', event.value);
|
||||
this.scheduleSave();
|
||||
}
|
||||
@@ -890,6 +890,10 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
}
|
||||
|
||||
public async showAttachMenu() {
|
||||
// If the keyboard is editing a WebView, the standard Keyboard.dismiss()
|
||||
// may not work. As such, we also need to call hideKeyboard on the editorRef
|
||||
this.editorRef.current?.hideKeyboard();
|
||||
|
||||
const buttons = [];
|
||||
|
||||
// On iOS, it will show "local files", which means certain files saved from the browser
|
||||
|
||||
@@ -255,8 +255,18 @@ class NotesScreenComponent extends BaseScreenComponent<any> {
|
||||
|
||||
const actionButtonComp = this.props.noteSelectionEnabled || !this.props.visible ? null : makeActionButtonComp();
|
||||
|
||||
// Ensure that screen readers can't focus the notes list when it isn't visible.
|
||||
// accessibilityElementsHidden is used on iOS and importantForAccessibility is used
|
||||
// on Android.
|
||||
const accessibilityHidden = !this.props.visible;
|
||||
|
||||
return (
|
||||
<View style={rootStyle}>
|
||||
<View
|
||||
style={rootStyle}
|
||||
|
||||
accessibilityElementsHidden={accessibilityHidden}
|
||||
importantForAccessibility={accessibilityHidden ? 'no-hide-descendants' : undefined}
|
||||
>
|
||||
<ScreenHeader title={iconString + title} showBackButton={false} parentComponent={thisComp} sortButton_press={this.sortButton_press} folderPickerOptions={this.folderPickerOptions()} showSearchButton={true} showSideMenuButton={true} />
|
||||
<NoteList />
|
||||
{actionButtonComp}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Banner, ActivityIndicator } from 'react-native-paper';
|
||||
import { _, languageName } from '@joplin/lib/locale';
|
||||
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { getVosk, Recorder, startRecording, Vosk } from '../../services/voiceTyping/vosk';
|
||||
import { IconSource } from 'react-native-paper/lib/typescript/src/components/Icon';
|
||||
import { IconSource } from 'react-native-paper/lib/typescript/components/Icon';
|
||||
import { modelIsDownloaded } from '../../services/voiceTyping/vosk.android';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -298,7 +298,7 @@ PODS:
|
||||
- React
|
||||
- react-native-saf-x (2.13.0):
|
||||
- React-Core
|
||||
- react-native-safe-area-context (4.7.1):
|
||||
- react-native-safe-area-context (4.7.2):
|
||||
- React-Core
|
||||
- react-native-slider (4.4.3):
|
||||
- React-Core
|
||||
@@ -402,8 +402,8 @@ PODS:
|
||||
- React-Core
|
||||
- RNDeviceInfo (10.8.0):
|
||||
- React-Core
|
||||
- RNExitApp (1.1.0):
|
||||
- React
|
||||
- RNExitApp (2.0.0):
|
||||
- React-Core
|
||||
- RNFileViewer (2.1.5):
|
||||
- React-Core
|
||||
- RNFS (2.20.0):
|
||||
@@ -414,7 +414,7 @@ PODS:
|
||||
- React
|
||||
- RNSecureRandom (1.0.1):
|
||||
- React
|
||||
- RNShare (9.2.3):
|
||||
- RNShare (9.2.4):
|
||||
- React-Core
|
||||
- RNVectorIcons (9.2.0):
|
||||
- React-Core
|
||||
@@ -674,7 +674,7 @@ SPEC CHECKSUMS:
|
||||
react-native-netinfo: fefd4e98d75cbdd6e85fc530f7111a8afdf2b0c5
|
||||
react-native-rsa-native: 12132eb627797529fdb1f0d22fd0f8f9678df64a
|
||||
react-native-saf-x: b758d1b38a96a96b8179a16e96ed966a507ca5c2
|
||||
react-native-safe-area-context: 9697629f7b2cda43cf52169bb7e0767d330648c2
|
||||
react-native-safe-area-context: 7aa8e6d9d0f3100a820efb1a98af68aa747f9284
|
||||
react-native-slider: 1cdd6ba29675df21f30544253bf7351d3c2d68c4
|
||||
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
|
||||
react-native-version-info: a106f23009ac0db4ee00de39574eb546682579b9
|
||||
@@ -697,13 +697,13 @@ SPEC CHECKSUMS:
|
||||
RNCPushNotificationIOS: 64218f3c776c03d7408284a819b2abfda1834bc8
|
||||
RNDateTimePicker: a21bfcc694e3190f8ddb5aacb0dd16f97b6be79f
|
||||
RNDeviceInfo: 5795b418ed3451ebcaf39384e6cf51f60cb931c9
|
||||
RNExitApp: c4e052df2568b43bec8a37c7cd61194d4cfee2c3
|
||||
RNExitApp: 00036cabe7bacbb413d276d5520bf74ba39afa6a
|
||||
RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592
|
||||
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
|
||||
RNLocalize: dbea38dcb344bf80ff18a1757b1becf11f70cae4
|
||||
RNQuickAction: 6d404a869dc872cde841ad3147416a670d13fa93
|
||||
RNSecureRandom: 07efbdf2cd99efe13497433668e54acd7df49fef
|
||||
RNShare: da6d90b6dc332f51f86498041d6e34211f96b630
|
||||
RNShare: 255701a138fcdef6c27dc93724548fd122e91f9e
|
||||
RNVectorIcons: fcc2f6cb32f5735b586e66d14103a74ce6ad61f8
|
||||
RNZipArchive: 68a0c6db4b1c103f846f1559622050df254a3ade
|
||||
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
|
||||
|
||||
@@ -14,22 +14,6 @@ import { setImmediate } from 'timers';
|
||||
// so is removed by jsdom).
|
||||
window.setImmediate = setImmediate;
|
||||
|
||||
// Prevents the CodeMirror error "getClientRects is undefined".
|
||||
// See https://github.com/jsdom/jsdom/issues/3002#issue-652790925
|
||||
document.createRange = () => {
|
||||
const range = new Range();
|
||||
range.getBoundingClientRect = jest.fn();
|
||||
range.getClientRects = () => {
|
||||
return {
|
||||
length: 0,
|
||||
item: () => null,
|
||||
[Symbol.iterator]: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
return range;
|
||||
};
|
||||
|
||||
|
||||
shimInit({ nodeSqlite: sqlite3 });
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const path = require('path');
|
||||
const localPackages = {
|
||||
'@joplin/lib': path.resolve(__dirname, '../lib/'),
|
||||
'@joplin/renderer': path.resolve(__dirname, '../renderer/'),
|
||||
'@joplin/editor': path.resolve(__dirname, '../editor/'),
|
||||
'@joplin/tools': path.resolve(__dirname, '../tools/'),
|
||||
'@joplin/utils': path.resolve(__dirname, '../utils/'),
|
||||
'@joplin/fork-htmlparser2': path.resolve(__dirname, '../fork-htmlparser2/'),
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@bam.tech/react-native-image-resizer": "3.0.7",
|
||||
"@joplin/editor": "~2.13",
|
||||
"@joplin/lib": "~2.13",
|
||||
"@joplin/react-native-alarm-notification": "~2.13",
|
||||
"@joplin/react-native-saf-x": "~2.13",
|
||||
@@ -49,21 +50,21 @@
|
||||
"react-native-dialogbox": "0.6.10",
|
||||
"react-native-document-picker": "9.0.1",
|
||||
"react-native-dropdownalert": "4.5.1",
|
||||
"react-native-exit-app": "1.1.0",
|
||||
"react-native-exit-app": "2.0.0",
|
||||
"react-native-file-viewer": "2.1.5",
|
||||
"react-native-fingerprint-scanner": "6.0.0",
|
||||
"react-native-fs": "2.20.0",
|
||||
"react-native-get-random-values": "1.9.0",
|
||||
"react-native-image-picker": "5.6.1",
|
||||
"react-native-localize": "3.0.2",
|
||||
"react-native-modal-datetime-picker": "17.0.0",
|
||||
"react-native-paper": "5.9.1",
|
||||
"react-native-modal-datetime-picker": "17.1.0",
|
||||
"react-native-paper": "5.10.3",
|
||||
"react-native-popup-menu": "0.16.1",
|
||||
"react-native-quick-actions": "0.3.13",
|
||||
"react-native-rsa-native": "2.0.5",
|
||||
"react-native-safe-area-context": "4.7.1",
|
||||
"react-native-safe-area-context": "4.7.2",
|
||||
"react-native-securerandom": "1.0.1",
|
||||
"react-native-share": "9.2.3",
|
||||
"react-native-share": "9.2.4",
|
||||
"react-native-side-menu-updated": "1.3.2",
|
||||
"react-native-sqlite-storage": "6.0.1",
|
||||
"react-native-url-polyfill": "1.3.0",
|
||||
@@ -86,19 +87,6 @@
|
||||
"@babel/core": "7.20.2",
|
||||
"@babel/preset-env": "7.20.2",
|
||||
"@babel/runtime": "7.20.0",
|
||||
"@codemirror/commands": "6.2.2",
|
||||
"@codemirror/lang-cpp": "6.0.2",
|
||||
"@codemirror/lang-html": "6.4.3",
|
||||
"@codemirror/lang-java": "6.0.1",
|
||||
"@codemirror/lang-javascript": "6.1.5",
|
||||
"@codemirror/lang-markdown": "6.1.0",
|
||||
"@codemirror/lang-php": "6.0.1",
|
||||
"@codemirror/lang-rust": "6.0.1",
|
||||
"@codemirror/language": "6.6.0",
|
||||
"@codemirror/legacy-modes": "6.3.2",
|
||||
"@codemirror/search": "6.3.0",
|
||||
"@codemirror/state": "6.2.0",
|
||||
"@codemirror/view": "6.9.3",
|
||||
"@joplin/tools": "~2.13",
|
||||
"@lezer/highlight": "1.1.4",
|
||||
"@testing-library/jest-native": "5.4.3",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
hash:"edb511738c14143467cc859e46bd6121", files: {
|
||||
hash:"5ff17e40ffa32867404a4e20c6c80099", files: {
|
||||
'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
|
||||
'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' },
|
||||
'katex/fonts/KaTeX_AMS-Regular.woff2': { data: require('./katex/fonts/KaTeX_AMS-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,111 @@
|
||||
import CodeMirror5Emulation from './CodeMirror5Emulation';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
const makeCodeMirrorEmulation = (initialDocText: string) => {
|
||||
const editorView = new EditorView({
|
||||
doc: initialDocText,
|
||||
});
|
||||
return new CodeMirror5Emulation(editorView, ()=>{});
|
||||
};
|
||||
|
||||
describe('CodeMirror5Emulation', () => {
|
||||
it('getSearchCursor should support searching for strings', () => {
|
||||
const codeMirror = makeCodeMirrorEmulation('testing --- this is a test.');
|
||||
|
||||
// Should find two matches for "test"
|
||||
// Note that the CodeMirror documentation specifies that a search cursor
|
||||
// should return a boolean when calling findNext/findPrevious. However,
|
||||
// the codemirror-vim adapter returns just a truthy/falsy value.
|
||||
const testCursor = codeMirror.getSearchCursor('test');
|
||||
expect(testCursor.findNext()).toBeTruthy();
|
||||
expect(testCursor.findNext()).toBeTruthy();
|
||||
|
||||
// Replace the second match
|
||||
testCursor.replace('passing test');
|
||||
expect(codeMirror.getValue()).toBe('testing --- this is a passing test.');
|
||||
|
||||
// Should also be able to find previous matches
|
||||
expect(testCursor.findPrevious()).toBeTruthy();
|
||||
|
||||
// Should return a falsy value when attempting to search past the end of
|
||||
// the document.
|
||||
expect(testCursor.findPrevious()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should fire update/change events on change', async () => {
|
||||
const codeMirror = makeCodeMirrorEmulation('testing --- this is a test.');
|
||||
|
||||
const updateCallback = jest.fn();
|
||||
const changeCallback = jest.fn();
|
||||
codeMirror.on('update', updateCallback);
|
||||
codeMirror.on('change', changeCallback);
|
||||
|
||||
expect(updateCallback).not.toHaveBeenCalled();
|
||||
expect(changeCallback).not.toHaveBeenCalled();
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Inserting text should trigger the update and change events
|
||||
codeMirror.editor.dispatch({
|
||||
changes: { from: 0, to: 1, insert: 'Test: ' },
|
||||
});
|
||||
|
||||
// Advance timers -- there may be a delay between the CM 6 event
|
||||
// and the dispatched CM 5 event.
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(updateCallback).toHaveBeenCalled();
|
||||
expect(changeCallback).toHaveBeenCalled();
|
||||
|
||||
// The change callback should be given two arguments:
|
||||
// - the CodeMirror emulation object
|
||||
// - a description of the changes
|
||||
expect(changeCallback.mock.lastCall[0]).toBe(codeMirror);
|
||||
expect(changeCallback.mock.lastCall[1]).toMatchObject({
|
||||
from: { line: 0, ch: 0 },
|
||||
to: { line: 0, ch: 1 },
|
||||
|
||||
// Arrays of lines
|
||||
text: ['Test: '],
|
||||
removed: ['t'],
|
||||
});
|
||||
});
|
||||
|
||||
it('defineOption should fire the option\'s update callback on change', () => {
|
||||
const codeMirror = makeCodeMirrorEmulation('Test 1\nTest 2');
|
||||
|
||||
const onOptionUpdate = jest.fn();
|
||||
codeMirror.defineOption('an-option!', 'test', onOptionUpdate);
|
||||
|
||||
const onOtherOptionUpdate = jest.fn();
|
||||
codeMirror.defineOption('an-option 2', 1, onOtherOptionUpdate);
|
||||
|
||||
|
||||
// onUpdate should be called once initially
|
||||
expect(onOtherOptionUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(onOptionUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(onOptionUpdate).toHaveBeenLastCalledWith(
|
||||
codeMirror,
|
||||
|
||||
// default value -- the new value
|
||||
'test',
|
||||
|
||||
// the original value (none, so given CodeMirror.Init)
|
||||
codeMirror.Init,
|
||||
);
|
||||
|
||||
// onUpdate should be called each time the option changes
|
||||
codeMirror.setOption('an-option!', 'test 2');
|
||||
expect(onOptionUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(onOptionUpdate).toHaveBeenLastCalledWith(
|
||||
codeMirror, 'test 2', 'test',
|
||||
);
|
||||
|
||||
codeMirror.setOption('an-option!', 'test...');
|
||||
expect(onOptionUpdate).toHaveBeenCalledTimes(3);
|
||||
|
||||
// The other update callback should not have been triggered
|
||||
// additional times if its option hasn't updated.
|
||||
expect(onOtherOptionUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import { EditorView, ViewPlugin, ViewUpdate, showPanel } from '@codemirror/view';
|
||||
import { Extension, Text, Transaction } from '@codemirror/state';
|
||||
import getScrollFraction from '../getScrollFraction';
|
||||
import { CodeMirror as BaseCodeMirror5Emulation, Vim } from '@replit/codemirror-vim';
|
||||
import { LogMessageCallback } from '../../types';
|
||||
import editorCommands from '../editorCommands/editorCommands';
|
||||
import { StateEffect } from '@codemirror/state';
|
||||
import { StreamParser } from '@codemirror/language';
|
||||
import Decorator, { LineWidgetOptions } from './Decorator';
|
||||
const { pregQuote } = require('@joplin/lib/string-utils-common');
|
||||
|
||||
|
||||
type CodeMirror5Command = (codeMirror: CodeMirror5Emulation)=> void;
|
||||
|
||||
type EditorEventCallback = (editor: CodeMirror5Emulation, ...args: any[])=> void;
|
||||
type OptionUpdateCallback = (editor: CodeMirror5Emulation, newVal: any, oldVal: any)=> void;
|
||||
|
||||
interface CodeMirror5OptionRecord {
|
||||
onUpdate: OptionUpdateCallback;
|
||||
value: any;
|
||||
}
|
||||
|
||||
interface DocumentPosition {
|
||||
line: number;
|
||||
ch: number;
|
||||
}
|
||||
|
||||
const documentPositionFromPos = (doc: Text, pos: number): DocumentPosition => {
|
||||
const line = doc.lineAt(pos);
|
||||
return {
|
||||
// CM 5 uses 0-based line numbering
|
||||
line: line.number - 1,
|
||||
ch: pos - line.from,
|
||||
};
|
||||
};
|
||||
|
||||
export default class CodeMirror5Emulation extends BaseCodeMirror5Emulation {
|
||||
private _events: Record<string, EditorEventCallback[]> = {};
|
||||
private _options: Record<string, CodeMirror5OptionRecord> = Object.create(null);
|
||||
private _decorator: Decorator;
|
||||
private _decoratorExtension: Extension;
|
||||
|
||||
// Used by some plugins to store state.
|
||||
public state: Record<string, any> = Object.create(null);
|
||||
|
||||
public Vim = Vim;
|
||||
|
||||
// Passed as initial state to plugins
|
||||
public Init = { toString: () => 'CodeMirror.Init' };
|
||||
|
||||
public constructor(
|
||||
public editor: EditorView,
|
||||
private logMessage: LogMessageCallback,
|
||||
) {
|
||||
super(editor);
|
||||
|
||||
const { decorator, extension: decoratorExtension } = Decorator.create(editor);
|
||||
this._decorator = decorator;
|
||||
this._decoratorExtension = decoratorExtension;
|
||||
|
||||
editor.dispatch({
|
||||
effects: StateEffect.appendConfig.of(this.makeCM6Extensions()),
|
||||
});
|
||||
}
|
||||
|
||||
private makeCM6Extensions() {
|
||||
const cm5 = this;
|
||||
const editor = this.editor;
|
||||
|
||||
return [
|
||||
// Fires events
|
||||
EditorView.domEventHandlers({
|
||||
scroll: () => CodeMirror5Emulation.signal(this, 'scroll'),
|
||||
focus: () => CodeMirror5Emulation.signal(this, 'focus'),
|
||||
paste: event => CodeMirror5Emulation.signal(this, 'paste', event),
|
||||
blur: () => CodeMirror5Emulation.signal(this, 'blur'),
|
||||
mousedown: event => CodeMirror5Emulation.signal(this, 'mousedown', event),
|
||||
}),
|
||||
ViewPlugin.fromClass(class {
|
||||
public update(update: ViewUpdate) {
|
||||
try {
|
||||
if (update.viewportChanged) {
|
||||
CodeMirror5Emulation.signal(
|
||||
cm5,
|
||||
'viewportChange',
|
||||
editor.viewport.from,
|
||||
editor.viewport.to,
|
||||
);
|
||||
}
|
||||
|
||||
if (update.docChanged) {
|
||||
cm5.fireChangeEvents(update);
|
||||
cm5.onChange(update);
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
cm5.onSelectionChange();
|
||||
}
|
||||
|
||||
CodeMirror5Emulation.signal(cm5, 'update');
|
||||
// Catch the error -- otherwise, CodeMirror will de-register the update listener.
|
||||
} catch (error) {
|
||||
cm5.logMessage(`Error dispatching update: ${error}`);
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Decorations
|
||||
this._decoratorExtension,
|
||||
|
||||
// Some plugins rely on a CodeMirror-measure element
|
||||
// to store temporary content.
|
||||
showPanel.of(() => {
|
||||
const dom = document.createElement('div');
|
||||
dom.classList.add('CodeMirror-measure');
|
||||
return { dom };
|
||||
}),
|
||||
|
||||
// Note: We can allow legacy CM5 CSS to apply to the editor
|
||||
// with a line similar to the following:
|
||||
// EditorView.editorAttributes.of({ class: 'CodeMirror' }),
|
||||
// Many of these styles, however, don't work well with CodeMirror 6.
|
||||
];
|
||||
}
|
||||
|
||||
private isEventHandledBySuperclass(eventName: string) {
|
||||
return ['beforeSelectionChange'].includes(eventName);
|
||||
}
|
||||
|
||||
public on(eventName: string, callback: EditorEventCallback) {
|
||||
if (this.isEventHandledBySuperclass(eventName)) {
|
||||
return super.on(eventName, callback);
|
||||
}
|
||||
this._events[eventName] ??= [];
|
||||
this._events[eventName].push(callback);
|
||||
}
|
||||
|
||||
public off(eventName: string, callback: EditorEventCallback) {
|
||||
if (!(eventName in this._events)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._events[eventName] = this._events[eventName].filter(
|
||||
otherCallback => otherCallback !== callback,
|
||||
);
|
||||
}
|
||||
|
||||
public static signal(target: CodeMirror5Emulation, eventName: string, ...args: any[]) {
|
||||
const listeners = target._events[eventName] ?? [];
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener(target, ...args);
|
||||
}
|
||||
|
||||
super.signal(target, eventName, ...args);
|
||||
}
|
||||
|
||||
private fireChangeEvents(update: ViewUpdate) {
|
||||
type ChangeRecord = {
|
||||
from: DocumentPosition;
|
||||
to: DocumentPosition;
|
||||
text: string[];
|
||||
removed: string[];
|
||||
transaction: Transaction;
|
||||
};
|
||||
const changes: ChangeRecord[] = [];
|
||||
const origDoc = update.startState.doc;
|
||||
|
||||
for (const transaction of update.transactions) {
|
||||
transaction.changes.iterChanges((fromA, toA, _fromB, _toB, inserted: Text) => {
|
||||
changes.push({
|
||||
from: documentPositionFromPos(origDoc, fromA),
|
||||
to: documentPositionFromPos(origDoc, toA),
|
||||
text: inserted.sliceString(0).split('\n'),
|
||||
removed: origDoc.sliceString(fromA, toA).split('\n'),
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Delay firing events -- event listeners may try to create transactions.
|
||||
// (this is done by the rich markdown plugin).
|
||||
setTimeout(() => {
|
||||
for (const change of changes) {
|
||||
CodeMirror5Emulation.signal(this, 'change', change);
|
||||
|
||||
// If triggered by a user, also send the inputRead event
|
||||
if (change.transaction.isUserEvent('input')) {
|
||||
CodeMirror5Emulation.signal(this, 'inputRead', change);
|
||||
}
|
||||
}
|
||||
|
||||
CodeMirror5Emulation.signal(this, 'changes', changes);
|
||||
}, 0);
|
||||
|
||||
}
|
||||
|
||||
// codemirror-vim's adapter doesn't match the CM5 docs -- wrap it.
|
||||
public getCursor(mode?: 'head' | 'anchor' | 'from' | 'to'| 'start' | 'end') {
|
||||
if (mode === 'from') {
|
||||
mode = 'start';
|
||||
}
|
||||
if (mode === 'to') {
|
||||
mode = 'end';
|
||||
}
|
||||
|
||||
return super.getCursor(mode);
|
||||
}
|
||||
|
||||
public override getSearchCursor(query: RegExp|string, pos?: DocumentPosition|null|0) {
|
||||
// The superclass CodeMirror adapter only supports regular expression
|
||||
// arguments.
|
||||
if (typeof query === 'string') {
|
||||
query = new RegExp(pregQuote(query));
|
||||
}
|
||||
return super.getSearchCursor(query, pos || { line: 0, ch: 0 });
|
||||
}
|
||||
|
||||
public lineAtHeight(height: number, _mode?: 'local') {
|
||||
const lineInfo = this.editor.lineBlockAtHeight(height);
|
||||
|
||||
// - 1: Convert to zero-based.
|
||||
const lineNumber = this.editor.state.doc.lineAt(lineInfo.to).number - 1;
|
||||
return lineNumber;
|
||||
}
|
||||
|
||||
public heightAtLine(lineNumber: number, mode?: 'local') {
|
||||
// CodeMirror 5 uses 0-based line numbers. CM6 uses 1-based
|
||||
// line numbers.
|
||||
const doc = this.editor.state.doc;
|
||||
const lineInfo = doc.line(Math.min(lineNumber + 1, doc.lines));
|
||||
const lineBlock = this.editor.lineBlockAt(lineInfo.from);
|
||||
|
||||
const height = lineBlock.top;
|
||||
if (mode === 'local') {
|
||||
const editorTop = this.editor.lineBlockAt(0).top;
|
||||
return height - editorTop;
|
||||
} else {
|
||||
return height;
|
||||
}
|
||||
}
|
||||
|
||||
public lineInfo(lineNumber: number) {
|
||||
const line = this.editor.state.doc.line(lineNumber + 1);
|
||||
|
||||
const result = {
|
||||
line: lineNumber,
|
||||
|
||||
// Note: In CM5, a line handle is not just a line number
|
||||
handle: lineNumber,
|
||||
|
||||
text: line.text,
|
||||
gutterMarkers: [] as any[],
|
||||
textClass: ['cm-line', ...this._decorator.getLineClasses(lineNumber)],
|
||||
bgClass: '',
|
||||
wrapClass: '',
|
||||
widgets: this._decorator.getLineWidgets(lineNumber),
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public getStateAfter(_line: number) {
|
||||
// TODO: Should return parser state. Returning an empty object
|
||||
// allows some plugins to run without crashing, however.
|
||||
return {};
|
||||
}
|
||||
|
||||
public getScrollPercent() {
|
||||
return getScrollFraction(this.editor);
|
||||
}
|
||||
|
||||
public defineExtension(name: string, value: any) {
|
||||
(CodeMirror5Emulation.prototype as any)[name] ??= value;
|
||||
}
|
||||
|
||||
public defineOption(name: string, defaultValue: any, onUpdate: OptionUpdateCallback) {
|
||||
this._options[name] = {
|
||||
value: defaultValue,
|
||||
onUpdate,
|
||||
};
|
||||
onUpdate(this, defaultValue, this.Init);
|
||||
}
|
||||
|
||||
// Override codemirror-vim's setOption to allow user-defined options
|
||||
public override setOption(name: string, value: any) {
|
||||
if (name in this._options) {
|
||||
const oldValue = this._options[name].value;
|
||||
this._options[name].value = value;
|
||||
this._options[name].onUpdate(this, value, oldValue);
|
||||
} else {
|
||||
super.setOption(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
public override getOption(name: string): any {
|
||||
if (name in this._options) {
|
||||
return this._options[name].value;
|
||||
} else {
|
||||
return super.getOption(name);
|
||||
}
|
||||
}
|
||||
|
||||
// codemirror-vim's API doesn't match the API docs here -- it expects addOverlay
|
||||
// to return a SearchQuery. As such, this override returns "any".
|
||||
public override addOverlay<State>(modeObject: StreamParser<State>|{ query: RegExp }): any {
|
||||
if ('query' in modeObject) {
|
||||
return super.addOverlay(modeObject);
|
||||
}
|
||||
|
||||
this._decorator.addOverlay(modeObject);
|
||||
}
|
||||
|
||||
public addLineClass(lineNumber: number, where: string, className: string) {
|
||||
this._decorator.addLineClass(lineNumber, where, className);
|
||||
}
|
||||
|
||||
public removeLineClass(lineNumber: number, where: string, className: string) {
|
||||
this._decorator.removeLineClass(lineNumber, where, className);
|
||||
}
|
||||
|
||||
public addLineWidget(lineNumber: number, node: HTMLElement, options: LineWidgetOptions) {
|
||||
this._decorator.addLineWidget(lineNumber, node, options);
|
||||
}
|
||||
|
||||
// TODO: Currently copied from useCursorUtils.ts.
|
||||
// TODO: Remove the duplicate code when CodeMirror 5 is eventually removed.
|
||||
public wrapSelections(string1: string, string2: string) {
|
||||
const selectedStrings = this.getSelections();
|
||||
|
||||
// Batches the insert operations, if this wasn't done the inserts
|
||||
// could potentially overwrite one another
|
||||
this.operation(() => {
|
||||
for (let i = 0; i < selectedStrings.length; i++) {
|
||||
const selected = selectedStrings[i];
|
||||
|
||||
// Remove white space on either side of selection
|
||||
const start = selected.search(/[^\s]/);
|
||||
const end = selected.search(/[^\s](?=[\s]*$)/);
|
||||
const core = selected.substring(start, end - start + 1);
|
||||
|
||||
// If selection can be toggled do that
|
||||
if (core.startsWith(string1) && core.endsWith(string2)) {
|
||||
const inside = core.substring(string1.length, core.length - string1.length - string2.length);
|
||||
selectedStrings[i] = selected.substring(0, start) + inside + selected.substring(end + 1);
|
||||
} else {
|
||||
selectedStrings[i] = selected.substring(0, start) + string1 + core + string2 + selected.substring(end + 1);
|
||||
}
|
||||
}
|
||||
this.replaceSelections(selectedStrings);
|
||||
});
|
||||
}
|
||||
|
||||
public static commands = (() => {
|
||||
const commands: Record<string, CodeMirror5Command> = {
|
||||
...BaseCodeMirror5Emulation.commands,
|
||||
};
|
||||
|
||||
for (const commandName in editorCommands) {
|
||||
const command = editorCommands[commandName as keyof typeof editorCommands];
|
||||
|
||||
commands[commandName] = (codeMirror: CodeMirror5Emulation) => command(codeMirror.editor);
|
||||
}
|
||||
|
||||
// as any: Required to properly extend the base class -- without this,
|
||||
// the commands dictionary isn't known (by TypeScript) to have the same
|
||||
// properties as the commands dictionary in the parent class.
|
||||
return commands as any;
|
||||
})();
|
||||
|
||||
public commands = CodeMirror5Emulation.commands;
|
||||
|
||||
private joplinCommandToCodeMirrorCommand(commandName: string): string|null {
|
||||
const match = /^editor\.(.*)$/g.exec(commandName);
|
||||
|
||||
if (!match || !(match[1] in CodeMirror5Emulation.commands)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match[1] as string;
|
||||
}
|
||||
|
||||
public supportsJoplinCommand(commandName: string): boolean {
|
||||
return this.joplinCommandToCodeMirrorCommand(commandName) in CodeMirror5Emulation.commands;
|
||||
}
|
||||
|
||||
public execJoplinCommand(joplinCommandName: string) {
|
||||
const commandName = this.joplinCommandToCodeMirrorCommand(joplinCommandName);
|
||||
|
||||
if (commandName === null) {
|
||||
this.logMessage(`Unsupported Joplin command, ${joplinCommandName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.execCommand(commandName);
|
||||
}
|
||||
|
||||
public commandExists(commandName: string) {
|
||||
return commandName in CodeMirror5Emulation.commands;
|
||||
}
|
||||
|
||||
public execCommand(name: string) {
|
||||
if (!this.commandExists(name)) {
|
||||
this.logMessage(`Unsupported CodeMirror command, ${name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
CodeMirror5Emulation.commands[name as (keyof typeof CodeMirror5Emulation.commands)](this);
|
||||
}
|
||||
}
|
||||
|
||||
385
packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.ts
Normal file
385
packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
|
||||
// Handles adding decorations to the CodeMirror editor -- converts CodeMirror5-style calls
|
||||
// to input accepted by CodeMirror 6
|
||||
|
||||
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate, WidgetType } from '@codemirror/view';
|
||||
import { ChangeDesc, Extension, Range, RangeSetBuilder, StateEffect, StateField, Transaction } from '@codemirror/state';
|
||||
import { StreamParser, StringStream, indentUnit } from '@codemirror/language';
|
||||
|
||||
interface DecorationRange {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
const mapRangeConfig = {
|
||||
// Updates a range based on some change to the document
|
||||
map: <T extends DecorationRange> (range: T, change: ChangeDesc): T => {
|
||||
const from = change.mapPos(range.from);
|
||||
const to = change.mapPos(range.to);
|
||||
return {
|
||||
...range,
|
||||
from: Math.min(from, to),
|
||||
to: Math.max(from, to),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
interface LineCssDecorationSpec extends DecorationRange {
|
||||
cssClass: string;
|
||||
}
|
||||
|
||||
const addLineDecorationEffect = StateEffect.define<LineCssDecorationSpec>(mapRangeConfig);
|
||||
const removeLineDecorationEffect = StateEffect.define<LineCssDecorationSpec>(mapRangeConfig);
|
||||
const addMarkDecorationEffect = StateEffect.define<LineCssDecorationSpec>(mapRangeConfig);
|
||||
// TODO: Support removing mark decorations
|
||||
// const removeMarkDecorationEffect = StateEffect.define<LineDecorationSpec>(mapRangeConfig);
|
||||
|
||||
export interface LineWidgetOptions {
|
||||
className?: string;
|
||||
above?: boolean;
|
||||
}
|
||||
|
||||
interface LineWidgetDecorationSpec extends DecorationRange {
|
||||
element: HTMLElement;
|
||||
options: LineWidgetOptions;
|
||||
}
|
||||
const addLineWidgetEffect = StateEffect.define<LineWidgetDecorationSpec>(mapRangeConfig);
|
||||
const removeLineWidgetEffect = StateEffect.define<{ element: HTMLElement }>();
|
||||
|
||||
|
||||
class WidgetDecorationWrapper extends WidgetType {
|
||||
public constructor(
|
||||
public readonly element: HTMLElement,
|
||||
public readonly options: LineWidgetOptions,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public override toDOM() {
|
||||
const container = document.createElement('div');
|
||||
this.element.remove();
|
||||
container.appendChild(this.element);
|
||||
|
||||
if (this.options.className) {
|
||||
container.classList.add(this.options.className);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
}
|
||||
|
||||
interface LineWidgetControl {
|
||||
node: HTMLElement;
|
||||
clear(): void;
|
||||
changed(): void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default class Decorator {
|
||||
private _extension: Extension;
|
||||
private _effectDecorations: DecorationSet = Decoration.none;
|
||||
|
||||
private constructor(private editor: EditorView) {
|
||||
const decorator = this;
|
||||
this._extension = [
|
||||
// Overlay decorations -- recreate all decorations when the editor changes
|
||||
// (overlay decorations require parsing the document and may change output
|
||||
// when the editor/view changes.)
|
||||
ViewPlugin.fromClass(class {
|
||||
public decorations: DecorationSet;
|
||||
|
||||
public constructor(view: EditorView) {
|
||||
this.decorations = decorator.createOverlayDecorations(view);
|
||||
}
|
||||
|
||||
public update(update: ViewUpdate) {
|
||||
if (update.viewportChanged || update.docChanged) {
|
||||
this.decorations = decorator.createOverlayDecorations(update.view);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
decorations: v => v.decorations,
|
||||
}),
|
||||
|
||||
// Other decorations based on effects. See the decoration examples: https://codemirror.net/examples/decoration/
|
||||
// Note that EditorView.decorations.from is required for block widgets.
|
||||
StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update: (_, viewUpdate) => decorator.updateEffectDecorations([viewUpdate]),
|
||||
provide: field => EditorView.decorations.from(field),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public static create(editor: EditorView) {
|
||||
const decorator = new Decorator(editor);
|
||||
|
||||
return { decorator, extension: decorator._extension };
|
||||
}
|
||||
|
||||
private _decorationCache: Record<string, Decoration> = Object.create(null);
|
||||
private _overlays: (StreamParser<any>)[] = [];
|
||||
|
||||
private classNameToCssDecoration(className: string, isLineDecoration: boolean) {
|
||||
let decoration;
|
||||
|
||||
if (className in this._decorationCache) {
|
||||
decoration = this._decorationCache[className];
|
||||
} else {
|
||||
const attributes = { class: className };
|
||||
|
||||
if (isLineDecoration) {
|
||||
decoration = Decoration.line({ attributes });
|
||||
} else {
|
||||
decoration = Decoration.mark({ attributes });
|
||||
}
|
||||
|
||||
this._decorationCache[className] = decoration;
|
||||
}
|
||||
|
||||
return decoration;
|
||||
}
|
||||
|
||||
private updateEffectDecorations(transactions: Transaction[]) {
|
||||
let decorations = this._effectDecorations;
|
||||
|
||||
// Update decoration positions
|
||||
for (const transaction of transactions) {
|
||||
decorations = decorations.map(transaction.changes);
|
||||
|
||||
// Add or remove decorations
|
||||
for (const effect of transaction.effects) {
|
||||
const isMarkDecoration = effect.is(addMarkDecorationEffect);
|
||||
const isLineDecoration = effect.is(addLineDecorationEffect);
|
||||
if (isMarkDecoration || isLineDecoration) {
|
||||
const decoration = this.classNameToCssDecoration(
|
||||
effect.value.cssClass, isLineDecoration,
|
||||
);
|
||||
|
||||
const value = effect.value;
|
||||
const from = effect.value.from;
|
||||
|
||||
// Line decorations are specified to have a size-zero range.
|
||||
const to = isLineDecoration ? from : value.to;
|
||||
|
||||
decorations = decorations.update({
|
||||
add: [decoration.range(from, to)],
|
||||
});
|
||||
} else if (effect.is(removeLineDecorationEffect)) {
|
||||
const doc = transaction.state.doc;
|
||||
const targetFrom = doc.lineAt(effect.value.from).from;
|
||||
const targetTo = doc.lineAt(effect.value.to).to;
|
||||
|
||||
const targetDecoration = this.classNameToCssDecoration(effect.value.cssClass, true);
|
||||
|
||||
decorations = decorations.update({
|
||||
// Returns true only for decorations that should be kept.
|
||||
filter: (from, to, value) => {
|
||||
if (from >= targetFrom && to <= targetTo && value.eq(targetDecoration)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
} else if (effect.is(addLineWidgetEffect)) {
|
||||
const options = effect.value.options;
|
||||
const decoration = Decoration.widget({
|
||||
widget: new WidgetDecorationWrapper(effect.value.element, options),
|
||||
side: options.above ? -1 : 1,
|
||||
block: true,
|
||||
});
|
||||
|
||||
decorations = decorations.update({
|
||||
add: [decoration.range(options.above ? effect.value.from : effect.value.to)],
|
||||
});
|
||||
} else if (effect.is(removeLineWidgetEffect)) {
|
||||
decorations = decorations.update({
|
||||
// Returns true only for decorations that should be kept.
|
||||
filter: (_from, _to, value) => {
|
||||
return value.spec.widget?.element !== effect.value.element;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._effectDecorations = decorations;
|
||||
return decorations;
|
||||
}
|
||||
|
||||
private createOverlayDecorations(view: EditorView): DecorationSet {
|
||||
const makeDecoration = (
|
||||
tokenName: string, start: number, stop: number,
|
||||
) => {
|
||||
const isLineDecoration = tokenName.startsWith('line-');
|
||||
|
||||
// CM5 prefixes class names with cm-
|
||||
tokenName = `cm-${tokenName}`;
|
||||
|
||||
const decoration = this.classNameToCssDecoration(tokenName, isLineDecoration);
|
||||
return decoration.range(start, stop);
|
||||
};
|
||||
|
||||
const indentSize = view.state.facet(indentUnit).length;
|
||||
const newDecorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const overlay of this._overlays) {
|
||||
const state = overlay.startState?.(indentSize) ?? {};
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
const fromLine = view.state.doc.lineAt(from);
|
||||
const toLine = view.state.doc.lineAt(to);
|
||||
|
||||
const fromLineNumber = fromLine.number;
|
||||
const toLineNumber = toLine.number;
|
||||
|
||||
for (let i = fromLineNumber; i <= toLineNumber; i++) {
|
||||
const line = view.state.doc.line(i);
|
||||
|
||||
const reader = new StringStream(
|
||||
line.text,
|
||||
view.state.tabSize,
|
||||
indentSize,
|
||||
);
|
||||
let lastPos = 0;
|
||||
|
||||
(reader as any).baseToken ??= (): null => null;
|
||||
|
||||
while (!reader.eol()) {
|
||||
const token = overlay.token(reader, state);
|
||||
|
||||
if (token) {
|
||||
for (const className of token.split(/\s+/)) {
|
||||
if (className.startsWith('line-')) {
|
||||
newDecorations.push(makeDecoration(className, line.from, line.from));
|
||||
} else {
|
||||
const from = lastPos + line.from;
|
||||
const to = reader.pos + line.from;
|
||||
newDecorations.push(makeDecoration(className, from, to));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reader.pos === lastPos) {
|
||||
throw new Error(
|
||||
'Mark decoration position did not increase -- overlays must advance with each call to .token()',
|
||||
);
|
||||
}
|
||||
|
||||
lastPos = reader.pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Required by CodeMirror:
|
||||
// Should be sorted by from position, then by length.
|
||||
newDecorations.sort((a, b) => {
|
||||
if (a.from !== b.from) {
|
||||
return a.from - b.from;
|
||||
}
|
||||
|
||||
return a.to - b.to;
|
||||
});
|
||||
|
||||
// Per the documentation, new tokens should be added in
|
||||
// increasing order.
|
||||
const decorations = new RangeSetBuilder<Decoration>();
|
||||
|
||||
for (const decoration of newDecorations) {
|
||||
decorations.add(decoration.from, decoration.to, decoration.value);
|
||||
}
|
||||
|
||||
return decorations.finish();
|
||||
}
|
||||
|
||||
public addOverlay<State>(modeObject: StreamParser<State>) {
|
||||
this._overlays.push(modeObject);
|
||||
}
|
||||
|
||||
private addRemoveLineClass(lineNumber: number, className: string, add: boolean) {
|
||||
// + 1: Convert from zero-indexed to one-indexed
|
||||
const line = this.editor.state.doc.line(lineNumber + 1);
|
||||
|
||||
const effect = add ? addLineDecorationEffect : removeLineDecorationEffect;
|
||||
this.editor.dispatch({
|
||||
effects: effect.of({
|
||||
cssClass: className,
|
||||
from: line.from,
|
||||
to: line.to,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public addLineClass(lineNumber: number, _where: string, className: string) {
|
||||
this.addRemoveLineClass(lineNumber, className, true);
|
||||
}
|
||||
|
||||
public removeLineClass(lineNumber: number, _where: string, className: string) {
|
||||
this.addRemoveLineClass(lineNumber, className, false);
|
||||
}
|
||||
|
||||
public getLineClasses(lineNumber: number) {
|
||||
const line = this.editor.state.doc.line(lineNumber + 1);
|
||||
const lineClasses: string[] = [];
|
||||
|
||||
this._effectDecorations.between(line.from, line.to, (from, to, decoration) => {
|
||||
if (from === line.from && to === line.to) {
|
||||
const className = decoration.spec?.class;
|
||||
if (typeof className === 'string') {
|
||||
lineClasses.push(className);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return lineClasses;
|
||||
}
|
||||
|
||||
private createLineWidgetControl(node: HTMLElement, options: LineWidgetOptions): LineWidgetControl {
|
||||
return {
|
||||
node,
|
||||
clear: () => {
|
||||
this.editor.dispatch({
|
||||
effects: removeLineWidgetEffect.of({ element: node }),
|
||||
});
|
||||
},
|
||||
changed: () => {
|
||||
this.editor.requestMeasure();
|
||||
},
|
||||
className: options.className,
|
||||
};
|
||||
}
|
||||
|
||||
public getLineWidgets(lineNumber: number): LineWidgetControl[] {
|
||||
const line = this.editor.state.doc.line(lineNumber + 1);
|
||||
const lineWidgets: LineWidgetControl[] = [];
|
||||
|
||||
this._effectDecorations.between(line.from, line.to, (from, to, decoration) => {
|
||||
if (from >= line.from && from <= line.to && from === to) {
|
||||
const widget = decoration.spec?.widget;
|
||||
if (widget && widget instanceof WidgetDecorationWrapper) {
|
||||
lineWidgets.push(this.createLineWidgetControl(widget.element, widget.options));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return lineWidgets;
|
||||
}
|
||||
|
||||
public addLineWidget(lineNumber: number, node: HTMLElement, options: LineWidgetOptions): LineWidgetControl {
|
||||
const line = this.editor.state.doc.line(lineNumber + 1);
|
||||
|
||||
const lineWidgetOptions = {
|
||||
from: line.from,
|
||||
to: line.to,
|
||||
element: node,
|
||||
options,
|
||||
};
|
||||
this.editor.dispatch({
|
||||
effects: addLineWidgetEffect.of(lineWidgetOptions),
|
||||
});
|
||||
|
||||
return this.createLineWidgetControl(node, options);
|
||||
}
|
||||
}
|
||||
46
packages/editor/CodeMirror/CodeMirrorControl.test.ts
Normal file
46
packages/editor/CodeMirror/CodeMirrorControl.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import createEditor from './createEditor';
|
||||
import createEditorSettings from './testUtil/createEditorSettings';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
const createEditorControls = (initialText: string) => {
|
||||
const editorSettings = createEditorSettings(Setting.THEME_LIGHT);
|
||||
|
||||
return createEditor(document.body, {
|
||||
initialText,
|
||||
settings: editorSettings,
|
||||
onEvent: _event => {},
|
||||
onLogMessage: _message => {},
|
||||
});
|
||||
};
|
||||
|
||||
describe('CodeMirrorControl', () => {
|
||||
it('clearHistory should clear the undo/redo history', () => {
|
||||
const controls = createEditorControls('');
|
||||
|
||||
const insertedText = 'Testing... This is a test...';
|
||||
controls.insertText(insertedText);
|
||||
|
||||
const fullInsertedText = insertedText;
|
||||
expect(controls.getValue()).toBe(fullInsertedText);
|
||||
|
||||
// Undo should work before clearing history
|
||||
controls.undo();
|
||||
expect(controls.getValue()).toBe('');
|
||||
|
||||
controls.redo();
|
||||
controls.clearHistory();
|
||||
|
||||
expect(controls.getValue()).toBe(fullInsertedText);
|
||||
|
||||
// Should not be able to undo cleared changes
|
||||
controls.undo();
|
||||
expect(controls.getValue()).toBe(fullInsertedText);
|
||||
|
||||
// Should be able to undo new changes
|
||||
controls.insertText('!!!');
|
||||
expect(controls.getValue()).toBe(`${fullInsertedText}!!!`);
|
||||
|
||||
controls.undo();
|
||||
expect(controls.getValue()).toBe(fullInsertedText);
|
||||
});
|
||||
});
|
||||
137
packages/editor/CodeMirror/CodeMirrorControl.ts
Normal file
137
packages/editor/CodeMirror/CodeMirrorControl.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { EditorCommandType, EditorControl, EditorSettings, LogMessageCallback, PluginData, SearchState } from '../types';
|
||||
import CodeMirror5Emulation from './CodeMirror5Emulation/CodeMirror5Emulation';
|
||||
import editorCommands from './editorCommands/editorCommands';
|
||||
import { EditorSelection, StateEffect } from '@codemirror/state';
|
||||
import { updateLink } from './markdown/markdownCommands';
|
||||
import { SearchQuery, setSearchQuery } from '@codemirror/search';
|
||||
import PluginLoader from './PluginLoader';
|
||||
|
||||
interface Callbacks {
|
||||
onUndoRedo(): void;
|
||||
onSettingsChange(newSettings: EditorSettings): void;
|
||||
onClearHistory(): void;
|
||||
onRemove(): void;
|
||||
onLogMessage: LogMessageCallback;
|
||||
}
|
||||
|
||||
export default class CodeMirrorControl extends CodeMirror5Emulation implements EditorControl {
|
||||
private _pluginControl: PluginLoader;
|
||||
|
||||
public constructor(
|
||||
editor: EditorView,
|
||||
private _callbacks: Callbacks,
|
||||
) {
|
||||
super(editor, _callbacks.onLogMessage);
|
||||
|
||||
this._pluginControl = new PluginLoader(this, _callbacks.onLogMessage);
|
||||
}
|
||||
|
||||
public supportsCommand(name: string) {
|
||||
return name in editorCommands || super.commandExists(name);
|
||||
}
|
||||
|
||||
public override execCommand(name: string) {
|
||||
if (name in editorCommands) {
|
||||
editorCommands[name as EditorCommandType](this.editor);
|
||||
} else if (super.commandExists(name)) {
|
||||
super.execCommand(name);
|
||||
}
|
||||
|
||||
if (name === EditorCommandType.Undo || name === EditorCommandType.Redo) {
|
||||
this._callbacks.onUndoRedo();
|
||||
}
|
||||
}
|
||||
|
||||
public undo() {
|
||||
this.execCommand(EditorCommandType.Undo);
|
||||
this._callbacks.onUndoRedo();
|
||||
}
|
||||
|
||||
public redo() {
|
||||
this.execCommand(EditorCommandType.Redo);
|
||||
this._callbacks.onUndoRedo();
|
||||
}
|
||||
|
||||
public select(anchor: number, head: number) {
|
||||
this.editor.dispatch(this.editor.state.update({
|
||||
selection: { anchor, head },
|
||||
scrollIntoView: true,
|
||||
}));
|
||||
}
|
||||
|
||||
public clearHistory() {
|
||||
this._callbacks.onClearHistory();
|
||||
}
|
||||
|
||||
public setScrollPercent(fraction: number) {
|
||||
const maxScroll = this.editor.scrollDOM.scrollHeight - this.editor.scrollDOM.clientHeight;
|
||||
this.editor.scrollDOM.scrollTop = fraction * maxScroll;
|
||||
}
|
||||
|
||||
public insertText(text: string) {
|
||||
this.editor.dispatch(this.editor.state.replaceSelection(text));
|
||||
}
|
||||
|
||||
public updateBody(newBody: string) {
|
||||
// TODO: doc.toString() can be slow for large documents.
|
||||
const currentBody = this.editor.state.doc.toString();
|
||||
|
||||
if (newBody !== currentBody) {
|
||||
// For now, collapse the selection to a single cursor
|
||||
// to ensure that the selection stays within the document
|
||||
// (and thus avoids an exception).
|
||||
const mainCursorPosition = this.editor.state.selection.main.anchor;
|
||||
const newCursorPosition = Math.min(mainCursorPosition, newBody.length);
|
||||
|
||||
this.editor.dispatch(this.editor.state.update({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: this.editor.state.doc.length,
|
||||
insert: newBody,
|
||||
},
|
||||
selection: EditorSelection.cursor(newCursorPosition),
|
||||
scrollIntoView: true,
|
||||
}));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public updateLink(newLabel: string, newUrl: string) {
|
||||
updateLink(newLabel, newUrl)(this.editor);
|
||||
}
|
||||
|
||||
public updateSettings(newSettings: EditorSettings) {
|
||||
this._callbacks.onSettingsChange(newSettings);
|
||||
}
|
||||
|
||||
public setSearchState(newState: SearchState) {
|
||||
const query = new SearchQuery({
|
||||
search: newState.searchText,
|
||||
caseSensitive: newState.caseSensitive,
|
||||
regexp: newState.useRegex,
|
||||
replace: newState.replaceText,
|
||||
});
|
||||
this.editor.dispatch({
|
||||
effects: setSearchQuery.of(query),
|
||||
});
|
||||
}
|
||||
|
||||
public addStyles(...styles: Parameters<typeof EditorView.theme>) {
|
||||
this.editor.dispatch({
|
||||
effects: StateEffect.appendConfig.of(EditorView.theme(...styles)),
|
||||
});
|
||||
}
|
||||
|
||||
public setPlugins(plugins: PluginData[]) {
|
||||
return this._pluginControl.setPlugins(plugins);
|
||||
}
|
||||
|
||||
public remove() {
|
||||
this._pluginControl.remove();
|
||||
this._callbacks.onRemove();
|
||||
}
|
||||
}
|
||||
156
packages/editor/CodeMirror/PluginLoader.ts
Normal file
156
packages/editor/CodeMirror/PluginLoader.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { LogMessageCallback, PluginData } from '../types';
|
||||
import CodeMirrorControl from './CodeMirrorControl';
|
||||
|
||||
let pluginScriptIdCounter = 0;
|
||||
|
||||
type OnScriptLoadCallback = (exports: any)=> void;
|
||||
type OnPluginRemovedCallback = ()=> void;
|
||||
|
||||
export default class PluginLoader {
|
||||
private pluginScriptsContainer: HTMLElement;
|
||||
private loadedPluginIds: string[] = [];
|
||||
private pluginRemovalCallbacks: Record<string, OnPluginRemovedCallback> = {};
|
||||
|
||||
public constructor(private editor: CodeMirrorControl, private logMessage: LogMessageCallback) {
|
||||
this.pluginScriptsContainer = document.createElement('div');
|
||||
this.pluginScriptsContainer.style.display = 'none';
|
||||
|
||||
// For testing
|
||||
this.pluginScriptsContainer.id = 'joplin-plugin-scripts-container';
|
||||
|
||||
document.body.appendChild(this.pluginScriptsContainer);
|
||||
|
||||
(window as any).scriptLoadCallbacks ??= Object.create(null);
|
||||
}
|
||||
|
||||
public async setPlugins(plugins: PluginData[]) {
|
||||
for (const plugin of plugins) {
|
||||
if (!this.loadedPluginIds.includes(plugin.pluginId)) {
|
||||
this.addPlugin(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old plugins
|
||||
const pluginIds = plugins.map(plugin => plugin.pluginId);
|
||||
const removedIds = this.loadedPluginIds
|
||||
.filter(id => !pluginIds.includes(id));
|
||||
|
||||
for (const id of removedIds) {
|
||||
if (id in this.pluginRemovalCallbacks) {
|
||||
this.pluginRemovalCallbacks[id]();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addPlugin(plugin: PluginData) {
|
||||
const onRemoveCallbacks: OnPluginRemovedCallback[] = [];
|
||||
|
||||
this.logMessage(`Loading plugin ${plugin.pluginId}`);
|
||||
|
||||
const addScript = (onLoad: OnScriptLoadCallback) => {
|
||||
const scriptElement = document.createElement('script');
|
||||
|
||||
onRemoveCallbacks.push(() => {
|
||||
scriptElement.remove();
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
const scriptId = pluginScriptIdCounter++;
|
||||
const js = await plugin.contentScriptJs();
|
||||
|
||||
// Stop if cancelled
|
||||
if (!this.loadedPluginIds.includes(plugin.pluginId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
scriptElement.innerText = `
|
||||
(async () => {
|
||||
const exports = {};
|
||||
|
||||
${js};
|
||||
|
||||
window.scriptLoadCallbacks[${scriptId}](exports);
|
||||
})();
|
||||
`;
|
||||
|
||||
(window as any).scriptLoadCallbacks[scriptId] = onLoad;
|
||||
|
||||
this.pluginScriptsContainer.appendChild(scriptElement);
|
||||
})();
|
||||
};
|
||||
|
||||
const addStyles = (cssStrings: string[]) => {
|
||||
// A container for style elements
|
||||
const styleContainer = document.createElement('div');
|
||||
|
||||
onRemoveCallbacks.push(() => {
|
||||
styleContainer.remove();
|
||||
});
|
||||
|
||||
for (const cssText of cssStrings) {
|
||||
const style = document.createElement('style');
|
||||
style.innerText = cssText;
|
||||
styleContainer.appendChild(style);
|
||||
}
|
||||
|
||||
this.pluginScriptsContainer.appendChild(styleContainer);
|
||||
};
|
||||
|
||||
this.pluginRemovalCallbacks[plugin.pluginId] = () => {
|
||||
for (const callback of onRemoveCallbacks) {
|
||||
callback();
|
||||
}
|
||||
|
||||
this.loadedPluginIds = this.loadedPluginIds.filter(id => {
|
||||
return id !== plugin.pluginId;
|
||||
});
|
||||
};
|
||||
|
||||
addScript(exports => {
|
||||
if (!exports?.default || !(typeof exports.default === 'function')) {
|
||||
throw new Error('All plugins must have a function default export');
|
||||
}
|
||||
|
||||
const context = {
|
||||
postMessage: plugin.postMessageHandler,
|
||||
pluginId: plugin.pluginId,
|
||||
contentScriptId: plugin.contentScriptId,
|
||||
};
|
||||
const loadedPlugin = exports.default(context);
|
||||
|
||||
loadedPlugin.plugin?.(this.editor);
|
||||
|
||||
if (loadedPlugin.codeMirrorOptions) {
|
||||
for (const key in loadedPlugin.codeMirrorOptions) {
|
||||
this.editor.setOption(key, loadedPlugin.codeMirrorOptions[key]);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedPlugin.assets) {
|
||||
const cssStrings = [];
|
||||
|
||||
for (const asset of loadedPlugin.assets()) {
|
||||
if (!asset.inline) {
|
||||
this.logMessage('Warning: The CM6 plugin API currently only supports inline CSS.');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (asset.mime !== 'text/css') {
|
||||
throw new Error('Inline assets must have property "mime" set to "text/css"');
|
||||
}
|
||||
|
||||
cssStrings.push(asset.text);
|
||||
}
|
||||
|
||||
addStyles(cssStrings);
|
||||
}
|
||||
});
|
||||
|
||||
this.loadedPluginIds.push(plugin.pluginId);
|
||||
}
|
||||
|
||||
public remove() {
|
||||
this.pluginScriptsContainer.remove();
|
||||
}
|
||||
}
|
||||
|
||||
70
packages/editor/CodeMirror/configFromSettings.ts
Normal file
70
packages/editor/CodeMirror/configFromSettings.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
import { closeBrackets } from '@codemirror/autocomplete';
|
||||
import { EditorKeymap, EditorLanguageType, EditorSettings } from '../types';
|
||||
import createTheme from './theme';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
|
||||
import { MarkdownMathExtension } from './markdown/markdownMathParser';
|
||||
import syntaxHighlightingLanguages from './markdown/syntaxHighlightingLanguages';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { defaultKeymap, emacsStyleKeymap } from '@codemirror/commands';
|
||||
import { vim } from '@replit/codemirror-vim';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
|
||||
const configFromSettings = (settings: EditorSettings) => {
|
||||
const languageExtension = (() => {
|
||||
const openingBrackets = '`([{\'"‘“(《「『【〔〖〘〚'.split('');
|
||||
|
||||
const language = settings.language;
|
||||
if (language === EditorLanguageType.Markdown) {
|
||||
return [
|
||||
markdown({
|
||||
extensions: [
|
||||
GitHubFlavoredMarkdownExtension,
|
||||
|
||||
// Don't highlight KaTeX if the user disabled it
|
||||
settings.katexEnabled ? MarkdownMathExtension : [],
|
||||
],
|
||||
codeLanguages: syntaxHighlightingLanguages,
|
||||
}),
|
||||
markdownLanguage.data.of({ closeBrackets: openingBrackets }),
|
||||
];
|
||||
} else if (language === EditorLanguageType.Html) {
|
||||
return html();
|
||||
} else {
|
||||
const exhaustivenessCheck: never = language;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
})();
|
||||
|
||||
const extensions = [
|
||||
languageExtension,
|
||||
createTheme(settings.themeData),
|
||||
EditorView.contentAttributes.of({
|
||||
autocapitalize: 'sentence',
|
||||
autocorrect: settings.spellcheckEnabled ? 'true' : 'false',
|
||||
spellcheck: settings.spellcheckEnabled ? 'true' : 'false',
|
||||
}),
|
||||
EditorState.readOnly.of(settings.readOnly),
|
||||
indentUnit.of(settings.indentWithTabs ? '\t' : ' '),
|
||||
];
|
||||
|
||||
if (settings.automatchBraces) {
|
||||
extensions.push(closeBrackets());
|
||||
}
|
||||
|
||||
if (settings.keymap === EditorKeymap.Vim) {
|
||||
extensions.push(vim());
|
||||
} else if (settings.keymap === EditorKeymap.Emacs) {
|
||||
extensions.push(keymap.of(emacsStyleKeymap));
|
||||
}
|
||||
|
||||
if (!settings.ignoreModifiers) {
|
||||
extensions.push(keymap.of(defaultKeymap));
|
||||
}
|
||||
|
||||
return extensions;
|
||||
};
|
||||
|
||||
export default configFromSettings;
|
||||
122
packages/editor/CodeMirror/createEditor.test.ts
Normal file
122
packages/editor/CodeMirror/createEditor.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import createEditor from './createEditor';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { forceParsing } from '@codemirror/language';
|
||||
import loadLangauges from './testUtil/loadLanguages';
|
||||
|
||||
import { expect, describe, it } from '@jest/globals';
|
||||
import createEditorSettings from './testUtil/createEditorSettings';
|
||||
|
||||
|
||||
describe('createEditor', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
// This checks for a regression -- occasionally, when updating packages,
|
||||
// syntax highlighting in the CodeMirror editor stops working. This is usually
|
||||
// fixed by
|
||||
// 1. removing all `@codemirror/` and `@lezer/` dependencies from yarn.lock,
|
||||
// 2. upgrading all CodeMirror packages to the latest versions in package.json, and
|
||||
// 3. re-running `yarn install`.
|
||||
//
|
||||
// See https://github.com/laurent22/joplin/issues/7253
|
||||
it('should give headings a different style', async () => {
|
||||
const headerLineText = '# Testing...';
|
||||
const initialText = `${headerLineText}\nThis is a test.`;
|
||||
const editorSettings = createEditorSettings(Setting.THEME_LIGHT);
|
||||
|
||||
await loadLangauges();
|
||||
const editor = createEditor(document.body, {
|
||||
initialText,
|
||||
settings: editorSettings,
|
||||
onEvent: _event => {},
|
||||
onLogMessage: _message => {},
|
||||
});
|
||||
|
||||
// Force the generation of the syntax tree now.
|
||||
forceParsing(editor.editor);
|
||||
|
||||
const headerLine = document.body.querySelector('.cm-headerLine')!;
|
||||
expect(headerLine.textContent).toBe(headerLineText);
|
||||
|
||||
// CodeMirror nests the tag that styles the header within .cm-headerLine:
|
||||
// <div class='cm-headerLine'><span class='someclass'>Testing...</span></div>
|
||||
const headerLineContent = document.body.querySelectorAll('.cm-headerLine > span');
|
||||
expect(headerLineContent.length).toBeGreaterThanOrEqual(1);
|
||||
for (const part of headerLineContent) {
|
||||
const style = getComputedStyle(part);
|
||||
expect(style.fontSize).toBe('1.6em');
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
editor.remove();
|
||||
});
|
||||
|
||||
it('should support loading plugins', async () => {
|
||||
const initialText = '# Test\nThis is a test.';
|
||||
const editorSettings = createEditorSettings(Setting.THEME_LIGHT);
|
||||
|
||||
const editor = createEditor(document.body, {
|
||||
initialText,
|
||||
settings: editorSettings,
|
||||
onEvent: _event => {},
|
||||
onLogMessage: _message => {},
|
||||
});
|
||||
|
||||
const getContentScriptJs = jest.fn(async () => {
|
||||
return `
|
||||
exports.default = context => {
|
||||
context.postMessage(context.pluginId);
|
||||
};
|
||||
`;
|
||||
});
|
||||
const postMessageHandler = jest.fn();
|
||||
|
||||
const testPlugin1 = {
|
||||
pluginId: 'a.plugin.id',
|
||||
contentScriptId: 'a.plugin.id.contentScript',
|
||||
contentScriptJs: getContentScriptJs,
|
||||
postMessageHandler,
|
||||
};
|
||||
const testPlugin2 = {
|
||||
pluginId: 'another.plugin.id',
|
||||
contentScriptId: 'another.plugin.id.contentScript',
|
||||
contentScriptJs: getContentScriptJs,
|
||||
postMessageHandler,
|
||||
};
|
||||
|
||||
// Should be able to load a plugin
|
||||
await editor.setPlugins([
|
||||
testPlugin1,
|
||||
]);
|
||||
|
||||
// Allow plugins to load
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
// Because plugin loading is done by adding script elements to the document,
|
||||
// we test for the presence of these script elements, rather than waiting for
|
||||
// them to run.
|
||||
expect(document.querySelectorAll('#joplin-plugin-scripts-container')).toHaveLength(1);
|
||||
|
||||
// Only one script should be present.
|
||||
const scriptContainer = document.querySelector('#joplin-plugin-scripts-container');
|
||||
expect(scriptContainer.querySelectorAll('script')).toHaveLength(1);
|
||||
|
||||
// Adding another plugin should add another script element
|
||||
await editor.setPlugins([
|
||||
testPlugin2, testPlugin1,
|
||||
]);
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
// There should now be script elements for each plugin
|
||||
expect(scriptContainer.querySelectorAll('script')).toHaveLength(2);
|
||||
|
||||
// Removing the editor should remove the script container
|
||||
editor.remove();
|
||||
expect(document.querySelectorAll('#joplin-plugin-scripts-container')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
303
packages/editor/CodeMirror/createEditor.ts
Normal file
303
packages/editor/CodeMirror/createEditor.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import { indentOnInput, syntaxHighlighting } from '@codemirror/language';
|
||||
import {
|
||||
openSearchPanel, closeSearchPanel, getSearchQuery,
|
||||
highlightSelectionMatches, search,
|
||||
} from '@codemirror/search';
|
||||
|
||||
import { classHighlighter } from '@lezer/highlight';
|
||||
|
||||
import {
|
||||
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command,
|
||||
} from '@codemirror/view';
|
||||
import { history, undoDepth, redoDepth, standardKeymap } from '@codemirror/commands';
|
||||
|
||||
import { keymap, KeyBinding } from '@codemirror/view';
|
||||
import { searchKeymap } from '@codemirror/search';
|
||||
import { historyKeymap } from '@codemirror/commands';
|
||||
|
||||
import { SearchState, EditorProps, EditorSettings } from '../types';
|
||||
import { EditorEventType, SelectionRangeChangeEvent } from '../events';
|
||||
import {
|
||||
decreaseIndent, increaseIndent,
|
||||
toggleBolded, toggleCode,
|
||||
toggleItalicized, toggleMath,
|
||||
} from './markdown/markdownCommands';
|
||||
import decoratorExtension from './markdown/decoratorExtension';
|
||||
import computeSelectionFormatting from './markdown/computeSelectionFormatting';
|
||||
import { selectionFormattingEqual } from '../SelectionFormatting';
|
||||
import configFromSettings from './configFromSettings';
|
||||
import getScrollFraction from './getScrollFraction';
|
||||
import CodeMirrorControl from './CodeMirrorControl';
|
||||
|
||||
const createEditor = (
|
||||
parentElement: HTMLElement, props: EditorProps,
|
||||
): CodeMirrorControl => {
|
||||
const initialText = props.initialText;
|
||||
let settings = props.settings;
|
||||
|
||||
props.onLogMessage('Initializing CodeMirror...');
|
||||
|
||||
let searchVisible = false;
|
||||
|
||||
// Handles firing an event when the undo/redo stack changes
|
||||
let schedulePostUndoRedoDepthChangeId_: ReturnType<typeof setTimeout>|null = null;
|
||||
let lastUndoDepth = 0;
|
||||
let lastRedoDepth = 0;
|
||||
const schedulePostUndoRedoDepthChange = (editor: EditorView, doItNow = false) => {
|
||||
if (schedulePostUndoRedoDepthChangeId_ !== null) {
|
||||
if (doItNow) {
|
||||
clearTimeout(schedulePostUndoRedoDepthChangeId_);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
schedulePostUndoRedoDepthChangeId_ = setTimeout(() => {
|
||||
schedulePostUndoRedoDepthChangeId_ = null;
|
||||
const newUndoDepth = undoDepth(editor.state);
|
||||
const newRedoDepth = redoDepth(editor.state);
|
||||
|
||||
if (newUndoDepth !== lastUndoDepth || newRedoDepth !== lastRedoDepth) {
|
||||
props.onEvent({
|
||||
kind: EditorEventType.UndoRedoDepthChange,
|
||||
undoDepth: newUndoDepth,
|
||||
redoDepth: newRedoDepth,
|
||||
});
|
||||
lastUndoDepth = newUndoDepth;
|
||||
lastRedoDepth = newRedoDepth;
|
||||
}
|
||||
}, doItNow ? 0 : 1000);
|
||||
};
|
||||
|
||||
let currentDocText = props.initialText;
|
||||
const notifyDocChanged = (viewUpdate: ViewUpdate) => {
|
||||
if (viewUpdate.docChanged) {
|
||||
currentDocText = editor.state.doc.toString();
|
||||
props.onEvent({
|
||||
kind: EditorEventType.Change,
|
||||
value: currentDocText,
|
||||
});
|
||||
|
||||
schedulePostUndoRedoDepthChange(editor);
|
||||
}
|
||||
};
|
||||
|
||||
const notifyLinkEditRequest = () => {
|
||||
props.onEvent({
|
||||
kind: EditorEventType.EditLink,
|
||||
});
|
||||
};
|
||||
|
||||
const onSearchDialogUpdate = () => {
|
||||
const query = getSearchQuery(editor.state);
|
||||
const searchState: SearchState = {
|
||||
searchText: query.search,
|
||||
replaceText: query.replace,
|
||||
useRegex: query.regexp,
|
||||
caseSensitive: query.caseSensitive,
|
||||
dialogVisible: searchVisible,
|
||||
};
|
||||
props.onEvent({
|
||||
kind: EditorEventType.UpdateSearchDialog,
|
||||
searchState,
|
||||
});
|
||||
};
|
||||
|
||||
const showSearchDialog = () => {
|
||||
if (!searchVisible) {
|
||||
openSearchPanel(editor);
|
||||
}
|
||||
searchVisible = true;
|
||||
onSearchDialogUpdate();
|
||||
};
|
||||
|
||||
const hideSearchDialog = () => {
|
||||
if (searchVisible) {
|
||||
closeSearchPanel(editor);
|
||||
}
|
||||
searchVisible = false;
|
||||
onSearchDialogUpdate();
|
||||
};
|
||||
|
||||
const globalSpellcheckEnabled = () => {
|
||||
return editor.contentDOM.spellcheck;
|
||||
};
|
||||
|
||||
const notifySelectionChange = (viewUpdate: ViewUpdate) => {
|
||||
if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
|
||||
const mainRange = viewUpdate.state.selection.main;
|
||||
const event: SelectionRangeChangeEvent = {
|
||||
kind: EditorEventType.SelectionRangeChange,
|
||||
|
||||
anchor: mainRange.anchor,
|
||||
head: mainRange.head,
|
||||
from: mainRange.from,
|
||||
to: mainRange.to,
|
||||
};
|
||||
props.onEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
const notifySelectionFormattingChange = (viewUpdate?: ViewUpdate) => {
|
||||
const spellcheck = globalSpellcheckEnabled();
|
||||
|
||||
// If we can't determine the previous formatting, post the update regardless
|
||||
if (!viewUpdate) {
|
||||
const formatting = computeSelectionFormatting(editor.state, spellcheck);
|
||||
props.onEvent({
|
||||
kind: EditorEventType.SelectionFormattingChange,
|
||||
formatting,
|
||||
});
|
||||
} else if (viewUpdate.docChanged || !viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
|
||||
|
||||
// Only post the update if something changed
|
||||
const oldFormatting = computeSelectionFormatting(viewUpdate.startState, spellcheck);
|
||||
const newFormatting = computeSelectionFormatting(viewUpdate.state, spellcheck);
|
||||
|
||||
if (!selectionFormattingEqual(oldFormatting, newFormatting)) {
|
||||
props.onEvent({
|
||||
kind: EditorEventType.SelectionFormattingChange,
|
||||
formatting: newFormatting,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Returns a keyboard command that returns true (so accepts the keybind)
|
||||
// alwaysActive: true if this command should be registered even if ignoreModifiers is given.
|
||||
const keyCommand = (key: string, run: Command, alwaysActive?: boolean): KeyBinding => {
|
||||
return {
|
||||
key,
|
||||
run: editor => {
|
||||
if (settings.ignoreModifiers && !alwaysActive) return false;
|
||||
|
||||
return run(editor);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const historyCompartment = new Compartment();
|
||||
const dynamicConfig = new Compartment();
|
||||
|
||||
const editor = new EditorView({
|
||||
state: EditorState.create({
|
||||
// See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
|
||||
// for a sample configuration.
|
||||
extensions: [
|
||||
dynamicConfig.of(configFromSettings(props.settings)),
|
||||
historyCompartment.of(history()),
|
||||
|
||||
search(settings.useExternalSearch ? {
|
||||
createPanel(_: EditorView) {
|
||||
return {
|
||||
// The actual search dialog is implemented with react native,
|
||||
// use a dummy element.
|
||||
dom: document.createElement('div'),
|
||||
mount() {
|
||||
showSearchDialog();
|
||||
},
|
||||
destroy() {
|
||||
hideSearchDialog();
|
||||
},
|
||||
};
|
||||
},
|
||||
} : undefined),
|
||||
drawSelection(),
|
||||
highlightSpecialChars(),
|
||||
highlightSelectionMatches(),
|
||||
indentOnInput(),
|
||||
|
||||
EditorView.domEventHandlers({
|
||||
scroll: (_event, view) => {
|
||||
props.onEvent({
|
||||
kind: EditorEventType.Scroll,
|
||||
fraction: getScrollFraction(view),
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
EditorState.tabSize.of(4),
|
||||
|
||||
// Apply styles to entire lines (block-display decorations)
|
||||
decoratorExtension,
|
||||
|
||||
// Adds additional CSS classes to tokens (the default CSS classes are
|
||||
// auto-generated and thus unstable).
|
||||
syntaxHighlighting(classHighlighter),
|
||||
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||
notifyDocChanged(viewUpdate);
|
||||
notifySelectionChange(viewUpdate);
|
||||
notifySelectionFormattingChange(viewUpdate);
|
||||
}),
|
||||
keymap.of([
|
||||
// Custom mod-f binding: Toggle the external dialog implementation
|
||||
// (don't show/hide the Panel dialog).
|
||||
keyCommand('Mod-f', (_: EditorView) => {
|
||||
if (searchVisible) {
|
||||
hideSearchDialog();
|
||||
} else {
|
||||
showSearchDialog();
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
// Markdown formatting keyboard shortcuts
|
||||
keyCommand('Mod-b', toggleBolded),
|
||||
keyCommand('Mod-i', toggleItalicized),
|
||||
keyCommand('Mod-$', toggleMath),
|
||||
keyCommand('Mod-`', toggleCode),
|
||||
keyCommand('Mod-[', decreaseIndent),
|
||||
keyCommand('Mod-]', increaseIndent),
|
||||
keyCommand('Mod-k', (_: EditorView) => {
|
||||
notifyLinkEditRequest();
|
||||
return true;
|
||||
}),
|
||||
keyCommand('Tab', increaseIndent, true),
|
||||
keyCommand('Shift-Tab', decreaseIndent, true),
|
||||
|
||||
...standardKeymap, ...historyKeymap, ...searchKeymap,
|
||||
]),
|
||||
],
|
||||
doc: initialText,
|
||||
}),
|
||||
parent: parentElement,
|
||||
});
|
||||
|
||||
const editorControls = new CodeMirrorControl(editor, {
|
||||
onClearHistory: () => {
|
||||
// Clear history by removing then re-add the history extension.
|
||||
// Just re-adding the history extension isn't enough.
|
||||
editor.dispatch({
|
||||
effects: historyCompartment.reconfigure([]),
|
||||
});
|
||||
editor.dispatch({
|
||||
effects: historyCompartment.reconfigure(history()),
|
||||
});
|
||||
},
|
||||
onSettingsChange: (newSettings: EditorSettings) => {
|
||||
settings = newSettings;
|
||||
editor.dispatch({
|
||||
effects: dynamicConfig.reconfigure(
|
||||
configFromSettings(newSettings),
|
||||
),
|
||||
});
|
||||
},
|
||||
onUndoRedo: () => {
|
||||
// This callback is triggered when undo/redo is called
|
||||
// directly. Show visual feedback immediately.
|
||||
schedulePostUndoRedoDepthChange(editor, true);
|
||||
},
|
||||
onLogMessage: props.onLogMessage,
|
||||
onRemove: () => {
|
||||
editor.destroy();
|
||||
},
|
||||
});
|
||||
|
||||
return editorControls;
|
||||
};
|
||||
|
||||
export default createEditor;
|
||||
|
||||
|
||||
69
packages/editor/CodeMirror/editorCommands/editorCommands.ts
Normal file
69
packages/editor/CodeMirror/editorCommands/editorCommands.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { EditorCommandType, ListType } from '../../types';
|
||||
import { undo, redo, selectAll, indentSelection, cursorDocStart, cursorDocEnd, cursorLineStart, cursorLineEnd, deleteToLineStart, deleteToLineEnd, undoSelection, redoSelection, cursorPageDown, cursorPageUp, cursorCharRight, cursorCharLeft, insertNewlineAndIndent, cursorLineDown, cursorLineUp } from '@codemirror/commands';
|
||||
import {
|
||||
decreaseIndent, increaseIndent,
|
||||
toggleBolded, toggleCode,
|
||||
toggleHeaderLevel, toggleItalicized,
|
||||
toggleList, toggleMath,
|
||||
} from '../markdown/markdownCommands';
|
||||
import swapLine, { SwapLineDirection } from './swapLine';
|
||||
import { closeSearchPanel, findNext, findPrevious, openSearchPanel, replaceAll, replaceNext } from '@codemirror/search';
|
||||
|
||||
type EditorCommandFunction = (editor: EditorView)=> void;
|
||||
|
||||
const editorCommands: Record<EditorCommandType, EditorCommandFunction> = {
|
||||
[EditorCommandType.Undo]: undo,
|
||||
[EditorCommandType.Redo]: redo,
|
||||
[EditorCommandType.SelectAll]: selectAll,
|
||||
[EditorCommandType.Focus]: editor => editor.focus(),
|
||||
|
||||
[EditorCommandType.ToggleBolded]: toggleBolded,
|
||||
[EditorCommandType.ToggleItalicized]: toggleItalicized,
|
||||
[EditorCommandType.ToggleCode]: toggleCode,
|
||||
[EditorCommandType.ToggleMath]: toggleMath,
|
||||
[EditorCommandType.ToggleNumberedList]: toggleList(ListType.OrderedList),
|
||||
[EditorCommandType.ToggleBulletedList]: toggleList(ListType.UnorderedList),
|
||||
[EditorCommandType.ToggleCheckList]: toggleList(ListType.CheckList),
|
||||
[EditorCommandType.ToggleHeading]: toggleHeaderLevel(2),
|
||||
[EditorCommandType.ToggleHeading1]: toggleHeaderLevel(1),
|
||||
[EditorCommandType.ToggleHeading2]: toggleHeaderLevel(2),
|
||||
[EditorCommandType.ToggleHeading3]: toggleHeaderLevel(3),
|
||||
[EditorCommandType.ToggleHeading4]: toggleHeaderLevel(4),
|
||||
[EditorCommandType.ToggleHeading5]: toggleHeaderLevel(5),
|
||||
|
||||
[EditorCommandType.ScrollSelectionIntoView]: editor => {
|
||||
editor.dispatch(editor.state.update({
|
||||
scrollIntoView: true,
|
||||
}));
|
||||
},
|
||||
[EditorCommandType.DeleteToLineEnd]: deleteToLineEnd,
|
||||
[EditorCommandType.DeleteToLineStart]: deleteToLineStart,
|
||||
[EditorCommandType.IndentMore]: increaseIndent,
|
||||
[EditorCommandType.IndentLess]: decreaseIndent,
|
||||
[EditorCommandType.IndentAuto]: indentSelection,
|
||||
[EditorCommandType.InsertNewlineAndIndent]: insertNewlineAndIndent,
|
||||
[EditorCommandType.SwapLineUp]: swapLine(SwapLineDirection.Up),
|
||||
[EditorCommandType.SwapLineDown]: swapLine(SwapLineDirection.Down),
|
||||
[EditorCommandType.GoDocEnd]: cursorDocEnd,
|
||||
[EditorCommandType.GoDocStart]: cursorDocStart,
|
||||
[EditorCommandType.GoLineStart]: cursorLineStart,
|
||||
[EditorCommandType.GoLineEnd]: cursorLineEnd,
|
||||
[EditorCommandType.GoLineUp]: cursorLineUp,
|
||||
[EditorCommandType.GoLineDown]: cursorLineDown,
|
||||
[EditorCommandType.GoPageUp]: cursorPageUp,
|
||||
[EditorCommandType.GoPageDown]: cursorPageDown,
|
||||
[EditorCommandType.GoCharLeft]: cursorCharLeft,
|
||||
[EditorCommandType.GoCharRight]: cursorCharRight,
|
||||
[EditorCommandType.UndoSelection]: undoSelection,
|
||||
[EditorCommandType.RedoSelection]: redoSelection,
|
||||
|
||||
[EditorCommandType.ShowSearch]: openSearchPanel,
|
||||
[EditorCommandType.HideSearch]: closeSearchPanel,
|
||||
[EditorCommandType.FindNext]: findNext,
|
||||
[EditorCommandType.FindPrevious]: findPrevious,
|
||||
[EditorCommandType.ReplaceNext]: replaceNext,
|
||||
[EditorCommandType.ReplaceAll]: replaceAll,
|
||||
};
|
||||
export default editorCommands;
|
||||
|
||||
11
packages/editor/CodeMirror/editorCommands/supportsCommand.ts
Normal file
11
packages/editor/CodeMirror/editorCommands/supportsCommand.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
import { EditorCommandType } from '../../types';
|
||||
|
||||
// The CodeMirror 6 editor supports all EditorCommandTypes.
|
||||
// Note that this file is separate from editorCommands.ts to allow importing it in
|
||||
// non-browser contexts.
|
||||
const supportsCommand = (commandName: EditorCommandType) => {
|
||||
return Object.values(EditorCommandType).includes(commandName);
|
||||
};
|
||||
|
||||
export default supportsCommand;
|
||||
49
packages/editor/CodeMirror/editorCommands/swapLine.ts
Normal file
49
packages/editor/CodeMirror/editorCommands/swapLine.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import { Command, EditorView } from '@codemirror/view';
|
||||
|
||||
export enum SwapLineDirection {
|
||||
Up = -1,
|
||||
Down = 1,
|
||||
}
|
||||
|
||||
const swapLine = (direction: SwapLineDirection): Command => (editor: EditorView) => {
|
||||
const state = editor.state;
|
||||
const doc = state.doc;
|
||||
|
||||
const transaction = state.changeByRange(range => {
|
||||
const currentLine = doc.lineAt(range.anchor);
|
||||
const otherLineNumber = currentLine.number + direction;
|
||||
|
||||
// Out of range? No changes.
|
||||
if (otherLineNumber <= 0 || otherLineNumber > doc.lines) {
|
||||
return { range };
|
||||
}
|
||||
|
||||
const otherLine = doc.line(otherLineNumber);
|
||||
|
||||
let deltaPos;
|
||||
if (direction === SwapLineDirection.Down) {
|
||||
// +1: include newline
|
||||
deltaPos = otherLine.length + 1;
|
||||
} else {
|
||||
deltaPos = otherLine.from - currentLine.from;
|
||||
}
|
||||
|
||||
return {
|
||||
range: EditorSelection.range(range.anchor + deltaPos, range.head + deltaPos),
|
||||
changes: [{
|
||||
from: currentLine.from,
|
||||
to: currentLine.to,
|
||||
insert: otherLine.text,
|
||||
}, {
|
||||
from: otherLine.from,
|
||||
to: otherLine.to,
|
||||
insert: currentLine.text,
|
||||
}],
|
||||
};
|
||||
});
|
||||
|
||||
editor.dispatch(transaction);
|
||||
return true;
|
||||
};
|
||||
export default swapLine;
|
||||
11
packages/editor/CodeMirror/getScrollFraction.ts
Normal file
11
packages/editor/CodeMirror/getScrollFraction.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
|
||||
const getScrollFraction = (view: EditorView) => {
|
||||
const maxScroll = view.scrollDOM.scrollHeight - view.scrollDOM.clientHeight;
|
||||
|
||||
// Prevent division by zero
|
||||
return maxScroll > 0 ? view.scrollDOM.scrollTop / maxScroll : 0;
|
||||
};
|
||||
|
||||
export default getScrollFraction;
|
||||
@@ -0,0 +1,123 @@
|
||||
import SelectionFormatting, { MutableSelectionFormatting, defaultSelectionFormatting } from '../../SelectionFormatting';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
|
||||
const computeSelectionFormatting = (state: EditorState, globalSpellcheck: boolean): SelectionFormatting => {
|
||||
const range = state.selection.main;
|
||||
const formatting: MutableSelectionFormatting = {
|
||||
...defaultSelectionFormatting,
|
||||
selectedText: state.doc.sliceString(range.from, range.to),
|
||||
spellChecking: globalSpellcheck,
|
||||
};
|
||||
|
||||
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 = {
|
||||
...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;
|
||||
};
|
||||
export default computeSelectionFormatting;
|
||||
|
||||
@@ -40,6 +40,10 @@ const urlDecoration = Decoration.mark({
|
||||
attributes: { class: 'cm-url', ...noSpellCheckAttrs },
|
||||
});
|
||||
|
||||
const htmlTagNameDecoration = Decoration.mark({
|
||||
attributes: { class: 'cm-htmlTag', ...noSpellCheckAttrs },
|
||||
});
|
||||
|
||||
const blockQuoteDecoration = Decoration.line({
|
||||
attributes: { class: 'cm-blockQuote' },
|
||||
});
|
||||
@@ -48,6 +52,26 @@ const headerLineDecoration = Decoration.line({
|
||||
attributes: { class: 'cm-headerLine' },
|
||||
});
|
||||
|
||||
const tableHeaderDecoration = Decoration.line({
|
||||
attributes: { class: 'cm-tableHeader' },
|
||||
});
|
||||
|
||||
const tableBodyDecoration = Decoration.line({
|
||||
attributes: { class: 'cm-tableRow' },
|
||||
});
|
||||
|
||||
const tableDelimiterDecoration = Decoration.line({
|
||||
attributes: { class: 'cm-tableDelimiter' },
|
||||
});
|
||||
|
||||
const horizontalRuleDecoration = Decoration.mark({
|
||||
attributes: { class: 'cm-hr' },
|
||||
});
|
||||
|
||||
const taskMarkerDecoration = Decoration.mark({
|
||||
attributes: { class: 'cm-taskMarker' },
|
||||
});
|
||||
|
||||
type DecorationDescription = { pos: number; length?: number; decoration: Decoration };
|
||||
|
||||
// Returns a set of [Decoration]s, associated with block syntax groups that require
|
||||
@@ -125,6 +149,25 @@ const computeDecorations = (view: EditorView) => {
|
||||
case 'ATXHeading6':
|
||||
addDecorationToLines(viewFrom, viewTo, headerLineDecoration);
|
||||
break;
|
||||
case 'HTMLTag':
|
||||
case 'TagName':
|
||||
addDecorationToRange(viewFrom, viewTo, htmlTagNameDecoration);
|
||||
break;
|
||||
case 'TableHeader':
|
||||
addDecorationToLines(viewFrom, viewTo, tableHeaderDecoration);
|
||||
break;
|
||||
case 'TableDelimiter':
|
||||
addDecorationToLines(viewFrom, viewTo, tableDelimiterDecoration);
|
||||
break;
|
||||
case 'TableRow':
|
||||
addDecorationToLines(viewFrom, viewTo, tableBodyDecoration);
|
||||
break;
|
||||
case 'HorizontalRule':
|
||||
addDecorationToRange(viewFrom, viewTo, horizontalRuleDecoration);
|
||||
break;
|
||||
case 'TaskMarker':
|
||||
addDecorationToRange(viewFrom, viewTo, taskMarkerDecoration);
|
||||
break;
|
||||
}
|
||||
|
||||
// Only block decorations will have differing first and last lines
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import { ListType } from '../types';
|
||||
import createEditor from './testUtil/createEditor';
|
||||
import { ListType } from '../../types';
|
||||
import createTestEditor from '../testUtil/createTestEditor';
|
||||
import { toggleList } from './markdownCommands';
|
||||
|
||||
describe('markdownCommands.bulletedVsChecklist', () => {
|
||||
@@ -13,7 +13,7 @@ describe('markdownCommands.bulletedVsChecklist', () => {
|
||||
const expectedTags = ['BulletList', 'Task'];
|
||||
|
||||
it('should remove a checklist following a bulleted list without modifying the bulleted list', async () => {
|
||||
const editor = await createEditor(
|
||||
const editor = await createTestEditor(
|
||||
initialDocText, EditorSelection.cursor(bulletedListPart.length + 5), expectedTags,
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('markdownCommands.bulletedVsChecklist', () => {
|
||||
});
|
||||
|
||||
it('should remove an unordered list following a checklist without modifying the checklist', async () => {
|
||||
const editor = await createEditor(
|
||||
const editor = await createTestEditor(
|
||||
initialDocText, EditorSelection.cursor(bulletedListPart.length - 5), expectedTags,
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('markdownCommands.bulletedVsChecklist', () => {
|
||||
});
|
||||
|
||||
it('should replace a selection of unordered and task lists with a correctly-numbered list', async () => {
|
||||
const editor = await createEditor(
|
||||
const editor = await createTestEditor(
|
||||
initialDocText, EditorSelection.range(0, initialDocText.length), expectedTags,
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EditorSelection } from '@codemirror/state';
|
||||
import {
|
||||
toggleBolded, toggleCode, toggleHeaderLevel, toggleItalicized, toggleMath, updateLink,
|
||||
} from './markdownCommands';
|
||||
import createEditor from './testUtil/createEditor';
|
||||
import createTestEditor from '../testUtil/createTestEditor';
|
||||
import { blockMathTagName } from './markdownMathParser';
|
||||
|
||||
describe('markdownCommands', () => {
|
||||
@@ -11,7 +11,7 @@ describe('markdownCommands', () => {
|
||||
|
||||
it('should bold/italicize everything selected', async () => {
|
||||
const initialDocText = 'Testing...';
|
||||
const editor = await createEditor(
|
||||
const editor = await createTestEditor(
|
||||
initialDocText, EditorSelection.range(0, initialDocText.length), [],
|
||||
);
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('markdownCommands', () => {
|
||||
|
||||
it('for a cursor, bolding, then italicizing, should produce a bold-italic region', async () => {
|
||||
const initialDocText = '';
|
||||
const editor = await createEditor(
|
||||
const editor = await createTestEditor(
|
||||
initialDocText, EditorSelection.cursor(0), [],
|
||||
);
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('markdownCommands', () => {
|
||||
|
||||
it('toggling math should both create and navigate out of math regions', async () => {
|
||||
const initialDocText = 'Testing... ';
|
||||
const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
|
||||
const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
|
||||
|
||||
toggleMath(editor);
|
||||
expect(editor.state.doc.toString()).toBe('Testing... $$');
|
||||
@@ -72,7 +72,7 @@ describe('markdownCommands', () => {
|
||||
|
||||
it('toggling inline code should both create and navigate out of an inline code region', async () => {
|
||||
const initialDocText = 'Testing...\n\n';
|
||||
const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
|
||||
const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
|
||||
|
||||
toggleCode(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
|
||||
@@ -84,7 +84,7 @@ describe('markdownCommands', () => {
|
||||
|
||||
it('should set headers to the proper levels (when toggling)', async () => {
|
||||
const initialDocText = 'Testing...\nThis is a test.';
|
||||
const editor = await createEditor(initialDocText, EditorSelection.cursor(3), []);
|
||||
const editor = await createTestEditor(initialDocText, EditorSelection.cursor(3), []);
|
||||
|
||||
toggleHeaderLevel(1)(editor);
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('markdownCommands', () => {
|
||||
|
||||
it('headers should toggle properly within block quotes', async () => {
|
||||
const initialDocText = 'Testing...\n\n> This is a test.\n> ...a test';
|
||||
const editor = await createEditor(
|
||||
const editor = await createTestEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor('Testing...\n\n> This'.length),
|
||||
['Blockquote'],
|
||||
@@ -134,7 +134,7 @@ describe('markdownCommands', () => {
|
||||
|
||||
it('block math should be created correctly within block quotes', async () => {
|
||||
const initialDocText = 'Testing...\n\n> This is a test.\n> y = mx + b\n> ...a test';
|
||||
const editor = await createEditor(
|
||||
const editor = await createTestEditor(
|
||||
initialDocText,
|
||||
EditorSelection.range(
|
||||
'Testing...\n\n> This'.length,
|
||||
@@ -157,7 +157,7 @@ describe('markdownCommands', () => {
|
||||
it('block math should be correctly removed within block quotes', async () => {
|
||||
const initialDocText = 'Testing...\n\n> $$\n> This is a test.\n> y = mx + b\n> $$\n> ...a test';
|
||||
|
||||
const editor = await createEditor(
|
||||
const editor = await createTestEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor('Testing...\n\n> $$\n> This is'.length),
|
||||
['Blockquote', blockMathTagName],
|
||||
@@ -173,7 +173,7 @@ describe('markdownCommands', () => {
|
||||
|
||||
it('updateLink should replace link titles and isolate URLs if no title is given', async () => {
|
||||
const initialDocText = '[foo](http://example.com/)';
|
||||
const editor = await createEditor(initialDocText, EditorSelection.cursor('[f'.length), ['Link']);
|
||||
const editor = await createTestEditor(initialDocText, EditorSelection.cursor('[f'.length), ['Link']);
|
||||
|
||||
updateLink('bar', 'https://example.com/')(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
@@ -188,7 +188,7 @@ describe('markdownCommands', () => {
|
||||
|
||||
it('toggling math twice, starting on a line with content, should a math block', async () => {
|
||||
const initialDocText = 'Testing... ';
|
||||
const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
|
||||
const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
|
||||
|
||||
toggleMath(editor);
|
||||
toggleMath(editor);
|
||||
@@ -198,7 +198,7 @@ describe('markdownCommands', () => {
|
||||
|
||||
it('toggling math twice on an empty line should create an empty math block', async () => {
|
||||
const initialDocText = 'Testing...\n\n';
|
||||
const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
|
||||
const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
|
||||
|
||||
toggleMath(editor);
|
||||
toggleMath(editor);
|
||||
@@ -208,7 +208,7 @@ describe('markdownCommands', () => {
|
||||
|
||||
it('toggling code twice on an empty line should create an empty code block', async () => {
|
||||
const initialDocText = 'Testing...\n\n';
|
||||
const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
|
||||
const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []);
|
||||
|
||||
// Toggling code twice should create a block code region
|
||||
toggleCode(editor);
|
||||
@@ -222,7 +222,7 @@ describe('markdownCommands', () => {
|
||||
|
||||
it('toggling math twice inside a block quote should produce an empty math block', async () => {
|
||||
const initialDocText = '> Testing...> \n> ';
|
||||
const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), ['Blockquote']);
|
||||
const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), ['Blockquote']);
|
||||
|
||||
toggleMath(editor);
|
||||
toggleMath(editor);
|
||||
@@ -2,8 +2,8 @@ import { EditorSelection, EditorState } from '@codemirror/state';
|
||||
import {
|
||||
increaseIndent, toggleList,
|
||||
} from './markdownCommands';
|
||||
import { ListType } from '../types';
|
||||
import createEditor from './testUtil/createEditor';
|
||||
import { ListType } from '../../types';
|
||||
import createTestEditor from '../testUtil/createTestEditor';
|
||||
|
||||
describe('markdownCommands.toggleList', () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('markdownCommands.toggleList', () => {
|
||||
it('should remove the same type of list', async () => {
|
||||
const initialDocText = '- testing\n- this is a `test`\n';
|
||||
|
||||
const editor = await createEditor(
|
||||
const editor = await createTestEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor(5),
|
||||
['BulletList', 'InlineCode'],
|
||||
@@ -26,7 +26,7 @@ describe('markdownCommands.toggleList', () => {
|
||||
|
||||
it('should insert a numbered list with correct numbering', async () => {
|
||||
const initialDocText = 'Testing...\nThis is a test\nof list toggling...';
|
||||
const editor = await createEditor(
|
||||
const editor = await createTestEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor('Testing...\nThis is a'.length),
|
||||
[],
|
||||
@@ -51,7 +51,7 @@ describe('markdownCommands.toggleList', () => {
|
||||
const unorderedListText = '- 1\n- 2\n- 3\n- 4\n- 5\n- 6\n- 7';
|
||||
|
||||
it('should correctly replace an unordered list with a numbered list', async () => {
|
||||
const editor = await createEditor(
|
||||
const editor = await createTestEditor(
|
||||
unorderedListText,
|
||||
EditorSelection.cursor(unorderedListText.length),
|
||||
['BulletList'],
|
||||
@@ -154,7 +154,7 @@ describe('markdownCommands.toggleList', () => {
|
||||
it('should toggle a numbered list without changing its sublists', async () => {
|
||||
const initialDocText = '1. Foo\n2. Bar\n3. Baz\n\t- Test\n\t- of\n\t- sublists\n4. Foo';
|
||||
|
||||
const editor = await createEditor(
|
||||
const editor = await createTestEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor(0),
|
||||
['OrderedList', 'BulletList'],
|
||||
@@ -169,7 +169,7 @@ describe('markdownCommands.toggleList', () => {
|
||||
it('should toggle a sublist without changing the parent list', async () => {
|
||||
const initialDocText = '1. This\n2. is\n3. ';
|
||||
|
||||
const editor = await createEditor(
|
||||
const editor = await createTestEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor(initialDocText.length),
|
||||
['OrderedList'],
|
||||
@@ -192,7 +192,7 @@ describe('markdownCommands.toggleList', () => {
|
||||
it('should toggle lists properly within block quotes', async () => {
|
||||
const preSubListText = '> # List test\n> * This\n> * is\n';
|
||||
const initialDocText = `${preSubListText}> \t* a\n> \t* test\n> * of list toggling`;
|
||||
const editor = await createEditor(
|
||||
const editor = await createTestEditor(
|
||||
initialDocText, EditorSelection.cursor(preSubListText.length + 3),
|
||||
['BlockQuote', 'BulletList'],
|
||||
);
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { EditorView, Command } from '@codemirror/view';
|
||||
|
||||
import { ListType } from '../types';
|
||||
import { ListType } from '../../types';
|
||||
import {
|
||||
SelectionRange, EditorSelection, ChangeSpec, Line, TransactionSpec,
|
||||
} from '@codemirror/state';
|
||||
@@ -3,11 +3,11 @@ import { SyntaxNode } from '@lezer/common';
|
||||
import { EditorSelection, EditorState } from '@codemirror/state';
|
||||
import { blockMathTagName, inlineMathContentTagName, inlineMathTagName } from './markdownMathParser';
|
||||
|
||||
import createEditor from './testUtil/createEditor';
|
||||
import createTestEditor from '../testUtil/createTestEditor';
|
||||
|
||||
// Creates an EditorState with math and markdown extensions
|
||||
const createEditorState = async (initialText: string, expectedTags: string[]): Promise<EditorState> => {
|
||||
return (await createEditor(initialText, EditorSelection.cursor(0), expectedTags)).state;
|
||||
return (await createTestEditor(initialText, EditorSelection.cursor(0), expectedTags)).state;
|
||||
};
|
||||
|
||||
// Returns a list of all nodes with the given name in the given editor's syntax tree.
|
||||
24
packages/editor/CodeMirror/testUtil/createEditorSettings.ts
Normal file
24
packages/editor/CodeMirror/testUtil/createEditorSettings.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { EditorKeymap, EditorLanguageType, EditorSettings } from '../../types';
|
||||
|
||||
const createEditorSettings = (themeId: number) => {
|
||||
const themeData = themeStyle(themeId);
|
||||
const editorSettings: EditorSettings = {
|
||||
katexEnabled: true,
|
||||
spellcheckEnabled: true,
|
||||
useExternalSearch: true,
|
||||
readOnly: false,
|
||||
automatchBraces: false,
|
||||
ignoreModifiers: false,
|
||||
|
||||
keymap: EditorKeymap.Default,
|
||||
language: EditorLanguageType.Markdown,
|
||||
themeData,
|
||||
|
||||
indentWithTabs: true,
|
||||
};
|
||||
|
||||
return editorSettings;
|
||||
};
|
||||
|
||||
export default createEditorSettings;
|
||||
@@ -3,13 +3,13 @@ import { GFM as GithubFlavoredMarkdownExt } from '@lezer/markdown';
|
||||
import { indentUnit, syntaxTree } from '@codemirror/language';
|
||||
import { SelectionRange, EditorSelection, EditorState } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { MarkdownMathExtension } from '../markdownMathParser';
|
||||
import { MarkdownMathExtension } from '../markdown/markdownMathParser';
|
||||
import forceFullParse from './forceFullParse';
|
||||
import loadLangauges from './loadLanguages';
|
||||
|
||||
// Creates and returns a minimal editor with markdown extensions. Waits to return the editor
|
||||
// until all syntax tree tags in `expectedSyntaxTreeTags` exist.
|
||||
const createEditor = async (
|
||||
const createTestEditor = async (
|
||||
initialText: string, initialSelection: SelectionRange, expectedSyntaxTreeTags: string[],
|
||||
): Promise<EditorView> => {
|
||||
await loadLangauges();
|
||||
@@ -62,4 +62,4 @@ const createEditor = async (
|
||||
return editor;
|
||||
};
|
||||
|
||||
export default createEditor;
|
||||
export default createTestEditor;
|
||||
@@ -1,4 +1,4 @@
|
||||
import syntaxHighlightingLanguages from '../syntaxHighlightingLanguages';
|
||||
import syntaxHighlightingLanguages from '../markdown/syntaxHighlightingLanguages';
|
||||
|
||||
// Ensure languages we use are loaded. Without this, tests may randomly fail (LanguageDescriptions
|
||||
// are loaded asyncronously, in the background).
|
||||
@@ -8,7 +8,7 @@ import { tags } from '@lezer/highlight';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Extension } from '@codemirror/state';
|
||||
|
||||
import { inlineMathTag, mathTag } from './markdownMathParser';
|
||||
import { inlineMathTag, mathTag } from './markdown/markdownMathParser';
|
||||
|
||||
// For an example on how to customize the theme, see:
|
||||
//
|
||||
@@ -25,6 +25,12 @@ import { inlineMathTag, mathTag } from './markdownMathParser';
|
||||
//
|
||||
// [theme] should be a joplin theme (see @joplin/lib/theme)
|
||||
const createTheme = (theme: any): Extension[] => {
|
||||
// If the theme hasn't loaded yet, return nothing.
|
||||
// (createTheme should be called again after the theme has loaded).
|
||||
if (!theme) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const isDarkTheme = theme.appearance === 'dark';
|
||||
|
||||
const baseGlobalStyle: Record<string, string> = {
|
||||
@@ -34,15 +40,19 @@ const createTheme = (theme: any): Extension[] => {
|
||||
// On iOS, apply system font scaling (e.g. font scaling
|
||||
// set in accessibility settings).
|
||||
font: '-apple-system-body',
|
||||
|
||||
// Fill container horizontally
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
const baseCursorStyle: Record<string, string> = { };
|
||||
const baseContentStyle: Record<string, string> = {
|
||||
fontFamily: theme.fontFamily,
|
||||
fontSize: `${theme.fontSize}${theme.fontSizeUnits ?? 'px'}`,
|
||||
|
||||
// To allow accessibility font scaling, we also need to set the
|
||||
// fontSize to a value in `em`s (relative scaling relative to
|
||||
// parent font size).
|
||||
fontSize: `${theme.fontSize}em`,
|
||||
// Avoid using units here -- 1.55em, for example, can cause lines to overlap
|
||||
// if some lines contain text with a large enough font size.
|
||||
lineHeight: theme.isDesktop ? '1.55' : undefined,
|
||||
};
|
||||
const baseSelectionStyle: Record<string, string> = { };
|
||||
const blurredSelectionStyle: Record<string, string> = { };
|
||||
@@ -60,17 +70,42 @@ const createTheme = (theme: any): Extension[] => {
|
||||
blurredSelectionStyle.backgroundColor = '#444';
|
||||
}
|
||||
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
const monospaceStyle = {
|
||||
fontFamily: theme.monospaceFont || 'monospace',
|
||||
};
|
||||
|
||||
// This is equivalent to the default selection style -- our styling must
|
||||
// be at least this specific.
|
||||
const selectionBackgroundSelector = '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground';
|
||||
|
||||
const codeMirrorTheme = EditorView.theme({
|
||||
'&': baseGlobalStyle,
|
||||
|
||||
// These must be !important or more specific than CodeMirror's built-ins
|
||||
'.cm-content': {
|
||||
fontFamily: theme.fontFamily,
|
||||
...baseContentStyle,
|
||||
paddingBottom: theme.isDesktop ? '400px' : undefined,
|
||||
},
|
||||
'&.cm-focused .cm-cursor': baseCursorStyle,
|
||||
'&.cm-focused .cm-selectionBackground, ::selection': baseSelectionStyle,
|
||||
'.cm-selectionBackground': blurredSelectionStyle,
|
||||
|
||||
// The desktop app sets the font for these elements to a specific font.
|
||||
// Override this.
|
||||
'& div, & span, & a': {
|
||||
fontFamily: 'inherit',
|
||||
},
|
||||
|
||||
// Override the default border around CodeMirror panels
|
||||
'& > .cm-panels': {
|
||||
border: 'none',
|
||||
},
|
||||
|
||||
// &.cm-focused is used to give these styles higher specificity
|
||||
// than the defaults.
|
||||
[selectionBackgroundSelector]: baseSelectionStyle,
|
||||
'&.cm-focused ::selection': baseSelectionStyle,
|
||||
'& ::selection': blurredSelectionStyle,
|
||||
'& .cm-selectionLayer .cm-selectionBackground': blurredSelectionStyle,
|
||||
|
||||
'&.cm-editor.cm-focused': {
|
||||
outline: 'none !important',
|
||||
@@ -101,6 +136,8 @@ const createTheme = (theme: any): Extension[] => {
|
||||
borderStyle: 'solid',
|
||||
borderColor: theme.colorFaded,
|
||||
backgroundColor: 'rgba(155, 155, 155, 0.1)',
|
||||
|
||||
...(theme.isDesktop ? monospaceStyle : {}),
|
||||
},
|
||||
|
||||
// CodeMirror wraps the existing inline span in an additional element.
|
||||
@@ -113,12 +150,21 @@ const createTheme = (theme: any): Extension[] => {
|
||||
borderStyle: 'solid',
|
||||
borderColor: isDarkTheme ? 'rgba(200, 200, 200, 0.5)' : 'rgba(100, 100, 100, 0.5)',
|
||||
borderRadius: '4px',
|
||||
|
||||
...(theme.isDesktop ? monospaceStyle : {}),
|
||||
},
|
||||
|
||||
'& .cm-mathBlock, & .cm-inlineMath': {
|
||||
color: isDarkTheme ? '#9fa' : '#276',
|
||||
},
|
||||
|
||||
'& .cm-tableHeader, & .cm-tableRow, & .cm-tableDelimiter': monospaceStyle,
|
||||
'& .cm-taskMarker': monospaceStyle,
|
||||
|
||||
// Override the default URL style when the URL is within a link
|
||||
'& .tok-url.tok-link, & .tok-link.tok-meta, & .tok-link.tok-string': {
|
||||
opacity: theme.isDesktop ? 0.6 : 1,
|
||||
},
|
||||
|
||||
// Style the search widget. Use ':root' to increase the selector's precedence
|
||||
// (override the existing preset styles).
|
||||
@@ -128,9 +174,7 @@ const createTheme = (theme: any): Extension[] => {
|
||||
color: isDarkTheme ? 'white' : 'black',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const appearanceTheme = EditorView.theme({}, { dark: isDarkTheme });
|
||||
}, { dark: isDarkTheme });
|
||||
|
||||
const baseHeadingStyle = {
|
||||
fontWeight: 'bold',
|
||||
@@ -150,7 +194,6 @@ const createTheme = (theme: any): Extension[] => {
|
||||
...baseHeadingStyle,
|
||||
tag: tags.heading1,
|
||||
fontSize: '1.6em',
|
||||
borderBottom: `1px solid ${theme.dividerColor}`,
|
||||
},
|
||||
{
|
||||
...baseHeadingStyle,
|
||||
@@ -189,7 +232,7 @@ const createTheme = (theme: any): Extension[] => {
|
||||
{
|
||||
tag: tags.link,
|
||||
color: theme.urlColor,
|
||||
textDecoration: 'underline',
|
||||
textDecoration: theme.isDesktop ? undefined : 'underline',
|
||||
},
|
||||
{
|
||||
tag: [mathTag, inlineMathTag],
|
||||
@@ -220,8 +263,7 @@ const createTheme = (theme: any): Extension[] => {
|
||||
]);
|
||||
|
||||
return [
|
||||
baseTheme,
|
||||
appearanceTheme,
|
||||
codeMirrorTheme,
|
||||
syntaxHighlighting(highlightingStyle),
|
||||
|
||||
// If we haven't defined highlighting for tags, fall back
|
||||
73
packages/editor/SelectionFormatting.ts
Normal file
73
packages/editor/SelectionFormatting.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Stores information about the current content of the user's selection
|
||||
export interface MutableSelectionFormatting {
|
||||
bolded: boolean;
|
||||
italicized: boolean;
|
||||
inChecklist: boolean;
|
||||
inCode: boolean;
|
||||
inUnorderedList: boolean;
|
||||
inOrderedList: boolean;
|
||||
inMath: boolean;
|
||||
inLink: boolean;
|
||||
spellChecking: boolean;
|
||||
unspellCheckableRegion: boolean;
|
||||
|
||||
// Link data, both fields are null if not in a link.
|
||||
linkData: {
|
||||
readonly linkText: string|null;
|
||||
readonly linkURL: string|null;
|
||||
};
|
||||
|
||||
// If [headerLevel], [listLevel], etc. are zero, then the
|
||||
// selection isn't in a header/list
|
||||
headerLevel: number;
|
||||
listLevel: number;
|
||||
|
||||
// Content of the selection
|
||||
selectedText: string;
|
||||
}
|
||||
type SelectionFormatting = Readonly<MutableSelectionFormatting>;
|
||||
export default SelectionFormatting;
|
||||
|
||||
export const defaultSelectionFormatting: SelectionFormatting = {
|
||||
bolded: false,
|
||||
italicized: false,
|
||||
inChecklist: false,
|
||||
inCode: false,
|
||||
inUnorderedList: false,
|
||||
inOrderedList: false,
|
||||
inMath: false,
|
||||
inLink: false,
|
||||
spellChecking: false,
|
||||
unspellCheckableRegion: false,
|
||||
|
||||
linkData: {
|
||||
linkText: null,
|
||||
linkURL: null,
|
||||
},
|
||||
|
||||
headerLevel: 0,
|
||||
listLevel: 0,
|
||||
|
||||
selectedText: '',
|
||||
};
|
||||
|
||||
export const selectionFormattingEqual = (a: SelectionFormatting, b: SelectionFormatting): boolean => {
|
||||
// Get keys from the default so that only SelectionFormatting key/value pairs are
|
||||
// considered. If a and/or b inherit from SelectionFormatting, we want to ignore
|
||||
// keys added by child interfaces.
|
||||
const keys = Object.keys(defaultSelectionFormatting) as (keyof SelectionFormatting)[];
|
||||
|
||||
for (const key of keys) {
|
||||
if (key === 'linkData') {
|
||||
// A deeper check is required for linkData
|
||||
if (a[key].linkText !== b[key].linkText || a[key].linkURL !== b[key].linkURL) {
|
||||
return false;
|
||||
}
|
||||
} else if (a[key] !== b[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
65
packages/editor/events.ts
Normal file
65
packages/editor/events.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type SelectionFormatting from './SelectionFormatting';
|
||||
import type { SearchState } from './types';
|
||||
|
||||
export enum EditorEventType {
|
||||
Change,
|
||||
UndoRedoDepthChange,
|
||||
SelectionRangeChange,
|
||||
SelectionFormattingChange,
|
||||
UpdateSearchDialog,
|
||||
EditLink,
|
||||
Scroll,
|
||||
}
|
||||
|
||||
export interface ChangeEvent {
|
||||
kind: EditorEventType.Change;
|
||||
|
||||
// New editor content
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface UndoRedoDepthChangeEvent {
|
||||
kind: EditorEventType.UndoRedoDepthChange;
|
||||
|
||||
undoDepth: number;
|
||||
redoDepth: number;
|
||||
}
|
||||
|
||||
export interface SelectionRangeChangeEvent {
|
||||
kind: EditorEventType.SelectionRangeChange;
|
||||
|
||||
anchor: number;
|
||||
head: number;
|
||||
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export interface SelectionFormattingChangeEvent {
|
||||
kind: EditorEventType.SelectionFormattingChange;
|
||||
formatting: SelectionFormatting;
|
||||
}
|
||||
|
||||
export interface EditorScrolledEvent {
|
||||
kind: EditorEventType.Scroll;
|
||||
|
||||
// A fraction from 0 to 1, where 1 corresponds to the end of the document
|
||||
fraction: number;
|
||||
}
|
||||
|
||||
export interface UpdateSearchDialogEvent {
|
||||
kind: EditorEventType.UpdateSearchDialog;
|
||||
searchState: SearchState;
|
||||
}
|
||||
|
||||
export interface RequestEditLinkEvent {
|
||||
kind: EditorEventType.EditLink;
|
||||
}
|
||||
|
||||
|
||||
export type EditorEvent =
|
||||
ChangeEvent|UndoRedoDepthChangeEvent|SelectionRangeChangeEvent|
|
||||
EditorScrolledEvent|
|
||||
SelectionFormattingChangeEvent|UpdateSearchDialogEvent|
|
||||
RequestEditLinkEvent;
|
||||
|
||||
19
packages/editor/jest.config.js
Normal file
19
packages/editor/jest.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
'moduleFileExtensions': [
|
||||
'ts',
|
||||
'tsx',
|
||||
'js',
|
||||
'jsx',
|
||||
],
|
||||
|
||||
'transform': {
|
||||
'\\.(ts|tsx)$': 'ts-jest',
|
||||
},
|
||||
|
||||
testEnvironment: 'jsdom',
|
||||
testMatch: ['**/*.test.(ts|tsx)'],
|
||||
setupFilesAfterEnv: ['./jest.setup.js'],
|
||||
|
||||
testPathIgnorePatterns: ['<rootDir>/node_modules/'],
|
||||
slowTestThreshold: 40,
|
||||
};
|
||||
16
packages/editor/jest.setup.js
Normal file
16
packages/editor/jest.setup.js
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
// Prevents the CodeMirror error "getClientRects is undefined".
|
||||
// See https://github.com/jsdom/jsdom/issues/3002#issue-652790925
|
||||
document.createRange = () => {
|
||||
const range = new Range();
|
||||
range.getBoundingClientRect = jest.fn();
|
||||
range.getClientRects = () => {
|
||||
return {
|
||||
length: 0,
|
||||
item: () => null,
|
||||
[Symbol.iterator]: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
return range;
|
||||
};
|
||||
47
packages/editor/package.json
Normal file
47
packages/editor/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@joplin/editor",
|
||||
"version": "2.13.0",
|
||||
"description": "Web-based markdown editor",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"tsc": "tsc --project tsconfig.json",
|
||||
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json",
|
||||
"postinstall": "yarn run tsc",
|
||||
"test": "jest",
|
||||
"test-ci": "yarn test"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/laurent22/joplin.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/lib": "~2.13",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.3",
|
||||
"@types/react": "18.0.24",
|
||||
"@types/react-redux": "7.1.26",
|
||||
"@types/styled-components": "5.1.26",
|
||||
"jest": "29.5.0",
|
||||
"jest-environment-jsdom": "29.5.0",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "6.9.0",
|
||||
"@codemirror/commands": "6.2.5",
|
||||
"@codemirror/lang-cpp": "6.0.2",
|
||||
"@codemirror/lang-html": "6.4.6",
|
||||
"@codemirror/lang-java": "6.0.1",
|
||||
"@codemirror/lang-javascript": "6.2.1",
|
||||
"@codemirror/lang-markdown": "6.2.1",
|
||||
"@codemirror/lang-php": "6.0.1",
|
||||
"@codemirror/lang-rust": "6.0.1",
|
||||
"@codemirror/language": "6.9.0",
|
||||
"@codemirror/legacy-modes": "6.3.3",
|
||||
"@codemirror/search": "6.5.2",
|
||||
"@codemirror/state": "6.2.1",
|
||||
"@codemirror/view": "6.18.0",
|
||||
"@lezer/markdown": "1.1.0",
|
||||
"@replit/codemirror-vim": "6.0.14"
|
||||
}
|
||||
}
|
||||
11
packages/editor/tsconfig.json
Normal file
11
packages/editor/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules",
|
||||
"**/dist"
|
||||
]
|
||||
}
|
||||
163
packages/editor/types.ts
Normal file
163
packages/editor/types.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { Theme } from '@joplin/lib/themes/type';
|
||||
import type { EditorEvent } from './events';
|
||||
|
||||
// Editor commands. For compatibility, the string values of these commands
|
||||
// should correspond with the CodeMirror 5 commands:
|
||||
// https://codemirror.net/5/doc/manual.html#commands
|
||||
export enum EditorCommandType {
|
||||
Undo = 'undo',
|
||||
Redo = 'redo',
|
||||
SelectAll = 'selectAll',
|
||||
Focus = 'focus',
|
||||
|
||||
// Formatting editor commands
|
||||
ToggleBolded = 'textBold',
|
||||
ToggleItalicized = 'textItalic',
|
||||
ToggleCode = 'textCode',
|
||||
ToggleMath = 'textMath',
|
||||
|
||||
ToggleNumberedList = 'textNumberedList',
|
||||
ToggleBulletedList = 'textBulletedList',
|
||||
ToggleCheckList = 'textCheckbox',
|
||||
|
||||
ToggleHeading = 'textHeading',
|
||||
ToggleHeading1 = 'textHeading1',
|
||||
ToggleHeading2 = 'textHeading2',
|
||||
ToggleHeading3 = 'textHeading3',
|
||||
ToggleHeading4 = 'textHeading4',
|
||||
ToggleHeading5 = 'textHeading5',
|
||||
|
||||
// Find commands
|
||||
ShowSearch = 'find',
|
||||
HideSearch = 'hideSearchDialog',
|
||||
FindNext = 'findNext',
|
||||
FindPrevious = 'findPrev',
|
||||
ReplaceNext = 'replace',
|
||||
ReplaceAll = 'replaceAll',
|
||||
|
||||
// Editing and navigation commands
|
||||
ScrollSelectionIntoView = 'scrollSelectionIntoView',
|
||||
DeleteToLineEnd = 'killLine',
|
||||
DeleteToLineStart = 'delLineLeft',
|
||||
IndentMore = 'indentMore',
|
||||
IndentLess = 'indentLess',
|
||||
IndentAuto = 'indentAuto',
|
||||
InsertNewlineAndIndent = 'newlineAndIndent',
|
||||
|
||||
SwapLineUp = 'swapLineUp',
|
||||
SwapLineDown = 'swapLineDown',
|
||||
|
||||
GoDocEnd = 'goDocEnd',
|
||||
GoDocStart = 'goDocStart',
|
||||
GoLineStart = 'goLineStart',
|
||||
GoLineEnd = 'goLineEnd',
|
||||
GoLineUp = 'goLineUp',
|
||||
GoLineDown = 'goLineDown',
|
||||
GoPageUp = 'goPageUp',
|
||||
GoPageDown = 'goPageDown',
|
||||
GoCharLeft = 'goCharLeft',
|
||||
GoCharRight = 'goCharRight',
|
||||
|
||||
UndoSelection = 'undoSelection',
|
||||
RedoSelection = 'redoSelection',
|
||||
}
|
||||
|
||||
// Because the editor package can run in a WebView, plugin content scripts
|
||||
// need to be provided as text, rather than as file paths.
|
||||
export interface PluginData {
|
||||
pluginId: string;
|
||||
contentScriptId: string;
|
||||
contentScriptJs: ()=> Promise<string>;
|
||||
postMessageHandler: (message: any)=> any;
|
||||
}
|
||||
|
||||
export interface EditorControl {
|
||||
supportsCommand(name: EditorCommandType|string): boolean;
|
||||
execCommand(name: EditorCommandType|string): void;
|
||||
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
|
||||
select(anchor: number, head: number): void;
|
||||
|
||||
// 0 corresponds to the top, 1 corresponds to the bottom.
|
||||
setScrollPercent(fraction: number): void;
|
||||
|
||||
insertText(text: string): void;
|
||||
updateBody(newBody: string): void;
|
||||
|
||||
updateSettings(newSettings: EditorSettings): void;
|
||||
|
||||
// Create a new link or update the currently selected link with
|
||||
// the given [label] and [url].
|
||||
updateLink(label: string, url: string): void;
|
||||
|
||||
setSearchState(state: SearchState): void;
|
||||
|
||||
setPlugins(plugins: PluginData[]): Promise<void>;
|
||||
}
|
||||
|
||||
export enum EditorLanguageType {
|
||||
Markdown,
|
||||
Html,
|
||||
}
|
||||
|
||||
export enum EditorKeymap {
|
||||
Default = 'default',
|
||||
Vim = 'vim',
|
||||
Emacs = 'emacs',
|
||||
}
|
||||
|
||||
export interface EditorSettings {
|
||||
// EditorSettings objects are deserialized within WebViews, where
|
||||
// [themeStyle(themeId: number)] doesn't work. As such, we need both
|
||||
// a Theme must be provided.
|
||||
themeData: Theme;
|
||||
|
||||
// True if the search panel is implemented outside of the editor (e.g. with
|
||||
// React Native).
|
||||
useExternalSearch: boolean;
|
||||
|
||||
automatchBraces: boolean;
|
||||
|
||||
// True if internal command keyboard shortcuts should be ignored (thus
|
||||
// allowing Joplin shortcuts to run).
|
||||
ignoreModifiers: boolean;
|
||||
|
||||
language: EditorLanguageType;
|
||||
|
||||
keymap: EditorKeymap;
|
||||
|
||||
katexEnabled: boolean;
|
||||
spellcheckEnabled: boolean;
|
||||
readOnly: boolean;
|
||||
|
||||
indentWithTabs: boolean;
|
||||
}
|
||||
|
||||
export type LogMessageCallback = (message: string)=> void;
|
||||
export type OnEventCallback = (event: EditorEvent)=> void;
|
||||
|
||||
export interface EditorProps {
|
||||
settings: EditorSettings;
|
||||
initialText: string;
|
||||
|
||||
onEvent: OnEventCallback;
|
||||
onLogMessage: LogMessageCallback;
|
||||
}
|
||||
|
||||
export interface SearchState {
|
||||
useRegex: boolean;
|
||||
caseSensitive: boolean;
|
||||
|
||||
searchText: string;
|
||||
replaceText: string;
|
||||
dialogVisible: boolean;
|
||||
}
|
||||
|
||||
// Possible types of lists in the editor
|
||||
export enum ListType {
|
||||
CheckList,
|
||||
OrderedList,
|
||||
UnorderedList,
|
||||
}
|
||||
@@ -46,11 +46,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.4",
|
||||
"@types/node": "18.17.12",
|
||||
"@types/node": "18.17.14",
|
||||
"@typescript-eslint/eslint-plugin": "5.62.0",
|
||||
"@typescript-eslint/parser": "5.62.0",
|
||||
"coveralls": "3.1.1",
|
||||
"eslint": "8.46.0",
|
||||
"eslint": "8.47.0",
|
||||
"jest": "29.6.4",
|
||||
"prettier": "2.8.8",
|
||||
"ts-jest": "29.1.1",
|
||||
|
||||
@@ -178,6 +178,8 @@ describe('import-enex-md-gen', () => {
|
||||
expected = expected.replace(/RESOURCE_ID_2/, svgResource.id);
|
||||
|
||||
expect(note.body).toBe(expected);
|
||||
const filePath = `${enexSampleBaseDir}/invalid_html.enex`;
|
||||
await expectThrow(async () => importEnex('', filePath));
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface MarkdownTableHeader {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
filter?: Function;
|
||||
disableEscape?: boolean;
|
||||
disableHtmlEscape?: boolean;
|
||||
justify?: MarkdownTableJustify;
|
||||
}
|
||||
|
||||
@@ -39,10 +40,12 @@ const markdownUtils = {
|
||||
return url;
|
||||
},
|
||||
|
||||
escapeTableCell(text: string) {
|
||||
escapeTableCell(text: string, escapeHtml = true) {
|
||||
// Disable HTML code
|
||||
text = text.replace(/</g, '<');
|
||||
text = text.replace(/>/g, '>');
|
||||
if (escapeHtml) {
|
||||
text = text.replace(/</g, '<');
|
||||
text = text.replace(/>/g, '>');
|
||||
}
|
||||
// Table cells can't contain new lines so replace with <br/>
|
||||
text = text.replace(/\n/g, '<br/>');
|
||||
// "|" is a reserved characters that should be escaped
|
||||
@@ -173,7 +176,7 @@ const markdownUtils = {
|
||||
for (let j = 0; j < headers.length; j++) {
|
||||
const h = headers[j];
|
||||
const value = (h.filter ? h.filter(row[h.name]) : row[h.name]) || '';
|
||||
const valueMd = h.disableEscape ? value : markdownUtils.escapeTableCell(value);
|
||||
const valueMd = h.disableEscape ? value : markdownUtils.escapeTableCell(value, !h.disableHtmlEscape);
|
||||
rowMd.push(stringPadding(valueMd, minCellWidth, ' ', stringPadding.RIGHT));
|
||||
}
|
||||
output.push(`| ${rowMd.join(' | ')} |`);
|
||||
|
||||
@@ -1022,23 +1022,6 @@ class Setting extends BaseModel {
|
||||
'folders.sortOrder.reverse': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] },
|
||||
trackLocation: { value: true, type: SettingItemType.Bool, section: 'note', storage: SettingStorage.File, isGlobal: true, public: true, label: () => _('Save geo-location with notes') },
|
||||
|
||||
// 2020-10-29: For now disable the beta editor due to
|
||||
// underlying bugs in the TextInput component which we cannot
|
||||
// fix. Also the editor crashes in Android and in some cases in
|
||||
// iOS.
|
||||
// https://discourse.joplinapp.org/t/anyone-using-the-beta-editor-on-ios/11658/9
|
||||
'editor.beta': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
section: 'note',
|
||||
public: false,
|
||||
appTypes: [AppType.Mobile],
|
||||
label: () => 'Opt-in to the editor beta',
|
||||
description: () => 'This beta adds list continuation and syntax highlighting. If you find bugs, please report them in the Discourse forum.',
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'editor.usePlainText': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
@@ -1475,6 +1458,20 @@ class Setting extends BaseModel {
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
// 2023-09-07: This setting is now used to track the desktop beta editor. It
|
||||
// was used to track the mobile beta editor previously.
|
||||
'editor.beta': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
section: 'general',
|
||||
public: true,
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => 'Opt-in to the editor beta',
|
||||
description: () => 'This beta adds improved accessibility and plugin API compatibility with the mobile editor. If you find bugs, please report them in the Discourse forum.',
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'net.customCertificates': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
"@types/fs-extra": "11.0.1",
|
||||
"@types/jest": "29.5.4",
|
||||
"@types/js-yaml": "4.0.5",
|
||||
"@types/node": "18.17.12",
|
||||
"@types/node": "18.17.14",
|
||||
"@types/node-rsa": "1.1.1",
|
||||
"@types/react": "18.2.21",
|
||||
"@types/uuid": "9.0.2",
|
||||
"@types/uuid": "9.0.3",
|
||||
"clean-html": "1.5.0",
|
||||
"jest": "29.6.4",
|
||||
"sharp": "0.32.5",
|
||||
|
||||
@@ -1163,10 +1163,12 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
|
||||
break;
|
||||
|
||||
case 'NOTE_LIST_RENDERER_ADD':
|
||||
const noteListRendererIds = draft.noteListRendererIds.slice();
|
||||
if (noteListRendererIds.includes(action.value)) throw new Error('Note list renderer is already registered: ' + action.value);
|
||||
noteListRendererIds.push(action.value);
|
||||
draft.noteListRendererIds = noteListRendererIds;
|
||||
{
|
||||
const noteListRendererIds = draft.noteListRendererIds.slice();
|
||||
if (noteListRendererIds.includes(action.value)) throw new Error(`Note list renderer is already registered: ${action.value}`);
|
||||
noteListRendererIds.push(action.value);
|
||||
draft.noteListRendererIds = noteListRendererIds;
|
||||
}
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
@@ -262,5 +262,39 @@ describe('services_CommandService', () => {
|
||||
await expectNotThrow(async () => service.isEnabled('test1', { cond1: true, cond2: false }));
|
||||
}));
|
||||
|
||||
it('commands should allow specifying an icon', () => {
|
||||
const service = newService();
|
||||
|
||||
const iconName = 'fas fa-check';
|
||||
registerCommand(service, {
|
||||
declaration: {
|
||||
name: 'test-command-with-icon',
|
||||
label: 'Adding icons to commands',
|
||||
iconName,
|
||||
},
|
||||
runtime: {
|
||||
execute: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const command = service.commandByName('test-command-with-icon');
|
||||
expect(command.declaration.iconName).toBe(iconName);
|
||||
});
|
||||
|
||||
it('commands should have a non-empty default icon', () => {
|
||||
const service = newService();
|
||||
|
||||
registerCommand(service, {
|
||||
declaration: {
|
||||
name: 'test1',
|
||||
label: 'Test toolbar icon',
|
||||
},
|
||||
runtime: {
|
||||
execute: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const command = service.commandByName('test1');
|
||||
expect(command.declaration.iconName).toBe('fas fa-cog');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,7 +182,7 @@ export default class CommandService extends BaseService {
|
||||
public registerDeclaration(declaration: CommandDeclaration) {
|
||||
declaration = { ...declaration };
|
||||
if (!declaration.label) declaration.label = '';
|
||||
if (!declaration.iconName) declaration.iconName = '';
|
||||
if (!declaration.iconName) declaration.iconName = 'fas fa-cog';
|
||||
|
||||
this.commands_[declaration.name] = {
|
||||
declaration: declaration,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import markdownUtils, { MarkdownTableHeader, MarkdownTableRow } from '../markdownUtils';
|
||||
import { _ } from '../locale';
|
||||
import { htmlentities } from '@joplin/utils/html';
|
||||
|
||||
type FeatureId = string;
|
||||
|
||||
@@ -12,6 +13,7 @@ export enum PlanName {
|
||||
|
||||
interface PlanFeature {
|
||||
title: string;
|
||||
description?: string;
|
||||
basic: boolean;
|
||||
pro: boolean;
|
||||
teams: boolean;
|
||||
@@ -112,6 +114,8 @@ export function findPrice(prices: StripePublicConfigPrice[], query: FindPriceQue
|
||||
}
|
||||
|
||||
const features = (): Record<FeatureId, PlanFeature> => {
|
||||
const shareNotebookTitle = _('Share a notebook with others');
|
||||
|
||||
return {
|
||||
maxItemSize: {
|
||||
title: _('Max note or attachment size'),
|
||||
@@ -139,6 +143,7 @@ const features = (): Record<FeatureId, PlanFeature> => {
|
||||
},
|
||||
publishNote: {
|
||||
title: _('Publish notes to the internet'),
|
||||
description: 'You can publish a note from the Joplin app. You will get a link that you can share with other users, who can then view the note in their browser.',
|
||||
basic: true,
|
||||
pro: true,
|
||||
teams: true,
|
||||
@@ -157,18 +162,21 @@ const features = (): Record<FeatureId, PlanFeature> => {
|
||||
},
|
||||
collaborate: {
|
||||
title: _('Collaborate on a notebook with others'),
|
||||
description: _('This allows another user to share a notebook with you, and you can then both collaborate on it. It does not however allow you to share a notebook with someone else, unless you have the feature "%s".', shareNotebookTitle),
|
||||
basic: true,
|
||||
pro: true,
|
||||
teams: true,
|
||||
},
|
||||
share: {
|
||||
title: _('Share a notebook with others'),
|
||||
title: shareNotebookTitle,
|
||||
description: 'You can share a notebook with other Joplin Cloud users, who can then view the notes and edit them.',
|
||||
basic: false,
|
||||
pro: true,
|
||||
teams: true,
|
||||
},
|
||||
emailToNote: {
|
||||
title: _('Email to Note'),
|
||||
description: 'You can save your emails in Joplin Cloud by forwarding your emails to a special email address. The subject of the email will become the note title, and the email body will become the note content.',
|
||||
basic: false,
|
||||
pro: true,
|
||||
teams: true,
|
||||
@@ -185,8 +193,15 @@ const features = (): Record<FeatureId, PlanFeature> => {
|
||||
pro: false,
|
||||
teams: true,
|
||||
},
|
||||
sharingAccessControl: {
|
||||
title: _('Sharing access control'),
|
||||
// sharingAccessControl: {
|
||||
// title: _('Sharing access control'),
|
||||
// basic: false,
|
||||
// pro: false,
|
||||
// teams: true,
|
||||
// },
|
||||
sharePermissions: {
|
||||
title: _('Share permissions'),
|
||||
description: 'With this feature you can define whether a notebook you share with someone can be edited or is read-only. It can be useful for example to share documentation that you do not want to be modified.',
|
||||
basic: false,
|
||||
pro: false,
|
||||
teams: true,
|
||||
@@ -235,7 +250,7 @@ export const getFeatureById = (featureId: FeatureId): PlanFeature => {
|
||||
export const getFeaturesByPlan = (planName: PlanName, featureOn: boolean): PlanFeature[] => {
|
||||
const output: PlanFeature[] = [];
|
||||
|
||||
for (const [, v] of Object.entries(features)) {
|
||||
for (const [, v] of Object.entries(features())) {
|
||||
if (v[planName] === featureOn) {
|
||||
output.push(v);
|
||||
}
|
||||
@@ -261,6 +276,7 @@ export const createFeatureTableMd = () => {
|
||||
{
|
||||
name: 'featureLabel',
|
||||
label: 'Feature',
|
||||
disableHtmlEscape: true,
|
||||
},
|
||||
{
|
||||
name: 'basic',
|
||||
@@ -285,9 +301,20 @@ export const createFeatureTableMd = () => {
|
||||
return '✔️';
|
||||
};
|
||||
|
||||
for (const [, feature] of Object.entries(features())) {
|
||||
const makeFeatureLabel = (featureId: string, feature: PlanFeature) => {
|
||||
const output: string[] = [
|
||||
`${htmlentities(feature.title)}`,
|
||||
];
|
||||
if (feature.description) {
|
||||
output.push(`<a data-id=${htmlentities(featureId)} class="feature-title" name="feature-${htmlentities(featureId)}" href="#feature-${htmlentities(featureId)}">i</a>`);
|
||||
output.push(`<div class="feature-description feature-description-${htmlentities(featureId)}">${htmlentities(feature.description)}</div>`);
|
||||
}
|
||||
return output.join('');
|
||||
};
|
||||
|
||||
for (const [id, feature] of Object.entries(features())) {
|
||||
const row: MarkdownTableRow = {
|
||||
featureLabel: feature.title,
|
||||
featureLabel: makeFeatureLabel(id, feature),
|
||||
basic: getCellInfo(PlanName.Basic, feature),
|
||||
pro: getCellInfo(PlanName.Pro, feature),
|
||||
teams: getCellInfo(PlanName.Teams, feature),
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "11.0.1",
|
||||
"@types/jest": "29.5.4",
|
||||
"@types/node": "18.17.12",
|
||||
"@types/node": "18.17.14",
|
||||
"jest": "29.6.4",
|
||||
"source-map-loader": "4.0.1",
|
||||
"typescript": "5.1.6",
|
||||
|
||||
1550
packages/renderer/assets/mermaid/mermaid.min.js
vendored
1550
packages/renderer/assets/mermaid/mermaid.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.4",
|
||||
"@types/node": "18.17.12",
|
||||
"@types/node": "18.17.14",
|
||||
"jest": "29.6.4",
|
||||
"jest-environment-jsdom": "29.6.4",
|
||||
"ts-jest": "29.1.1",
|
||||
@@ -49,7 +49,7 @@
|
||||
"markdown-it-sup": "1.0.0",
|
||||
"markdown-it-toc-done-right": "4.2.0",
|
||||
"md5": "2.3.0",
|
||||
"mermaid": "9.2.2"
|
||||
"mermaid": "10.4.0"
|
||||
},
|
||||
"gitHead": "eb4b0e64eab40a51b0895d3a40a9d8c3cb7b1b14"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user