You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-30 20:39:46 +02:00
Compare commits
19 Commits
note_link_
...
v2.9.2
Author | SHA1 | Date | |
---|---|---|---|
|
2807a32e64 | ||
|
e6b0e20f08 | ||
|
0dc92c17f5 | ||
|
308c7b11c2 | ||
|
75be518d8a | ||
|
9f97a2e910 | ||
|
03c3188a4a | ||
|
bd5ce114a1 | ||
|
d326700d32 | ||
|
5bf949fcb3 | ||
|
9d6d2f770a | ||
|
3e12313f85 | ||
|
358178f83d | ||
|
6ea40c9895 | ||
|
0191de8bb4 | ||
|
a114e1b5f7 | ||
|
cf22ec0c8b | ||
|
e074f099c4 | ||
|
c5ad2975d6 |
@@ -69,6 +69,7 @@ packages/tools/node_modules
|
||||
packages/tools/PortableAppsLauncher
|
||||
packages/turndown-plugin-gfm/
|
||||
packages/turndown/
|
||||
packages/pdf-viewer/dist
|
||||
plugin_types/
|
||||
readme/
|
||||
|
||||
@@ -856,24 +857,66 @@ packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleTwice.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleTwice.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleTwice.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.togglingLists.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.togglingLists.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.togglingLists.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/types.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/types.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/types.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js.map
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.d.ts
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js.map
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.d.ts
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js.map
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.d.ts
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js.map
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.d.ts
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.js
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.js.map
|
||||
packages/app-mobile/components/NoteEditor/types.d.ts
|
||||
packages/app-mobile/components/NoteEditor/types.js
|
||||
packages/app-mobile/components/NoteEditor/types.js.map
|
||||
packages/app-mobile/components/SelectDateTimeDialog.d.ts
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js.map
|
||||
@@ -1909,6 +1952,27 @@ packages/lib/uuid.js.map
|
||||
packages/lib/versionInfo.d.ts
|
||||
packages/lib/versionInfo.js
|
||||
packages/lib/versionInfo.js.map
|
||||
packages/pdf-viewer/Page.d.ts
|
||||
packages/pdf-viewer/Page.js
|
||||
packages/pdf-viewer/Page.js.map
|
||||
packages/pdf-viewer/VerticalPages.d.ts
|
||||
packages/pdf-viewer/VerticalPages.js
|
||||
packages/pdf-viewer/VerticalPages.js.map
|
||||
packages/pdf-viewer/hooks/useIsFocused.d.ts
|
||||
packages/pdf-viewer/hooks/useIsFocused.js
|
||||
packages/pdf-viewer/hooks/useIsFocused.js.map
|
||||
packages/pdf-viewer/hooks/useIsVisible.d.ts
|
||||
packages/pdf-viewer/hooks/useIsVisible.js
|
||||
packages/pdf-viewer/hooks/useIsVisible.js.map
|
||||
packages/pdf-viewer/miniViewer.d.ts
|
||||
packages/pdf-viewer/miniViewer.js
|
||||
packages/pdf-viewer/miniViewer.js.map
|
||||
packages/pdf-viewer/pdfSource.d.ts
|
||||
packages/pdf-viewer/pdfSource.js
|
||||
packages/pdf-viewer/pdfSource.js.map
|
||||
packages/pdf-viewer/pdfSource.test.d.ts
|
||||
packages/pdf-viewer/pdfSource.test.js
|
||||
packages/pdf-viewer/pdfSource.test.js.map
|
||||
packages/plugin-repo-cli/commands/updateRelease.d.ts
|
||||
packages/plugin-repo-cli/commands/updateRelease.js
|
||||
packages/plugin-repo-cli/commands/updateRelease.js.map
|
||||
|
63
.gitignore
vendored
63
.gitignore
vendored
@@ -846,24 +846,66 @@ packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleTwice.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleTwice.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleTwice.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.togglingLists.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.togglingLists.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.togglingLists.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/types.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/types.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/types.js.map
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.d.ts
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js.map
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.d.ts
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js.map
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.d.ts
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js.map
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.d.ts
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js.map
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.d.ts
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.js
|
||||
packages/app-mobile/components/NoteEditor/SelectionFormatting.js.map
|
||||
packages/app-mobile/components/NoteEditor/types.d.ts
|
||||
packages/app-mobile/components/NoteEditor/types.js
|
||||
packages/app-mobile/components/NoteEditor/types.js.map
|
||||
packages/app-mobile/components/SelectDateTimeDialog.d.ts
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js.map
|
||||
@@ -1899,6 +1941,27 @@ packages/lib/uuid.js.map
|
||||
packages/lib/versionInfo.d.ts
|
||||
packages/lib/versionInfo.js
|
||||
packages/lib/versionInfo.js.map
|
||||
packages/pdf-viewer/Page.d.ts
|
||||
packages/pdf-viewer/Page.js
|
||||
packages/pdf-viewer/Page.js.map
|
||||
packages/pdf-viewer/VerticalPages.d.ts
|
||||
packages/pdf-viewer/VerticalPages.js
|
||||
packages/pdf-viewer/VerticalPages.js.map
|
||||
packages/pdf-viewer/hooks/useIsFocused.d.ts
|
||||
packages/pdf-viewer/hooks/useIsFocused.js
|
||||
packages/pdf-viewer/hooks/useIsFocused.js.map
|
||||
packages/pdf-viewer/hooks/useIsVisible.d.ts
|
||||
packages/pdf-viewer/hooks/useIsVisible.js
|
||||
packages/pdf-viewer/hooks/useIsVisible.js.map
|
||||
packages/pdf-viewer/miniViewer.d.ts
|
||||
packages/pdf-viewer/miniViewer.js
|
||||
packages/pdf-viewer/miniViewer.js.map
|
||||
packages/pdf-viewer/pdfSource.d.ts
|
||||
packages/pdf-viewer/pdfSource.js
|
||||
packages/pdf-viewer/pdfSource.js.map
|
||||
packages/pdf-viewer/pdfSource.test.d.ts
|
||||
packages/pdf-viewer/pdfSource.test.js
|
||||
packages/pdf-viewer/pdfSource.test.js.map
|
||||
packages/plugin-repo-cli/commands/updateRelease.d.ts
|
||||
packages/plugin-repo-cli/commands/updateRelease.js
|
||||
packages/plugin-repo-cli/commands/updateRelease.js.map
|
||||
|
@@ -1,4 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Mon, 06 Jun 2022 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 06 Jun 2022 00:00:00 GMT</pubDate><item><title><![CDATA[Joplin 2.8 is available!]]></title><description><![CDATA[<p>As always a lot of changes and new features in this new version available on both desktop and mobile.</p>
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Mon, 08 Aug 2022 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 08 Aug 2022 00:00:00 GMT</pubDate><item><title><![CDATA[Joplin first meetup on 30 August!]]></title><description><![CDATA[<p>We are glad to announce <a href="https://www.meetup.com/joplin/events/287611873/">the first Joplin Meetup</a> that will take place on 30 August 2022 in London!</p>
|
||||
<p>This is an opportunity to meet other Joplin users as well as some of the main contributors, to discuss the apps, or to ask questions and exchange tips and tricks on how to use the app, develop plugins or contribute to the application. Everybody, technical or not, is welcome!</p>
|
||||
<p>We will meet at the Old Thameside Inn next to London Bridge. If the weather allows we will be on the terrace outside, if not inside.</p>
|
||||
<p>More information on the official Meetup page:</p>
|
||||
<p><a href="https://www.meetup.com/joplin/events/287611873/">https://www.meetup.com/joplin/events/287611873/</a></p>
|
||||
]]></description><link>https://joplinapp.org/news/20220808-first-meetup/</link><guid isPermaLink="false">20220808-first-meetup</guid><pubDate>Mon, 08 Aug 2022 00:00:00 GMT</pubDate><twitter-text>Joplin will have its first Meetup on 30 August! Come and join us at the Old Thameside Inn next to London Bridge! https://www.meetup.com/joplin/events/287611873/</twitter-text></item><item><title><![CDATA[Joplin 2.8 is available!]]></title><description><![CDATA[<p>As always a lot of changes and new features in this new version available on both desktop and mobile.</p>
|
||||
<h1>Multiple profile support<a name="multiple-profile-support" href="#multiple-profile-support" class="heading-anchor">🔗</a></h1>
|
||||
<p>Perhaps the most visible change in this version is the support for multiple profiles. You can now create as many application profile as you wish, each with their own settings, and easily switch from one to another. The main use case is to support for example a "work" profile and a "personal" profile, to allow you to keep things independent, and each profile can sync with a different sync target.</p>
|
||||
<p>To create a new profile, open <strong>File > Switch profile</strong> and select <strong>Create new profile</strong>, enter the profile name and press OK. The app will automatically switch to this new profile, which you can now configure.</p>
|
||||
@@ -256,6 +261,4 @@
|
||||
]]></description><link>https://joplinapp.org/news/20210706-140228/</link><guid isPermaLink="false">20210706-140228</guid><pubDate>Tue, 06 Jul 2021 14:02:28 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Any ideas for a Joplin tagline?]]></title><description><![CDATA[<p>I'm going to update the website front page to better showcase the application. I have most of the sections right, but the part I'm still not sure about is the top tagline, so I'm wondering if anyone had any suggestion about it?</p>
|
||||
<p>From what I can see on Google Keep or Evernote for example it should be something like "Use our app to get X or Y benefit", it should be a sentence that directly speaks to the user essentially.</p>
|
||||
<p>So far I have "Your notes, anywhere you are" but I'm not certain that's particularly inspiring. Any other idea about what tagline could be used?</p>
|
||||
]]></description><link>https://joplinapp.org/news/20210705-094247/</link><guid isPermaLink="false">20210705-094247</guid><pubDate>Mon, 05 Jul 2021 09:42:47 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Poll: What's the size of your note collection?]]></title><description><![CDATA[<p>Poll is on the forum:</p>
|
||||
<p><a href="https://discourse.joplinapp.org/t/poll-whats-the-size-of-your-note-collection/18191">https://discourse.joplinapp.org/t/poll-whats-the-size-of-your-note-collection/18191</a></p>
|
||||
]]></description><link>https://joplinapp.org/news/20210624-171844/</link><guid isPermaLink="false">20210624-171844</guid><pubDate>Thu, 24 Jun 2021 17:18:44 GMT</pubDate><twitter-text></twitter-text></item></channel></rss>
|
||||
]]></description><link>https://joplinapp.org/news/20210705-094247/</link><guid isPermaLink="false">20210705-094247</guid><pubDate>Mon, 05 Jul 2021 09:42:47 GMT</pubDate><twitter-text></twitter-text></item></channel></rss>
|
@@ -4,6 +4,10 @@
|
||||
|
||||
* * *
|
||||
|
||||
Joplin will have [its first Meetup on 30 August 2022](https://discourse.joplinapp.org/t/joplin-first-meetup-on-30-august/26808)! Come and join us at the Old Thameside Inn next to London Bridge!
|
||||
|
||||
* * *
|
||||
|
||||
🌞 Joplin participates in **Google Summer of Code 2022**! More info on [the announcement post](https://github.com/laurent22/joplin/blob/dev/readme/news/20220308-gsoc2022-start.md). 🌞
|
||||
|
||||
* * *
|
||||
@@ -83,8 +87,8 @@ A community maintained list of these distributions can be found here: [Unofficia
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1439535?s=96&v=4"/></br>[fbloise](https://github.com/fbloise) | <img width="50" src="https://avatars2.githubusercontent.com/u/49439044?s=96&v=4"/></br>[fourstepper](https://github.com/fourstepper) | <img width="50" src="https://avatars2.githubusercontent.com/u/38898566?s=96&v=4"/></br>[h4sh5](https://github.com/h4sh5) | <img width="50" src="https://avatars2.githubusercontent.com/u/3266447?s=96&v=4"/></br>[iamwillbar](https://github.com/iamwillbar) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/37297218?s=96&v=4"/></br>[Jesssullivan](https://github.com/Jesssullivan) | <img width="50" src="https://avatars2.githubusercontent.com/u/1248504?s=96&v=4"/></br>[joesfer](https://github.com/joesfer) | <img width="50" src="https://avatars2.githubusercontent.com/u/5588131?s=96&v=4"/></br>[kianenigma](https://github.com/kianenigma) | <img width="50" src="https://avatars2.githubusercontent.com/u/24908652?s=96&v=4"/></br>[konishi-t](https://github.com/konishi-t) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/1788010?s=96&v=4"/></br>[maxtruxa](https://github.com/maxtruxa) | <img width="50" src="https://avatars2.githubusercontent.com/u/29300939?s=96&v=4"/></br>[mcejp](https://github.com/mcejp) | <img width="50" src="https://avatars2.githubusercontent.com/u/1168659?s=96&v=4"/></br>[nicholashead](https://github.com/nicholashead) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/5782817?s=96&v=4"/></br>[piccobit](https://github.com/piccobit) | <img width="50" src="https://avatars2.githubusercontent.com/u/77214738?s=96&v=4"/></br>[Polymathic-Company](https://github.com/Polymathic-Company) | <img width="50" src="https://avatars2.githubusercontent.com/u/47742?s=96&v=4"/></br>[ravenscroftj](https://github.com/ravenscroftj) | <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/73081837?s=96&v=4"/></br>[thismarty](https://github.com/thismarty) | <img width="50" src="https://avatars2.githubusercontent.com/u/15859362?s=96&v=4"/></br>[thomasbroussard](https://github.com/thomasbroussard) | | |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/5782817?s=96&v=4"/></br>[piccobit](https://github.com/piccobit) | <img width="50" src="https://avatars2.githubusercontent.com/u/77214738?s=96&v=4"/></br>[Polymathic-Company](https://github.com/Polymathic-Company) | <img width="50" src="https://avatars2.githubusercontent.com/u/47742?s=96&v=4"/></br>[ravenscroftj](https://github.com/ravenscroftj) | <img width="50" src="https://avatars2.githubusercontent.com/u/327998?s=96&v=4"/></br>[sif](https://github.com/sif) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) | <img width="50" src="https://avatars2.githubusercontent.com/u/73081837?s=96&v=4"/></br>[thismarty](https://github.com/thismarty) | <img width="50" src="https://avatars2.githubusercontent.com/u/15859362?s=96&v=4"/></br>[thomasbroussard](https://github.com/thomasbroussard) | |
|
||||
<!-- SPONSORS-GITHUB -->
|
||||
|
||||
<!-- TOC -->
|
||||
|
@@ -614,6 +614,8 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
resourceInfos: props.resourceInfos,
|
||||
contentMaxWidth: props.contentMaxWidth,
|
||||
mapsToLine: true,
|
||||
// Always using useCustomPdfViewer for now, we can add a new setting for it in future if we need to.
|
||||
useCustomPdfViewer: true,
|
||||
}));
|
||||
|
||||
if (cancelled) return;
|
||||
|
@@ -20,6 +20,7 @@ export interface MarkupToHtmlOptions {
|
||||
plugins?: Record<string, any>;
|
||||
bodyOnly?: boolean;
|
||||
mapsToLine?: boolean;
|
||||
useCustomPdfViewer?: boolean;
|
||||
}
|
||||
|
||||
export default function useMarkupToHtml(deps: HookDependencies) {
|
||||
|
@@ -653,6 +653,17 @@
|
||||
e.preventDefault();
|
||||
}));
|
||||
|
||||
document.addEventListener('click', webviewLib.logEnabledEventHandler(e => {
|
||||
document.querySelectorAll('.media-pdf').forEach(element => {
|
||||
if(!!element.contentWindow){
|
||||
element.contentWindow.postMessage({
|
||||
type: 'blur'
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
);
|
||||
}));
|
||||
|
||||
let lastClientWidth_ = NaN, lastClientHeight_ = NaN, lastScrollTop_ = NaN;
|
||||
|
||||
window.addEventListener('resize', webviewLib.logEnabledEventHandler(() => {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.9.1",
|
||||
"version": "2.9.2",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
@@ -138,6 +138,7 @@
|
||||
"@fortawesome/fontawesome-free": "^5.13.0",
|
||||
"@joeattardi/emoji-button": "^4.6.0",
|
||||
"@joplin/lib": "~2.9",
|
||||
"@joplin/pdf-viewer": "~2.9",
|
||||
"@joplin/renderer": "~2.9",
|
||||
"async-mutex": "^0.1.3",
|
||||
"codemirror": "^5.56.0",
|
||||
|
@@ -13,46 +13,65 @@ function fileIsNewerThan(path1, path2) {
|
||||
return stat1.mtime > stat2.mtime;
|
||||
}
|
||||
|
||||
function convertJsx(path) {
|
||||
function convertJsx(paths) {
|
||||
chdir(`${__dirname}/..`);
|
||||
|
||||
fs.readdirSync(path).forEach((filename) => {
|
||||
const jsxPath = `${path}/${filename}`;
|
||||
const p = jsxPath.split('.');
|
||||
if (p.length <= 1) return;
|
||||
const ext = p[p.length - 1];
|
||||
if (ext !== 'jsx') return;
|
||||
p.pop();
|
||||
paths.forEach(path => {
|
||||
fs.readdirSync(path).forEach((filename) => {
|
||||
const jsxPath = `${path}/${filename}`;
|
||||
const p = jsxPath.split('.');
|
||||
if (p.length <= 1) return;
|
||||
const ext = p[p.length - 1];
|
||||
if (ext !== 'jsx') return;
|
||||
p.pop();
|
||||
|
||||
const basePath = p.join('.');
|
||||
const basePath = p.join('.');
|
||||
|
||||
const jsPath = `${basePath}.min.js`;
|
||||
const jsPath = `${basePath}.min.js`;
|
||||
|
||||
if (fileIsNewerThan(jsxPath, jsPath)) {
|
||||
console.info(`Compiling ${jsxPath}...`);
|
||||
if (fileIsNewerThan(jsxPath, jsPath)) {
|
||||
console.info(`Compiling ${jsxPath}...`);
|
||||
|
||||
// { shell: true } is needed to get it working on Windows:
|
||||
// https://discourse.joplinapp.org/t/attempting-to-build-on-windows/22559/12
|
||||
const result = spawnSync('yarn', ['run', 'babel', '--presets', 'react', '--out-file', jsPath, jsxPath], { shell: true });
|
||||
if (result.status !== 0) {
|
||||
const msg = [];
|
||||
if (result.stdout) msg.push(result.stdout.toString());
|
||||
if (result.stderr) msg.push(result.stderr.toString());
|
||||
console.error(msg.join('\n'));
|
||||
if (result.error) console.error(result.error);
|
||||
process.exit(result.status);
|
||||
// { shell: true } is needed to get it working on Windows:
|
||||
// https://discourse.joplinapp.org/t/attempting-to-build-on-windows/22559/12
|
||||
const result = spawnSync('yarn', ['run', 'babel', '--presets', 'react', '--out-file', jsPath, jsxPath], { shell: true });
|
||||
if (result.status !== 0) {
|
||||
const msg = [];
|
||||
if (result.stdout) msg.push(result.stdout.toString());
|
||||
if (result.stderr) msg.push(result.stderr.toString());
|
||||
console.error(msg.join('\n'));
|
||||
if (result.error) console.error(result.error);
|
||||
process.exit(result.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = function() {
|
||||
convertJsx(`${__dirname}/../gui`);
|
||||
convertJsx(`${__dirname}/../gui/MainScreen`);
|
||||
convertJsx(`${__dirname}/../gui/NoteList`);
|
||||
convertJsx(`${__dirname}/../plugins`);
|
||||
function build(path) {
|
||||
chdir(path);
|
||||
|
||||
const result = spawnSync('yarn', ['run', 'build'], { shell: true });
|
||||
if (result.status !== 0) {
|
||||
const msg = [];
|
||||
if (result.stdout) msg.push(result.stdout.toString());
|
||||
if (result.stderr) msg.push(result.stderr.toString());
|
||||
console.error(msg.join('\n'));
|
||||
if (result.error) console.error(result.error);
|
||||
process.exit(result.status);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = function() {
|
||||
convertJsx([
|
||||
`${__dirname}/../gui`,
|
||||
`${__dirname}/../gui/MainScreen`,
|
||||
`${__dirname}/../gui/NoteList`,
|
||||
`${__dirname}/../plugins`,
|
||||
]);
|
||||
|
||||
build(`${__dirname}/../../pdf-viewer`);
|
||||
|
||||
// TODO: should get from node_modules @joplin/lib
|
||||
const libContent = [
|
||||
fs.readFileSync(`${basePath}/packages/lib/string-utils-common.js`, 'utf8'),
|
||||
fs.readFileSync(`${basePath}/packages/lib/markJsUtils.js`, 'utf8'),
|
||||
|
@@ -72,6 +72,10 @@ async function main() {
|
||||
src: langSourceDir,
|
||||
dest: `${buildLibDir}/tinymce/langs`,
|
||||
},
|
||||
{
|
||||
src: resolve(__dirname, '../../pdf-viewer/dist'),
|
||||
dest: `${buildLibDir}/@joplin/pdf-viewer`,
|
||||
},
|
||||
];
|
||||
|
||||
const files = [
|
||||
@@ -87,6 +91,10 @@ async function main() {
|
||||
src: resolve(__dirname, '../../lib/services/plugins/sandboxProxy.js'),
|
||||
dest: `${buildLibDir}/@joplin/lib/services/plugins/sandboxProxy.js`,
|
||||
},
|
||||
{
|
||||
src: resolve(__dirname, '../../pdf-viewer/index.html'),
|
||||
dest: `${buildLibDir}/@joplin/pdf-viewer/index.html`,
|
||||
},
|
||||
];
|
||||
|
||||
// First we delete all the destination directories, then we copy the files.
|
||||
|
@@ -9,48 +9,52 @@
|
||||
// wrapper to access CodeMirror functionalities. Anything else should be done
|
||||
// from NoteEditor.tsx.
|
||||
|
||||
import { MarkdownMathExtension } from './markdownMathParser';
|
||||
import createTheme from './theme';
|
||||
import decoratorExtension from './decoratorExtension';
|
||||
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { highlightSelectionMatches, search } from '@codemirror/search';
|
||||
import { EditorView, drawSelection, highlightSpecialChars, ViewUpdate } from '@codemirror/view';
|
||||
import { undo, redo, history, undoDepth, redoDepth } from '@codemirror/commands';
|
||||
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { indentOnInput } from '@codemirror/language';
|
||||
import { searchKeymap } from '@codemirror/search';
|
||||
import { historyKeymap, defaultKeymap } from '@codemirror/commands';
|
||||
import { MarkdownMathExtension } from './markdownMathParser';
|
||||
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
|
||||
import syntaxHighlightingLanguages from './syntaxHighlightingLanguages';
|
||||
|
||||
interface CodeMirrorResult {
|
||||
editor: EditorView;
|
||||
undo: Function;
|
||||
redo: Function;
|
||||
select(anchor: number, head: number): void;
|
||||
scrollSelectionIntoView(): void;
|
||||
insertText(text: string): void;
|
||||
}
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
|
||||
import { indentOnInput, indentUnit, syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
openSearchPanel, closeSearchPanel, SearchQuery, setSearchQuery, getSearchQuery,
|
||||
highlightSelectionMatches, search, findNext, findPrevious, replaceAll, replaceNext,
|
||||
} from '@codemirror/search';
|
||||
|
||||
function postMessage(name: string, data: any) {
|
||||
(window as any).ReactNativeWebView.postMessage(JSON.stringify({
|
||||
data,
|
||||
name,
|
||||
}));
|
||||
}
|
||||
import {
|
||||
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command,
|
||||
} from '@codemirror/view';
|
||||
import { undo, redo, history, undoDepth, redoDepth, indentWithTab } from '@codemirror/commands';
|
||||
|
||||
function logMessage(...msg: any[]) {
|
||||
postMessage('onLog', { value: msg });
|
||||
}
|
||||
import { keymap, KeyBinding } from '@codemirror/view';
|
||||
import { searchKeymap } from '@codemirror/search';
|
||||
import { historyKeymap, defaultKeymap } from '@codemirror/commands';
|
||||
|
||||
export function initCodeMirror(parentElement: any, initialText: string, theme: any): CodeMirrorResult {
|
||||
import { CodeMirrorControl } from './types';
|
||||
import { EditorSettings, ListType, SearchState } from '../types';
|
||||
import { ChangeEvent, SelectionChangeEvent, Selection } from '../types';
|
||||
import SelectionFormatting from '../SelectionFormatting';
|
||||
import { logMessage, postMessage } from './webviewLogger';
|
||||
import {
|
||||
decreaseIndent, increaseIndent,
|
||||
toggleBolded, toggleCode,
|
||||
toggleHeaderLevel, toggleItalicized,
|
||||
toggleList, toggleMath, updateLink,
|
||||
} from './markdownCommands';
|
||||
|
||||
export function initCodeMirror(
|
||||
parentElement: any, initialText: string, settings: EditorSettings
|
||||
): CodeMirrorControl {
|
||||
logMessage('Initializing CodeMirror...');
|
||||
const theme = settings.themeData;
|
||||
|
||||
let searchVisible = false;
|
||||
|
||||
let schedulePostUndoRedoDepthChangeId_: any = 0;
|
||||
function schedulePostUndoRedoDepthChange(editor: EditorView, doItNow: boolean = false) {
|
||||
const schedulePostUndoRedoDepthChange = (editor: EditorView, doItNow: boolean = false) => {
|
||||
if (schedulePostUndoRedoDepthChangeId_) {
|
||||
if (doItNow) {
|
||||
clearTimeout(schedulePostUndoRedoDepthChangeId_);
|
||||
@@ -66,7 +70,193 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
||||
redoDepth: redoDepth(editor.state),
|
||||
});
|
||||
}, doItNow ? 0 : 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const notifyDocChanged = (viewUpdate: ViewUpdate) => {
|
||||
if (viewUpdate.docChanged) {
|
||||
const event: ChangeEvent = {
|
||||
value: editor.state.doc.toString(),
|
||||
};
|
||||
|
||||
postMessage('onChange', event);
|
||||
schedulePostUndoRedoDepthChange(editor);
|
||||
}
|
||||
};
|
||||
|
||||
const notifyLinkEditRequest = () => {
|
||||
postMessage('onRequestLinkEdit', null);
|
||||
};
|
||||
|
||||
const showSearchDialog = () => {
|
||||
const query = getSearchQuery(editor.state);
|
||||
const searchState: SearchState = {
|
||||
searchText: query.search,
|
||||
replaceText: query.replace,
|
||||
useRegex: query.regexp,
|
||||
caseSensitive: query.caseSensitive,
|
||||
dialogVisible: true,
|
||||
};
|
||||
|
||||
postMessage('onRequestShowSearch', searchState);
|
||||
searchVisible = true;
|
||||
};
|
||||
|
||||
const hideSearchDialog = () => {
|
||||
postMessage('onRequestHideSearch', null);
|
||||
searchVisible = false;
|
||||
};
|
||||
|
||||
const notifySelectionChange = (viewUpdate: ViewUpdate) => {
|
||||
if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
|
||||
const mainRange = viewUpdate.state.selection.main;
|
||||
const selection: Selection = {
|
||||
start: mainRange.from,
|
||||
end: mainRange.to,
|
||||
};
|
||||
const event: SelectionChangeEvent = {
|
||||
selection,
|
||||
};
|
||||
postMessage('onSelectionChange', event);
|
||||
}
|
||||
};
|
||||
|
||||
const notifySelectionFormattingChange = (viewUpdate?: ViewUpdate) => {
|
||||
// If we can't determine the previous formatting, post the update regardless
|
||||
if (!viewUpdate) {
|
||||
const formatting = computeSelectionFormatting(editor.state);
|
||||
postMessage('onSelectionFormattingChange', formatting.toJSON());
|
||||
} else if (viewUpdate.docChanged || !viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
|
||||
// Only post the update if something changed
|
||||
const oldFormatting = computeSelectionFormatting(viewUpdate.startState);
|
||||
const newFormatting = computeSelectionFormatting(viewUpdate.state);
|
||||
|
||||
if (!oldFormatting.eq(newFormatting)) {
|
||||
postMessage('onSelectionFormattingChange', newFormatting.toJSON());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const computeSelectionFormatting = (state: EditorState): SelectionFormatting => {
|
||||
const range = state.selection.main;
|
||||
const formatting: SelectionFormatting = new SelectionFormatting();
|
||||
formatting.selectedText = state.doc.sliceString(range.from, range.to);
|
||||
formatting.spellChecking = editor.contentDOM.spellcheck;
|
||||
|
||||
const parseLinkData = (nodeText: string) => {
|
||||
const linkMatch = nodeText.match(/\[([^\]]*)\]\(([^)]*)\)/);
|
||||
|
||||
if (linkMatch) {
|
||||
return {
|
||||
linkText: linkMatch[1],
|
||||
linkURL: linkMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Find nodes that overlap/are within the selected region
|
||||
syntaxTree(state).iterate({
|
||||
from: range.from, to: range.to,
|
||||
enter: node => {
|
||||
// Checklists don't have a specific containing node. As such,
|
||||
// we're in a checklist if we've selected a 'Task' node.
|
||||
if (node.name === 'Task') {
|
||||
formatting.inChecklist = true;
|
||||
}
|
||||
|
||||
// Only handle notes that contain the entire range.
|
||||
if (node.from > range.from || node.to < range.to) {
|
||||
return;
|
||||
}
|
||||
// Lazily compute the node's text
|
||||
const nodeText = () => state.doc.sliceString(node.from, node.to);
|
||||
|
||||
switch (node.name) {
|
||||
case 'StrongEmphasis':
|
||||
formatting.bolded = true;
|
||||
break;
|
||||
case 'Emphasis':
|
||||
formatting.italicized = true;
|
||||
break;
|
||||
case 'ListItem':
|
||||
formatting.listLevel += 1;
|
||||
break;
|
||||
case 'BulletList':
|
||||
formatting.inUnorderedList = true;
|
||||
break;
|
||||
case 'OrderedList':
|
||||
formatting.inOrderedList = true;
|
||||
break;
|
||||
case 'TaskList':
|
||||
formatting.inChecklist = true;
|
||||
break;
|
||||
case 'InlineCode':
|
||||
case 'FencedCode':
|
||||
formatting.inCode = true;
|
||||
formatting.unspellCheckableRegion = true;
|
||||
break;
|
||||
case 'InlineMath':
|
||||
case 'BlockMath':
|
||||
formatting.inMath = true;
|
||||
formatting.unspellCheckableRegion = true;
|
||||
break;
|
||||
case 'ATXHeading1':
|
||||
formatting.headerLevel = 1;
|
||||
break;
|
||||
case 'ATXHeading2':
|
||||
formatting.headerLevel = 2;
|
||||
break;
|
||||
case 'ATXHeading3':
|
||||
formatting.headerLevel = 3;
|
||||
break;
|
||||
case 'ATXHeading4':
|
||||
formatting.headerLevel = 4;
|
||||
break;
|
||||
case 'ATXHeading5':
|
||||
formatting.headerLevel = 5;
|
||||
break;
|
||||
case 'URL':
|
||||
formatting.inLink = true;
|
||||
formatting.linkData.linkURL = nodeText();
|
||||
formatting.unspellCheckableRegion = true;
|
||||
break;
|
||||
case 'Link':
|
||||
formatting.inLink = true;
|
||||
formatting.linkData = parseLinkData(nodeText());
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// The markdown parser marks checklists as unordered lists. Ensure
|
||||
// that they aren't marked as such.
|
||||
if (formatting.inChecklist) {
|
||||
if (!formatting.inUnorderedList) {
|
||||
// Even if the selection contains a Task, because an unordered list node
|
||||
// must contain a valid Task node, we're only in a checklist if we're also in
|
||||
// an unordered list.
|
||||
formatting.inChecklist = false;
|
||||
} else {
|
||||
formatting.inUnorderedList = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (formatting.unspellCheckableRegion) {
|
||||
formatting.spellChecking = false;
|
||||
}
|
||||
|
||||
return formatting;
|
||||
};
|
||||
|
||||
// Returns a keyboard command that returns true (so accepts the keybind)
|
||||
const keyCommand = (key: string, run: Command): KeyBinding => {
|
||||
return {
|
||||
key,
|
||||
run,
|
||||
preventDefault: true,
|
||||
};
|
||||
};
|
||||
|
||||
const editor = new EditorView({
|
||||
state: EditorState.create({
|
||||
@@ -75,37 +265,73 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
||||
extensions: [
|
||||
markdown({
|
||||
extensions: [
|
||||
MarkdownMathExtension,
|
||||
GitHubFlavoredMarkdownExtension,
|
||||
|
||||
// Don't highlight KaTeX if the user disabled it
|
||||
settings.katexEnabled ? MarkdownMathExtension : [],
|
||||
],
|
||||
codeLanguages: syntaxHighlightingLanguages,
|
||||
}),
|
||||
...createTheme(theme),
|
||||
history(),
|
||||
search(),
|
||||
search({
|
||||
createPanel(_: EditorView) {
|
||||
return {
|
||||
// The actual search dialog is implemented with react native,
|
||||
// use a dummy element.
|
||||
dom: document.createElement('div'),
|
||||
mount() {
|
||||
showSearchDialog();
|
||||
},
|
||||
destroy() {
|
||||
hideSearchDialog();
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
drawSelection(),
|
||||
highlightSpecialChars(),
|
||||
highlightSelectionMatches(),
|
||||
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' }),
|
||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||
if (viewUpdate.docChanged) {
|
||||
postMessage('onChange', { value: editor.state.doc.toString() });
|
||||
schedulePostUndoRedoDepthChange(editor);
|
||||
}
|
||||
|
||||
if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
|
||||
const mainRange = viewUpdate.state.selection.main;
|
||||
const selStart = mainRange.from;
|
||||
const selEnd = mainRange.to;
|
||||
postMessage('onSelectionChange', { selection: { start: selStart, end: selEnd } });
|
||||
}
|
||||
notifyDocChanged(viewUpdate);
|
||||
notifySelectionChange(viewUpdate);
|
||||
notifySelectionFormattingChange(viewUpdate);
|
||||
}),
|
||||
keymap.of([
|
||||
...defaultKeymap, ...historyKeymap, ...searchKeymap,
|
||||
// Custom mod-f binding: Toggle the external dialog implementation
|
||||
// (don't show/hide the Panel dialog).
|
||||
keyCommand('Mod-f', (_: EditorView) => {
|
||||
if (searchVisible) {
|
||||
hideSearchDialog();
|
||||
} else {
|
||||
showSearchDialog();
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
// Markdown formatting keyboard shortcuts
|
||||
keyCommand('Mod-b', toggleBolded),
|
||||
keyCommand('Mod-i', toggleItalicized),
|
||||
keyCommand('Mod-$', toggleMath),
|
||||
keyCommand('Mod-`', toggleCode),
|
||||
keyCommand('Mod-[', decreaseIndent),
|
||||
keyCommand('Mod-]', increaseIndent),
|
||||
keyCommand('Mod-k', (_: EditorView) => {
|
||||
notifyLinkEditRequest();
|
||||
return true;
|
||||
}),
|
||||
|
||||
...defaultKeymap, ...historyKeymap, indentWithTab, ...searchKeymap,
|
||||
]),
|
||||
],
|
||||
doc: initialText,
|
||||
@@ -113,7 +339,19 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
||||
parent: parentElement,
|
||||
});
|
||||
|
||||
return {
|
||||
const updateSearchQuery = (newState: SearchState) => {
|
||||
const query = new SearchQuery({
|
||||
search: newState.searchText,
|
||||
caseSensitive: newState.caseSensitive,
|
||||
regexp: newState.useRegex,
|
||||
replace: newState.replaceText,
|
||||
});
|
||||
editor.dispatch({
|
||||
effects: setSearchQuery.of(query),
|
||||
});
|
||||
};
|
||||
|
||||
const editorControls = {
|
||||
editor,
|
||||
undo: () => {
|
||||
undo(editor);
|
||||
@@ -137,5 +375,54 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
||||
insertText: (text: string) => {
|
||||
editor.dispatch(editor.state.replaceSelection(text));
|
||||
},
|
||||
toggleFindDialog: () => {
|
||||
const opened = openSearchPanel(editor);
|
||||
if (!opened) {
|
||||
closeSearchPanel(editor);
|
||||
}
|
||||
},
|
||||
setSpellcheckEnabled: (enabled: boolean) => {
|
||||
editor.contentDOM.spellcheck = enabled;
|
||||
notifySelectionFormattingChange();
|
||||
},
|
||||
|
||||
// Formatting
|
||||
toggleBolded: () => { toggleBolded(editor); },
|
||||
toggleItalicized: () => { toggleItalicized(editor); },
|
||||
toggleCode: () => { toggleCode(editor); },
|
||||
toggleMath: () => { toggleMath(editor); },
|
||||
increaseIndent: () => { increaseIndent(editor); },
|
||||
decreaseIndent: () => { decreaseIndent(editor); },
|
||||
toggleList: (kind: ListType) => { toggleList(kind)(editor); },
|
||||
toggleHeaderLevel: (level: number) => { toggleHeaderLevel(level)(editor); },
|
||||
updateLink: (label: string, url: string) => { updateLink(label, url)(editor); },
|
||||
|
||||
// Search
|
||||
searchControl: {
|
||||
findNext: () => {
|
||||
findNext(editor);
|
||||
},
|
||||
findPrevious: () => {
|
||||
findPrevious(editor);
|
||||
},
|
||||
replaceCurrent: () => {
|
||||
replaceNext(editor);
|
||||
},
|
||||
replaceAll: () => {
|
||||
replaceAll(editor);
|
||||
},
|
||||
setSearchState: (state: SearchState) => {
|
||||
updateSearchQuery(state);
|
||||
},
|
||||
showSearch: () => {
|
||||
showSearchDialog();
|
||||
},
|
||||
hideSearch: () => {
|
||||
hideSearchDialog();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return editorControls;
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,23 @@
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { GFM as GithubFlavoredMarkdownExt } from '@lezer/markdown';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
import { SelectionRange, EditorSelection, EditorState } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { MarkdownMathExtension } from './markdownMathParser';
|
||||
|
||||
// Creates and returns a minimal editor with markdown extensions
|
||||
const createEditor = (initialText: string, initialSelection: SelectionRange): EditorView => {
|
||||
return new EditorView({
|
||||
doc: initialText,
|
||||
selection: EditorSelection.create([initialSelection]),
|
||||
extensions: [
|
||||
markdown({
|
||||
extensions: [MarkdownMathExtension, GithubFlavoredMarkdownExt],
|
||||
}),
|
||||
indentUnit.of('\t'),
|
||||
EditorState.tabSize.of(4),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
export default createEditor;
|
48
packages/app-mobile/components/NoteEditor/CodeMirror/demo.html
vendored
Normal file
48
packages/app-mobile/components/NoteEditor/CodeMirror/demo.html
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
<!--
|
||||
Open this file in a web browser to more easily debug the CodeMirror editor.
|
||||
Messages will show up in the console when posted.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<meta charset="utf-8"/>
|
||||
<title>CodeMirror test</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="CodeMirror"></div>
|
||||
<script>
|
||||
// Override the default postMessage — codeMirrorBundle expects
|
||||
// this to be present.
|
||||
window.ReactNativeWebView = {
|
||||
postMessage: message => {
|
||||
console.log('postMessage:', message);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<script src="./CodeMirror.bundle.js"></script>
|
||||
<script>
|
||||
const parent = document.querySelector('.CodeMirror');
|
||||
const initialText = 'Testing...';
|
||||
|
||||
const settings = {
|
||||
katexEnabled: true,
|
||||
themeData: {
|
||||
fontSize: 1, // em
|
||||
fontFamily: 'serif',
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
backgroundColor2: '#330',
|
||||
color2: '#ff0',
|
||||
backgroundColor3: '#404',
|
||||
color3: '#f0f',
|
||||
backgroundColor4: '#555',
|
||||
color4: '#0ff',
|
||||
appearance: 'dark',
|
||||
},
|
||||
};
|
||||
|
||||
codeMirrorBundle.initCodeMirror(parent, initialText, settings);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import { ListType } from '../types';
|
||||
import createEditor from './createEditor';
|
||||
import { toggleList } from './markdownCommands';
|
||||
|
||||
describe('markdownCommands.bulletedVsChecklist', () => {
|
||||
const bulletedListPart = '- Test\n- This is a test.\n- 3\n- 4\n- 5';
|
||||
const checklistPart = '- [ ] This is a checklist\n- [ ] with multiple items.\n- [ ] ☑';
|
||||
const initialDocText = `${bulletedListPart}\n\n${checklistPart}`;
|
||||
|
||||
it('should remove a checklist following a bulleted list without modifying the bulleted list', () => {
|
||||
const editor = createEditor(
|
||||
initialDocText, EditorSelection.cursor(bulletedListPart.length + 5)
|
||||
);
|
||||
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
`${bulletedListPart}\n\nThis is a checklist\nwith multiple items.\n☑`
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove an unordered list following a checklist without modifying the checklist', () => {
|
||||
const editor = createEditor(
|
||||
initialDocText, EditorSelection.cursor(bulletedListPart.length - 5)
|
||||
);
|
||||
|
||||
toggleList(ListType.UnorderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
`Test\nThis is a test.\n3\n4\n5\n\n${checklistPart}`
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace a selection of unordered and task lists with a correctly-numbered list', () => {
|
||||
const editor = createEditor(
|
||||
initialDocText, EditorSelection.range(0, initialDocText.length)
|
||||
);
|
||||
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'1. Test\n2. This is a test.\n3. 3\n4. 4\n5. 5'
|
||||
+ '\n\n6. This is a checklist\n7. with multiple items.\n8. ☑'
|
||||
);
|
||||
});
|
||||
});
|
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import {
|
||||
toggleBolded, toggleCode, toggleHeaderLevel, toggleItalicized, toggleMath, updateLink,
|
||||
} from './markdownCommands';
|
||||
import { GFM as GithubFlavoredMarkdownExt } from '@lezer/markdown';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { MarkdownMathExtension } from './markdownMathParser';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
|
||||
// Creates and returns a minimal editor with markdown extensions
|
||||
const createEditor = (initialText: string, initialSelection: SelectionRange): EditorView => {
|
||||
return new EditorView({
|
||||
doc: initialText,
|
||||
selection: EditorSelection.create([initialSelection]),
|
||||
extensions: [
|
||||
markdown({
|
||||
extensions: [MarkdownMathExtension, GithubFlavoredMarkdownExt],
|
||||
}),
|
||||
indentUnit.of('\t'),
|
||||
EditorState.tabSize.of(4),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
describe('markdownCommands', () => {
|
||||
it('should bold/italicize everything selected', () => {
|
||||
const initialDocText = 'Testing...';
|
||||
const editor = createEditor(
|
||||
initialDocText, EditorSelection.range(0, initialDocText.length)
|
||||
);
|
||||
|
||||
toggleBolded(editor);
|
||||
|
||||
let mainSel = editor.state.selection.main;
|
||||
const boldedText = '**Testing...**';
|
||||
expect(editor.state.doc.toString()).toBe(boldedText);
|
||||
expect(mainSel.from).toBe(0);
|
||||
expect(mainSel.to).toBe(boldedText.length);
|
||||
|
||||
toggleBolded(editor);
|
||||
mainSel = editor.state.selection.main;
|
||||
expect(editor.state.doc.toString()).toBe(initialDocText);
|
||||
expect(mainSel.from).toBe(0);
|
||||
expect(mainSel.to).toBe(initialDocText.length);
|
||||
|
||||
toggleItalicized(editor);
|
||||
expect(editor.state.doc.toString()).toBe('*Testing...*');
|
||||
|
||||
toggleItalicized(editor);
|
||||
expect(editor.state.doc.toString()).toBe('Testing...');
|
||||
});
|
||||
|
||||
it('toggling math should both create and navigate out of math regions', () => {
|
||||
const initialDocText = 'Testing... ';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
|
||||
|
||||
toggleMath(editor);
|
||||
expect(editor.state.doc.toString()).toBe('Testing... $$');
|
||||
expect(editor.state.selection.main.empty).toBe(true);
|
||||
|
||||
editor.dispatch(editor.state.replaceSelection('3 + 3 \\neq 5'));
|
||||
expect(editor.state.doc.toString()).toBe('Testing... $3 + 3 \\neq 5$');
|
||||
|
||||
toggleMath(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('...'));
|
||||
expect(editor.state.doc.toString()).toBe('Testing... $3 + 3 \\neq 5$...');
|
||||
});
|
||||
|
||||
it('toggling inline code should both create and navigate out of an inline code region', () => {
|
||||
const initialDocText = 'Testing...\n\n';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
|
||||
|
||||
toggleCode(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
|
||||
toggleCode(editor);
|
||||
|
||||
editor.dispatch(editor.state.replaceSelection(' is a function.'));
|
||||
expect(editor.state.doc.toString()).toBe('Testing...\n\n`f(x) = ...` is a function.');
|
||||
});
|
||||
|
||||
it('should set headers to the proper levels (when toggling)', () => {
|
||||
const initialDocText = 'Testing...\nThis is a test.';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(3));
|
||||
|
||||
toggleHeaderLevel(1)(editor);
|
||||
|
||||
let mainSel = editor.state.selection.main;
|
||||
expect(editor.state.doc.toString()).toBe('# Testing...\nThis is a test.');
|
||||
expect(mainSel.empty).toBe(true);
|
||||
expect(mainSel.from).toBe('# Testing...'.length);
|
||||
|
||||
toggleHeaderLevel(2)(editor);
|
||||
|
||||
mainSel = editor.state.selection.main;
|
||||
expect(editor.state.doc.toString()).toBe('## Testing...\nThis is a test.');
|
||||
expect(mainSel.empty).toBe(true);
|
||||
expect(mainSel.from).toBe('## Testing...'.length);
|
||||
|
||||
toggleHeaderLevel(2)(editor);
|
||||
|
||||
mainSel = editor.state.selection.main;
|
||||
expect(editor.state.doc.toString()).toEqual(initialDocText);
|
||||
expect(mainSel.empty).toBe(true);
|
||||
expect(mainSel.from).toBe('Testing...'.length);
|
||||
});
|
||||
|
||||
it('headers should toggle properly within block quotes', () => {
|
||||
const initialDocText = 'Testing...\n\n> This is a test.\n> ...a test';
|
||||
const editor = createEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor('Testing...\n\n> This'.length)
|
||||
);
|
||||
|
||||
toggleHeaderLevel(1)(editor);
|
||||
|
||||
const mainSel = editor.state.selection.main;
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'Testing...\n\n> # This is a test.\n> ...a test'
|
||||
);
|
||||
expect(mainSel.empty).toBe(true);
|
||||
expect(mainSel.from).toBe('Testing...\n\n> # This is a test.'.length);
|
||||
|
||||
toggleHeaderLevel(3)(editor);
|
||||
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'Testing...\n\n> ### This is a test.\n> ...a test'
|
||||
);
|
||||
});
|
||||
|
||||
it('block math should properly toggle within block quotes', () => {
|
||||
const initialDocText = 'Testing...\n\n> This is a test.\n> y = mx + b\n> ...a test';
|
||||
const editor = createEditor(
|
||||
initialDocText,
|
||||
EditorSelection.range(
|
||||
'Testing...\n\n> This'.length,
|
||||
'Testing...\n\n> This is a test.\n> y = mx + b'.length
|
||||
)
|
||||
);
|
||||
|
||||
toggleMath(editor);
|
||||
|
||||
// Toggling math should surround the content in '$$'s
|
||||
let mainSel = editor.state.selection.main;
|
||||
expect(editor.state.doc.toString()).toEqual(
|
||||
'Testing...\n\n> $$\n> This is a test.\n> y = mx + b\n> $$\n> ...a test'
|
||||
);
|
||||
expect(mainSel.from).toBe('Testing...\n\n'.length);
|
||||
expect(mainSel.to).toBe('Testing...\n\n> $$\n> This is a test.\n> y = mx + b\n> $$'.length);
|
||||
|
||||
// Change to a cursor --- test cursor expansion
|
||||
editor.dispatch({
|
||||
selection: EditorSelection.cursor('Testing...\n\n> $$\n> This is'.length),
|
||||
});
|
||||
|
||||
// Toggling math again should remove the '$$'s
|
||||
toggleMath(editor);
|
||||
mainSel = editor.state.selection.main;
|
||||
expect(editor.state.doc.toString()).toEqual(initialDocText);
|
||||
expect(mainSel.from).toBe('Testing...\n\n'.length);
|
||||
expect(mainSel.to).toBe('Testing...\n\n> This is a test.\n> y = mx + b'.length);
|
||||
});
|
||||
|
||||
it('updateLink should replace link titles and isolate URLs if no title is given', () => {
|
||||
const initialDocText = '[foo](http://example.com/)';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor('[f'.length));
|
||||
|
||||
updateLink('bar', 'https://example.com/')(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'[bar](https://example.com/)'
|
||||
);
|
||||
|
||||
updateLink('', 'https://example.com/')(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'https://example.com/'
|
||||
);
|
||||
});
|
||||
|
||||
it('toggling math twice, starting on a line with content, should a math block', () => {
|
||||
const initialDocText = 'Testing... ';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
|
||||
|
||||
toggleMath(editor);
|
||||
toggleMath(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
|
||||
expect(editor.state.doc.toString()).toBe('Testing... \n$$\nf(x) = ...\n$$');
|
||||
});
|
||||
|
||||
it('toggling math twice on an empty line should create an empty math block', () => {
|
||||
const initialDocText = 'Testing...\n\n';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
|
||||
|
||||
toggleMath(editor);
|
||||
toggleMath(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
|
||||
expect(editor.state.doc.toString()).toBe('Testing...\n\n$$\nf(x) = ...\n$$');
|
||||
});
|
||||
|
||||
it('toggling code twice on an empty line should create an empty code block', () => {
|
||||
const initialDocText = 'Testing...\n\n';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
|
||||
|
||||
// Toggling code twice should create a block code region
|
||||
toggleCode(editor);
|
||||
toggleCode(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
|
||||
expect(editor.state.doc.toString()).toBe('Testing...\n\n```\nf(x) = ...\n```');
|
||||
|
||||
toggleCode(editor);
|
||||
expect(editor.state.doc.toString()).toBe('Testing...\n\nf(x) = ...\n');
|
||||
});
|
||||
|
||||
it('toggling math twice inside a block quote should produce an empty math block', () => {
|
||||
const initialDocText = '> Testing...> \n> ';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
|
||||
|
||||
toggleMath(editor);
|
||||
toggleMath(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'> Testing...> \n> \n> $$\n> f(x) = ...\n> $$'
|
||||
);
|
||||
|
||||
// If we toggle math again, everything from the start of the line with the first
|
||||
// $$ to the end of the document should be selected.
|
||||
toggleMath(editor);
|
||||
const sel = editor.state.selection.main;
|
||||
expect(sel.from).toBe('> Testing...> \n> \n'.length);
|
||||
expect(sel.to).toBe(editor.state.doc.length);
|
||||
});
|
||||
|
||||
it('toggling inline code should both create and navigate out of an inline code region', () => {
|
||||
const initialDocText = 'Testing...\n\n';
|
||||
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
|
||||
|
||||
toggleCode(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
|
||||
toggleCode(editor);
|
||||
|
||||
editor.dispatch(editor.state.replaceSelection(' is a function.'));
|
||||
expect(editor.state.doc.toString()).toBe('Testing...\n\n`f(x) = ...` is a function.');
|
||||
});
|
||||
});
|
||||
|
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { EditorSelection, EditorState } from '@codemirror/state';
|
||||
import {
|
||||
increaseIndent, toggleList,
|
||||
} from './markdownCommands';
|
||||
import { ListType } from '../types';
|
||||
import createEditor from './createEditor';
|
||||
|
||||
describe('markdownCommands.toggleList', () => {
|
||||
it('should remove the same type of list', () => {
|
||||
const initialDocText = '- testing\n- this is a test';
|
||||
|
||||
const editor = createEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor(5)
|
||||
);
|
||||
|
||||
toggleList(ListType.UnorderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'testing\nthis is a test'
|
||||
);
|
||||
});
|
||||
|
||||
it('should insert a numbered list with correct numbering', () => {
|
||||
const initialDocText = 'Testing...\nThis is a test\nof list toggling...';
|
||||
const editor = createEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor('Testing...\nThis is a'.length)
|
||||
);
|
||||
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'Testing...\n1. This is a test\nof list toggling...'
|
||||
);
|
||||
|
||||
editor.setState(EditorState.create({
|
||||
doc: initialDocText,
|
||||
selection: EditorSelection.range(4, initialDocText.length),
|
||||
}));
|
||||
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'1. Testing...\n2. This is a test\n3. of list toggling...'
|
||||
);
|
||||
});
|
||||
|
||||
const numberedListText = '- 1\n- 2\n- 3\n- 4\n- 5\n- 6\n- 7';
|
||||
|
||||
it('should correctly replace an unordered list with a numbered list', () => {
|
||||
const editor = createEditor(
|
||||
numberedListText,
|
||||
EditorSelection.cursor(numberedListText.length)
|
||||
);
|
||||
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'1. 1\n2. 2\n3. 3\n4. 4\n5. 5\n6. 6\n7. 7'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it('should correctly replace an unordered list with a checklist', () => {
|
||||
const editor = createEditor(
|
||||
numberedListText,
|
||||
EditorSelection.cursor(numberedListText.length)
|
||||
);
|
||||
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'- [ ] 1\n- [ ] 2\n- [ ] 3\n- [ ] 4\n- [ ] 5\n- [ ] 6\n- [ ] 7'
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly toggle a sublist of a bulleted list', () => {
|
||||
const preSubListText = '# List test\n * This\n * is\n';
|
||||
const initialDocText = `${preSubListText}\t* a\n\t* test\n * of list toggling`;
|
||||
|
||||
const editor = createEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor(preSubListText.length + '\t* a'.length)
|
||||
);
|
||||
|
||||
// Indentation should be preserved when changing list types
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'# List test\n * This\n * is\n\t1. a\n\t2. test\n * of list toggling'
|
||||
);
|
||||
|
||||
// The changed region should be selected
|
||||
expect(editor.state.selection.main.from).toBe(preSubListText.length);
|
||||
expect(editor.state.selection.main.to).toBe(
|
||||
`${preSubListText}\t1. a\n\t2. test`.length
|
||||
);
|
||||
|
||||
// Indentation should not be preserved when removing lists
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.selection.main.from).toBe(preSubListText.length);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'# List test\n * This\n * is\na\ntest\n * of list toggling'
|
||||
);
|
||||
|
||||
|
||||
// Put the cursor in the middle of the list
|
||||
editor.dispatch({ selection: EditorSelection.cursor(preSubListText.length) });
|
||||
|
||||
// Sublists should be changed
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
const expectedChecklistPart =
|
||||
'# List test\n - [ ] This\n - [ ] is\n - [ ] a\n - [ ] test\n - [ ] of list toggling';
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
expectedChecklistPart
|
||||
);
|
||||
|
||||
editor.dispatch({ selection: EditorSelection.cursor(editor.state.doc.length) });
|
||||
editor.dispatch(editor.state.replaceSelection('\n\n\n'));
|
||||
|
||||
// toggleList should also create a new list if the cursor is on an empty line.
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
editor.dispatch(editor.state.replaceSelection('Test.\n2. Test2\n3. Test3'));
|
||||
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
`${expectedChecklistPart}\n\n\n1. Test.\n2. Test2\n3. Test3`
|
||||
);
|
||||
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
`${expectedChecklistPart}\n\n\n- [ ] Test.\n- [ ] Test2\n- [ ] Test3`
|
||||
);
|
||||
|
||||
// The entire checklist should have been selected (and thus will now be indented)
|
||||
increaseIndent(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
`${expectedChecklistPart}\n\n\n\t- [ ] Test.\n\t- [ ] Test2\n\t- [ ] Test3`
|
||||
);
|
||||
});
|
||||
|
||||
it('should toggle a numbered list without changing its sublists', () => {
|
||||
const initialDocText = '1. Foo\n2. Bar\n3. Baz\n\t- Test\n\t- of\n\t- sublists\n4. Foo';
|
||||
|
||||
const editor = createEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor(0)
|
||||
);
|
||||
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'- [ ] Foo\n- [ ] Bar\n- [ ] Baz\n\t- Test\n\t- of\n\t- sublists\n- [ ] Foo'
|
||||
);
|
||||
});
|
||||
|
||||
it('should toggle a sublist without changing the parent list', () => {
|
||||
const initialDocText = '1. This\n2. is\n3. ';
|
||||
|
||||
const editor = createEditor(
|
||||
initialDocText,
|
||||
EditorSelection.cursor(initialDocText.length)
|
||||
);
|
||||
|
||||
increaseIndent(editor);
|
||||
expect(editor.state.selection.main.empty).toBe(true);
|
||||
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'1. This\n2. is\n\t- [ ] '
|
||||
);
|
||||
|
||||
editor.dispatch(editor.state.replaceSelection('a test.'));
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'1. This\n2. is\n\t- [ ] a test.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should toggle lists properly within block quotes', () => {
|
||||
const preSubListText = '> # List test\n> * This\n> * is\n';
|
||||
const initialDocText = `${preSubListText}> \t* a\n> \t* test\n> * of list toggling`;
|
||||
const editor = createEditor(
|
||||
initialDocText, EditorSelection.cursor(preSubListText.length + 3)
|
||||
);
|
||||
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
'> # List test\n> * This\n> * is\n> \t1. a\n> \t2. test\n> * of list toggling'
|
||||
);
|
||||
expect(editor.state.selection.main.from).toBe(preSubListText.length);
|
||||
});
|
||||
});
|
@@ -0,0 +1,440 @@
|
||||
// CodeMirror 6 commands that modify markdown formatting (e.g. toggleBold).
|
||||
|
||||
import { EditorView, Command } from '@codemirror/view';
|
||||
|
||||
import { ListType } from '../types';
|
||||
import {
|
||||
SelectionRange, EditorSelection, ChangeSpec, Line, TransactionSpec,
|
||||
} from '@codemirror/state';
|
||||
import { getIndentUnit, indentString, syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
RegionSpec, growSelectionToNode, renumberList,
|
||||
toggleInlineFormatGlobally, toggleRegionFormatGlobally, toggleSelectedLinesStartWith,
|
||||
isIndentationEquivalent, stripBlockquote, tabsToSpaces,
|
||||
} from './markdownReformatter';
|
||||
|
||||
const startingSpaceRegex = /^(\s*)/;
|
||||
|
||||
export const toggleBolded: Command = (view: EditorView): boolean => {
|
||||
const spec = RegionSpec.of({ template: '**', nodeName: 'StrongEmphasis' });
|
||||
const changes = toggleInlineFormatGlobally(view.state, spec);
|
||||
|
||||
view.dispatch(changes);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const toggleItalicized: Command = (view: EditorView): boolean => {
|
||||
const changes = toggleInlineFormatGlobally(view.state, {
|
||||
nodeName: 'Emphasis',
|
||||
|
||||
template: { start: '*', end: '*' },
|
||||
matcher: { start: /[_*]/g, end: /[_*]/g },
|
||||
});
|
||||
view.dispatch(changes);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// If the selected region is an empty inline code block, it will be converted to
|
||||
// a block (fenced) code block.
|
||||
export const toggleCode: Command = (view: EditorView): boolean => {
|
||||
const codeFenceRegex = /^```\w*\s*$/;
|
||||
const inlineRegionSpec = RegionSpec.of({ template: '`', nodeName: 'InlineCode' });
|
||||
const blockRegionSpec: RegionSpec = {
|
||||
nodeName: 'FencedCode',
|
||||
template: { start: '```', end: '```' },
|
||||
matcher: { start: codeFenceRegex, end: codeFenceRegex },
|
||||
};
|
||||
|
||||
const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec);
|
||||
view.dispatch(changes);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const toggleMath: Command = (view: EditorView): boolean => {
|
||||
const blockStartRegex = /^\$\$/;
|
||||
const blockEndRegex = /\$\$\s*$/;
|
||||
const inlineRegionSpec = RegionSpec.of({ nodeName: 'InlineMath', template: '$' });
|
||||
const blockRegionSpec = RegionSpec.of({
|
||||
nodeName: 'BlockMath',
|
||||
template: '$$',
|
||||
matcher: {
|
||||
start: blockStartRegex,
|
||||
end: blockEndRegex,
|
||||
},
|
||||
});
|
||||
|
||||
const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec);
|
||||
view.dispatch(changes);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const toggleList = (listType: ListType): Command => {
|
||||
return (view: EditorView): boolean => {
|
||||
let state = view.state;
|
||||
let doc = state.doc;
|
||||
|
||||
const orderedListTag = 'OrderedList';
|
||||
const unorderedListTag = 'BulletList';
|
||||
|
||||
// RegExps for different list types. The regular expressions MUST
|
||||
// be mutually exclusive.
|
||||
// `(?!\[[ xX]+\]\s?)` means "not followed by [x] or [ ]".
|
||||
const bulletedRegex = /^\s*([-*])(?!\s\[[ xX]+\])\s?/;
|
||||
const checklistRegex = /^\s*[-*]\s\[[ xX]+\]\s?/;
|
||||
const numberedRegex = /^\s*\d+\.\s?/;
|
||||
|
||||
const listRegexes: Record<ListType, RegExp> = {
|
||||
[ListType.OrderedList]: numberedRegex,
|
||||
[ListType.CheckList]: checklistRegex,
|
||||
[ListType.UnorderedList]: bulletedRegex,
|
||||
};
|
||||
|
||||
const getContainerType = (line: Line): ListType|null => {
|
||||
const lineContent = stripBlockquote(line);
|
||||
|
||||
// Determine the container's type.
|
||||
const checklistMatch = lineContent.match(checklistRegex);
|
||||
const bulletListMatch = lineContent.match(bulletedRegex);
|
||||
const orderedListMatch = lineContent.match(numberedRegex);
|
||||
|
||||
if (checklistMatch) {
|
||||
return ListType.CheckList;
|
||||
} else if (bulletListMatch) {
|
||||
return ListType.UnorderedList;
|
||||
} else if (orderedListMatch) {
|
||||
return ListType.OrderedList;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const changes: TransactionSpec = state.changeByRange((sel: SelectionRange) => {
|
||||
const changes: ChangeSpec[] = [];
|
||||
let containerType: ListType|null = null;
|
||||
|
||||
// Total number of characters added (deleted if negative)
|
||||
let charsAdded = 0;
|
||||
|
||||
const originalSel = sel;
|
||||
let fromLine: Line;
|
||||
let toLine: Line;
|
||||
let firstLineIndentation: string;
|
||||
let firstLineInBlockQuote: boolean;
|
||||
let fromLineContent: string;
|
||||
const computeSelectionProps = () => {
|
||||
fromLine = doc.lineAt(sel.from);
|
||||
toLine = doc.lineAt(sel.to);
|
||||
fromLineContent = stripBlockquote(fromLine);
|
||||
firstLineIndentation = fromLineContent.match(startingSpaceRegex)[0];
|
||||
firstLineInBlockQuote = (fromLineContent !== fromLine.text);
|
||||
|
||||
containerType = getContainerType(fromLine);
|
||||
};
|
||||
computeSelectionProps();
|
||||
|
||||
const origFirstLineIndentation = firstLineIndentation;
|
||||
const origContainerType = containerType;
|
||||
|
||||
// Grow [sel] to the smallest containing list
|
||||
if (sel.empty) {
|
||||
sel = growSelectionToNode(state, sel, [orderedListTag, unorderedListTag]);
|
||||
computeSelectionProps();
|
||||
}
|
||||
|
||||
// Reset the selection if it seems likely the user didn't want the selection
|
||||
// to be expanded
|
||||
const isIndentationDiff =
|
||||
!isIndentationEquivalent(state, firstLineIndentation, origFirstLineIndentation);
|
||||
if (isIndentationDiff) {
|
||||
const expandedRegionIndentation = firstLineIndentation;
|
||||
sel = originalSel;
|
||||
computeSelectionProps();
|
||||
|
||||
// Use the indentation level of the expanded region if it's greater.
|
||||
// This makes sense in the case where unindented text is being converted to
|
||||
// the same type of list as its container. For example,
|
||||
// 1. Foobar
|
||||
// unindented text
|
||||
// that should be made a part of the above list.
|
||||
//
|
||||
// becoming
|
||||
//
|
||||
// 1. Foobar
|
||||
// 2. unindented text
|
||||
// 3. that should be made a part of the above list.
|
||||
const wasGreaterIndentation = (
|
||||
tabsToSpaces(state, expandedRegionIndentation).length
|
||||
> tabsToSpaces(state, firstLineIndentation).length
|
||||
);
|
||||
if (wasGreaterIndentation) {
|
||||
firstLineIndentation = expandedRegionIndentation;
|
||||
}
|
||||
} else if (
|
||||
(origContainerType !== containerType && (origContainerType ?? null) !== null)
|
||||
|| containerType !== getContainerType(toLine)
|
||||
) {
|
||||
// If the container type changed, this could be an artifact of checklists/bulleted
|
||||
// lists sharing the same node type.
|
||||
// Find the closest range of the same type of list to the original selection
|
||||
let newFromLineNo = doc.lineAt(originalSel.from).number;
|
||||
let newToLineNo = doc.lineAt(originalSel.to).number;
|
||||
let lastFromLineNo;
|
||||
let lastToLineNo;
|
||||
|
||||
while (newFromLineNo !== lastFromLineNo || newToLineNo !== lastToLineNo) {
|
||||
lastFromLineNo = newFromLineNo;
|
||||
lastToLineNo = newToLineNo;
|
||||
|
||||
if (lastFromLineNo - 1 >= 1) {
|
||||
const testFromLine = doc.line(lastFromLineNo - 1);
|
||||
if (getContainerType(testFromLine) === origContainerType) {
|
||||
newFromLineNo --;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastToLineNo + 1 <= doc.lines) {
|
||||
const testToLine = doc.line(lastToLineNo + 1);
|
||||
if (getContainerType(testToLine) === origContainerType) {
|
||||
newToLineNo ++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sel = EditorSelection.range(
|
||||
doc.line(newFromLineNo).from,
|
||||
doc.line(newToLineNo).to
|
||||
);
|
||||
computeSelectionProps();
|
||||
}
|
||||
|
||||
// Determine whether the expanded selection should be empty
|
||||
if (originalSel.empty && fromLine.number === toLine.number) {
|
||||
sel = EditorSelection.cursor(toLine.to);
|
||||
}
|
||||
|
||||
// Select entire lines (if not just a cursor)
|
||||
if (!sel.empty) {
|
||||
sel = EditorSelection.range(fromLine.from, toLine.to);
|
||||
}
|
||||
|
||||
// Number of the item in the list (e.g. 2 for the 2nd item in the list)
|
||||
let listItemCounter = 1;
|
||||
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum ++) {
|
||||
const line = doc.line(lineNum);
|
||||
const lineContent = stripBlockquote(line);
|
||||
const lineContentFrom = line.to - lineContent.length;
|
||||
const inBlockQuote = (lineContent !== line.text);
|
||||
const indentation = lineContent.match(startingSpaceRegex)[0];
|
||||
|
||||
const wrongIndentaton = !isIndentationEquivalent(state, indentation, firstLineIndentation);
|
||||
|
||||
// If not the right list level,
|
||||
if (inBlockQuote !== firstLineInBlockQuote || wrongIndentaton) {
|
||||
// We'll be starting a new list
|
||||
listItemCounter = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't add list numbers to otherwise empty lines (unless it's the first line)
|
||||
if (lineNum !== fromLine.number && line.text.trim().length === 0) {
|
||||
// Do not reset the counter -- the markdown renderer doesn't!
|
||||
continue;
|
||||
}
|
||||
|
||||
const deleteFrom = lineContentFrom;
|
||||
let deleteTo = deleteFrom + indentation.length;
|
||||
|
||||
// If we need to remove an existing list,
|
||||
const currentContainer = getContainerType(line);
|
||||
if (currentContainer !== null) {
|
||||
const containerRegex = listRegexes[currentContainer];
|
||||
const containerMatch = lineContent.match(containerRegex);
|
||||
if (!containerMatch) {
|
||||
throw new Error(
|
||||
'Assertion failed: container regex does not match line content.'
|
||||
);
|
||||
}
|
||||
|
||||
deleteTo = lineContentFrom + containerMatch[0].length;
|
||||
}
|
||||
|
||||
let replacementString;
|
||||
|
||||
if (listType === containerType) {
|
||||
// Delete the existing list if it's the same type as the current
|
||||
replacementString = '';
|
||||
} else if (listType === ListType.OrderedList) {
|
||||
replacementString = `${firstLineIndentation}${listItemCounter}. `;
|
||||
} else if (listType === ListType.CheckList) {
|
||||
replacementString = `${firstLineIndentation}- [ ] `;
|
||||
} else {
|
||||
replacementString = `${firstLineIndentation}- `;
|
||||
}
|
||||
|
||||
changes.push({
|
||||
from: deleteFrom,
|
||||
to: deleteTo,
|
||||
insert: replacementString,
|
||||
});
|
||||
charsAdded -= deleteTo - deleteFrom;
|
||||
charsAdded += replacementString.length;
|
||||
listItemCounter++;
|
||||
}
|
||||
|
||||
// Don't change cursors to selections
|
||||
if (sel.empty) {
|
||||
// Position the cursor at the end of the last line modified
|
||||
sel = EditorSelection.cursor(toLine.to + charsAdded);
|
||||
} else {
|
||||
sel = EditorSelection.range(
|
||||
sel.from,
|
||||
sel.to + charsAdded
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
range: sel,
|
||||
};
|
||||
});
|
||||
view.dispatch(changes);
|
||||
state = view.state;
|
||||
doc = state.doc;
|
||||
|
||||
// Renumber the list
|
||||
view.dispatch(state.changeByRange((sel: SelectionRange) => {
|
||||
return renumberList(state, sel);
|
||||
}));
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
export const toggleHeaderLevel = (level: number): Command => {
|
||||
return (view: EditorView): boolean => {
|
||||
let headerStr = '';
|
||||
for (let i = 0; i < level; i++) {
|
||||
headerStr += '#';
|
||||
}
|
||||
|
||||
const matchEmpty = true;
|
||||
// Remove header formatting for any other level
|
||||
let changes = toggleSelectedLinesStartWith(
|
||||
view.state,
|
||||
new RegExp(
|
||||
// Check all numbers of #s lower than [level]
|
||||
`${level - 1 >= 1 ? `(?:^[#]{1,${level - 1}}\\s)|` : ''
|
||||
|
||||
// Check all number of #s higher than [level]
|
||||
}(?:^[#]{${level + 1},}\\s)`
|
||||
),
|
||||
'',
|
||||
matchEmpty
|
||||
);
|
||||
view.dispatch(changes);
|
||||
|
||||
// Set to the proper header level
|
||||
changes = toggleSelectedLinesStartWith(
|
||||
view.state,
|
||||
// We want exactly [level] '#' characters.
|
||||
new RegExp(`^[#]{${level}} `),
|
||||
`${headerStr} `,
|
||||
matchEmpty
|
||||
);
|
||||
view.dispatch(changes);
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
// Prepends the given editor's indentUnit to all lines of the current selection
|
||||
// and re-numbers modified ordered lists (if any).
|
||||
export const increaseIndent: Command = (view: EditorView): boolean => {
|
||||
const matchEmpty = true;
|
||||
const matchNothing = /$ ^/;
|
||||
const indentUnit = indentString(view.state, getIndentUnit(view.state));
|
||||
|
||||
const changes = toggleSelectedLinesStartWith(
|
||||
view.state,
|
||||
// Delete nothing
|
||||
matchNothing,
|
||||
// ...and thus always add indentUnit.
|
||||
indentUnit,
|
||||
matchEmpty
|
||||
);
|
||||
view.dispatch(changes);
|
||||
|
||||
// Fix any lists
|
||||
view.dispatch(view.state.changeByRange((sel: SelectionRange) => {
|
||||
return renumberList(view.state, sel);
|
||||
}));
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const decreaseIndent: Command = (view: EditorView): boolean => {
|
||||
const matchEmpty = true;
|
||||
const changes = toggleSelectedLinesStartWith(
|
||||
view.state,
|
||||
// Assume indentation is either a tab or in units
|
||||
// of n spaces.
|
||||
new RegExp(`^(?:[\\t]|[ ]{1,${getIndentUnit(view.state)}})`),
|
||||
// Don't add new text
|
||||
'',
|
||||
matchEmpty
|
||||
);
|
||||
|
||||
view.dispatch(changes);
|
||||
|
||||
// Fix any lists
|
||||
view.dispatch(view.state.changeByRange((sel: SelectionRange) => {
|
||||
return renumberList(view.state, sel);
|
||||
}));
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const updateLink = (label: string, url: string): Command => {
|
||||
// Empty label? Just include the URL.
|
||||
const linkText = label === '' ? url : `[${label}](${url})`;
|
||||
|
||||
return (editor: EditorView): boolean => {
|
||||
const transaction = editor.state.changeByRange((sel: SelectionRange) => {
|
||||
const changes = [];
|
||||
|
||||
// Search for a link that overlaps [sel]
|
||||
let linkFrom: number | null = null;
|
||||
let linkTo: number | null = null;
|
||||
syntaxTree(editor.state).iterate({
|
||||
from: sel.from, to: sel.to,
|
||||
enter: node => {
|
||||
const haveFoundLink = (linkFrom !== null && linkTo !== null);
|
||||
|
||||
if (node.name === 'Link' || (node.name === 'URL' && !haveFoundLink)) {
|
||||
linkFrom = node.from;
|
||||
linkTo = node.to;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
linkFrom ??= sel.from;
|
||||
linkTo ??= sel.to;
|
||||
|
||||
changes.push({
|
||||
from: linkFrom, to: linkTo,
|
||||
insert: linkText,
|
||||
});
|
||||
|
||||
return {
|
||||
changes,
|
||||
range: EditorSelection.range(linkFrom, linkFrom + linkText.length),
|
||||
};
|
||||
});
|
||||
|
||||
editor.dispatch(transaction);
|
||||
return true;
|
||||
};
|
||||
};
|
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
findInlineMatch, MatchSide, RegionSpec, tabsToSpaces, toggleRegionFormatGlobally,
|
||||
} from './markdownReformatter';
|
||||
import { Text as DocumentText, EditorSelection, EditorState } from '@codemirror/state';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
|
||||
describe('markdownReformatter', () => {
|
||||
const boldSpec: RegionSpec = RegionSpec.of({
|
||||
template: '**',
|
||||
});
|
||||
|
||||
it('matching a bolded region: should return the length of the match', () => {
|
||||
const doc = DocumentText.of(['**test**']);
|
||||
const sel = EditorSelection.range(0, 5);
|
||||
|
||||
// matchStart returns the length of the match
|
||||
expect(findInlineMatch(doc, boldSpec, sel, MatchSide.Start)).toBe(2);
|
||||
});
|
||||
|
||||
it('matching a bolded region: should match the end of a region, if next to the cursor', () => {
|
||||
const doc = DocumentText.of(['**...** test.']);
|
||||
const sel = EditorSelection.range(5, 5);
|
||||
expect(findInlineMatch(doc, boldSpec, sel, MatchSide.End)).toBe(2);
|
||||
});
|
||||
|
||||
it('matching a bolded region: should return -1 if no match is found', () => {
|
||||
const doc = DocumentText.of(['**...** test.']);
|
||||
const sel = EditorSelection.range(3, 3);
|
||||
expect(findInlineMatch(doc, boldSpec, sel, MatchSide.Start)).toBe(-1);
|
||||
});
|
||||
|
||||
it('should match a custom specification of italicized regions', () => {
|
||||
const spec: RegionSpec = {
|
||||
template: { start: '*', end: '*' },
|
||||
matcher: { start: /[*_]/g, end: /[*_]/g },
|
||||
};
|
||||
const testString = 'This is a _test_';
|
||||
const testDoc = DocumentText.of([testString]);
|
||||
const fullSel = EditorSelection.range('This is a '.length, testString.length);
|
||||
|
||||
// should match the start of the region
|
||||
expect(findInlineMatch(testDoc, spec, fullSel, MatchSide.Start)).toBe(1);
|
||||
|
||||
// should match the end of the region
|
||||
expect(findInlineMatch(testDoc, spec, fullSel, MatchSide.End)).toBe(1);
|
||||
});
|
||||
|
||||
const listSpec: RegionSpec = {
|
||||
template: { start: ' - ', end: '' },
|
||||
matcher: {
|
||||
start: /^\s*[-*]\s/g,
|
||||
end: /$/g,
|
||||
},
|
||||
};
|
||||
|
||||
it('matching a custom list: should not match a list if not within the selection', () => {
|
||||
const doc = DocumentText.of(['- Test...']);
|
||||
const sel = EditorSelection.range(1, 6);
|
||||
|
||||
// Beginning of list not selected: no match
|
||||
expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(-1);
|
||||
});
|
||||
|
||||
it('matching a custom list: should match start of selected, unindented list', () => {
|
||||
const doc = DocumentText.of(['- Test...']);
|
||||
const sel = EditorSelection.range(0, 6);
|
||||
|
||||
expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(2);
|
||||
});
|
||||
|
||||
it('matching a custom list: should match start of indented list', () => {
|
||||
const doc = DocumentText.of([' - Test...']);
|
||||
const sel = EditorSelection.range(0, 6);
|
||||
|
||||
expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(5);
|
||||
});
|
||||
|
||||
it('matching a custom list: should match the end of an item in an indented list', () => {
|
||||
const doc = DocumentText.of([' - Test...']);
|
||||
const sel = EditorSelection.range(0, 6);
|
||||
|
||||
// Zero-length, but found, selection
|
||||
expect(findInlineMatch(doc, listSpec, sel, MatchSide.End)).toBe(0);
|
||||
});
|
||||
|
||||
const multiLineTestText = `Internal text manipulation
|
||||
This is a test...
|
||||
of block and inline region toggling.`;
|
||||
const codeFenceRegex = /^``````\w*\s*$/;
|
||||
const inlineCodeRegionSpec = RegionSpec.of({
|
||||
template: '`',
|
||||
nodeName: 'InlineCode',
|
||||
});
|
||||
const blockCodeRegionSpec: RegionSpec = {
|
||||
template: { start: '``````', end: '``````' },
|
||||
matcher: { start: codeFenceRegex, end: codeFenceRegex },
|
||||
};
|
||||
|
||||
it('should create an empty inline region around the cursor, if given an empty selection', () => {
|
||||
const initialState: EditorState = EditorState.create({
|
||||
doc: multiLineTestText,
|
||||
selection: EditorSelection.cursor(0),
|
||||
});
|
||||
|
||||
const changes = toggleRegionFormatGlobally(
|
||||
initialState, inlineCodeRegionSpec, blockCodeRegionSpec
|
||||
);
|
||||
|
||||
const newState = initialState.update(changes).state;
|
||||
expect(newState.doc.toString()).toEqual(`\`\`${multiLineTestText}`);
|
||||
});
|
||||
|
||||
it('should wrap multiple selected lines in block formatting', () => {
|
||||
const initialState: EditorState = EditorState.create({
|
||||
doc: multiLineTestText,
|
||||
selection: EditorSelection.range(0, multiLineTestText.length),
|
||||
});
|
||||
|
||||
const changes = toggleRegionFormatGlobally(
|
||||
initialState, inlineCodeRegionSpec, blockCodeRegionSpec
|
||||
);
|
||||
|
||||
const newState = initialState.update(changes).state;
|
||||
const editorText = newState.doc.toString();
|
||||
expect(editorText).toBe(`\`\`\`\`\`\`\n${multiLineTestText}\n\`\`\`\`\`\``);
|
||||
expect(newState.selection.main.from).toBe(0);
|
||||
expect(newState.selection.main.to).toBe(editorText.length);
|
||||
});
|
||||
|
||||
it('should convert tabs to spaces based on indentUnit', () => {
|
||||
const state: EditorState = EditorState.create({
|
||||
doc: multiLineTestText,
|
||||
selection: EditorSelection.cursor(0),
|
||||
extensions: [
|
||||
indentUnit.of(' '),
|
||||
],
|
||||
});
|
||||
expect(tabsToSpaces(state, '\t')).toBe(' ');
|
||||
expect(tabsToSpaces(state, '\t ')).toBe(' ');
|
||||
expect(tabsToSpaces(state, ' \t ')).toBe(' ');
|
||||
});
|
||||
});
|
@@ -0,0 +1,712 @@
|
||||
import {
|
||||
Text as DocumentText, EditorSelection, SelectionRange, ChangeSpec, EditorState, Line, TransactionSpec,
|
||||
} from '@codemirror/state';
|
||||
import { getIndentUnit, syntaxTree } from '@codemirror/language';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
|
||||
// pregQuote escapes text for usage in regular expressions
|
||||
const { pregQuote } = require('@joplin/lib/string-utils-common');
|
||||
|
||||
// Length of the symbol that starts a block quote
|
||||
const blockQuoteStartLen = '> '.length;
|
||||
const blockQuoteRegex = /^>\s/;
|
||||
|
||||
// Specifies the update of a single selection region and its contents
|
||||
type SelectionUpdate = { range: SelectionRange; changes?: ChangeSpec };
|
||||
|
||||
// Specifies how a to find the start/stop of a type of formatting
|
||||
interface RegionMatchSpec {
|
||||
start: RegExp;
|
||||
end: RegExp;
|
||||
}
|
||||
|
||||
// Describes a region's formatting
|
||||
export interface RegionSpec {
|
||||
// The name of the node corresponding to the region in the syntax tree
|
||||
nodeName?: string;
|
||||
|
||||
// Text to be inserted before and after the region when toggling.
|
||||
template: { start: string; end: string };
|
||||
|
||||
// How to identify the region
|
||||
matcher: RegionMatchSpec;
|
||||
}
|
||||
|
||||
export namespace RegionSpec { // eslint-disable-line no-redeclare
|
||||
interface RegionSpecConfig {
|
||||
nodeName?: string;
|
||||
template: string | { start: string; end: string };
|
||||
matcher?: RegionMatchSpec;
|
||||
}
|
||||
|
||||
// Creates a new RegionSpec, given a simplified set of options.
|
||||
// If [config.template] is a string, it is used as both the starting and ending
|
||||
// templates.
|
||||
// Similarly, if [config.matcher] is not given, a matcher is created based on
|
||||
// [config.template].
|
||||
export const of = (config: RegionSpecConfig): RegionSpec => {
|
||||
let templateStart: string, templateEnd: string;
|
||||
if (typeof config.template === 'string') {
|
||||
templateStart = config.template;
|
||||
templateEnd = config.template;
|
||||
} else {
|
||||
templateStart = config.template.start;
|
||||
templateEnd = config.template.end;
|
||||
}
|
||||
|
||||
const matcher: RegionMatchSpec =
|
||||
config.matcher ?? matcherFromTemplate(templateStart, templateEnd);
|
||||
|
||||
return {
|
||||
nodeName: config.nodeName,
|
||||
template: { start: templateStart, end: templateEnd },
|
||||
matcher,
|
||||
};
|
||||
};
|
||||
|
||||
const matcherFromTemplate = (start: string, end: string): RegionMatchSpec => {
|
||||
// See https://stackoverflow.com/a/30851002
|
||||
const escapedStart = pregQuote(start);
|
||||
const escapedEnd = pregQuote(end);
|
||||
|
||||
return {
|
||||
start: new RegExp(escapedStart, 'g'),
|
||||
end: new RegExp(escapedEnd, 'g'),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export enum MatchSide {
|
||||
Start,
|
||||
End,
|
||||
}
|
||||
|
||||
// Returns the length of a match for this in the given selection,
|
||||
// -1 if no match is found.
|
||||
export const findInlineMatch = (
|
||||
doc: DocumentText, spec: RegionSpec, sel: SelectionRange, side: MatchSide
|
||||
): number => {
|
||||
const [regex, template] = (() => {
|
||||
if (side === MatchSide.Start) {
|
||||
return [spec.matcher.start, spec.template.start];
|
||||
} else {
|
||||
return [spec.matcher.end, spec.template.end];
|
||||
}
|
||||
})();
|
||||
const [startIndex, endIndex] = (() => {
|
||||
if (!sel.empty) {
|
||||
return [sel.from, sel.to];
|
||||
}
|
||||
|
||||
const bufferSize = template.length;
|
||||
if (side === MatchSide.Start) {
|
||||
return [sel.from - bufferSize, sel.to];
|
||||
} else {
|
||||
return [sel.from, sel.to + bufferSize];
|
||||
}
|
||||
})();
|
||||
const searchText = doc.sliceString(startIndex, endIndex);
|
||||
|
||||
// Returns true if [idx] is in the right place (the match is at
|
||||
// the end of the string or the beginning based on startIndex/endIndex).
|
||||
const indexSatisfies = (idx: number, len: number): boolean => {
|
||||
idx += startIndex;
|
||||
if (side === MatchSide.Start) {
|
||||
return idx === startIndex;
|
||||
} else {
|
||||
return idx + len === endIndex;
|
||||
}
|
||||
};
|
||||
|
||||
// Enforce 'g' flag.
|
||||
if (!regex.global) {
|
||||
throw new Error('Regular expressions used by RegionSpec must have the global flag!');
|
||||
}
|
||||
|
||||
// Search from the beginning.
|
||||
regex.lastIndex = 0;
|
||||
|
||||
let foundMatch = null;
|
||||
let match;
|
||||
while ((match = regex.exec(searchText)) !== null) {
|
||||
if (indexSatisfies(match.index, match[0].length)) {
|
||||
foundMatch = match;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundMatch) {
|
||||
const matchLength = foundMatch[0].length;
|
||||
const matchIndex = foundMatch.index;
|
||||
|
||||
// If the match isn't in the right place,
|
||||
if (indexSatisfies(matchIndex, matchLength)) {
|
||||
return matchLength;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
};
|
||||
|
||||
export const stripBlockquote = (line: Line): string => {
|
||||
const match = line.text.match(blockQuoteRegex);
|
||||
|
||||
if (match) {
|
||||
return line.text.substring(match[0].length);
|
||||
}
|
||||
|
||||
return line.text;
|
||||
};
|
||||
|
||||
export const tabsToSpaces = (state: EditorState, text: string): string => {
|
||||
const chunks = text.split('\t');
|
||||
const spaceLen = getIndentUnit(state);
|
||||
let result = chunks[0];
|
||||
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
for (let j = result.length % spaceLen; j < spaceLen; j++) {
|
||||
result += ' ';
|
||||
}
|
||||
|
||||
result += chunks[i];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Returns true iff [a] (an indentation string) is roughly equivalent to [b].
|
||||
export const isIndentationEquivalent = (state: EditorState, a: string, b: string): boolean => {
|
||||
// Consider sublists to be the same as their parent list if they have the same
|
||||
// label plus or minus 1 space.
|
||||
return Math.abs(tabsToSpaces(state, a).length - tabsToSpaces(state, b).length) <= 1;
|
||||
};
|
||||
|
||||
// Expands and returns a copy of [sel] to the smallest container node with name in [nodeNames].
|
||||
export const growSelectionToNode = (
|
||||
state: EditorState, sel: SelectionRange, nodeNames: string|string[]|null
|
||||
): SelectionRange => {
|
||||
if (!nodeNames) {
|
||||
return sel;
|
||||
}
|
||||
|
||||
const isAcceptableNode = (name: string): boolean => {
|
||||
if (typeof nodeNames === 'string') {
|
||||
return name === nodeNames;
|
||||
}
|
||||
|
||||
for (const otherName of nodeNames) {
|
||||
if (otherName === name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
let newFrom = null;
|
||||
let newTo = null;
|
||||
let smallestLen = Infinity;
|
||||
|
||||
// Find the smallest range.
|
||||
syntaxTree(state).iterate({
|
||||
from: sel.from, to: sel.to,
|
||||
enter: node => {
|
||||
if (isAcceptableNode(node.name)) {
|
||||
if (node.to - node.from < smallestLen) {
|
||||
newFrom = node.from;
|
||||
newTo = node.to;
|
||||
smallestLen = newTo - newFrom;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// If it's in such a node,
|
||||
if (newFrom !== null && newTo !== null) {
|
||||
return EditorSelection.range(newFrom, newTo);
|
||||
} else {
|
||||
return sel;
|
||||
}
|
||||
};
|
||||
|
||||
// Toggles whether the given selection matches the inline region specified by [spec].
|
||||
//
|
||||
// For example, something similar to toggleSurrounded('**', '**') would surround
|
||||
// every selection range with asterisks (including the caret).
|
||||
// If the selection is already surrounded by these characters, they are
|
||||
// removed.
|
||||
const toggleInlineRegionSurrounded = (
|
||||
doc: DocumentText, sel: SelectionRange, spec: RegionSpec
|
||||
): SelectionUpdate => {
|
||||
let content = doc.sliceString(sel.from, sel.to);
|
||||
const startMatchLen = findInlineMatch(doc, spec, sel, MatchSide.Start);
|
||||
const endMatchLen = findInlineMatch(doc, spec, sel, MatchSide.End);
|
||||
|
||||
const startsWithBefore = startMatchLen >= 0;
|
||||
const endsWithAfter = endMatchLen >= 0;
|
||||
|
||||
const changes = [];
|
||||
let finalSelStart = sel.from;
|
||||
let finalSelEnd = sel.to;
|
||||
|
||||
if (startsWithBefore && endsWithAfter) {
|
||||
// Remove the before and after.
|
||||
content = content.substring(startMatchLen);
|
||||
content = content.substring(0, content.length - endMatchLen);
|
||||
|
||||
finalSelEnd -= startMatchLen + endMatchLen;
|
||||
|
||||
changes.push({
|
||||
from: sel.from,
|
||||
to: sel.to,
|
||||
insert: content,
|
||||
});
|
||||
} else {
|
||||
changes.push({
|
||||
from: sel.from,
|
||||
insert: spec.template.start,
|
||||
});
|
||||
|
||||
changes.push({
|
||||
from: sel.to,
|
||||
insert: spec.template.start,
|
||||
});
|
||||
|
||||
// If not a caret,
|
||||
if (!sel.empty) {
|
||||
// Select the surrounding chars.
|
||||
finalSelEnd += spec.template.start.length + spec.template.end.length;
|
||||
} else {
|
||||
// Position the caret within the added content.
|
||||
finalSelStart = sel.from + spec.template.start.length;
|
||||
finalSelEnd = finalSelStart;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
range: EditorSelection.range(finalSelStart, finalSelEnd),
|
||||
};
|
||||
};
|
||||
|
||||
// Returns updated selections: For all selections in the given `EditorState`, toggles
|
||||
// whether each is contained in an inline region of type [spec].
|
||||
export const toggleInlineSelectionFormat = (
|
||||
state: EditorState, spec: RegionSpec, sel: SelectionRange
|
||||
): SelectionUpdate => {
|
||||
const endMatchLen = findInlineMatch(state.doc, spec, sel, MatchSide.End);
|
||||
|
||||
// If at the end of the region, move the
|
||||
// caret to the end.
|
||||
// E.g.
|
||||
// **foobar|**
|
||||
// **foobar**|
|
||||
if (sel.empty && endMatchLen > -1) {
|
||||
const newCursorPos = sel.from + endMatchLen;
|
||||
|
||||
return {
|
||||
range: EditorSelection.cursor(newCursorPos),
|
||||
};
|
||||
}
|
||||
|
||||
// Grow the selection to encompass the entire node.
|
||||
const newRange = growSelectionToNode(state, sel, spec.nodeName);
|
||||
return toggleInlineRegionSurrounded(state.doc, newRange, spec);
|
||||
};
|
||||
|
||||
// Like toggleInlineSelectionFormat, but for all selections in [state].
|
||||
export const toggleInlineFormatGlobally = (
|
||||
state: EditorState, spec: RegionSpec
|
||||
): TransactionSpec => {
|
||||
const changes = state.changeByRange((sel: SelectionRange) => {
|
||||
return toggleInlineSelectionFormat(state, spec, sel);
|
||||
});
|
||||
return changes;
|
||||
};
|
||||
|
||||
// Toggle formatting in a region, applying block formatting
|
||||
export const toggleRegionFormatGlobally = (
|
||||
state: EditorState,
|
||||
|
||||
inlineSpec: RegionSpec,
|
||||
blockSpec: RegionSpec
|
||||
): TransactionSpec => {
|
||||
const doc = state.doc;
|
||||
const preserveBlockQuotes = true;
|
||||
|
||||
const getMatchEndPoints = (
|
||||
match: RegExpMatchArray, line: Line, inBlockQuote: boolean
|
||||
): [startIdx: number, stopIdx: number] => {
|
||||
const startIdx = line.from + match.index;
|
||||
let stopIdx;
|
||||
|
||||
// Don't treat '> ' as part of the line's content if we're in a blockquote.
|
||||
let contentLength = line.text.length;
|
||||
if (inBlockQuote && preserveBlockQuotes) {
|
||||
contentLength -= blockQuoteStartLen;
|
||||
}
|
||||
|
||||
// If it matches the entire line, remove the newline character.
|
||||
if (match[0].length === contentLength) {
|
||||
stopIdx = line.to + 1;
|
||||
} else {
|
||||
stopIdx = startIdx + match[0].length;
|
||||
|
||||
// Take into account the extra '> ' characters, if necessary
|
||||
if (inBlockQuote && preserveBlockQuotes) {
|
||||
stopIdx += blockQuoteStartLen;
|
||||
}
|
||||
}
|
||||
|
||||
stopIdx = Math.min(stopIdx, doc.length);
|
||||
return [startIdx, stopIdx];
|
||||
};
|
||||
|
||||
// Returns a change spec that converts an inline region to a block region
|
||||
// only if the user's cursor is in an empty inline region.
|
||||
// For example,
|
||||
// $|$ -> $$\n|\n$$ where | represents the cursor.
|
||||
const handleInlineToBlockConversion = (sel: SelectionRange) => {
|
||||
if (!sel.empty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startMatchLen = findInlineMatch(doc, inlineSpec, sel, MatchSide.Start);
|
||||
const stopMatchLen = findInlineMatch(doc, inlineSpec, sel, MatchSide.End);
|
||||
|
||||
if (startMatchLen >= 0 && stopMatchLen >= 0) {
|
||||
const fromLine = doc.lineAt(sel.from);
|
||||
const inBlockQuote = fromLine.text.match(blockQuoteRegex);
|
||||
|
||||
let lineStartStr = '\n';
|
||||
if (inBlockQuote && preserveBlockQuotes) {
|
||||
lineStartStr = '\n> ';
|
||||
}
|
||||
|
||||
|
||||
const inlineStart = sel.from - startMatchLen;
|
||||
const inlineStop = sel.from + stopMatchLen;
|
||||
|
||||
// Determine the text that starts the new block (e.g. \n$$\n for
|
||||
// a math block).
|
||||
let blockStart = `${blockSpec.template.start}${lineStartStr}`;
|
||||
if (fromLine.from !== inlineStart) {
|
||||
// Add a line before to put the start of the block
|
||||
// on its own line.
|
||||
blockStart = lineStartStr + blockStart;
|
||||
}
|
||||
|
||||
return {
|
||||
changes: [
|
||||
{
|
||||
from: inlineStart,
|
||||
to: inlineStop,
|
||||
insert: `${blockStart}${lineStartStr}${blockSpec.template.end}`,
|
||||
},
|
||||
],
|
||||
|
||||
range: EditorSelection.cursor(inlineStart + blockStart.length),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const changes = state.changeByRange((sel: SelectionRange) => {
|
||||
const blockConversion = handleInlineToBlockConversion(sel);
|
||||
if (blockConversion) {
|
||||
return blockConversion;
|
||||
}
|
||||
|
||||
// If we're in the block version, grow the selection to cover the entire region.
|
||||
sel = growSelectionToNode(state, sel, blockSpec.nodeName);
|
||||
|
||||
const fromLine = doc.lineAt(sel.from);
|
||||
const toLine = doc.lineAt(sel.to);
|
||||
let fromLineText = fromLine.text;
|
||||
let toLineText = toLine.text;
|
||||
|
||||
let charsAdded = 0;
|
||||
const changes = [];
|
||||
|
||||
// Single line: Inline toggle.
|
||||
if (fromLine.number === toLine.number) {
|
||||
return toggleInlineSelectionFormat(state, inlineSpec, sel);
|
||||
}
|
||||
|
||||
// Are all lines in a block quote?
|
||||
let inBlockQuote = true;
|
||||
for (let i = fromLine.number; i <= toLine.number; i++) {
|
||||
const line = doc.line(i);
|
||||
|
||||
if (!line.text.match(blockQuoteRegex)) {
|
||||
inBlockQuote = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore block quote characters if in a block quote.
|
||||
if (inBlockQuote && preserveBlockQuotes) {
|
||||
fromLineText = fromLineText.substring(blockQuoteStartLen);
|
||||
toLineText = toLineText.substring(blockQuoteStartLen);
|
||||
}
|
||||
|
||||
// Otherwise, we're toggling the block version
|
||||
const startMatch = blockSpec.matcher.start.exec(fromLineText);
|
||||
const stopMatch = blockSpec.matcher.end.exec(toLineText);
|
||||
if (startMatch && stopMatch) {
|
||||
// Get start and stop indicies for the starting and ending matches
|
||||
const [fromMatchFrom, fromMatchTo] = getMatchEndPoints(startMatch, fromLine, inBlockQuote);
|
||||
const [toMatchFrom, toMatchTo] = getMatchEndPoints(stopMatch, toLine, inBlockQuote);
|
||||
|
||||
// Delete content of the first line
|
||||
changes.push({
|
||||
from: fromMatchFrom,
|
||||
to: fromMatchTo,
|
||||
});
|
||||
charsAdded -= fromMatchTo - fromMatchFrom;
|
||||
|
||||
// Delete content of the last line
|
||||
changes.push({
|
||||
from: toMatchFrom,
|
||||
to: toMatchTo,
|
||||
});
|
||||
charsAdded -= toMatchTo - toMatchFrom;
|
||||
} else {
|
||||
let insertBefore, insertAfter;
|
||||
|
||||
if (inBlockQuote && preserveBlockQuotes) {
|
||||
insertBefore = `> ${blockSpec.template.start}\n`;
|
||||
insertAfter = `\n> ${blockSpec.template.end}`;
|
||||
} else {
|
||||
insertBefore = `${blockSpec.template.start}\n`;
|
||||
insertAfter = `\n${blockSpec.template.end}`;
|
||||
}
|
||||
|
||||
changes.push({
|
||||
from: fromLine.from,
|
||||
insert: insertBefore,
|
||||
});
|
||||
|
||||
changes.push({
|
||||
from: toLine.to,
|
||||
insert: insertAfter,
|
||||
});
|
||||
charsAdded += insertBefore.length + insertAfter.length;
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
|
||||
// Selection should now encompass all lines that were changed.
|
||||
range: EditorSelection.range(
|
||||
fromLine.from, toLine.to + charsAdded
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
// Toggles whether all lines in the user's selection start with [regex].
|
||||
export const toggleSelectedLinesStartWith = (
|
||||
state: EditorState,
|
||||
regex: RegExp,
|
||||
template: string,
|
||||
matchEmpty: boolean,
|
||||
|
||||
// Name associated with what [regex] matches (e.g. FencedCode)
|
||||
nodeName?: string
|
||||
): TransactionSpec => {
|
||||
const ignoreBlockQuotes = true;
|
||||
const getLineContentStart = (line: Line): number => {
|
||||
if (!ignoreBlockQuotes) {
|
||||
return line.from;
|
||||
}
|
||||
|
||||
const blockQuoteMatch = line.text.match(blockQuoteRegex);
|
||||
if (blockQuoteMatch) {
|
||||
return line.from + blockQuoteMatch[0].length;
|
||||
}
|
||||
|
||||
return line.from;
|
||||
};
|
||||
|
||||
const getLineContent = (line: Line): string => {
|
||||
const contentStart = getLineContentStart(line);
|
||||
return line.text.substring(contentStart - line.from);
|
||||
};
|
||||
|
||||
const changes = state.changeByRange((sel: SelectionRange) => {
|
||||
// Attempt to select all lines in the region
|
||||
if (nodeName && sel.empty) {
|
||||
sel = growSelectionToNode(state, sel, nodeName);
|
||||
}
|
||||
|
||||
const doc = state.doc;
|
||||
const fromLine = doc.lineAt(sel.from);
|
||||
const toLine = doc.lineAt(sel.to);
|
||||
let hasProp = false;
|
||||
let charsAdded = 0;
|
||||
|
||||
const changes = [];
|
||||
const lines = [];
|
||||
|
||||
for (let i = fromLine.number; i <= toLine.number; i++) {
|
||||
const line = doc.line(i);
|
||||
const text = getLineContent(line);
|
||||
|
||||
// If already matching [regex],
|
||||
if (text.search(regex) === 0) {
|
||||
hasProp = true;
|
||||
}
|
||||
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const text = getLineContent(line);
|
||||
const contentFrom = getLineContentStart(line);
|
||||
|
||||
// Only process if the line is non-empty.
|
||||
if (!matchEmpty && text.trim().length === 0
|
||||
// Treat the first line differently
|
||||
&& fromLine.number < line.number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasProp) {
|
||||
const match = text.match(regex);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
changes.push({
|
||||
from: contentFrom,
|
||||
to: contentFrom + match[0].length,
|
||||
insert: '',
|
||||
});
|
||||
|
||||
charsAdded -= match[0].length;
|
||||
} else {
|
||||
changes.push({
|
||||
from: contentFrom,
|
||||
insert: template,
|
||||
});
|
||||
|
||||
charsAdded += template.length;
|
||||
}
|
||||
}
|
||||
|
||||
// If the selection is empty and a single line was changed, don't grow it.
|
||||
// (user might be adding a list/header, in which case, selecting the just
|
||||
// added text isn't helpful)
|
||||
let newSel;
|
||||
if (sel.empty && fromLine.number === toLine.number) {
|
||||
const regionEnd = toLine.to + charsAdded;
|
||||
newSel = EditorSelection.cursor(regionEnd);
|
||||
} else {
|
||||
newSel = EditorSelection.range(fromLine.from, toLine.to + charsAdded);
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
|
||||
// Selection should now encompass all lines that were changed.
|
||||
range: newSel,
|
||||
};
|
||||
});
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
// Ensures that ordered lists within [sel] are numbered in ascending order.
|
||||
export const renumberList = (state: EditorState, sel: SelectionRange): SelectionUpdate => {
|
||||
const doc = state.doc;
|
||||
|
||||
const listItemRegex = /^(\s*)(\d+)\.\s?/;
|
||||
const changes: ChangeSpec[] = [];
|
||||
const fromLine = doc.lineAt(sel.from);
|
||||
const toLine = doc.lineAt(sel.to);
|
||||
let charsAdded = 0;
|
||||
|
||||
// Re-numbers ordered lists and sublists with numbers on each line in [linesToHandle]
|
||||
const handleLines = (linesToHandle: Line[]) => {
|
||||
let currentGroupIndentation = '';
|
||||
let nextListNumber = 1;
|
||||
const listNumberStack: number[] = [];
|
||||
let prevLineNumber;
|
||||
|
||||
for (const line of linesToHandle) {
|
||||
// Don't re-handle lines.
|
||||
if (line.number === prevLineNumber) {
|
||||
continue;
|
||||
}
|
||||
prevLineNumber = line.number;
|
||||
|
||||
const filteredText = stripBlockquote(line);
|
||||
const match = filteredText.match(listItemRegex);
|
||||
const indentation = match[1];
|
||||
|
||||
const indentationLen = tabsToSpaces(state, indentation).length;
|
||||
const targetIndentLen = tabsToSpaces(state, currentGroupIndentation).length;
|
||||
if (targetIndentLen < indentationLen) {
|
||||
listNumberStack.push(nextListNumber);
|
||||
nextListNumber = 1;
|
||||
} else if (targetIndentLen > indentationLen) {
|
||||
nextListNumber = listNumberStack.pop() ?? parseInt(match[2], 10);
|
||||
}
|
||||
|
||||
if (targetIndentLen !== indentationLen) {
|
||||
currentGroupIndentation = indentation;
|
||||
}
|
||||
|
||||
const from = line.to - filteredText.length;
|
||||
const to = from + match[0].length;
|
||||
const inserted = `${indentation}${nextListNumber}. `;
|
||||
nextListNumber++;
|
||||
|
||||
changes.push({
|
||||
from,
|
||||
to,
|
||||
insert: inserted,
|
||||
});
|
||||
charsAdded -= to - from;
|
||||
charsAdded += inserted.length;
|
||||
}
|
||||
};
|
||||
|
||||
const linesToHandle: Line[] = [];
|
||||
syntaxTree(state).iterate({
|
||||
from: sel.from,
|
||||
to: sel.to,
|
||||
enter: (nodeRef: SyntaxNodeRef) => {
|
||||
if (nodeRef.name === 'ListItem') {
|
||||
for (const node of nodeRef.node.parent.getChildren('ListItem')) {
|
||||
const line = doc.lineAt(node.from);
|
||||
const filteredText = stripBlockquote(line);
|
||||
const match = filteredText.match(listItemRegex);
|
||||
if (match) {
|
||||
linesToHandle.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
linesToHandle.sort((a, b) => a.number - b.number);
|
||||
handleLines(linesToHandle);
|
||||
|
||||
// Re-position the selection in a way that makes sense
|
||||
if (sel.empty) {
|
||||
sel = EditorSelection.cursor(toLine.to + charsAdded);
|
||||
} else {
|
||||
sel = EditorSelection.range(
|
||||
fromLine.from,
|
||||
toLine.to + charsAdded
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
range: sel,
|
||||
changes,
|
||||
};
|
||||
};
|
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
|
||||
setSpellcheckEnabled(enabled: boolean): void;
|
||||
|
||||
// Toggle whether we're in a type of region.
|
||||
toggleBolded(): void;
|
||||
toggleItalicized(): void;
|
||||
toggleList(kind: ListType): void;
|
||||
toggleCode(): void;
|
||||
toggleMath(): void;
|
||||
toggleHeaderLevel(level: number): void;
|
||||
|
||||
// Create a new link or update the currently selected link with
|
||||
// the given [label] and [url].
|
||||
updateLink(label: string, url: string): void;
|
||||
|
||||
increaseIndent(): void;
|
||||
decreaseIndent(): void;
|
||||
scrollSelectionIntoView(): void;
|
||||
|
||||
searchControl: SearchControl;
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
// Handle logging strings when running in a WebView.
|
||||
|
||||
// Because this will be running both in a WebView and in nodeJS, we need to use
|
||||
// globalThis in place of window. We need to tell ESLint that we're doing this:
|
||||
/* global globalThis*/
|
||||
|
||||
export function postMessage(name: string, data: any) {
|
||||
// Only call postMessage if we're running in a WebView (this code may be called
|
||||
// in integration tests).
|
||||
(globalThis as any).ReactNativeWebView?.postMessage(JSON.stringify({
|
||||
data,
|
||||
name,
|
||||
}));
|
||||
}
|
||||
|
||||
export function logMessage(...msg: any[]) {
|
||||
postMessage('onLog', { value: msg });
|
||||
}
|
||||
|
156
packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx
Normal file
156
packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
// Dialog allowing the user to update/create links
|
||||
|
||||
const React = require('react');
|
||||
const { useState, useEffect, useMemo, useRef } = require('react');
|
||||
const { StyleSheet } = require('react-native');
|
||||
const { View, Modal, Text, TextInput, Button } = require('react-native');
|
||||
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { EditorControl } from './types';
|
||||
import SelectionFormatting from './SelectionFormatting';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
interface LinkDialogProps {
|
||||
editorControl: EditorControl;
|
||||
selectionState: SelectionFormatting;
|
||||
visible: boolean;
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
const EditLinkDialog = (props: LinkDialogProps) => {
|
||||
// The content of the link selected in the editor (if any)
|
||||
const editorLinkData = props.selectionState.linkData;
|
||||
const [linkLabel, setLinkLabel] = useState('');
|
||||
const [linkURL, setLinkURL] = useState('');
|
||||
|
||||
const linkInputRef = useRef();
|
||||
|
||||
// Reset the label and URL when shown/hidden
|
||||
useEffect(() => {
|
||||
setLinkLabel(editorLinkData.linkText ?? props.selectionState.selectedText);
|
||||
setLinkURL(editorLinkData.linkURL ?? '');
|
||||
}, [
|
||||
props.visible, editorLinkData.linkText, props.selectionState.selectedText,
|
||||
editorLinkData.linkURL,
|
||||
]);
|
||||
|
||||
const [styles, placeholderColor] = useMemo(() => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const styleSheet = StyleSheet.create({
|
||||
modalContent: {
|
||||
margin: 15,
|
||||
padding: 30,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
|
||||
elevation: 5,
|
||||
shadowOffset: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 1,
|
||||
},
|
||||
button: {
|
||||
color: theme.color2,
|
||||
backgroundColor: theme.backgroundColor2,
|
||||
},
|
||||
text: {
|
||||
color: theme.color,
|
||||
},
|
||||
header: {
|
||||
color: theme.color,
|
||||
fontSize: 22,
|
||||
},
|
||||
input: {
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
|
||||
minHeight: 48,
|
||||
borderBottomColor: theme.backgroundColor3,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'column',
|
||||
paddingBottom: 10,
|
||||
},
|
||||
});
|
||||
const placeholderColor = theme.colorFaded;
|
||||
return [styleSheet, placeholderColor];
|
||||
}, [props.themeId]);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
props.editorControl.updateLink(linkLabel, linkURL);
|
||||
props.editorControl.hideLinkDialog();
|
||||
}, [props.editorControl, linkLabel, linkURL]);
|
||||
|
||||
// See https://www.hingehealth.com/engineering-blog/accessible-react-native-textinput/
|
||||
// for more about creating accessible RN inputs.
|
||||
const linkTextInput = (
|
||||
<View style={styles.inputContainer} accessible>
|
||||
<Text style={styles.text}>{_('Link Text')}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={_('Description of the link')}
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={linkLabel}
|
||||
|
||||
returnKeyType="next"
|
||||
autoFocus
|
||||
|
||||
onSubmitEditing={() => {
|
||||
linkInputRef.current.focus();
|
||||
}}
|
||||
onChangeText={(text: string) => setLinkLabel(text)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const linkURLInput = (
|
||||
<View style={styles.inputContainer} accessible>
|
||||
<Text style={styles.text}>{_('URL')}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={_('URL')}
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={linkURL}
|
||||
ref={linkInputRef}
|
||||
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
textContentType="URL"
|
||||
returnKeyType="done"
|
||||
|
||||
onSubmitEditing={onSubmit}
|
||||
onChangeText={(text: string) => setLinkURL(text)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
visible={props.visible}
|
||||
onRequestClose={() => {
|
||||
props.editorControl.hideLinkDialog();
|
||||
}}>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.header}>{_('Edit Link')}</Text>
|
||||
<View>
|
||||
{linkTextInput}
|
||||
{linkURLInput}
|
||||
</View>
|
||||
<Button
|
||||
style={styles.button}
|
||||
onPress={onSubmit}
|
||||
title={_('Done')}
|
||||
/>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditLinkDialog;
|
@@ -1,28 +1,26 @@
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import EditLinkDialog from './EditLinkDialog';
|
||||
import { defaultSearchState, SearchPanel } from './SearchPanel';
|
||||
|
||||
const React = require('react');
|
||||
const { forwardRef, useImperativeHandle, useEffect, useMemo, useState, useCallback, useRef } = require('react');
|
||||
const { forwardRef, useImperativeHandle } = require('react');
|
||||
const { useEffect, useMemo, useState, useCallback, useRef } = require('react');
|
||||
const { WebView } = require('react-native-webview');
|
||||
const { View } = require('react-native');
|
||||
const { editorFont } = require('../global-style');
|
||||
|
||||
export interface ChangeEvent {
|
||||
value: string;
|
||||
}
|
||||
import SelectionFormatting from './SelectionFormatting';
|
||||
import {
|
||||
EditorSettings,
|
||||
EditorControl,
|
||||
|
||||
export interface UndoRedoDepthChangeEvent {
|
||||
undoDepth: number;
|
||||
redoDepth: number;
|
||||
}
|
||||
|
||||
export interface Selection {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface SelectionChangeEvent {
|
||||
selection: Selection;
|
||||
}
|
||||
ChangeEvent, UndoRedoDepthChangeEvent, Selection, SelectionChangeEvent,
|
||||
ListType,
|
||||
SearchState,
|
||||
} from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
type ChangeEventHandler = (event: ChangeEvent)=> void;
|
||||
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
|
||||
@@ -53,6 +51,18 @@ function useCss(themeId: number): string {
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
width: 100vh;
|
||||
width: 100vw;
|
||||
min-width: 100vw;
|
||||
box-sizing: border-box;
|
||||
|
||||
padding-left: 1px;
|
||||
padding-right: 1px;
|
||||
padding-bottom: 1px;
|
||||
padding-top: 10px;
|
||||
|
||||
font-size: 13pt;
|
||||
}
|
||||
`;
|
||||
@@ -62,28 +72,27 @@ function useCss(themeId: number): string {
|
||||
function useHtml(css: string): string {
|
||||
const [html, setHtml] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setHtml(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<style>
|
||||
.cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
useMemo(() => {
|
||||
setHtml(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>${_('Note editor')}</title>
|
||||
<style>
|
||||
.cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
${css}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; height:100vh; width:100vh; width:100vw; min-width:100vw; box-sizing: border-box; padding: 10px;">
|
||||
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
);
|
||||
${css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}, [css]);
|
||||
|
||||
return html;
|
||||
@@ -105,6 +114,11 @@ function NoteEditor(props: Props, ref: any) {
|
||||
cm.select(${props.initialSelection.start}, ${props.initialSelection.end});
|
||||
` : '';
|
||||
|
||||
const editorSettings: EditorSettings = {
|
||||
themeData: editorTheme(props.themeId),
|
||||
katexEnabled: Setting.value('markdown.plugin.katex') as boolean,
|
||||
};
|
||||
|
||||
const injectedJavaScript = `
|
||||
function postMessage(name, data) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||
@@ -117,51 +131,158 @@ function NoteEditor(props: Props, ref: any) {
|
||||
postMessage('onLog', { value: msg });
|
||||
}
|
||||
|
||||
// This variable is not used within this script
|
||||
// but is called using "injectJavaScript" from
|
||||
// the wrapper component.
|
||||
window.cm = null;
|
||||
// Globalize logMessage, postMessage
|
||||
window.logMessage = logMessage;
|
||||
window.postMessage = postMessage;
|
||||
|
||||
try {
|
||||
${shim.injectedJs('codeMirrorBundle')};
|
||||
window.onerror = (message, source, lineno) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: " + message + " in file://" + source + ", line " + lineno
|
||||
);
|
||||
};
|
||||
|
||||
const parentElement = document.getElementsByClassName('CodeMirror')[0];
|
||||
const theme = ${JSON.stringify(editorTheme(props.themeId))};
|
||||
const initialText = ${JSON.stringify(props.initialText)};
|
||||
if (!window.cm) {
|
||||
// This variable is not used within this script
|
||||
// but is called using "injectJavaScript" from
|
||||
// the wrapper component.
|
||||
window.cm = null;
|
||||
|
||||
cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, theme);
|
||||
${setInitialSelectionJS}
|
||||
try {
|
||||
${shim.injectedJs('codeMirrorBundle')};
|
||||
|
||||
// Fixes https://github.com/laurent22/joplin/issues/5949
|
||||
window.onresize = () => {
|
||||
cm.scrollSelectionIntoView();
|
||||
};
|
||||
} catch (e) {
|
||||
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
|
||||
const parentElement = document.getElementsByClassName('CodeMirror')[0];
|
||||
const initialText = ${JSON.stringify(props.initialText)};
|
||||
const settings = ${JSON.stringify(editorSettings)};
|
||||
|
||||
cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, settings);
|
||||
${setInitialSelectionJS}
|
||||
|
||||
window.onresize = () => {
|
||||
cm.scrollSelectionIntoView();
|
||||
};
|
||||
} catch (e) {
|
||||
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
|
||||
}
|
||||
}
|
||||
true;
|
||||
`;
|
||||
|
||||
const css = useCss(props.themeId);
|
||||
const html = useHtml(css);
|
||||
const [selectionState, setSelectionState] = useState(new SelectionFormatting());
|
||||
const [searchState, setSearchState] = useState(defaultSearchState);
|
||||
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
|
||||
|
||||
// / Runs [js] in the context of the CodeMirror frame.
|
||||
const injectJS = (js: string) => {
|
||||
webviewRef.current.injectJavaScript(`
|
||||
try {
|
||||
${js}
|
||||
}
|
||||
catch(e) {
|
||||
logMessage('Error in injected JS:' + e, e);
|
||||
throw e;
|
||||
};
|
||||
|
||||
true;`);
|
||||
};
|
||||
|
||||
|
||||
const editorControl: EditorControl = {
|
||||
undo() {
|
||||
injectJS('cm.undo();');
|
||||
},
|
||||
redo() {
|
||||
injectJS('cm.redo();');
|
||||
},
|
||||
select(anchor: number, head: number) {
|
||||
injectJS(
|
||||
`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)});`
|
||||
);
|
||||
},
|
||||
insertText(text: string) {
|
||||
injectJS(`cm.insertText(${JSON.stringify(text)});`);
|
||||
},
|
||||
|
||||
toggleBolded() {
|
||||
injectJS('cm.toggleBolded();');
|
||||
},
|
||||
toggleItalicized() {
|
||||
injectJS('cm.toggleItalicized();');
|
||||
},
|
||||
toggleList(listType: ListType) {
|
||||
injectJS(`cm.toggleList(${JSON.stringify(listType)});`);
|
||||
},
|
||||
toggleCode() {
|
||||
injectJS('cm.toggleCode();');
|
||||
},
|
||||
toggleMath() {
|
||||
injectJS('cm.toggleMath();');
|
||||
},
|
||||
toggleHeaderLevel(level: number) {
|
||||
injectJS(`cm.toggleHeaderLevel(${level});`);
|
||||
},
|
||||
increaseIndent() {
|
||||
injectJS('cm.increaseIndent();');
|
||||
},
|
||||
decreaseIndent() {
|
||||
injectJS('cm.decreaseIndent();');
|
||||
},
|
||||
updateLink(label: string, url: string) {
|
||||
injectJS(`cm.updateLink(
|
||||
${JSON.stringify(label)},
|
||||
${JSON.stringify(url)}
|
||||
);`);
|
||||
},
|
||||
scrollSelectionIntoView() {
|
||||
injectJS('cm.scrollSelectionIntoView();');
|
||||
},
|
||||
showLinkDialog() {
|
||||
setLinkDialogVisible(true);
|
||||
},
|
||||
hideLinkDialog() {
|
||||
setLinkDialogVisible(false);
|
||||
},
|
||||
hideKeyboard() {
|
||||
injectJS('document.activeElement?.blur();');
|
||||
},
|
||||
setSpellcheckEnabled(enabled: boolean) {
|
||||
injectJS(`cm.setSpellcheckEnabled(${enabled ? 'true' : 'false'});`);
|
||||
},
|
||||
searchControl: {
|
||||
findNext() {
|
||||
injectJS('cm.searchControl.findNext();');
|
||||
},
|
||||
findPrevious() {
|
||||
injectJS('cm.searchControl.findPrevious();');
|
||||
},
|
||||
replaceCurrent() {
|
||||
injectJS('cm.searchControl.replaceCurrent();');
|
||||
},
|
||||
replaceAll() {
|
||||
injectJS('cm.searchControl.replaceAll();');
|
||||
},
|
||||
setSearchState(state: SearchState) {
|
||||
injectJS(`cm.searchControl.setSearchState(${JSON.stringify(state)})`);
|
||||
setSearchState(state);
|
||||
},
|
||||
showSearch() {
|
||||
const newSearchState: SearchState = Object.assign({}, searchState);
|
||||
newSearchState.dialogVisible = true;
|
||||
|
||||
setSearchState(newSearchState);
|
||||
},
|
||||
hideSearch() {
|
||||
const newSearchState: SearchState = Object.assign({}, searchState);
|
||||
newSearchState.dialogVisible = false;
|
||||
|
||||
setSearchState(newSearchState);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
undo: function() {
|
||||
webviewRef.current.injectJavaScript('cm.undo(); true;');
|
||||
},
|
||||
redo: function() {
|
||||
webviewRef.current.injectJavaScript('cm.redo(); true;');
|
||||
},
|
||||
select: (anchor: number, head: number) => {
|
||||
webviewRef.current.injectJavaScript(
|
||||
`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)}); true;`
|
||||
);
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
webviewRef.current.injectJavaScript(`cm.insertText(${JSON.stringify(text)}); true;`);
|
||||
},
|
||||
};
|
||||
return editorControl;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -211,6 +332,26 @@ function NoteEditor(props: Props, ref: any) {
|
||||
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 (handlers[msg.name]) {
|
||||
@@ -224,24 +365,53 @@ function NoteEditor(props: Props, ref: any) {
|
||||
console.error('NoteEditor: webview error');
|
||||
});
|
||||
|
||||
|
||||
// - `setSupportMultipleWindows` must be `true` for security reasons:
|
||||
// https://github.com/react-native-webview/react-native-webview/releases/tag/v11.0.0
|
||||
// - `scrollEnabled` prevents iOS from scrolling the document (has no effect on Android)
|
||||
// when the editor is focused.
|
||||
return <WebView
|
||||
style={props.style}
|
||||
ref={webviewRef}
|
||||
scrollEnabled={false}
|
||||
useWebKit={true}
|
||||
source={source}
|
||||
setSupportMultipleWindows={true}
|
||||
allowingReadAccessToURL={`file://${Setting.value('resourceDir')}`}
|
||||
originWhitelist={['file://*', './*', 'http://*', 'https://*']}
|
||||
allowFileAccess={true}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
onMessage={onMessage}
|
||||
onError={onError}
|
||||
/>;
|
||||
return (
|
||||
<View style={{
|
||||
...props.style,
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<EditLinkDialog
|
||||
visible={linkDialogVisible}
|
||||
themeId={props.themeId}
|
||||
editorControl={editorControl}
|
||||
selectionState={selectionState}
|
||||
/>
|
||||
<View style={{
|
||||
flexGrow: 1,
|
||||
flexShrink: 0,
|
||||
minHeight: '40%',
|
||||
}}>
|
||||
<WebView
|
||||
style={{
|
||||
backgroundColor: editorSettings.themeData.backgroundColor,
|
||||
}}
|
||||
ref={webviewRef}
|
||||
scrollEnabled={false}
|
||||
useWebKit={true}
|
||||
source={source}
|
||||
setSupportMultipleWindows={true}
|
||||
hideKeyboardAccessoryView={true}
|
||||
allowingReadAccessToURL={`file://${Setting.value('resourceDir')}`}
|
||||
originWhitelist={['file://*', './*', 'http://*', 'https://*']}
|
||||
allowFileAccess={true}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
onMessage={onMessage}
|
||||
onError={onError}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<SearchPanel
|
||||
editorSettings={editorSettings}
|
||||
searchControl={editorControl.searchControl}
|
||||
searchState={searchState}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(NoteEditor);
|
||||
|
355
packages/app-mobile/components/NoteEditor/SearchPanel.tsx
Normal file
355
packages/app-mobile/components/NoteEditor/SearchPanel.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
// Displays a find/replace dialog
|
||||
|
||||
const React = require('react');
|
||||
const { StyleSheet } = require('react-native');
|
||||
const { TextInput, View, Text, TouchableOpacity } = require('react-native');
|
||||
const { useMemo, useState, useEffect } = require('react');
|
||||
const MaterialCommunityIcon = require('react-native-vector-icons/MaterialCommunityIcons').default;
|
||||
|
||||
import { SearchControl, SearchState, EditorSettings } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { BackHandler } from 'react-native';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
|
||||
const buttonSize = 48;
|
||||
|
||||
type OnChangeCallback = (text: string)=> void;
|
||||
type Callback = ()=> void;
|
||||
|
||||
export const defaultSearchState: SearchState = {
|
||||
useRegex: false,
|
||||
caseSensitive: false,
|
||||
|
||||
searchText: '',
|
||||
replaceText: '',
|
||||
dialogVisible: false,
|
||||
};
|
||||
|
||||
export interface SearchPanelProps {
|
||||
searchControl: SearchControl;
|
||||
searchState: SearchState;
|
||||
editorSettings: EditorSettings;
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
styles: any;
|
||||
iconName: string;
|
||||
title: string;
|
||||
onPress: Callback;
|
||||
}
|
||||
|
||||
const ActionButton = (
|
||||
props: ActionButtonProps
|
||||
) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={props.styles.button}
|
||||
onPress={props.onPress}
|
||||
|
||||
accessibilityLabel={props.title}
|
||||
accessibilityRole='button'
|
||||
>
|
||||
<MaterialCommunityIcon name={props.iconName} style={props.styles.buttonText}/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
interface ToggleButtonProps {
|
||||
styles: any;
|
||||
iconName: string;
|
||||
title: string;
|
||||
active: boolean;
|
||||
onToggle: Callback;
|
||||
}
|
||||
const ToggleButton = (props: ToggleButtonProps) => {
|
||||
const active = props.active;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
...props.styles.toggleButton,
|
||||
...(active ? props.styles.toggleButtonActive : {}),
|
||||
}}
|
||||
onPress={props.onToggle}
|
||||
|
||||
accessibilityState={{
|
||||
checked: props.active,
|
||||
}}
|
||||
accessibilityLabel={props.title}
|
||||
accessibilityRole='switch'
|
||||
>
|
||||
<MaterialCommunityIcon name={props.iconName} style={
|
||||
active ? props.styles.activeButtonText : props.styles.buttonText
|
||||
}/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const useStyles = (theme: Theme) => {
|
||||
return useMemo(() => {
|
||||
const buttonStyle = {
|
||||
width: buttonSize,
|
||||
height: buttonSize,
|
||||
backgroundColor: theme.backgroundColor4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 1,
|
||||
};
|
||||
const buttonTextStyle = {
|
||||
color: theme.color4,
|
||||
fontSize: 30,
|
||||
};
|
||||
|
||||
return StyleSheet.create({
|
||||
button: buttonStyle,
|
||||
toggleButton: {
|
||||
...buttonStyle,
|
||||
},
|
||||
toggleButtonActive: {
|
||||
...buttonStyle,
|
||||
backgroundColor: theme.backgroundColor3,
|
||||
},
|
||||
input: {
|
||||
flexGrow: 1,
|
||||
height: buttonSize,
|
||||
backgroundColor: theme.backgroundColor4,
|
||||
color: theme.color4,
|
||||
},
|
||||
buttonText: buttonTextStyle,
|
||||
activeButtonText: {
|
||||
...buttonTextStyle,
|
||||
color: theme.color4,
|
||||
},
|
||||
text: {
|
||||
color: theme.color,
|
||||
},
|
||||
labeledInput: {
|
||||
flexGrow: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 10,
|
||||
},
|
||||
});
|
||||
}, [theme]);
|
||||
};
|
||||
|
||||
export const SearchPanel = (props: SearchPanelProps) => {
|
||||
const placeholderColor = props.editorSettings.themeData.color3;
|
||||
const styles = useStyles(props.editorSettings.themeData);
|
||||
|
||||
const [showingAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const state = props.searchState;
|
||||
const control = props.searchControl;
|
||||
|
||||
const updateSearchState = (changedData: any) => {
|
||||
const newState = Object.assign({}, state, changedData);
|
||||
control.setSearchState(newState);
|
||||
};
|
||||
|
||||
// Creates a TextInut with the given parameters
|
||||
const createInput = (
|
||||
placeholder: string, value: string, onChange: OnChangeCallback, autoFocus: boolean
|
||||
) => {
|
||||
return (
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
autoFocus={autoFocus}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={placeholderColor}
|
||||
returnKeyType='search'
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={control.findNext}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Close the search dialog on back button press
|
||||
useEffect(() => {
|
||||
// Only register the listener if the dialog is visible
|
||||
if (!state.dialogVisible) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const backListener = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
control.hideSearch();
|
||||
return true;
|
||||
});
|
||||
|
||||
return () => backListener.remove();
|
||||
}, [state.dialogVisible]);
|
||||
|
||||
|
||||
|
||||
const closeButton = (
|
||||
<ActionButton
|
||||
styles={styles}
|
||||
iconName="close"
|
||||
onPress={control.hideSearch}
|
||||
title={_('Close search')}
|
||||
/>
|
||||
);
|
||||
|
||||
const showDetailsButton = (
|
||||
<ActionButton
|
||||
styles={styles}
|
||||
iconName="menu-down"
|
||||
onPress={() => setShowAdvanced(true)}
|
||||
title={_('Show advanced')}
|
||||
/>
|
||||
);
|
||||
|
||||
const hideDetailsButton = (
|
||||
<ActionButton
|
||||
styles={styles}
|
||||
iconName="menu-up"
|
||||
onPress={() => setShowAdvanced(false)}
|
||||
title={_('Hide advanced')}
|
||||
/>
|
||||
);
|
||||
|
||||
const searchTextInput = createInput(
|
||||
_('Search for...'),
|
||||
state.searchText,
|
||||
(newText: string) => {
|
||||
updateSearchState({
|
||||
searchText: newText,
|
||||
});
|
||||
},
|
||||
|
||||
// Autofocus
|
||||
true
|
||||
);
|
||||
|
||||
const replaceTextInput = createInput(
|
||||
_('Replace with...'),
|
||||
state.replaceText,
|
||||
(newText: string) => {
|
||||
updateSearchState({
|
||||
replaceText: newText,
|
||||
});
|
||||
},
|
||||
|
||||
// Don't autofocus
|
||||
false
|
||||
);
|
||||
|
||||
const labeledSearchInput = (
|
||||
<View style={styles.labeledInput} accessible>
|
||||
<Text style={styles.text}>{_('Find: ')}</Text>
|
||||
{ searchTextInput }
|
||||
</View>
|
||||
);
|
||||
|
||||
const labeledReplaceInput = (
|
||||
<View style={styles.labeledInput} accessible>
|
||||
<Text style={styles.text}>{_('Replace: ')}</Text>
|
||||
{ replaceTextInput }
|
||||
</View>
|
||||
);
|
||||
|
||||
const toNextButton = (
|
||||
<ActionButton
|
||||
styles={styles}
|
||||
iconName="menu-right"
|
||||
onPress={control.findNext}
|
||||
title={_('Next match')}
|
||||
/>
|
||||
);
|
||||
|
||||
const toPrevButton = (
|
||||
<ActionButton
|
||||
styles={styles}
|
||||
iconName="menu-left"
|
||||
onPress={control.findPrevious}
|
||||
title={_('Previous match')}
|
||||
/>
|
||||
);
|
||||
|
||||
const replaceButton = (
|
||||
<ActionButton
|
||||
styles={styles}
|
||||
iconName="swap-horizontal"
|
||||
onPress={control.replaceCurrent}
|
||||
title={_('Replace')}
|
||||
/>
|
||||
);
|
||||
|
||||
const replaceAllButton = (
|
||||
<ActionButton
|
||||
styles={styles}
|
||||
iconName="reply-all"
|
||||
onPress={control.replaceAll}
|
||||
title={_('Replace all')}
|
||||
/>
|
||||
);
|
||||
|
||||
const regexpButton = (
|
||||
<ToggleButton
|
||||
styles={styles}
|
||||
iconName="regex"
|
||||
onToggle={() => {
|
||||
updateSearchState({
|
||||
useRegex: !state.useRegex,
|
||||
});
|
||||
}}
|
||||
active={state.useRegex}
|
||||
title={_('Regular expression')}
|
||||
/>
|
||||
);
|
||||
|
||||
const caseSensitiveButton = (
|
||||
<ToggleButton
|
||||
styles={styles}
|
||||
iconName="format-letter-case"
|
||||
onToggle={() => {
|
||||
updateSearchState({
|
||||
caseSensitive: !state.caseSensitive,
|
||||
});
|
||||
}}
|
||||
active={state.caseSensitive}
|
||||
title={_('Case sensitive')}
|
||||
/>
|
||||
);
|
||||
|
||||
const simpleLayout = (
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
{ closeButton }
|
||||
{ searchTextInput }
|
||||
{ showDetailsButton }
|
||||
{ toPrevButton }
|
||||
{ toNextButton }
|
||||
</View>
|
||||
);
|
||||
|
||||
const advancedLayout = (
|
||||
<View style={{ flexDirection: 'column', alignItems: 'center' }}>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
{ closeButton }
|
||||
{ labeledSearchInput }
|
||||
{ hideDetailsButton }
|
||||
{ toPrevButton }
|
||||
{ toNextButton }
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
{ regexpButton }
|
||||
{ caseSensitiveButton }
|
||||
{ labeledReplaceInput }
|
||||
{ replaceButton }
|
||||
{ replaceAllButton }
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!state.dialogVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return showingAdvanced ? advancedLayout : simpleLayout;
|
||||
};
|
||||
|
||||
export default SearchPanel;
|
@@ -0,0 +1,98 @@
|
||||
// Stores information about the current content of the user's selection
|
||||
|
||||
export default class SelectionFormatting {
|
||||
public bolded: boolean = false;
|
||||
public italicized: boolean = false;
|
||||
public inChecklist: boolean = false;
|
||||
public inCode: boolean = false;
|
||||
public inUnorderedList: boolean = false;
|
||||
public inOrderedList: boolean = false;
|
||||
public inMath: boolean = false;
|
||||
public inLink: boolean = false;
|
||||
public spellChecking: boolean = false;
|
||||
public unspellCheckableRegion: boolean = 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: number = 0;
|
||||
public listLevel: number = 0;
|
||||
|
||||
// Content of the selection
|
||||
public selectedText: string = '';
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
61
packages/app-mobile/components/NoteEditor/types.ts
Normal file
61
packages/app-mobile/components/NoteEditor/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Types related to the NoteEditor
|
||||
|
||||
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 {
|
||||
themeData: any;
|
||||
katexEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface ChangeEvent {
|
||||
// New editor content
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface UndoRedoDepthChangeEvent {
|
||||
undoDepth: number;
|
||||
redoDepth: number;
|
||||
}
|
||||
|
||||
export interface Selection {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface SelectionChangeEvent {
|
||||
selection: Selection;
|
||||
}
|
||||
|
||||
export interface SearchControl {
|
||||
findNext(): void;
|
||||
findPrevious(): void;
|
||||
replaceCurrent(): void;
|
||||
replaceAll(): void;
|
||||
setSearchState(state: SearchState): void;
|
||||
|
||||
showSearch(): void;
|
||||
hideSearch(): void;
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
@@ -5,7 +5,8 @@ import shim from '@joplin/lib/shim';
|
||||
import UndoRedoService from '@joplin/lib/services/UndoRedoService';
|
||||
import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer';
|
||||
import checkPermissions from '../../utils/checkPermissions';
|
||||
import NoteEditor, { ChangeEvent, UndoRedoDepthChangeEvent } from '../NoteEditor/NoteEditor';
|
||||
import NoteEditor from '../NoteEditor/NoteEditor';
|
||||
import { ChangeEvent, UndoRedoDepthChangeEvent } from '../NoteEditor/types';
|
||||
|
||||
const FileViewer = require('react-native-file-viewer').default;
|
||||
const React = require('react');
|
||||
|
@@ -98,6 +98,7 @@
|
||||
"jest": "^28.1.1",
|
||||
"jest-environment-jsdom": "^28.1.1",
|
||||
"jetifier": "^1.6.5",
|
||||
"jsdom": "^20.0.0",
|
||||
"metro-react-native-babel-preset": "^0.66.2",
|
||||
"nodemon": "^2.0.12",
|
||||
"ts-jest": "^28.0.5",
|
||||
|
@@ -277,6 +277,13 @@ export default class BaseApplication {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--disable-smooth-scrolling') {
|
||||
// Electron-specific flag - ignore it
|
||||
// Allows users to disable smooth scrolling
|
||||
argv.splice(0, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.length && arg[0] === '-') {
|
||||
throw new JoplinError(_('Unknown flag: %s', arg), 'flagError');
|
||||
} else {
|
||||
|
1
packages/pdf-viewer/.gitignore
vendored
Normal file
1
packages/pdf-viewer/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dist/*
|
100
packages/pdf-viewer/Page.tsx
Normal file
100
packages/pdf-viewer/Page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import useIsVisible from './hooks/useIsVisible';
|
||||
import { PdfData, ScaledSize } from './pdfSource';
|
||||
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||
|
||||
require('./pages.css');
|
||||
|
||||
export interface PageProps {
|
||||
pdf: PdfData;
|
||||
pageNo: number;
|
||||
focusOnLoad: boolean;
|
||||
isAnchored: boolean;
|
||||
scaledSize: ScaledSize;
|
||||
isDarkTheme: boolean;
|
||||
container: React.MutableRefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
|
||||
export default function Page(props: PageProps) {
|
||||
const [error, setError] = useState(null);
|
||||
const [page, setPage] = useState(null);
|
||||
const [scale, setScale] = useState(null);
|
||||
const [timestamp, setTimestamp] = useState(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useIsVisible(canvasRef, props.container);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !page || !props.scaledSize || (scale && props.scaledSize.scale === scale)) return;
|
||||
try {
|
||||
const viewport = page.getViewport({ scale: props.scaledSize.scale || 1.0 });
|
||||
const canvas = canvasRef.current;
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const pageTimestamp = new Date().getTime();
|
||||
setTimestamp(pageTimestamp);
|
||||
page.render({
|
||||
canvasContext: ctx,
|
||||
viewport,
|
||||
// Used so that the page rendering is throttled to some extent.
|
||||
// https://stackoverflow.com/questions/18069448/halting-pdf-js-page-rendering
|
||||
continueCallback: function(cont: any) {
|
||||
if (timestamp !== pageTimestamp) {
|
||||
return;
|
||||
}
|
||||
cont();
|
||||
},
|
||||
});
|
||||
setScale(props.scaledSize.scale);
|
||||
|
||||
} catch (error) {
|
||||
error.message = `Error rendering page no. ${props.pageNo}: ${error.message}`;
|
||||
setError(error);
|
||||
throw error;
|
||||
}
|
||||
}, [page, props.scaledSize, isVisible]);
|
||||
|
||||
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||
if (page || !isVisible || !props.pdf) return;
|
||||
try {
|
||||
const _page = await props.pdf.getPage(props.pageNo);
|
||||
if (event.cancelled) return;
|
||||
setPage(_page);
|
||||
} catch (error) {
|
||||
console.error('Page load error', props.pageNo, error);
|
||||
setError(error);
|
||||
}
|
||||
}, [page, props.scaledSize, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.focusOnLoad) {
|
||||
props.container.current.scrollTop = wrapperRef.current.offsetTop;
|
||||
// console.warn('setting focus on page', props.pageNo, wrapperRef.current.offsetTop);
|
||||
}
|
||||
}, [props.focusOnLoad]);
|
||||
|
||||
let style: any = {};
|
||||
if (props.scaledSize) {
|
||||
style = {
|
||||
...style,
|
||||
width: props.scaledSize.width,
|
||||
height: props.scaledSize.height,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-wrapper" ref={wrapperRef} style={style}>
|
||||
<canvas ref={canvasRef} className="page-canvas" style={style}>
|
||||
<div>
|
||||
{error ? 'ERROR' : 'Loading..'}
|
||||
</div>
|
||||
Page {props.pageNo}
|
||||
</canvas>
|
||||
<div className="page-info">
|
||||
{props.isAnchored ? '📌' : ''} Page {props.pageNo}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
17
packages/pdf-viewer/README.md
Normal file
17
packages/pdf-viewer/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# PDF VIEWER
|
||||
|
||||
//Todo
|
||||
|
||||
## Usage
|
||||
|
||||
```javascript
|
||||
import viewer from '@joplin/pdf-viewer';
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
//Todo
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
59
packages/pdf-viewer/VerticalPages.tsx
Normal file
59
packages/pdf-viewer/VerticalPages.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { PdfData, ScaledSize } from './pdfSource';
|
||||
import Page from './Page';
|
||||
|
||||
require('./pages.css');
|
||||
|
||||
|
||||
export interface VerticalPagesProps {
|
||||
pdf: PdfData;
|
||||
isDarkTheme: boolean;
|
||||
anchorPage: number;
|
||||
container: React.MutableRefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
|
||||
export default function VerticalPages(props: VerticalPagesProps) {
|
||||
const [scaledSize, setScaledSize] = useState<ScaledSize>(null);
|
||||
const innerContainerEl = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
let resizeTimer: number = null;
|
||||
let cancelled = false;
|
||||
const updateSize = async () => {
|
||||
if (props.pdf) {
|
||||
const innerWidth = innerContainerEl.current.clientWidth;
|
||||
const scaledSize = await props.pdf.getScaledSize(null, innerWidth - 10);
|
||||
if (cancelled) return;
|
||||
setScaledSize(scaledSize);
|
||||
}
|
||||
};
|
||||
const onResize = () => {
|
||||
if (resizeTimer) {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = null;
|
||||
}
|
||||
resizeTimer = window.setTimeout(updateSize, 200);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
updateSize()
|
||||
.catch(console.error);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener('resize', onResize);
|
||||
if (resizeTimer) {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = null;
|
||||
}
|
||||
};
|
||||
}, [props.pdf]);
|
||||
|
||||
return (<div className='pages-holder' ref={innerContainerEl} >
|
||||
{Array.from(Array(props.pdf.pageCount).keys()).map((i: number) => {
|
||||
// setting focusOnLoad only after scaledSize is set so that the container height is set correctly
|
||||
return <Page pdf={props.pdf} pageNo={i + 1} focusOnLoad={scaledSize && props.anchorPage && props.anchorPage === i + 1}
|
||||
isAnchored={props.anchorPage && props.anchorPage === i + 1}
|
||||
isDarkTheme={props.isDarkTheme} scaledSize={scaledSize} container={props.container} key={i} />;
|
||||
}
|
||||
)}
|
||||
</div>);
|
||||
}
|
14
packages/pdf-viewer/config/cssTransform.js
Normal file
14
packages/pdf-viewer/config/cssTransform.js
Normal file
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
// This is a custom Jest transformer turning style imports into empty objects.
|
||||
// http://facebook.github.io/jest/docs/en/webpack.html
|
||||
|
||||
module.exports = {
|
||||
process() {
|
||||
return 'module.exports = {};';
|
||||
},
|
||||
getCacheKey() {
|
||||
// The output is always the same.
|
||||
return 'cssTransform';
|
||||
},
|
||||
};
|
BIN
packages/pdf-viewer/config/welcome.pdf
Normal file
BIN
packages/pdf-viewer/config/welcome.pdf
Normal file
Binary file not shown.
26
packages/pdf-viewer/hooks/useIsFocused.ts
Normal file
26
packages/pdf-viewer/hooks/useIsFocused.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const useIsFocused = () => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.data.type === 'blur') {
|
||||
setIsFocused(false);
|
||||
}
|
||||
};
|
||||
const onClick = (_event: MouseEvent) => {
|
||||
setIsFocused(true);
|
||||
};
|
||||
window.addEventListener('message', onMessage);
|
||||
document.addEventListener('click', onClick);
|
||||
return () => {
|
||||
window.removeEventListener('message', onMessage);
|
||||
document.removeEventListener('click', onClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isFocused;
|
||||
};
|
||||
|
||||
export default useIsFocused;
|
37
packages/pdf-viewer/hooks/useIsVisible.ts
Normal file
37
packages/pdf-viewer/hooks/useIsVisible.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
|
||||
const useIsVisible = (elementRef: React.MutableRefObject<HTMLElement>, rootRef: React.MutableRefObject<HTMLElement>) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
let observer: IntersectionObserver = null;
|
||||
if (elementRef.current) {
|
||||
observer = new IntersectionObserver((entries, _observer) => {
|
||||
let visible = false;
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
visible = true;
|
||||
setIsVisible(true);
|
||||
}
|
||||
});
|
||||
if (!visible) {
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, {
|
||||
root: rootRef.current,
|
||||
rootMargin: '0px 0px 0px 0px',
|
||||
threshold: 0,
|
||||
});
|
||||
observer.observe(elementRef.current);
|
||||
}
|
||||
return () => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isVisible;
|
||||
};
|
||||
|
||||
export default useIsVisible;
|
19
packages/pdf-viewer/index.html
Normal file
19
packages/pdf-viewer/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<div id="pdf-root">
|
||||
<div id="splash-screen">
|
||||
Loading PDF...
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
#pdf-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#splash-screen{
|
||||
display:flex;
|
||||
justify-content:center;
|
||||
align-items:center;
|
||||
width:100%;
|
||||
height:100%;
|
||||
}
|
||||
</style>
|
||||
<script src="./main.js"></script>
|
172
packages/pdf-viewer/jest.config.js
Normal file
172
packages/pdf-viewer/jest.config.js
Normal file
@@ -0,0 +1,172 @@
|
||||
// For a detailed explanation regarding each configuration property, visit:
|
||||
// https://jestjs.io/docs/en/configuration.html
|
||||
|
||||
module.exports = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/tmp/jest_rs",
|
||||
|
||||
// Automatically clear mock calls and instances between every test
|
||||
// clearMocks: false,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
// collectCoverage: false,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: undefined,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
// coverageDirectory: undefined,
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
coverageProvider: 'v8',
|
||||
|
||||
preset: 'ts-jest',
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "json",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
// moduleNameMapper: {},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: undefined,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state between every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state between every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
roots: [
|
||||
'<rootDir>',
|
||||
],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: 'jsdom',
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
testMatch: [
|
||||
'**/*.test.ts',
|
||||
],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jasmine2",
|
||||
|
||||
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||
// testURL: "http://localhost",
|
||||
|
||||
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||
// timers: "real",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: undefined,
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
60
packages/pdf-viewer/miniViewer.tsx
Normal file
60
packages/pdf-viewer/miniViewer.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import shim from '@joplin/lib/shim';
|
||||
shim.setReact(React);
|
||||
import { render } from 'react-dom';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import useIsFocused from './hooks/useIsFocused';
|
||||
import { PdfData } from './pdfSource';
|
||||
import VerticalPages from './VerticalPages';
|
||||
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||
|
||||
require('./viewer.css');
|
||||
|
||||
// Setting worker path to worker bundle.
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'pdf.worker.js';
|
||||
|
||||
|
||||
function MiniViewerApp(props: { pdfPath: string; isDarkTheme: boolean; anchorPage: number }) {
|
||||
const [pdf, setPdf] = useState<PdfData>(null);
|
||||
const isFocused = useIsFocused();
|
||||
const containerEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||
const pdfData = new PdfData();
|
||||
await pdfData.loadDoc(props.pdfPath);
|
||||
if (event.cancelled) return;
|
||||
setPdf(pdfData);
|
||||
}, []);
|
||||
|
||||
if (!pdf) {
|
||||
return (
|
||||
<div className="mini-app loading">
|
||||
<div>Loading pdf..</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mini-app${isFocused ? ' focused' : ''}`}>
|
||||
<div className={`app-pages${isFocused ? ' focused' : ''}`} ref={containerEl}>
|
||||
<VerticalPages pdf={pdf} isDarkTheme={props.isDarkTheme} anchorPage={props.anchorPage} container={containerEl} />
|
||||
</div>
|
||||
<div className='app-bottom-bar'>
|
||||
<div className='pdf-info'>
|
||||
{pdf.pageCount} pages
|
||||
</div>
|
||||
<div>{isFocused ? '' : 'Click to enable scroll'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const url = window.frameElement.getAttribute('url');
|
||||
const appearance = window.frameElement.getAttribute('appearance');
|
||||
const anchorPage = window.frameElement.getAttribute('anchorPage');
|
||||
|
||||
document.documentElement.setAttribute('data-theme', appearance);
|
||||
|
||||
render(
|
||||
<MiniViewerApp pdfPath={url} isDarkTheme={appearance === 'dark'} anchorPage={anchorPage ? Number(anchorPage) : null} />,
|
||||
document.getElementById('pdf-root')
|
||||
);
|
52
packages/pdf-viewer/package-lock.json
generated
Normal file
52
packages/pdf-viewer/package-lock.json
generated
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@joplin/pdf-viewer",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": {
|
||||
"version": "15.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
|
||||
"integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/react": {
|
||||
"version": "18.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.14.tgz",
|
||||
"integrity": "sha512-x4gGuASSiWmo0xjDLpm5mPb52syZHJx02VKbqUKdLmKtAwIh63XClGsiTI1K6DO5q7ox4xAsQrU+Gl3+gGXF9Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"version": "16.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.0.tgz",
|
||||
"integrity": "sha512-OL2lk7LYGjxn4b0efW3Pvf2KBVP0y1v3wip1Bp7nA79NkOpElH98q3WdCEdDj93b2b0zaeBG9DvriuKjIK5xDA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/scheduler": {
|
||||
"version": "0.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
|
||||
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==",
|
||||
"dev": true
|
||||
},
|
||||
"csstype": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz",
|
||||
"integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==",
|
||||
"dev": true
|
||||
},
|
||||
"typescript": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.2.tgz",
|
||||
"integrity": "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
42
packages/pdf-viewer/package.json
Normal file
42
packages/pdf-viewer/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@joplin/pdf-viewer",
|
||||
"version": "2.9.0",
|
||||
"description": "Provides embedded PDF viewers for Joplin",
|
||||
"main": "dist/main.js",
|
||||
"types": "src/main.ts",
|
||||
"private": true,
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"scripts": {
|
||||
"tsc": "tsc --project tsconfig.json",
|
||||
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json",
|
||||
"build": "webpack --config webpack.config.js",
|
||||
"test": "jest",
|
||||
"test-ci": "yarn test"
|
||||
},
|
||||
"author": "Joplin",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^28.1.6",
|
||||
"@types/pdfjs-dist": "^2.10.378",
|
||||
"@types/react": "^18.0.14",
|
||||
"@types/react-dom": "^16.9.0",
|
||||
"babel-jest": "^28.1.3",
|
||||
"css-loader": "^6.7.1",
|
||||
"jest": "^28.1.3",
|
||||
"jest-environment-jsdom": "^28.1.3",
|
||||
"style-loader": "^3.3.1",
|
||||
"ts-jest": "^28.0.7",
|
||||
"ts-loader": "^9.3.0",
|
||||
"typescript": "^4.0.5",
|
||||
"webpack": "^5.73.0",
|
||||
"webpack-cli": "^4.10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/lib": "workspace:^",
|
||||
"pdfjs-dist": "^2.14.305",
|
||||
"react": "16.13.1",
|
||||
"react-dom": "16.9.0"
|
||||
}
|
||||
}
|
76
packages/pdf-viewer/pages.css
Normal file
76
packages/pdf-viewer/pages.css
Normal file
@@ -0,0 +1,76 @@
|
||||
::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
border: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--grey);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.app-pages {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: start;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
padding-top: 0px;
|
||||
overflow-y: hidden;
|
||||
padding-right: 0.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-pages.focused {
|
||||
padding-right: 0px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pages-holder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
row-gap: 2px;
|
||||
}
|
||||
|
||||
.page-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border: solid thin rgba(120, 120, 120, 0.498);
|
||||
background: rgb(233, 233, 233);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
padding: 0.3rem;
|
||||
background: rgba(203, 203, 203, 0.509);
|
||||
border-radius: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(91, 91, 91, 0.829);
|
||||
backdrop-filter: blur(0.5rem);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.page-info:hover {
|
||||
opacity: 0.3;
|
||||
}
|
56
packages/pdf-viewer/pdfSource.test.ts
Normal file
56
packages/pdf-viewer/pdfSource.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { PdfData } from './pdfSource';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { readFile } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = require('pdfjs-dist/legacy/build/pdf.worker.entry');
|
||||
|
||||
const pdfFilePath1 = resolve('config/welcome.pdf');
|
||||
|
||||
|
||||
function loadFile(filePath: string) {
|
||||
return new Promise<Uint8Array>((resolve, reject) => {
|
||||
readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(new Uint8Array((data)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('pdfData', () => {
|
||||
|
||||
test('Should have correct page count', async () => {
|
||||
const file = await loadFile(pdfFilePath1);
|
||||
const pdf = new PdfData();
|
||||
await pdf.loadDoc(file);
|
||||
expect(pdf.pageCount).toBe(1);
|
||||
});
|
||||
|
||||
test('Should throw error on invalid file', async () => {
|
||||
const pdf = new PdfData();
|
||||
await expect(async () => {
|
||||
await pdf.loadDoc('');
|
||||
}).rejects.toThrowError();
|
||||
});
|
||||
|
||||
test('Should get correct page size', async () => {
|
||||
const file = await loadFile(pdfFilePath1);
|
||||
const pdf = new PdfData();
|
||||
await pdf.loadDoc(file);
|
||||
const size = await pdf.getPageSize();
|
||||
expect(size.height).toBeCloseTo(841.91998);
|
||||
expect(size.width).toBeCloseTo(594.95996);
|
||||
});
|
||||
|
||||
test('Should calculate scaled size', async () => {
|
||||
const file = await loadFile(pdfFilePath1);
|
||||
const pdf = new PdfData();
|
||||
await pdf.loadDoc(file);
|
||||
const scaledSize = await pdf.getScaledSize(null, 200);
|
||||
expect(scaledSize.scale).toBeCloseTo(0.336157);
|
||||
});
|
||||
|
||||
});
|
74
packages/pdf-viewer/pdfSource.ts
Normal file
74
packages/pdf-viewer/pdfSource.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
|
||||
export interface ScaledSize {
|
||||
height: number;
|
||||
width: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export class PdfData {
|
||||
public url: string | Uint8Array;
|
||||
private doc: any = null;
|
||||
public pageCount: number = null;
|
||||
private pages: any = {};
|
||||
private pageSize: {
|
||||
height: number;
|
||||
width: number;
|
||||
} = null;
|
||||
|
||||
public constructor() {
|
||||
}
|
||||
|
||||
public loadDoc = async (url: string | Uint8Array) => {
|
||||
this.url = url;
|
||||
const loadingTask = pdfjsLib.getDocument(url);
|
||||
try {
|
||||
const pdfDocument: any = await loadingTask.promise;
|
||||
this.doc = pdfDocument;
|
||||
this.pageCount = pdfDocument.numPages;
|
||||
} catch (error) {
|
||||
error.message = `Could not load document: ${error.message}`;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
public getPage = async (pageNo: number) => {
|
||||
if (!this.doc) {
|
||||
throw new Error('Document not loaded');
|
||||
}
|
||||
if (!this.pages[pageNo]) {
|
||||
this.pages[pageNo] = await this.doc.getPage(pageNo);
|
||||
}
|
||||
return this.pages[pageNo];
|
||||
};
|
||||
|
||||
public getPageSize = async () => {
|
||||
if (!this.pageSize) {
|
||||
const page = await this.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 1.0 });
|
||||
this.pageSize = {
|
||||
height: viewport.height,
|
||||
width: viewport.width,
|
||||
};
|
||||
}
|
||||
return this.pageSize;
|
||||
};
|
||||
|
||||
public getScaledSize = async (height: number = null, width: number = null): Promise<ScaledSize> => {
|
||||
const actualSize = await this.getPageSize();
|
||||
let scale = 1.0;
|
||||
if (height && width) {
|
||||
scale = Math.min(height / actualSize.height, width / actualSize.width);
|
||||
} else if (height) {
|
||||
scale = height / actualSize.height;
|
||||
} else if (width) {
|
||||
scale = width / actualSize.width;
|
||||
}
|
||||
return {
|
||||
height: actualSize.height * scale,
|
||||
width: actualSize.width * scale,
|
||||
scale,
|
||||
};
|
||||
};
|
||||
}
|
17
packages/pdf-viewer/tsconfig.json
Normal file
17
packages/pdf-viewer/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
/* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
"esModuleInterop": true,
|
||||
/* Required since pdf.js does not have a ts binding.*/
|
||||
"allowJs": true,
|
||||
},
|
||||
"rootDir": ".",
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules",
|
||||
],
|
||||
}
|
98
packages/pdf-viewer/viewer.css
Normal file
98
packages/pdf-viewer/viewer.css
Normal file
@@ -0,0 +1,98 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--white: rgb(255, 255, 255);
|
||||
--light: rgb(219, 219, 219);
|
||||
--grey: rgb(128, 128, 128);
|
||||
--dark: rgb(1, 0, 34);
|
||||
--black: rgb(24, 24, 24);
|
||||
|
||||
--red: #ff000d;
|
||||
--blue: #00A8FF;
|
||||
--green: rgb(0, 167, 28);
|
||||
--purple: rgb(132, 0, 255);
|
||||
--orange: rgb(255, 164, 27);
|
||||
|
||||
--primary: var(--black);
|
||||
--secondary: var(--dark);
|
||||
--tertiary: var(--light);
|
||||
--bg: var(--white);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--primary: var(--white);
|
||||
--secondary: var(--light);
|
||||
--tertiary: var(--dark);
|
||||
--bg: var(--black);
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
-ms-overflow-style: scrollbar;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
font-size: var(--s);
|
||||
font-weight: 300;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
hr {
|
||||
box-sizing: content-box;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.mini-app {
|
||||
display: grid;
|
||||
grid-template-rows: auto 2rem;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background-color: var(--bg);
|
||||
overflow: hidden;
|
||||
border-radius: 0.4rem;
|
||||
border: solid thin var(--tertiary);
|
||||
padding-top: 0.6rem;
|
||||
padding-right: 0.25rem;
|
||||
background-color: rgb(240, 241, 245);
|
||||
}
|
||||
|
||||
.mini-app.focused {
|
||||
border: solid thin var(--grey);
|
||||
}
|
||||
|
||||
.mini-app.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mini-app {
|
||||
background-color: rgb(54, 54, 54);
|
||||
}
|
||||
|
||||
.app-bottom-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--grey);
|
||||
}
|
29
packages/pdf-viewer/webpack.config.js
Normal file
29
packages/pdf-viewer/webpack.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
main: './miniViewer.tsx',
|
||||
'pdf.worker': 'pdfjs-dist/build/pdf.worker.entry',
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
clean: true,
|
||||
},
|
||||
};
|
@@ -27,6 +27,7 @@ export interface RenderOptions {
|
||||
codeHighlightCacheKey?: string;
|
||||
plainResourceRendering?: boolean;
|
||||
mapsToLine?: boolean;
|
||||
useCustomPdfViewer?: boolean;
|
||||
}
|
||||
|
||||
interface RendererRule {
|
||||
@@ -170,7 +171,7 @@ export interface RuleOptions {
|
||||
audioPlayerEnabled: boolean;
|
||||
videoPlayerEnabled: boolean;
|
||||
pdfViewerEnabled: boolean;
|
||||
|
||||
useCustomPdfViewer: boolean;
|
||||
itemIdToUrl?: ItemIdToUrlHandler;
|
||||
}
|
||||
|
||||
|
@@ -7,6 +7,8 @@ export interface Options {
|
||||
audioPlayerEnabled: boolean;
|
||||
videoPlayerEnabled: boolean;
|
||||
pdfViewerEnabled: boolean;
|
||||
useCustomPdfViewer: boolean;
|
||||
theme: any;
|
||||
}
|
||||
|
||||
function resourceUrl(resourceFullPath: string): string {
|
||||
@@ -42,6 +44,16 @@ export default function(link: Link, options: Options) {
|
||||
}
|
||||
|
||||
if (options.pdfViewerEnabled && resource.mime === 'application/pdf') {
|
||||
if (options.useCustomPdfViewer) {
|
||||
let anchorPageNo = null;
|
||||
if (link.href.indexOf('#') > 0) {
|
||||
anchorPageNo = Number(link.href.split('#').pop());
|
||||
if (anchorPageNo < 1) anchorPageNo = null;
|
||||
}
|
||||
return `<iframe src="../../vendor/lib/@joplin/pdf-viewer/index.html" url="${escapedResourcePath}"
|
||||
appearance="${options.theme.appearance}" ${anchorPageNo ? `anchorPage="${anchorPageNo}"` : ''}
|
||||
class="media-player media-pdf"></iframe>`;
|
||||
}
|
||||
return `<object data="${escapedResourcePath}" class="media-player media-pdf" type="${escapedMime}"></object>`;
|
||||
}
|
||||
|
||||
|
@@ -375,7 +375,12 @@ export default function(theme: any, options: Options = null) {
|
||||
}
|
||||
|
||||
.media-player.media-pdf {
|
||||
min-height: 100vh;
|
||||
min-height: 35rem;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Clear the CODE style if the element is within a joplin-editable block */
|
||||
|
@@ -135,6 +135,7 @@ async function main() {
|
||||
await updatePackageVersion(`${rootDir}/packages/renderer/package.json`, majorMinorVersion, options);
|
||||
await updatePackageVersion(`${rootDir}/packages/server/package.json`, majorMinorVersion, options);
|
||||
await updatePackageVersion(`${rootDir}/packages/tools/package.json`, majorMinorVersion, options);
|
||||
await updatePackageVersion(`${rootDir}/packages/pdf-viewer/package.json`, majorMinorVersion, options);
|
||||
|
||||
if (options.updateVersion) {
|
||||
await updateGradleVersion(`${rootDir}/packages/app-mobile/android/app/build.gradle`, majorMinorVersion);
|
||||
|
@@ -103,6 +103,14 @@
|
||||
{
|
||||
"name": "Polymathic-Company",
|
||||
"id": "77214738"
|
||||
},
|
||||
{
|
||||
"name": "sif",
|
||||
"id": "327998"
|
||||
},
|
||||
{
|
||||
"name": "skyrunner15",
|
||||
"id": "54626606"
|
||||
}
|
||||
],
|
||||
"orgs": [
|
||||
|
@@ -1,9 +1,7 @@
|
||||
# Coding style
|
||||
|
||||
Coding style is mostly enforced by a pre-commit hook that runs `eslint`. This hook is installed whenever running `yarn install` on any of the application directory. If for some reason the pre-commit hook didn't get installed, you can manually install it by running `yarn install` at the root of the repository.
|
||||
|
||||
|
||||
|
||||
# Rules
|
||||
|
||||
## Use TypeScript for new files
|
||||
|
||||
### Creating a new `.ts` file
|
||||
|
16
readme/news/20220808-first-meetup.md
Normal file
16
readme/news/20220808-first-meetup.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
tweet: Joplin will have its first Meetup on 30 August! Come and join us at the Old Thameside Inn next to London Bridge! https://www.meetup.com/joplin/events/287611873/
|
||||
forum_url: https://discourse.joplinapp.org/t/26808
|
||||
---
|
||||
|
||||
# Joplin first meetup on 30 August!
|
||||
|
||||
We are glad to announce [the first Joplin Meetup](https://www.meetup.com/joplin/events/287611873/) that will take place on 30 August 2022 in London!
|
||||
|
||||
This is an opportunity to meet other Joplin users as well as some of the main contributors, to discuss the apps, or to ask questions and exchange tips and tricks on how to use the app, develop plugins or contribute to the application. Everybody, technical or not, is welcome!
|
||||
|
||||
We will meet at the Old Thameside Inn next to London Bridge. If the weather allows we will be on the terrace outside, if not inside.
|
||||
|
||||
More information on the official Meetup page:
|
||||
|
||||
https://www.meetup.com/joplin/events/287611873/
|
Reference in New Issue
Block a user