Repository: CalebJohn/joplin-rich-markdown Branch: main Commit: 17b3ed7dd0e1 Files: 56 Total size: 204.5 KB Directory structure: gitextract_tmrezjxg/ ├── .gitignore ├── .npmignore ├── GENERATOR_DOC.md ├── LICENSE ├── README.md ├── TIPS.md ├── api/ │ ├── Global.d.ts │ ├── Joplin.d.ts │ ├── JoplinClipboard.d.ts │ ├── JoplinCommands.d.ts │ ├── JoplinContentScripts.d.ts │ ├── JoplinData.d.ts │ ├── JoplinFilters.d.ts │ ├── JoplinImaging.d.ts │ ├── JoplinInterop.d.ts │ ├── JoplinPlugins.d.ts │ ├── JoplinSettings.d.ts │ ├── JoplinViews.d.ts │ ├── JoplinViewsDialogs.d.ts │ ├── JoplinViewsEditor.d.ts │ ├── JoplinViewsMenuItems.d.ts │ ├── JoplinViewsMenus.d.ts │ ├── JoplinViewsNoteList.d.ts │ ├── JoplinViewsPanels.d.ts │ ├── JoplinViewsToolbarButtons.d.ts │ ├── JoplinWindow.d.ts │ ├── JoplinWorkspace.d.ts │ ├── index.ts │ ├── noteListType.d.ts │ ├── noteListType.ts │ └── types.ts ├── jest.config.js ├── package.json ├── plugin.config.json ├── shell.nix ├── src/ │ ├── clickHandlers.test.ts │ ├── clickHandlers.ts │ ├── cm6ListIndent.ts │ ├── cm6Requires.ts │ ├── imageData.ts │ ├── images.test.ts │ ├── images.ts │ ├── indent.ts │ ├── index.ts │ ├── manifest.json │ ├── overlay.test.ts │ ├── overlay.ts │ ├── richMarkdown.ts │ ├── settings.ts │ ├── style/ │ │ ├── extra_css_fixes.css │ │ ├── extra_fancy.css │ │ ├── focus.css │ │ └── stylish.css │ └── stylesheets.ts ├── tsconfig.json └── webpack.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ dist/ node_modules/ publish/*.json publish/ ================================================ FILE: .npmignore ================================================ *.md !README.md /*.jpl /api /src /dist tsconfig.json webpack.config.js ================================================ FILE: GENERATOR_DOC.md ================================================ # Plugin development This documentation describes how to create a plugin, and how to work with the plugin builder framework and API. ## Installation First, install [Yeoman](http://yeoman.io) and generator-joplin using [npm](https://www.npmjs.com/) (we assume you have pre-installed [node.js](https://nodejs.org/)). ```bash npm install -g yo@4.3.1 npm install -g generator-joplin ``` Then generate your new project: ```bash yo --node-package-manager npm joplin ``` ## Structure The main two files you will want to look at are: - `/src/index.ts`, which contains the entry point for the plugin source code. - `/src/manifest.json`, which is the plugin manifest. It contains information such as the plugin a name, version, etc. The file `/plugin.config.json` could also be useful if you intend to use [external scripts](#external-script-files), such as content scripts or webview scripts. ## Building the plugin The plugin is built using Webpack, which creates the compiled code in `/dist`. A JPL archive will also be created at the root, which can use to distribute the plugin. To build the plugin, simply run `npm run dist`. The project is setup to use TypeScript, although you can change the configuration to use plain JavaScript. ## Updating the manifest version number You can run `npm run updateVersion` to bump the patch part of the version number, so for example 1.0.3 will become 1.0.4. This script will update both the package.json and manifest.json version numbers so as to keep them in sync. ## Publishing the plugin To publish the plugin, add it to npmjs.com by running `npm publish`. Later on, a script will pick up your plugin and add it automatically to the Joplin plugin repository as long as the package satisfies these conditions: - In `package.json`, the name starts with "joplin-plugin-". For example, "joplin-plugin-toc". - In `package.json`, the keywords include "joplin-plugin". - In the `publish/` directory, there should be a .jpl and .json file (which are built by `npm run dist`) In general all this is done automatically by the plugin generator, which will set the name and keywords of package.json, and will put the right files in the "publish" directory. But if something doesn't work and your plugin doesn't appear in the repository, double-check the above conditions. ## Updating the plugin framework To update the plugin framework, run `npm run update`. In general this command tries to do the right thing - in particular it's going to merge the changes in package.json and .gitignore instead of overwriting. It will also leave "/src" as well as README.md untouched. The file that may cause problem is "webpack.config.js" because it's going to be overwritten. For that reason, if you want to change it, consider creating a separate JavaScript file and include it in webpack.config.js. That way, when you update, you only have to restore the line that include your file. ## External script files By default, the compiler (webpack) is going to compile `src/index.ts` only (as well as any file it imports), and any other file will simply be copied to the plugin package. In some cases this is sufficient, however if you have [content scripts](https://joplinapp.org/api/references/plugin_api/classes/joplincontentscripts.html) or [webview scripts](https://joplinapp.org/api/references/plugin_api/classes/joplinviewspanels.html#addscript) you might want to compile them too, in particular in these two cases: - The script is a TypeScript file - in which case it has to be compiled to JavaScript. - The script requires modules you've added to package.json. In that case, the script, whether JS or TS, must be compiled so that the dependencies are bundled with the JPL file. To get such an external script file to compile, you need to add it to the `extraScripts` array in `plugin.config.json`. The path you add should be relative to /src. For example, if you have a file in "/src/webviews/index.ts", the path should be set to "webviews/index.ts". Once compiled, the file will always be named with a .js extension. So you will get "webviews/index.js" in the plugin package, and that's the path you should use to reference the file. ## More information - [Joplin Plugin API](https://joplinapp.org/api/references/plugin_api/classes/joplin.html) - [Joplin Data API](https://joplinapp.org/help/api/references/rest_api) - [Joplin Plugin Manifest](https://joplinapp.org/api/references/plugin_manifest/) - Ask for help on the [forum](https://discourse.joplinapp.org/) or our [Discord channel](https://discord.gg/VSj7AFHvpq) ## License MIT © Laurent Cozic ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 Caleb John Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Rich Markdown A plugin that will finally allow you to ditch the markdown viewer, saving space and making your life easier. Rich Markdown comes as a collection of multiple features that can be toggled in settings. The main goal is to add better interaction features to the editor, namely [image](#images-on-hover) [viewing](#images-in-editor), [checkbox interaction](#checkbox), and [links](#links). It also adds a number of CSS classes that make it possible to add extra customization to the markdown editor. Checkout the image below to see how far this can go! ![Welcome Notebook Screenshot](https://github.com/CalebJohn/joplin-rich-markdown/blob/main/examples/welcome.png) The above theme can be applied in the plugin settings menu (this one is called "Stylish"). Thanks to [@uxamanda](https://discourse.joplinapp.org/t/plugin-rich-markdown/15053/105) for the awesome theme! # Features The Rich Markdown plugin supports a number of optional features. Unless otherwise stated, these features can be toggled under `Tools -> Options -> Rich Markdown -> Add additional CSS classes for enhanced customization`. ## Images on Hover When hovering over a markdown image tag with Ctrl (or Opt) pressed, a preview of the image will pop up below the mouse cursor. This is enabled by default. [Example of an image on hover](https://github.com/CalebJohn/joplin-rich-markdown/blob/main/examples/hover_image.png) ## Images in Editor Any image that is contained on it's own line of the markdown source will render directly on the line directly below. This works for both internal resources and generic links to images This is not enabled by default, but can be quick toggled in the View menu. [Example of images being rendered in markdown editor](https://github.com/CalebJohn/joplin-rich-markdown/blob/main/examples/inline_image.png) ## Checkbox Checkboxes can be toggled in the markdown source by Ctrl (or Opt) + Clicking or with the Perform action command in the context menu. This is enabled by default. ## Links Links can be followed by Ctrl (or Opt) + Clicking or with the Perform action command in the context menu. This is enabled by default. ## Highlighting Text surrounded by == (e.g. ==mark==) will now be highlighted in the editor. Insert syntax (++insert++), sub (~sub~), and sup (^sup^) syntaxes are also available and enabled in the same way. This can be enabled under `Tools -> Options -> Markdown -> Enable ==mark== syntax`. ## Align Lists Lists that get wrapped will have the wrap match the indentation of the list element. This includes checkboxes and block quotes. This is enabled by default. ## Extra CSS Some additional CSS classes have been added to further enable customization through [userchrome.css](https://joplinapp.org/help/apps/custom_css). Available classes are detailed below. `.cm-header.cm-rm-header-token`: The grouping of hashtags (#) at the start of a header `.cm-em.cm-rm-em-token`: The \* or \_ used to begin italics `.cm-strong.cm-rm-strong-token`: The \*\* or \_\_ used to begin bold `.cm-rm-highlight`: The highlighted text (including the ==) `.cm-rm-highlight.cm-rm-highlight-token`: The == used to begin highlighting `.cm-strikethrough.cm-rm-strike-token`: The \~\~ used to begin a strike through `.cm-rm-ins.cm-rm-ins-token`: The ++ used to begin the insert syntax `.cm-rm-sub.cm-rm-sub-token`: The ~ used to begin the sub syntax `.cm-rm-sup.cm-rm-sup-token`: The ^ used to begin the sup syntax `pre.cm-rm-hr.CodeMirror-line`: Used to support drawing a horizontal rule [see here](https://github.com/CalebJohn/joplin-rich-markdown/blob/main/TIPS.md#horizontal-rule) `pre.cm-rm-blockquote.CodeMirror-line`: The line that contains a block quote If you have a suggestion for something you'd like to be able to customize. Just let me know and I'll see whats possible. This is disabled by default. ## Image External Edits If an image is edited, the change will be reflected in the editor within 3s (by default) or immediately after any change to the note. The 3s period can be changed in the settings (under the advanced tab). Setting the value to 0s will prevent the periodic image changes, but image changes will still be picked up when the note is edited. # Installation The typical way to install plugins is through the built-in Joplin plugin manager. - Go to `Tools -> Options -> Plugins`(macOS: Joplin -> Preferences -> Plugins) - Search for "Rich Markdown" in the search box - Click Install and restart Joplin #### Or - Download the [plugin jpl](https://github.com/joplin/plugins/raw/master/plugins/plugin.calebjohn.rich-markdown/plugin.jpl) - Go to `Tools -> Options -> Plugins` - Click on the gear icon and select "Install from file" - Select the downloaded jpl file - Restart Joplin # Feature Requests Feature Requests are appreciated and encouraged. Not all feature requests will be technically feasible, so please be patient. Feature requests that align with the projects philosophy (below) are more likely to be implemented. The aim of this project is to ditch the markdown viewer without trying to turn a markdown document into some kind of unholy WYSIAWYG (what you see is almost what you get). To that end, feature requests that hide markdown or otherwise introduce invisible formatting will be treated skeptically. The intention of this project is not to build the worlds best (most performant, beautiful, etc.) markdown editor, but rather to build something that can make my (and others!) life a little bit nicer. Please make feature requests (please prepend the title with Feature Request) [on the github page](https://github.com/CalebJohn/joplin-rich-markdown/issues), or on the [rich markdown forum topic](https://discourse.joplinapp.org/t/plugin-rich-markdown/15053). # Tips Go to [Tips](https://github.com/CalebJohn/joplin-rich-markdown/blob/main/TIPS.md) to see a collection of CSS styling tips to get the most out of the Rich Markdown plugin. # Known Issues - file:// links only work with markdown link syntax (\[\]\(\) \<\>) - When hovering over an image on the bottom line, the image will be cut off - This can be fixed by scrolling the editor down enough to display the image - Links opened on Windows systems will be opened in the background (i.e. the browser won't jump to the front) - Images with a newline in the title won't be rendered in the editor e.g. ``` ![joplin is cool](some_image_path) ``` ================================================ FILE: TIPS.md ================================================ # Tips Note: Most of these have been wrapped up in to the themes (available in the plugin settings). So make sure to check there before spending too much time with custom css. The below is a collection of [userchrome.css](https://joplinapp.org/help/#custom-css) customizations that might be handy. If you have one of your own that you'd like added here, please make a [PR](https://github.com/CalebJohn/joplin-rich-markdown/pulls) or let me know [on the forum](https://discourse.joplinapp.org/t/plugin-rich-markdown/15053). ## Horizontal Rule ```css /* Render horizontal lines (made with \-\-\- or \*\*\*) as an actual line across the editor. */ div.CodeMirror .cm-hr { border-top: 1px solid #777; display: block; line-height: 0px; } ``` ## Subtle Headers ```css /* Reduce the size and visibility of the header hash tags. */ /* The additional CSS option must be enabled */ div.cm-editor .cm-header > .cm-rm-header-token { font-size: 0.9em; color: grey; } ``` ```css /* Additionally, this CSS unindent the "#" characters to align */ /* the header text with the rest of the text */ div.cm-editor .cm-header > .cm-rm-header-token { color: #cccccc; font-size: 0.9em; margin-left: -50px; max-width: 50px; width: 50px; overflow: hidden; display: inline-block; text-align: right; opacity: 0.5; } ``` Thanks to [uxamanda](https://discourse.joplinapp.org/t/plugin-rich-markdown/15053/105) for the code. ## Subtle Tokens ```css /* Reduces the intensity of the markdown tokens div.cm-editor .tok-meta { opacity: 0.5; } /* This is also available for highlight and strikethrough, but it doesn't look very good */ /* .cm-rm-highlight .cm-rm-highlight-token, .cm-strikethrough .cm-rm-strike-token, */ ``` ## Strikeout Checkboxes ```css /* strikeout and dim a checked checkbox */ div.CodeMirror span.cm-rm-checkboxed { text-decoration: line-through; opacity: 0.7; } ``` ## Highlight the Active Line ```css /* Requires the "highlight background of current line option to be enabled */ div.CodeMirror .CodeMirror-activeline.CodeMirror-activeline-background { background: grey !important; } ``` ## List Spacing ```css /* Match list spacing to the viewer */ div.CodeMirror .cm-rm-list-token { line-height: 2em; } ``` ## Code Blocks Code block highlighting is implemented by the main Joplin app as of v2.3.4 and was removed from Rich Markdown versions 0.8.0 onwards. ## Colour Schemes Each Joplin theme uses a different CodeMirror colour scheme, it's useful to know what these colour schemes are because they can be used to support customizations that differ across Joplin themes (see [Coloured List Tokens](#coloured-list-tokens) for an example). ``` Light: .cm-s-default Dark: .cm-s-material-darker Solarized Light: .cm-s-solarized Solarized Dark: .cm-s-solarized and .cm-s-solarized.cm-s-dark Dracula: .cm-s-dracula Nord: .cm-s-nord Aritim Dark: .cm-s-monokai OLED Dark: .cm-s-material-darker ``` ## General The Joplin forum has [a collection](https://discourse.joplinapp.org/t/joplin-customization/11195) of useful CSS snippets for customizations that aren't specific to this plugin. --- ## Warning #### Checkmark Checkboxes Much thanks to [ambrt](https://discourse.joplinapp.org/u/ambrt/) for the initial implementation. ```css /* Requires the additional Css option to be enabled */ div.CodeMirror .cm-rm-checkbox .cm-rm-checkbox-checked { display: none; } div.CodeMirror .cm-rm-checkbox .cm-rm-checkbox-checked + .cm-taskMarker:before { content: "✓"; } ``` ## Notes on legacy editor (pre 3.1.1) Some tokens had the class `.cm-overlay` pre-pended to them. This class is removed in the new editor, any styles that involved can simply have it removed. For example ```diff - div.CodeMirror .cm-overlay.cm-rm-list-token { + div.CodeMirror .cm-rm-list-token { ``` If still using the legacy editor, please view the older [TIPS](https://github.com/CalebJohn/joplin-rich-markdown/blob/7d6bd9c984176a1a0d6fdc5683c34f03669967b5/TIPS.md) ================================================ FILE: api/Global.d.ts ================================================ import Plugin from '../Plugin'; import Joplin from './Joplin'; /** * @ignore */ /** * @ignore */ export default class Global { private joplin_; constructor(implementation: any, plugin: Plugin, store: any); get joplin(): Joplin; get process(): any; } ================================================ FILE: api/Joplin.d.ts ================================================ import Plugin from '../Plugin'; import JoplinData from './JoplinData'; import JoplinPlugins from './JoplinPlugins'; import JoplinWorkspace from './JoplinWorkspace'; import JoplinFilters from './JoplinFilters'; import JoplinCommands from './JoplinCommands'; import JoplinViews from './JoplinViews'; import JoplinInterop from './JoplinInterop'; import JoplinSettings from './JoplinSettings'; import JoplinContentScripts from './JoplinContentScripts'; import JoplinClipboard from './JoplinClipboard'; import JoplinWindow from './JoplinWindow'; import BasePlatformImplementation from '../BasePlatformImplementation'; import JoplinImaging from './JoplinImaging'; /** * This is the main entry point to the Joplin API. You can access various services using the provided accessors. * * The API is now relatively stable and in general maintaining backward compatibility is a top priority, so you shouldn't except much breakages. * * If a breaking change ever becomes needed, best effort will be done to: * * - Deprecate features instead of removing them, so as to give you time to fix the issue; * - Document breaking changes in the changelog; * * So if you are developing a plugin, please keep an eye on the changelog as everything will be in there with information about how to update your code. */ export default class Joplin { private data_; private plugins_; private imaging_; private workspace_; private filters_; private commands_; private views_; private interop_; private settings_; private contentScripts_; private clipboard_; private window_; private implementation_; constructor(implementation: BasePlatformImplementation, plugin: Plugin, store: any); get data(): JoplinData; get clipboard(): JoplinClipboard; get imaging(): JoplinImaging; get window(): JoplinWindow; get plugins(): JoplinPlugins; get workspace(): JoplinWorkspace; get contentScripts(): JoplinContentScripts; /** * @ignore * * Not sure if it's the best way to hook into the app * so for now disable filters. */ get filters(): JoplinFilters; get commands(): JoplinCommands; get views(): JoplinViews; get interop(): JoplinInterop; get settings(): JoplinSettings; /** * It is not possible to bundle native packages with a plugin, because they * need to work cross-platforms. Instead access to certain useful native * packages is provided using this function. * * Currently these packages are available: * * - [sqlite3](https://www.npmjs.com/package/sqlite3) * - [fs-extra](https://www.npmjs.com/package/fs-extra) * * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/nativeModule) * * desktop */ require(_path: string): any; versionInfo(): Promise; } ================================================ FILE: api/JoplinClipboard.d.ts ================================================ export default class JoplinClipboard { private electronClipboard_; private electronNativeImage_; constructor(electronClipboard: any, electronNativeImage: any); readText(): Promise; writeText(text: string): Promise; /** desktop */ readHtml(): Promise; /** desktop */ writeHtml(html: string): Promise; /** * Returns the image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format. * * desktop */ readImage(): Promise; /** * Takes an image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format. * * desktop */ writeImage(dataUrl: string): Promise; /** * Returns the list available formats (mime types). * * For example [ 'text/plain', 'text/html' ] */ availableFormats(): Promise; } ================================================ FILE: api/JoplinCommands.d.ts ================================================ import { Command } from './types'; import Plugin from '../Plugin'; /** * This class allows executing or registering new Joplin commands. Commands * can be executed or associated with * {@link JoplinViewsToolbarButtons | toolbar buttons} or * {@link JoplinViewsMenuItems | menu items}. * * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command) * * ## Executing Joplin's internal commands * * It is also possible to execute internal Joplin's commands which, as of * now, are not well documented. You can find the list directly on GitHub * though at the following locations: * * * [Main screen commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/MainScreen/commands) * * [Global commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/commands) * * [Editor commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts) * * To view what arguments are supported, you can open any of these files * and look at the `execute()` command. * * Note that many of these commands only work on desktop. The more limited list of mobile * commands can be found in these places: * * * [Global commands](https://github.com/laurent22/joplin/tree/dev/packages/app-mobile/commands) * * [Editor commands](https://github.com/laurent22/joplin/blob/dev/packages/app-mobile/components/NoteEditor/commandDeclarations.ts) * * ## Executing editor commands * * There might be a situation where you want to invoke editor commands * without using a {@link JoplinContentScripts | contentScript}. For this * reason Joplin provides the built in `editor.execCommand` command. * * `editor.execCommand` should work with any core command in both the * [CodeMirror](https://codemirror.net/doc/manual.html#execCommand) and * [TinyMCE](https://www.tiny.cloud/docs/api/tinymce/tinymce.editorcommands/#execcommand) editors, * as well as most functions calls directly on a CodeMirror editor object (extensions). * * * [CodeMirror commands](https://codemirror.net/doc/manual.html#commands) * * [TinyMCE core editor commands](https://www.tiny.cloud/docs/advanced/editor-command-identifiers/#coreeditorcommands) * * `editor.execCommand` supports adding arguments for the commands. * * ```typescript * await joplin.commands.execute('editor.execCommand', { * name: 'madeUpCommand', // CodeMirror and TinyMCE * args: [], // CodeMirror and TinyMCE * ui: false, // TinyMCE only * value: '', // TinyMCE only * }); * ``` * * [View the example using the CodeMirror editor](https://github.com/laurent22/joplin/blob/dev/packages/app-cli/tests/support/plugins/codemirror_content_script/src/index.ts) * */ export default class JoplinCommands { private plugin_; constructor(plugin_: Plugin); /** * Executes the given command. * * The command can take any number of arguments, and the supported * arguments will vary based on the command. For custom commands, this * is the `args` passed to the `execute()` function. For built-in * commands, you can find the supported arguments by checking the links * above. * * ```typescript * // Create a new note in the current notebook: * await joplin.commands.execute('newNote'); * * // Create a new sub-notebook under the provided notebook * // Note: internally, notebooks are called "folders". * await joplin.commands.execute('newFolder', "SOME_FOLDER_ID"); * ``` */ execute(commandName: string, ...args: any[]): Promise; /** * Registers a new command. * * ```typescript * // Register a new commmand called "testCommand1" * * await joplin.commands.register({ * name: 'testCommand1', * label: 'My Test Command 1', * iconName: 'fas fa-music', * execute: () => { * alert('Testing plugin command 1'); * }, * }); * ``` */ register(command: Command): Promise; } ================================================ FILE: api/JoplinContentScripts.d.ts ================================================ import Plugin from '../Plugin'; import { ContentScriptType } from './types'; export default class JoplinContentScripts { private plugin; constructor(plugin: Plugin); /** * Registers a new content script. Unlike regular plugin code, which runs in * a separate process, content scripts run within the main process code and * thus allow improved performances and more customisations in specific * cases. It can be used for example to load a Markdown or editor plugin. * * Note that registering a content script in itself will do nothing - it * will only be loaded in specific cases by the relevant app modules (eg. * the Markdown renderer or the code editor). So it is not a way to inject * and run arbitrary code in the app, which for safety and performance * reasons is not supported. * * The plugin generator provides a way to build any content script you might * want to package as well as its dependencies. See the [Plugin Generator * doc](https://github.com/laurent22/joplin/blob/dev/packages/generator-joplin/README.md) * for more information. * * * [View the renderer demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script) * * [View the editor plugin tutorial](https://joplinapp.org/help/api/tutorials/cm6_plugin) * * [View the legacy editor demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script) * * See also the [postMessage demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) * * @param type Defines how the script will be used. See the type definition for more information about each supported type. * @param id A unique ID for the content script. * @param scriptPath Must be a path relative to the plugin main script. For example, if your file content_script.js is next to your index.ts file, you would set `scriptPath` to `"./content_script.js`. */ register(type: ContentScriptType, id: string, scriptPath: string): Promise; /** * Listens to a messages sent from the content script using postMessage(). * See {@link ContentScriptType} for more information as well as the * [postMessage * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) */ onMessage(contentScriptId: string, callback: any): Promise; } ================================================ FILE: api/JoplinData.d.ts ================================================ import { ModelType } from '../../../BaseModel'; import Plugin from '../Plugin'; import { Path } from './types'; /** * This module provides access to the Joplin data API: https://joplinapp.org/help/api/references/rest_api * This is the main way to retrieve data, such as notes, notebooks, tags, etc. * or to update them or delete them. * * This is also what you would use to search notes, via the `search` endpoint. * * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/simple) * * In general you would use the methods in this class as if you were using a REST API. There are four methods that map to GET, POST, PUT and DELETE calls. * And each method takes these parameters: * * * `path`: This is an array that represents the path to the resource in the form `["resourceName", "resourceId", "resourceLink"]` (eg. ["tags", ":id", "notes"]). The "resources" segment is the name of the resources you want to access (eg. "notes", "folders", etc.). If not followed by anything, it will refer to all the resources in that collection. The optional "resourceId" points to a particular resources within the collection. Finally, an optional "link" can be present, which links the resource to a collection of resources. This can be used in the API for example to retrieve all the notes associated with a tag. * * `query`: (Optional) The query parameters. In a URL, this is the part after the question mark "?". In this case, it should be an object with key/value pairs. * * `data`: (Optional) Applies to PUT and POST calls only. The request body contains the data you want to create or modify, for example the content of a note or folder. * * `files`: (Optional) Used to create new resources and associate them with files. * * Please refer to the [Joplin API documentation](https://joplinapp.org/help/api/references/rest_api) for complete details about each call. As the plugin runs within the Joplin application **you do not need an authorisation token** to use this API. * * For example: * * ```typescript * // Get a note ID, title and body * const noteId = 'some_note_id'; * const note = await joplin.data.get(['notes', noteId], { fields: ['id', 'title', 'body'] }); * * // Get all folders * const folders = await joplin.data.get(['folders']); * * // Set the note body * await joplin.data.put(['notes', noteId], null, { body: "New note body" }); * * // Create a new note under one of the folders * await joplin.data.post(['notes'], null, { body: "my new note", title: "some title", parent_id: folders[0].id }); * ``` */ export default class JoplinData { private api_; private pathSegmentRegex_; private plugin; constructor(plugin: Plugin); private serializeApiBody; private pathToString; get(path: Path, query?: any): Promise; post(path: Path, query?: any, body?: any, files?: any[]): Promise; put(path: Path, query?: any, body?: any, files?: any[]): Promise; delete(path: Path, query?: any): Promise; itemType(itemId: string): Promise; resourcePath(resourceId: string): Promise; /** * Gets an item user data. User data are key/value pairs. The `key` can be any * arbitrary string, while the `value` can be of any type supported by * [JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description) * * User data is synchronised across devices, and each value wil be merged based on their timestamp: * * - If value is modified by client 1, then modified by client 2, it will take the value from client 2 * - If value is modified by client 1, then deleted by client 2, the value will be deleted after merge * - If value is deleted by client 1, then updated by client 2, the value will be restored and set to the value from client 2 after merge */ userDataGet(itemType: ModelType, itemId: string, key: string): Promise; /** * Sets a note user data. See {@link JoplinData.userDataGet} for more details. */ userDataSet(itemType: ModelType, itemId: string, key: string, value: T): Promise; /** * Deletes a note user data. See {@link JoplinData.userDataGet} for more details. */ userDataDelete(itemType: ModelType, itemId: string, key: string): Promise; } ================================================ FILE: api/JoplinFilters.d.ts ================================================ import { FilterHandler } from '../../../eventManager'; /** * @ignore * * Not sure if it's the best way to hook into the app * so for now disable filters. */ export default class JoplinFilters { on(name: string, callback: FilterHandler): Promise; off(name: string, callback: FilterHandler): Promise; } ================================================ FILE: api/JoplinImaging.d.ts ================================================ import { Rectangle } from './types'; export interface CreateFromBufferOptions { width?: number; height?: number; scaleFactor?: number; } export interface CreateFromPdfOptions { /** * The first page to export. Defaults to `1`, the first page in * the document. */ minPage?: number; /** * The number of the last page to convert. Defaults to the last page * if not given. * * If `maxPage` is greater than the number of pages in the PDF, all pages * in the PDF will be converted to images. */ maxPage?: number; scaleFactor?: number; } export interface PdfInfo { pageCount: number; } export interface Implementation { createFromPath: (path: string) => Promise; createFromPdf: (path: string, options: CreateFromPdfOptions) => Promise; getPdfInfo: (path: string) => Promise; } export interface ResizeOptions { width?: number; height?: number; quality?: 'good' | 'better' | 'best'; } export type Handle = string; /** * Provides imaging functions to resize or process images. You create an image * using one of the `createFrom` functions, then use the other functions to * process the image. * * Images are associated with a handle which is what will be available to the * plugin. Once you are done with an image, free it using the `free()` function. * * [View the * example](https://github.com/laurent22/joplin/blob/dev/packages/app-cli/tests/support/plugins/imaging/src/index.ts) * * desktop */ export default class JoplinImaging { private implementation_; private images_; constructor(implementation: Implementation); private createImageHandle; private imageByHandle; private cacheImage; /** * Creates an image from the provided path. Note that images and PDFs are supported. If you * provide a URL instead of a local path, the file will be downloaded first then converted to an * image. */ createFromPath(filePath: string): Promise; createFromResource(resourceId: string): Promise; createFromPdfPath(path: string, options?: CreateFromPdfOptions): Promise; createFromPdfResource(resourceId: string, options?: CreateFromPdfOptions): Promise; getPdfInfoFromPath(path: string): Promise; getPdfInfoFromResource(resourceId: string): Promise; getSize(handle: Handle): Promise; resize(handle: Handle, options?: ResizeOptions): Promise; crop(handle: Handle, rectangle: Rectangle): Promise; toPngFile(handle: Handle, filePath: string): Promise; /** * Quality is between 0 and 100 */ toJpgFile(handle: Handle, filePath: string, quality?: number): Promise; private tempFilePath; /** * Creates a new Joplin resource from the image data. The image will be * first converted to a JPEG. */ toJpgResource(handle: Handle, resourceProps: any, quality?: number): Promise; /** * Creates a new Joplin resource from the image data. The image will be * first converted to a PNG. */ toPngResource(handle: Handle, resourceProps: any): Promise; /** * Image data is not automatically deleted by Joplin so make sure you call * this method on the handle once you are done. */ free(handles: Handle[] | Handle): Promise; } ================================================ FILE: api/JoplinInterop.d.ts ================================================ import { ExportModule, ImportModule } from './types'; /** * Provides a way to create modules to import external data into Joplin or to export notes into any arbitrary format. * * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/json_export) * * To implement an import or export module, you would simply define an object with various event handlers that are called * by the application during the import/export process. * * See the documentation of the [[ExportModule]] and [[ImportModule]] for more information. * * You may also want to refer to the Joplin API documentation to see the list of properties for each item (note, notebook, etc.) - https://joplinapp.org/help/api/references/rest_api * * desktop: While it is possible to register import and export * modules on mobile, there is no GUI to activate them. */ export default class JoplinInterop { registerExportModule(module: ExportModule): Promise; registerImportModule(module: ImportModule): Promise; } ================================================ FILE: api/JoplinPlugins.d.ts ================================================ import Plugin from '../Plugin'; import { ContentScriptType, Script } from './types'; /** * This class provides access to plugin-related features. */ export default class JoplinPlugins { private plugin; constructor(plugin: Plugin); /** * Registers a new plugin. This is the entry point when creating a plugin. You should pass a simple object with an `onStart` method to it. * That `onStart` method will be executed as soon as the plugin is loaded. * * ```typescript * joplin.plugins.register({ * onStart: async function() { * // Run your plugin code here * } * }); * ``` */ register(script: Script): Promise; /** * @deprecated Use joplin.contentScripts.register() */ registerContentScript(type: ContentScriptType, id: string, scriptPath: string): Promise; /** * Gets the plugin own data directory path. Use this to store any * plugin-related data. Unlike [[installationDir]], any data stored here * will be persisted. */ dataDir(): Promise; /** * Gets the plugin installation directory. This can be used to access any * asset that was packaged with the plugin. This directory should be * considered read-only because any data you store here might be deleted or * re-created at any time. To store new persistent data, use [[dataDir]]. */ installationDir(): Promise; /** * @deprecated Use joplin.require() */ require(_path: string): any; } ================================================ FILE: api/JoplinSettings.d.ts ================================================ import Plugin from '../Plugin'; import { SettingItem, SettingSection } from './types'; export interface ChangeEvent { /** * Setting keys that have been changed */ keys: string[]; } export type ChangeHandler = (event: ChangeEvent) => void; /** * This API allows registering new settings and setting sections, as well as getting and setting settings. Once a setting has been registered it will appear in the config screen and be editable by the user. * * Settings are essentially key/value pairs. * * Note: Currently this API does **not** provide access to Joplin's built-in settings. This is by design as plugins that modify user settings could give unexpected results * * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/settings) */ export default class JoplinSettings { private plugin_; constructor(plugin: Plugin); /** * Registers new settings. * Note that registering a setting item is dynamic and will be gone next time Joplin starts. * What it means is that you need to register the setting every time the plugin starts (for example in the onStart event). * The setting value however will be preserved from one launch to the next so there is no risk that it will be lost even if for some * reason the plugin fails to start at some point. */ registerSettings(settings: Record): Promise; /** * @deprecated Use joplin.settings.registerSettings() * * Registers a new setting. */ registerSetting(key: string, settingItem: SettingItem): Promise; /** * Registers a new setting section. Like for registerSetting, it is dynamic and needs to be done every time the plugin starts. */ registerSection(name: string, section: SettingSection): Promise; /** * Gets setting values (only applies to setting you registered from your plugin) */ values(keys: string[] | string): Promise>; /** * @deprecated Use joplin.settings.values() * * Gets a setting value (only applies to setting you registered from your plugin) */ value(key: string): Promise; /** * Sets a setting value (only applies to setting you registered from your plugin) */ setValue(key: string, value: any): Promise; /** * Gets a global setting value, including app-specific settings and those set by other plugins. * * The list of available settings is not documented yet, but can be found by looking at the source code: * * https://github.com/laurent22/joplin/blob/dev/packages/lib/models/Setting.ts#L142 */ globalValue(key: string): Promise; /** * Called when one or multiple settings of your plugin have been changed. * - For performance reasons, this event is triggered with a delay. * - You will only get events for your own plugin settings. */ onChange(handler: ChangeHandler): Promise; } ================================================ FILE: api/JoplinViews.d.ts ================================================ import Plugin from '../Plugin'; import JoplinViewsDialogs from './JoplinViewsDialogs'; import JoplinViewsMenuItems from './JoplinViewsMenuItems'; import JoplinViewsMenus from './JoplinViewsMenus'; import JoplinViewsToolbarButtons from './JoplinViewsToolbarButtons'; import JoplinViewsPanels from './JoplinViewsPanels'; import JoplinViewsNoteList from './JoplinViewsNoteList'; import JoplinViewsEditors from './JoplinViewsEditor'; /** * This namespace provides access to view-related services. * * All view services provide a `create()` method which you would use to create the view object, whether it's a dialog, a toolbar button or a menu item. * In some cases, the `create()` method will return a [[ViewHandle]], which you would use to act on the view, for example to set certain properties or call some methods. */ export default class JoplinViews { private store; private plugin; private panels_; private menuItems_; private menus_; private toolbarButtons_; private dialogs_; private editors_; private noteList_; private implementation_; constructor(implementation: any, plugin: Plugin, store: any); get dialogs(): JoplinViewsDialogs; get panels(): JoplinViewsPanels; get editors(): JoplinViewsEditors; get menuItems(): JoplinViewsMenuItems; get menus(): JoplinViewsMenus; get toolbarButtons(): JoplinViewsToolbarButtons; get noteList(): JoplinViewsNoteList; } ================================================ FILE: api/JoplinViewsDialogs.d.ts ================================================ import Plugin from '../Plugin'; import { ButtonSpec, ViewHandle, DialogResult } from './types'; /** * Allows creating and managing dialogs. A dialog is modal window that * contains a webview and a row of buttons. You can update the * webview using the `setHtml` method. Dialogs are hidden by default and * you need to call `open()` to open them. Once the user clicks on a * button, the `open` call will return an object indicating what button was * clicked on. * * ## Retrieving form values * * If your HTML content included one or more forms, a `formData` object * will also be included with the key/value for each form. * * ## Special button IDs * * The following buttons IDs have a special meaning: * * - `ok`, `yes`, `submit`, `confirm`: They are considered "submit" buttons * - `cancel`, `no`, `reject`: They are considered "dismiss" buttons * * This information is used by the application to determine what action * should be done when the user presses "Enter" or "Escape" within the * dialog. If they press "Enter", the first "submit" button will be * automatically clicked. If they press "Escape" the first "dismiss" button * will be automatically clicked. * * [View the demo * plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/dialog) */ export default class JoplinViewsDialogs { private store; private plugin; private implementation_; constructor(implementation: any, plugin: Plugin, store: any); private controller; /** * Creates a new dialog */ create(id: string): Promise; /** * Displays a message box with OK/Cancel buttons. Returns the button index that was clicked - "0" for OK and "1" for "Cancel" */ showMessageBox(message: string): Promise; /** * Displays a dialog to select a file or a directory. Same options and * output as * https://www.electronjs.org/docs/latest/api/dialog#dialogshowopendialogbrowserwindow-options * * desktop */ showOpenDialog(options: any): Promise; /** * Sets the dialog HTML content */ setHtml(handle: ViewHandle, html: string): Promise; /** * Adds and loads a new JS or CSS files into the dialog. */ addScript(handle: ViewHandle, scriptPath: string): Promise; /** * Sets the dialog buttons. */ setButtons(handle: ViewHandle, buttons: ButtonSpec[]): Promise; /** * Opens the dialog. * * On desktop, this closes any copies of the dialog open in different windows. */ open(handle: ViewHandle): Promise; /** * Toggle on whether to fit the dialog size to the content or not. * When set to false, the dialog is set to 90vw and 80vh * @default true */ setFitToContent(handle: ViewHandle, status: boolean): Promise; } ================================================ FILE: api/JoplinViewsEditor.d.ts ================================================ import Plugin from '../Plugin'; import { ActivationCheckCallback, ViewHandle, UpdateCallback } from './types'; /** * Allows creating alternative note editors. You can create a view to handle loading and saving the * note, and do your own rendering. * * Although it may be used to implement an alternative text editor, the more common use case may be * to render the note in a different, graphical way - for example displaying a graph, and * saving/loading the graph data in the associated note. In that case, you would detect whether the * current note contains graph data and, in this case, you'd display your viewer. * * Terminology: An editor is **active** when it can be used to edit the current note. Note that it * doesn't necessarily mean that your editor is visible - it just means that the user has the option * to switch to it (via the "toggle editor" button). A **visible** editor is active and is currently * being displayed. * * To implement an editor you need to listen to two events: * * - `onActivationCheck`: This is a way for the app to know whether your editor should be active or * not. Return `true` from this handler to activate your editor. * * - `onUpdate`: When this is called you should update your editor based on the current note * content. Call `joplin.workspace.selectedNote()` to get the current note. * * - `showEditorPlugin` and `toggleEditorPlugin` commands. Additionally you can use these commands * to display your editor via `joplin.commands.execute('showEditorPlugin')`. This is not always * necessary since the user can switch to your editor using the "toggle editor" button, however * you may want to programmatically display the editor in some cases - for example when creating a * new note specific to your editor. * * Note that only one editor view can be active at a time. This is why it is important not to * activate your view if it's not relevant to the current note. If more than one is active, it is * undefined which editor is going to be used to display the note. * * For an example of editor plugin, see the [YesYouKan * plugin](https://github.com/joplin/plugin-yesyoukan/blob/master/src/index.ts). In particular, * check the logic around `onActivationCheck` and `onUpdate` since this is the entry points for * using this API. */ export default class JoplinViewsEditors { private store; private plugin; private activationCheckHandlers_; constructor(plugin: Plugin, store: any); private controller; /** * Creates a new editor view */ create(id: string): Promise; /** * Sets the editor HTML content */ setHtml(handle: ViewHandle, html: string): Promise; /** * Adds and loads a new JS or CSS file into the panel. */ addScript(handle: ViewHandle, scriptPath: string): Promise; /** * See [[JoplinViewPanels]] */ onMessage(handle: ViewHandle, callback: Function): Promise; /** * Emitted when the editor can potentially be activated - this for example when the current note * is changed, or when the application is opened. At that point should can check the current * note and decide whether your editor should be activated or not. If it should return `true`, * otherwise return `false`. */ onActivationCheck(handle: ViewHandle, callback: ActivationCheckCallback): Promise; /** * Emitted when the editor content should be updated. This for example when the currently * selected note changes, or when the user makes the editor visible. */ onUpdate(handle: ViewHandle, callback: UpdateCallback): Promise; /** * See [[JoplinViewPanels]] */ postMessage(handle: ViewHandle, message: any): void; /** * Tells whether the editor is active or not. */ isActive(handle: ViewHandle): Promise; /** * Tells whether the editor is effectively visible or not. If the editor is inactive, this will * return `false`. If the editor is active and the user has switched to it, it will return * `true`. Otherwise it will return `false`. */ isVisible(handle: ViewHandle): Promise; } ================================================ FILE: api/JoplinViewsMenuItems.d.ts ================================================ import { CreateMenuItemOptions, MenuItemLocation } from './types'; import Plugin from '../Plugin'; /** * Allows creating and managing menu items. * * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command) * * desktop */ export default class JoplinViewsMenuItems { private store; private plugin; constructor(plugin: Plugin, store: any); /** * Creates a new menu item and associate it with the given command. You can specify under which menu the item should appear using the `location` parameter. */ create(id: string, commandName: string, location?: MenuItemLocation, options?: CreateMenuItemOptions): Promise; } ================================================ FILE: api/JoplinViewsMenus.d.ts ================================================ import { MenuItem, MenuItemLocation } from './types'; import Plugin from '../Plugin'; /** * Allows creating menus. * * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/menu) * * desktop */ export default class JoplinViewsMenus { private store; private plugin; constructor(plugin: Plugin, store: any); private registerCommandAccelerators; /** * Creates a new menu from the provided menu items and place it at the given location. As of now, it is only possible to place the * menu as a sub-menu of the application build-in menus. */ create(id: string, label: string, menuItems: MenuItem[], location?: MenuItemLocation): Promise; } ================================================ FILE: api/JoplinViewsNoteList.d.ts ================================================ import { Store } from 'redux'; import Plugin from '../Plugin'; import { ListRenderer } from './noteListType'; /** * This API allows you to customise how each note in the note list is rendered. * The renderer you implement follows a unidirectional data flow. * * The app provides the required dependencies whenever a note is updated - you * process these dependencies, and return some props, which are then passed to * your template and rendered. See [[ListRenderer]] for a detailed description * of each property of the renderer. * * ## Reference * * * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/note_list_renderer) * * * [Default simple renderer](https://github.com/laurent22/joplin/tree/dev/packages/lib/services/noteList/defaultListRenderer.ts) * * * [Default detailed renderer](https://github.com/laurent22/joplin/tree/dev/packages/lib/services/noteList/defaultMultiColumnsRenderer.ts) * * ## Screenshots: * * ### Top to bottom with title, date and body * * * * ### Left to right with thumbnails * * * * ### Top to bottom with editable title * * * * desktop */ export default class JoplinViewsNoteList { private plugin_; private store_; constructor(plugin: Plugin, store: Store); registerRenderer(renderer: ListRenderer): Promise; } ================================================ FILE: api/JoplinViewsPanels.d.ts ================================================ import Plugin from '../Plugin'; import { ViewHandle } from './types'; /** * Allows creating and managing view panels. View panels allow displaying any HTML * content (within a webview) and updating it in real-time. For example it * could be used to display a table of content for the active note, or * display various metadata or graph. * * On desktop, view panels currently are displayed at the right of the sidebar, though can * be moved with "View" > "Change application layout". * * On mobile, view panels are shown in a tabbed dialog that can be opened using a * toolbar button. * * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/toc) */ export default class JoplinViewsPanels { private store; private plugin; constructor(plugin: Plugin, store: any); private controller; /** * Creates a new panel */ create(id: string): Promise; /** * Sets the panel webview HTML */ setHtml(handle: ViewHandle, html: string): Promise; /** * Adds and loads a new JS or CSS files into the panel. */ addScript(handle: ViewHandle, scriptPath: string): Promise; /** * Called when a message is sent from the webview (using postMessage). * * To post a message from the webview to the plugin use: * * ```javascript * const response = await webviewApi.postMessage(message); * ``` * * - `message` can be any JavaScript object, string or number * - `response` is whatever was returned by the `onMessage` handler * * Using this mechanism, you can have two-way communication between the * plugin and webview. * * See the [postMessage * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) for more details. * */ onMessage(handle: ViewHandle, callback: Function): Promise; /** * Sends a message to the webview. * * The webview must have registered a message handler prior, otherwise the message is ignored. Use; * * ```javascript * webviewApi.onMessage((message) => { ... }); * ``` * * - `message` can be any JavaScript object, string or number * * The view API may have only one onMessage handler defined. * This method is fire and forget so no response is returned. * * It is particularly useful when the webview needs to react to events emitted by the plugin or the joplin api. */ postMessage(handle: ViewHandle, message: any): void; /** * Shows the panel */ show(handle: ViewHandle, show?: boolean): Promise; /** * Hides the panel */ hide(handle: ViewHandle): Promise; /** * Tells whether the panel is visible or not */ visible(handle: ViewHandle): Promise; isActive(handle: ViewHandle): Promise; } ================================================ FILE: api/JoplinViewsToolbarButtons.d.ts ================================================ import { ToolbarButtonLocation } from './types'; import Plugin from '../Plugin'; /** * Allows creating and managing toolbar buttons. * * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command) */ export default class JoplinViewsToolbarButtons { private store; private plugin; constructor(plugin: Plugin, store: any); /** * Creates a new toolbar button and associate it with the given command. */ create(id: string, commandName: string, location: ToolbarButtonLocation): Promise; } ================================================ FILE: api/JoplinWindow.d.ts ================================================ import Plugin from '../Plugin'; export default class JoplinWindow { private store_; constructor(_plugin: Plugin, store: any); /** * Loads a chrome CSS file. It will apply to the window UI elements, except * for the note viewer. It is the same as the "Custom stylesheet for * Joplin-wide app styles" setting. See the [Load CSS Demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/load_css) * for an example. * * desktop */ loadChromeCssFile(filePath: string): Promise; /** * Loads a note CSS file. It will apply to the note viewer, as well as any * exported or printed note. It is the same as the "Custom stylesheet for * rendered Markdown" setting. See the [Load CSS Demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/load_css) * for an example. * * desktop */ loadNoteCssFile(filePath: string): Promise; } ================================================ FILE: api/JoplinWorkspace.d.ts ================================================ import Plugin from '../Plugin'; import { FolderEntity } from '../../database/types'; import { Disposable, EditContextMenuFilterObject, FilterHandler } from './types'; declare enum ItemChangeEventType { Create = 1, Update = 2, Delete = 3 } interface ItemChangeEvent { id: string; event: ItemChangeEventType; } interface ResourceChangeEvent { id: string; } interface NoteContentChangeEvent { note: any; } interface NoteSelectionChangeEvent { value: string[]; } interface NoteAlarmTriggerEvent { noteId: string; } interface SyncCompleteEvent { withErrors: boolean; } type WorkspaceEventHandler = (event: EventType) => void; type ItemChangeHandler = WorkspaceEventHandler; type SyncStartHandler = () => void; type ResourceChangeHandler = WorkspaceEventHandler; /** * The workspace service provides access to all the parts of Joplin that * are being worked on - i.e. the currently selected notes or notebooks as * well as various related events, such as when a new note is selected, or * when the note content changes. * * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins) */ export default class JoplinWorkspace { private store; private plugin; constructor(plugin: Plugin, store: any); /** * Called when a new note or notes are selected. */ onNoteSelectionChange(callback: WorkspaceEventHandler): Promise; /** * Called when the content of a note changes. * @deprecated Use `onNoteChange()` instead, which is reliably triggered whenever the note content, or any note property changes. */ onNoteContentChange(callback: WorkspaceEventHandler): Promise; /** * Called when the content of the current note changes. */ onNoteChange(handler: ItemChangeHandler): Promise; /** * Called when a resource is changed. Currently this handled will not be * called when a resource is added or deleted. */ onResourceChange(handler: ResourceChangeHandler): Promise; /** * Called when an alarm associated with a to-do is triggered. */ onNoteAlarmTrigger(handler: WorkspaceEventHandler): Promise; /** * Called when the synchronisation process is starting. */ onSyncStart(handler: SyncStartHandler): Promise; /** * Called when the synchronisation process has finished. */ onSyncComplete(callback: WorkspaceEventHandler): Promise; /** * Called just before the editor context menu is about to open. Allows * adding items to it. * * desktop */ filterEditorContextMenu(handler: FilterHandler): void; /** * Gets the currently selected note. Will be `null` if no note is selected. */ selectedNote(): Promise; /** * Gets the currently selected folder. In some cases, for example during * search or when viewing a tag, no folder is actually selected in the user * interface. In that case, that function would return the last selected * folder. */ selectedFolder(): Promise; /** * Gets the IDs of the selected notes (can be zero, one, or many). Use the data API to retrieve information about these notes. */ selectedNoteIds(): Promise; } export {}; ================================================ FILE: api/index.ts ================================================ import type Joplin from './Joplin'; declare const joplin: Joplin; export default joplin; ================================================ FILE: api/noteListType.d.ts ================================================ import { Size } from './types'; type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.deleted_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_data' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.deleted_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.id' | 'note.is_conflict' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_'; export declare enum ItemFlow { TopToBottom = "topToBottom", LeftToRight = "leftToRight" } export type RenderNoteView = Record; export interface OnChangeEvent { elementId: string; value: any; noteId: string; } export interface OnClickEvent { elementId: string; } export type OnRenderNoteHandler = (props: any) => Promise; export type OnChangeHandler = (event: OnChangeEvent) => Promise; export type OnClickHandler = (event: OnClickEvent) => Promise; /** * Most of these are the built-in note properties, such as `note.title`, `note.todo_completed`, etc. * complemented with special properties such as `note.isWatched`, to know if a note is currently * opened in the external editor, and `note.tags` to get the list tags associated with the note. * * The `note.todoStatusText` property is a localised description of the to-do status (e.g. * "to-do, incomplete"). If you include an `` for to-do items that would * otherwise be unlabelled, consider adding `note.todoStatusText` as the checkbox's `aria-label`. * * ## Item properties * * The `item.*` properties are specific to the rendered item. The most important being * `item.selected`, which you can use to display the selected note in a different way. */ export type ListRendererDependency = ListRendererDatabaseDependency | 'item.index' | 'item.selected' | 'item.size.height' | 'item.size.width' | 'note.folder.title' | 'note.isWatched' | 'note.tags' | 'note.todoStatusText' | 'note.titleHtml'; export type ListRendererItemValueTemplates = Record; export declare const columnNames: readonly ["note.folder.title", "note.is_todo", "note.latitude", "note.longitude", "note.source_url", "note.tags", "note.title", "note.todo_completed", "note.todo_due", "note.user_created_time", "note.user_updated_time"]; export type ColumnName = typeof columnNames[number]; export interface ListRenderer { /** * It must be unique to your plugin. */ id: string; /** * Can be top to bottom or left to right. Left to right gives you more * option to set the size of the items since you set both its width and * height. */ flow: ItemFlow; /** * Whether the renderer supports multiple columns. Applies only when `flow` * is `topToBottom`. Defaults to `false`. */ multiColumns?: boolean; /** * The size of each item must be specified in advance for performance * reasons, and cannot be changed afterwards. If the item flow is top to * bottom, you only need to specify the item height (the width will be * ignored). */ itemSize: Size; /** * The CSS is relative to the list item container. What will appear in the * page is essentially `.note-list-item { YOUR_CSS; }`. It means you can use * child combinator with guarantee it will only apply to your own items. In * this example, the styling will apply to `.note-list-item > .content`: * * ```css * > .content { * padding: 10px; * } * ``` * * In order to get syntax highlighting working here, it's recommended * installing an editor extension such as [es6-string-html VSCode * extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html) */ itemCss?: string; /** * List the dependencies that your plugin needs to render the note list * items. Only these will be passed to your `onRenderNote` handler. Ensure * that you do not add more than what you need since there is a performance * penalty for each property. */ dependencies?: ListRendererDependency[]; headerTemplate?: string; headerHeight?: number; onHeaderClick?: OnClickHandler; /** * This property is set differently depending on the `multiColumns` property. * * ## If `multiColumns` is `false` * * There is only one column and the template is used to render the entire row. * * This is the HTML template that will be used to render the note list item. This is a [Mustache * template](https://github.com/janl/mustache.js) and it will receive the variable you return * from `onRenderNote` as tags. For example, if you return a property named `formattedDate` from * `onRenderNote`, you can insert it in the template using `Created date: {{formattedDate}}` * * ## If `multiColumns` is `true` * * Since there is multiple columns, this template will be used to render each note property * within the row. For example if the current columns are the Updated and Title properties, this * template will be called once to render the updated time and a second time to render the * title. To display the current property, the generic `value` property is provided - it will be * replaced at runtime by the actual note property. To render something different depending on * the note property, use `itemValueTemplate`. A minimal example would be * `{{value}}` which will simply render the current property inside a span tag. * * In order to get syntax highlighting working here, it's recommended installing an editor * extension such as [es6-string-html VSCode * extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html) * * ## Default property rendering * * Certain properties are automatically rendered once inserted in the Mustache template. Those * are in particular all the date-related fields, such as `note.user_updated_time` or * `note.todo_completed`. Internally, those are timestamps in milliseconds, however when * rendered we display them as date/time strings using the user's preferred time format. Another * notable auto-rendered property is `note.title` which is going to include additional HTML, * such as the search markers. * * If you do not want this default rendering behaviour, for example if you want to display the * raw timestamps in milliseconds, you can simply return custom properties from * `onRenderNote()`. For example: * * ```typescript * onRenderNote: async (props: any) => { * return { * ...props, * // Return the property under a different name * updatedTimeMs: props.note.user_updated_time, * } * }, * * itemTemplate: // html * ` *
* Raw timestamp: {{updatedTimeMs}} *
* `, * * ``` * * See * `[https://github.com/laurent22/joplin/blob/dev/packages/lib/services/noteList/renderViewProps.ts](renderViewProps.ts)` * for the list of properties that have a default rendering. */ itemTemplate: string; /** * This property applies only when `multiColumns` is `true`. It is used to render something * different for each note property. * * This is a map of actual dependencies to templates - you only need to return something if the * default, as specified in `template`, is not enough. * * Again you need to return a Mustache template and it will be combined with the `template` * property to create the final template. For example if you return a property named * `formattedDate` from `onRenderNote`, you can insert it in the template using * `{{formattedDate}}`. This string will replace `{{value}}` in the `template` property. * * So if the template property is set to `{{value}}`, the final template will be * `{{formattedDate}}`. * * The property would be set as so: * * ```javascript * itemValueTemplates: { * 'note.user_updated_time': '{{formattedDate}}', * } * ``` */ itemValueTemplates?: ListRendererItemValueTemplates; /** * This user-facing text is used for example in the View menu, so that your * renderer can be selected. */ label: () => Promise; /** * This is where most of the real-time processing will happen. When a note * is rendered for the first time and every time it changes, this handler * receives the properties specified in the `dependencies` property. You can * then process them, load any additional data you need, and once done you * need to return the properties that are needed in the `itemTemplate` HTML. * Again, to use the formatted date example, you could have such a renderer: * * ```typescript * dependencies: [ * 'note.title', * 'note.created_time', * ], * * itemTemplate: // html * ` *
* Title: {{note.title}}
* Date: {{formattedDate}} *
* `, * * onRenderNote: async (props: any) => { * const formattedDate = dayjs(props.note.created_time).format(); * return { * // Also return the props, so that note.title is available from the * // template * ...props, * formattedDate, * } * }, * ``` */ onRenderNote: OnRenderNoteHandler; /** * This handler allows adding some interactivity to the note renderer - whenever an input element * within the item is changed (for example, when a checkbox is clicked, or a text input is * changed), this `onChange` handler is going to be called. * * You can inspect `event.elementId` to know which element had some changes, and `event.value` * to know the new value. `event.noteId` also tells you what note is affected, so that you can * potentially apply changes to it. * * You specify the element ID, by setting a `data-id` attribute on the input. * * For example, if you have such a template: * * ```html *
* *
* ``` * * The event handler will receive an event with `elementId` set to `noteTitleInput`. * * ## Default event handlers * * Currently one click event is automatically handled: * * If there is a checkbox with a `data-id="todo-checkbox"` attribute is present, it is going to * automatically toggle the note to-do "completed" status. * * For example this is what is used in the default list renderer: * * `` */ onChange?: OnChangeHandler; } export interface NoteListColumn { name: ColumnName; width: number; } export type NoteListColumns = NoteListColumn[]; export declare const defaultWidth = 100; export declare const defaultListColumns: () => NoteListColumns; export {}; ================================================ FILE: api/noteListType.ts ================================================ /* eslint-disable multiline-comment-style */ import { Size } from './types'; // AUTO-GENERATED by generate-database-type type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.deleted_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_data' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.deleted_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.id' | 'note.is_conflict' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_'; // AUTO-GENERATED by generate-database-type export enum ItemFlow { TopToBottom = 'topToBottom', LeftToRight = 'leftToRight', } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied export type RenderNoteView = Record; export interface OnChangeEvent { elementId: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied value: any; noteId: string; } export interface OnClickEvent { elementId: string; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied export type OnRenderNoteHandler = (props: any)=> Promise; export type OnChangeHandler = (event: OnChangeEvent)=> Promise; export type OnClickHandler = (event: OnClickEvent)=> Promise; /** * Most of these are the built-in note properties, such as `note.title`, `note.todo_completed`, etc. * complemented with special properties such as `note.isWatched`, to know if a note is currently * opened in the external editor, and `note.tags` to get the list tags associated with the note. * * The `note.todoStatusText` property is a localised description of the to-do status (e.g. * "to-do, incomplete"). If you include an `` for to-do items that would * otherwise be unlabelled, consider adding `note.todoStatusText` as the checkbox's `aria-label`. * * ## Item properties * * The `item.*` properties are specific to the rendered item. The most important being * `item.selected`, which you can use to display the selected note in a different way. */ export type ListRendererDependency = ListRendererDatabaseDependency | 'item.index' | 'item.selected' | 'item.size.height' | 'item.size.width' | 'note.folder.title' | 'note.isWatched' | 'note.tags' | 'note.todoStatusText' | 'note.titleHtml'; export type ListRendererItemValueTemplates = Record; export const columnNames = [ 'note.folder.title', 'note.is_todo', 'note.latitude', 'note.longitude', 'note.source_url', 'note.tags', 'note.title', 'note.todo_completed', 'note.todo_due', 'note.user_created_time', 'note.user_updated_time', ] as const; export type ColumnName = typeof columnNames[number]; export interface ListRenderer { /** * It must be unique to your plugin. */ id: string; /** * Can be top to bottom or left to right. Left to right gives you more * option to set the size of the items since you set both its width and * height. */ flow: ItemFlow; /** * Whether the renderer supports multiple columns. Applies only when `flow` * is `topToBottom`. Defaults to `false`. */ multiColumns?: boolean; /** * The size of each item must be specified in advance for performance * reasons, and cannot be changed afterwards. If the item flow is top to * bottom, you only need to specify the item height (the width will be * ignored). */ itemSize: Size; /** * The CSS is relative to the list item container. What will appear in the * page is essentially `.note-list-item { YOUR_CSS; }`. It means you can use * child combinator with guarantee it will only apply to your own items. In * this example, the styling will apply to `.note-list-item > .content`: * * ```css * > .content { * padding: 10px; * } * ``` * * In order to get syntax highlighting working here, it's recommended * installing an editor extension such as [es6-string-html VSCode * extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html) */ itemCss?: string; /** * List the dependencies that your plugin needs to render the note list * items. Only these will be passed to your `onRenderNote` handler. Ensure * that you do not add more than what you need since there is a performance * penalty for each property. */ dependencies?: ListRendererDependency[]; headerTemplate?: string; headerHeight?: number; onHeaderClick?: OnClickHandler; /** * This property is set differently depending on the `multiColumns` property. * * ## If `multiColumns` is `false` * * There is only one column and the template is used to render the entire row. * * This is the HTML template that will be used to render the note list item. This is a [Mustache * template](https://github.com/janl/mustache.js) and it will receive the variable you return * from `onRenderNote` as tags. For example, if you return a property named `formattedDate` from * `onRenderNote`, you can insert it in the template using `Created date: {{formattedDate}}` * * ## If `multiColumns` is `true` * * Since there is multiple columns, this template will be used to render each note property * within the row. For example if the current columns are the Updated and Title properties, this * template will be called once to render the updated time and a second time to render the * title. To display the current property, the generic `value` property is provided - it will be * replaced at runtime by the actual note property. To render something different depending on * the note property, use `itemValueTemplate`. A minimal example would be * `{{value}}` which will simply render the current property inside a span tag. * * In order to get syntax highlighting working here, it's recommended installing an editor * extension such as [es6-string-html VSCode * extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html) * * ## Default property rendering * * Certain properties are automatically rendered once inserted in the Mustache template. Those * are in particular all the date-related fields, such as `note.user_updated_time` or * `note.todo_completed`. Internally, those are timestamps in milliseconds, however when * rendered we display them as date/time strings using the user's preferred time format. Another * notable auto-rendered property is `note.title` which is going to include additional HTML, * such as the search markers. * * If you do not want this default rendering behaviour, for example if you want to display the * raw timestamps in milliseconds, you can simply return custom properties from * `onRenderNote()`. For example: * * ```typescript * onRenderNote: async (props: any) => { * return { * ...props, * // Return the property under a different name * updatedTimeMs: props.note.user_updated_time, * } * }, * * itemTemplate: // html * ` *
* Raw timestamp: {{updatedTimeMs}} *
* `, * * ``` * * See * `[https://github.com/laurent22/joplin/blob/dev/packages/lib/services/noteList/renderViewProps.ts](renderViewProps.ts)` * for the list of properties that have a default rendering. */ itemTemplate: string; /** * This property applies only when `multiColumns` is `true`. It is used to render something * different for each note property. * * This is a map of actual dependencies to templates - you only need to return something if the * default, as specified in `template`, is not enough. * * Again you need to return a Mustache template and it will be combined with the `template` * property to create the final template. For example if you return a property named * `formattedDate` from `onRenderNote`, you can insert it in the template using * `{{formattedDate}}`. This string will replace `{{value}}` in the `template` property. * * So if the template property is set to `{{value}}`, the final template will be * `{{formattedDate}}`. * * The property would be set as so: * * ```javascript * itemValueTemplates: { * 'note.user_updated_time': '{{formattedDate}}', * } * ``` */ itemValueTemplates?: ListRendererItemValueTemplates; /** * This user-facing text is used for example in the View menu, so that your * renderer can be selected. */ label: ()=> Promise; /** * This is where most of the real-time processing will happen. When a note * is rendered for the first time and every time it changes, this handler * receives the properties specified in the `dependencies` property. You can * then process them, load any additional data you need, and once done you * need to return the properties that are needed in the `itemTemplate` HTML. * Again, to use the formatted date example, you could have such a renderer: * * ```typescript * dependencies: [ * 'note.title', * 'note.created_time', * ], * * itemTemplate: // html * ` *
* Title: {{note.title}}
* Date: {{formattedDate}} *
* `, * * onRenderNote: async (props: any) => { * const formattedDate = dayjs(props.note.created_time).format(); * return { * // Also return the props, so that note.title is available from the * // template * ...props, * formattedDate, * } * }, * ``` */ onRenderNote: OnRenderNoteHandler; /** * This handler allows adding some interactivity to the note renderer - whenever an input element * within the item is changed (for example, when a checkbox is clicked, or a text input is * changed), this `onChange` handler is going to be called. * * You can inspect `event.elementId` to know which element had some changes, and `event.value` * to know the new value. `event.noteId` also tells you what note is affected, so that you can * potentially apply changes to it. * * You specify the element ID, by setting a `data-id` attribute on the input. * * For example, if you have such a template: * * ```html *
* *
* ``` * * The event handler will receive an event with `elementId` set to `noteTitleInput`. * * ## Default event handlers * * Currently one click event is automatically handled: * * If there is a checkbox with a `data-id="todo-checkbox"` attribute is present, it is going to * automatically toggle the note to-do "completed" status. * * For example this is what is used in the default list renderer: * * `` */ onChange?: OnChangeHandler; } export interface NoteListColumn { name: ColumnName; width: number; } export type NoteListColumns = NoteListColumn[]; export const defaultWidth = 100; export const defaultListColumns = () => { const columns: NoteListColumns = [ { name: 'note.is_todo', width: 30, }, { name: 'note.user_updated_time', width: defaultWidth, }, { name: 'note.title', width: 0, }, ]; return columns; }; ================================================ FILE: api/types.ts ================================================ /* eslint-disable multiline-comment-style */ // ================================================================= // Command API types // ================================================================= export interface Command { /** * Name of command - must be globally unique */ name: string; /** * Label to be displayed on menu items or keyboard shortcut editor for example. * If it is missing, it's assumed it's a private command, to be called programmatically only. * In that case the command will not appear in the shortcut editor or command panel, and logically * should not be used as a menu item. */ label?: string; /** * Icon to be used on toolbar buttons for example */ iconName?: string; /** * Code to be ran when the command is executed. It may return a result. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied execute(...args: any[]): Promise; /** * Defines whether the command should be enabled or disabled, which in turns * affects the enabled state of any associated button or menu item. * * The condition should be expressed as a "when-clause" (as in Visual Studio * Code). It's a simple boolean expression that evaluates to `true` or * `false`. It supports the following operators: * * Operator | Symbol | Example * -- | -- | -- * Equality | == | "editorType == markdown" * Inequality | != | "currentScreen != config" * Or | \|\| | "noteIsTodo \|\| noteTodoCompleted" * And | && | "oneNoteSelected && !inConflictFolder" * * Joplin, unlike VSCode, also supports parentheses, which allows creating * more complex expressions such as `cond1 || (cond2 && cond3)`. Only one * level of parentheses is possible (nested ones aren't supported). * * Currently the supported context variables aren't documented, but you can * find the list below: * * - [Global When Clauses](https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts) * - [Desktop app When Clauses](https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts) * * Note: Commands are enabled by default unless you use this property. */ enabledCondition?: string; } // ================================================================= // Interop API types // ================================================================= export enum FileSystemItem { File = 'file', Directory = 'directory', } export enum ImportModuleOutputFormat { Markdown = 'md', Html = 'html', } /** * Used to implement a module to export data from Joplin. [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/json_export) for an example. * * In general, all the event handlers you'll need to implement take a `context` object as a first argument. This object will contain the export or import path as well as various optional properties, such as which notes or notebooks need to be exported. * * To get a better sense of what it will contain it can be useful to print it using `console.info(context)`. */ export interface ExportModule { /** * The format to be exported, eg "enex", "jex", "json", etc. */ format: string; /** * The description that will appear in the UI, for example in the menu item. */ description: string; /** * Whether the module will export a single file or multiple files in a directory. It affects the open dialog that will be presented to the user when using your exporter. */ target: FileSystemItem; /** * Only applies to single file exporters or importers * It tells whether the format can package multiple notes into one file. * For example JEX or ENEX can, but HTML cannot. */ isNoteArchive: boolean; /** * The extensions of the files exported by your module. For example, it is `["htm", "html"]` for the HTML module, and just `["jex"]` for the JEX module. */ fileExtensions?: string[]; /** * Called when the export process starts. */ onInit(context: ExportContext): Promise; /** * Called when an item needs to be processed. An "item" can be any Joplin object, such as a note, a folder, a notebook, etc. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied onProcessItem(context: ExportContext, itemType: number, item: any): Promise; /** * Called when a resource file needs to be exported. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied onProcessResource(context: ExportContext, resource: any, filePath: string): Promise; /** * Called when the export process is done. */ onClose(context: ExportContext): Promise; } export interface ImportModule { /** * The format to be exported, eg "enex", "jex", "json", etc. */ format: string; /** * The description that will appear in the UI, for example in the menu item. */ description: string; /** * Only applies to single file exporters or importers * It tells whether the format can package multiple notes into one file. * For example JEX or ENEX can, but HTML cannot. */ isNoteArchive: boolean; /** * The type of sources that are supported by the module. Tells whether the module can import files or directories or both. */ sources: FileSystemItem[]; /** * Tells the file extensions of the exported files. */ fileExtensions?: string[]; /** * Tells the type of notes that will be generated, either HTML or Markdown (default). */ outputFormat?: ImportModuleOutputFormat; /** * Called when the import process starts. There is only one event handler within which you should import the complete data. */ onExec(context: ImportContext): Promise; } export interface ExportOptions { format?: string; path?: string; sourceFolderIds?: string[]; sourceNoteIds?: string[]; // modulePath?: string; target?: FileSystemItem; } export interface ExportContext { destPath: string; options: ExportOptions; /** * You can attach your own custom data using this property - it will then be passed to each event handler, allowing you to keep state from one event to the next. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied userData?: any; } export interface ImportContext { sourcePath: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied options: any; warnings: string[]; } // ================================================================= // Misc types // ================================================================= export interface Script { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied onStart?(event: any): Promise; } export interface Disposable { // dispose():void; } export enum ModelType { Note = 1, Folder = 2, Setting = 3, Resource = 4, Tag = 5, NoteTag = 6, Search = 7, Alarm = 8, MasterKey = 9, ItemChange = 10, NoteResource = 11, ResourceLocalState = 12, Revision = 13, Migration = 14, SmartFilter = 15, Command = 16, } export interface VersionInfo { version: string; profileVersion: number; syncVersion: number; platform: 'desktop'|'mobile'; } // ================================================================= // Menu types // ================================================================= export interface CreateMenuItemOptions { accelerator: string; } export enum MenuItemLocation { File = 'file', Edit = 'edit', View = 'view', Note = 'note', Tools = 'tools', Help = 'help', /** * @deprecated Do not use - same as NoteListContextMenu */ Context = 'context', // If adding an item here, don't forget to update isContextMenuItemLocation() /** * When a command is called from the note list context menu, the * command will receive the following arguments: * * - `noteIds:string[]`: IDs of the notes that were right-clicked on. */ NoteListContextMenu = 'noteListContextMenu', EditorContextMenu = 'editorContextMenu', /** * When a command is called from a folder context menu, the * command will receive the following arguments: * * - `folderId:string`: ID of the folder that was right-clicked on */ FolderContextMenu = 'folderContextMenu', /** * When a command is called from a tag context menu, the * command will receive the following arguments: * * - `tagId:string`: ID of the tag that was right-clicked on */ TagContextMenu = 'tagContextMenu', } export function isContextMenuItemLocation(location: MenuItemLocation): boolean { return [ MenuItemLocation.Context, MenuItemLocation.NoteListContextMenu, MenuItemLocation.EditorContextMenu, MenuItemLocation.FolderContextMenu, MenuItemLocation.TagContextMenu, ].includes(location); } export interface MenuItem { /** * Command that should be associated with the menu item. All menu item should * have a command associated with them unless they are a sub-menu. */ commandName?: string; /** * Arguments that should be passed to the command. They will be as rest * parameters. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied commandArgs?: any[]; /** * Set to "separator" to create a divider line */ type?: ('normal' | 'separator' | 'submenu' | 'checkbox' | 'radio'); /** * Accelerator associated with the menu item */ accelerator?: string; /** * Menu items that should appear below this menu item. Allows creating a menu tree. */ submenu?: MenuItem[]; /** * Menu item label. If not specified, the command label will be used instead. */ label?: string; } // ================================================================= // View API types // ================================================================= export interface ButtonSpec { id: ButtonId; title?: string; onClick?(): void; } export type ButtonId = string; export enum ToolbarButtonLocation { /** * This toolbar in the top right corner of the application. It applies to the note as a whole, including its metadata. * * desktop */ NoteToolbar = 'noteToolbar', /** * This toolbar is right above the text editor. It applies to the note body only. */ EditorToolbar = 'editorToolbar', } export type ViewHandle = string; export interface EditorCommand { name: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied value?: any; } export interface DialogResult { id: ButtonId; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied formData?: any; } export interface Size { width?: number; height?: number; } export interface Rectangle { x?: number; y?: number; width?: number; height?: number; } export type ActivationCheckCallback = ()=> Promise; export type UpdateCallback = ()=> Promise; export type VisibleHandler = ()=> Promise; export interface EditContextMenuFilterObject { items: MenuItem[]; } export interface EditorActivationCheckFilterObject { activatedEditors: { pluginId: string; viewId: string; isActive: boolean; }[]; } export type FilterHandler = (object: T)=> Promise; // ================================================================= // Settings types // ================================================================= export enum SettingItemType { Int = 1, String = 2, Bool = 3, Array = 4, Object = 5, Button = 6, } export enum SettingItemSubType { FilePathAndArgs = 'file_path_and_args', FilePath = 'file_path', // Not supported on mobile! DirectoryPath = 'directory_path', // Not supported on mobile! } export enum AppType { Desktop = 'desktop', Mobile = 'mobile', Cli = 'cli', } export enum SettingStorage { Database = 1, File = 2, } // Redefine a simplified interface to mask internal details // and to remove function calls as they would have to be async. export interface SettingItem { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied value: any; type: SettingItemType; /** * Currently only used to display a file or directory selector. Always set * `type` to `SettingItemType.String` when using this property. */ subType?: SettingItemSubType; label: string; description?: string; /** * A public setting will appear in the Configuration screen and will be * modifiable by the user. A private setting however will not appear there, * and can only be changed programmatically. You may use this to store some * values that you do not want to directly expose. */ public: boolean; /** * You would usually set this to a section you would have created * specifically for the plugin. */ section?: string; /** * To create a setting with multiple options, set this property to `true`. * That setting will render as a dropdown list in the configuration screen. */ isEnum?: boolean; /** * This property is required when `isEnum` is `true`. In which case, it * should contain a map of value => label. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied options?: Record; /** * Reserved property. Not used at the moment. */ appTypes?: AppType[]; /** * Set this to `true` to store secure data, such as passwords. Any such * setting will be stored in the system keychain if one is available. */ secure?: boolean; /** * An advanced setting will be moved under the "Advanced" button in the * config screen. */ advanced?: boolean; /** * Set the min, max and step values if you want to restrict an int setting * to a particular range. */ minimum?: number; maximum?: number; step?: number; /** * Either store the setting in the database or in settings.json. Defaults to database. */ storage?: SettingStorage; } export interface SettingSection { label: string; iconName?: string; description?: string; name?: string; } // ================================================================= // Data API types // ================================================================= /** * An array of at least one element and at most three elements. * * - **[0]**: Resource name (eg. "notes", "folders", "tags", etc.) * - **[1]**: (Optional) Resource ID. * - **[2]**: (Optional) Resource link. */ export type Path = string[]; // ================================================================= // Content Script types // ================================================================= // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied export type PostMessageHandler = (message: any)=> Promise; /** * When a content script is initialised, it receives a `context` object. */ export interface ContentScriptContext { /** * The plugin ID that registered this content script */ pluginId: string; /** * The content script ID, which may be necessary to post messages */ contentScriptId: string; /** * Can be used by CodeMirror content scripts to post a message to the plugin */ postMessage: PostMessageHandler; } export interface ContentScriptModuleLoadedEvent { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied userData?: any; } export interface ContentScriptModule { onLoaded?: (event: ContentScriptModuleLoadedEvent)=> void; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied plugin: ()=> any; assets?: ()=> void; } export interface MarkdownItContentScriptModule extends Omit { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied plugin: (markdownIt: any, options: any)=> any; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied type EditorCommandCallback = (...args: any[])=> any; export interface CodeMirrorControl { /** Points to a CodeMirror 6 EditorView instance. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied editor: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied cm6: any; /** `extension` should be a [CodeMirror 6 extension](https://codemirror.net/docs/ref/#state.Extension). */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied addExtension(extension: any|any[]): void; supportsCommand(name: string): boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied execCommand(name: string, ...args: any[]): any; registerCommand(name: string, callback: EditorCommandCallback): void; joplinExtensions: { /** * Returns a [CodeMirror 6 extension](https://codemirror.net/docs/ref/#state.Extension) that * registers the given [CompletionSource](https://codemirror.net/docs/ref/#autocomplete.CompletionSource). * * Use this extension rather than the built-in CodeMirror [`autocompletion`](https://codemirror.net/docs/ref/#autocomplete.autocompletion) * if you don't want to use [languageData-based autocompletion](https://codemirror.net/docs/ref/#autocomplete.autocompletion^config.override). * * Using `autocompletion({ override: [ ... ]})` causes errors when done by multiple plugins. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied completionSource(completionSource: any): any; /** * Creates an extension that enables or disables [`languageData`-based autocompletion](https://codemirror.net/docs/ref/#autocomplete.autocompletion^config.override). */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied enableLanguageDataAutocomplete: { of: (enabled: boolean)=> any }; }; } export interface MarkdownEditorContentScriptModule extends Omit { plugin: (editorControl: CodeMirrorControl)=> void; } export enum ContentScriptType { /** * Registers a new Markdown-It plugin, which should follow the template * below. * * ```javascript * module.exports = { * default: function(context) { * return { * plugin: function(markdownIt, pluginOptions) { * // ... * }, * assets: { * // ... * }, * } * } * } * ``` * * See [the * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script) * for a simple Markdown-it plugin example. * * ## Exported members * * - The `context` argument is currently unused but could be used later on * to provide access to your own plugin so that the content script and * plugin can communicate. * * - The **required** `plugin` key is the actual Markdown-It plugin - check * the [official doc](https://github.com/markdown-it/markdown-it) for more * information. * * - Using the **optional** `assets` key you may specify assets such as JS * or CSS that should be loaded in the rendered HTML document. Check for * example the Joplin [Mermaid * plugin](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts) * to see how the data should be structured. * * ## Supporting the Rich Text Editor * * Joplin's Rich Text Editor works with rendered HTML, which is converted back * to markdown when saving. To prevent the original markdown for your plugin from * being lost, Joplin needs additional metadata. * * To provide this, * 1. Wrap the HTML generated by your plugin in an element with class `joplin-editable`. * For example, * ```html *
* ...your html... *
* ``` * 2. Add a child with class `joplin-source` that contains the original markdown that * was rendered by your plugin. Include `data-joplin-source-open`, `data-joplin-source-close`, * and `data-joplin-language` attributes. * For example, if your plugin rendered the following code block, * ```` * ```foo * ... original source here ... * ``` * ```` * then it should render to * ```html *
*
 ... original source here ... 
* ... rendered HTML here ... *
* ``` * * See [the demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script) * for a complete example. * * ## Getting the settings from the renderer * * You can access your plugin settings from the renderer by calling * `pluginOptions.settingValue("your-setting-key')`. * * ## Posting messages from the content script to your plugin * * The application provides the following function to allow executing * commands from the rendered HTML code: * * ```javascript * const response = await webviewApi.postMessage(contentScriptId, message); * ``` * * - `contentScriptId` is the ID you've defined when you registered the * content script. You can retrieve it from the * {@link ContentScriptContext | context}. * - `message` can be any basic JavaScript type (number, string, plain * object), but it cannot be a function or class instance. * * When you post a message, the plugin can send back a `response` thus * allowing two-way communication: * * ```javascript * await joplin.contentScripts.onMessage(contentScriptId, (message) => { * // Process message * return response; // Can be any object, string or number * }); * ``` * * See {@link JoplinContentScripts.onMessage} for more details, as well as * the [postMessage * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages). * * ## Registering an existing Markdown-it plugin * * To include a regular Markdown-It plugin, that doesn't make use of any * Joplin-specific features, you would simply create a file such as this: * * ```javascript * module.exports = { * default: function(context) { * return { * plugin: require('markdown-it-toc-done-right'); * } * } * } * ``` */ MarkdownItPlugin = 'markdownItPlugin', /** * Registers a new CodeMirror plugin, which should follow the template * below. * * ```javascript * module.exports = { * default: function(context) { * return { * plugin: function(CodeMirror) { * // ... * }, * codeMirrorResources: [], * codeMirrorOptions: { * // ... * }, * assets: { * // ... * }, * } * } * } * ``` * * - The `context` argument allows communicating with other parts of * your plugin (see below). * * - The `plugin` key is your CodeMirror plugin. This is where you can * register new commands with CodeMirror or interact with the CodeMirror * instance as needed. * * - **CodeMirror 5 only**: The `codeMirrorResources` key is an array of CodeMirror resources that * will be loaded and attached to the CodeMirror module. These are made up * of addons, keymaps, and modes. For example, for a plugin that want's to * enable clojure highlighting in code blocks. `codeMirrorResources` would * be set to `['mode/clojure/clojure']`. * This field is ignored on mobile and when the desktop beta editor is enabled. * * - **CodeMirror 5 only**: The `codeMirrorOptions` key contains all the * [CodeMirror](https://codemirror.net/doc/manual.html#config) options * that will be set or changed by this plugin. New options can alse be * declared via * [`CodeMirror.defineOption`](https://codemirror.net/doc/manual.html#defineOption), * and then have their value set here. For example, a plugin that enables * line numbers would set `codeMirrorOptions` to `{'lineNumbers': true}`. * * - Using the **optional** `assets` key you may specify **only** CSS assets * that should be loaded in the rendered HTML document. Check for example * the Joplin [Mermaid * plugin](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts) * to see how the data should be structured. * * One of the `plugin`, `codeMirrorResources`, or `codeMirrorOptions` keys * must be provided for the plugin to be valid. Having multiple or all * provided is also okay. * * See also: * - The [demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script) * for an example of all these keys being used in one plugin. * - See [the editor plugin tutorial](https://joplinapp.org/help/api/tutorials/cm6_plugin) * for how to develop a plugin for the mobile editor and the desktop beta markdown editor. * * ## Posting messages from the content script to your plugin * * In order to post messages to the plugin, you can use the postMessage * function passed to the {@link ContentScriptContext | context}. * * ```javascript * const response = await context.postMessage('messageFromCodeMirrorContentScript'); * ``` * * When you post a message, the plugin can send back a `response` thus * allowing two-way communication: * * ```javascript * await joplin.contentScripts.onMessage(contentScriptId, (message) => { * // Process message * return response; // Can be any object, string or number * }); * ``` * * See {@link JoplinContentScripts.onMessage} for more details, as well as * the [postMessage * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages). * */ CodeMirrorPlugin = 'codeMirrorPlugin', } ================================================ FILE: jest.config.js ================================================ /** @type {import('jest').Config} */ const config = { preset: 'ts-jest', testEnvironment: 'node', }; module.exports = config; ================================================ FILE: package.json ================================================ { "name": "joplin-plugin-rich-markdown", "version": "0.17.1", "scripts": { "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive", "prepare": "npm run dist", "update": "npm install -g generator-joplin && yo joplin --node-package-manager npm --update --force", "preversion": "npm run test", "updatetags": "LOGS=$(git log $(git describe --tags --abbrev=0 HEAD~1)..HEAD~1 --oneline) && git tag -fam \"v$npm_package_version\n\n$LOGS\" v$npm_package_version && git tag -fa v$npm_package_version", "postversion": "npm run updatetags && git push origin main --tags", "version": "sed -i '/\\\"version\\\": \\\"/s/[^\\\"]*\\\",/'\"$npm_package_version\\\",/\" src/manifest.json && git add src/manifest.json", "test": "jest", "updateVersion": "webpack --env joplin-plugin-config=updateVersion" }, "license": "MIT", "keywords": [ "joplin-plugin" ], "browser": { "child_process": false, "fs": false }, "devDependencies": { "@codemirror/language": "^6.11.2", "@types/jest": "^27.5.2", "@types/node": "^18.7.13", "chalk": "^4.1.0", "copy-webpack-plugin": "^11.0.0", "fs-extra": "^10.1.0", "glob": "^8.0.3", "jest": "^30.0.5", "jest-util": "^30.0.5", "on-build-webpack": "^0.1.0", "tar": "^6.1.11", "ts-jest": "^29.4.0", "ts-loader": "^9.3.1", "typescript": "^4.8.2", "webpack": "^5.74.0", "webpack-cli": "^4.10.0", "yargs": "^16.2.0" }, "dependencies": { "@joplin/lib": "^2.6.3" }, "files": [ "publish" ] } ================================================ FILE: plugin.config.json ================================================ { "extraScripts": [ "richMarkdown.ts", "images.ts", "indent.ts", "clickHandlers.ts", "stylesheets.ts", "overlay.ts" ] } ================================================ FILE: shell.nix ================================================ { pkgs ? import {} }: pkgs.mkShell rec { name = "nodejs"; NODE_OPTIONS="--openssl-legacy-provider"; buildInputs = with pkgs; [ nodejs nodePackages.webpack-cli ]; } ================================================ FILE: src/clickHandlers.test.ts ================================================ import * as ClickHandlers from './clickHandlers'; import * as Overlay from './overlay'; const test_text = ` soemthing this is a notes this note has [many](https://calebjohn.ca) links it also contains other links like this but it doesn't forget plain old https://joplinapp.org links and all the rest! (In this [page](https://joplinapp.org) you can see a test) it also has [reference links] which are neat! Links with () should not drop the trailing ) https://joplinapp.org/www(x=y) [even this](https://joplinapp.org/www(x=y)) [reference links]: https://joplinapp.org ` test("getMatchAt works for markdown links", () => { let match = ClickHandlers.getMatchAt(test_text, Overlay.link_regex, 45); expect(match[0]).toBe("[many](https://calebjohn.ca)"); expect(match[1]).toBe("https://calebjohn.ca"); expect(match[2]).toBeUndefined(); expect(match[3]).toBeUndefined(); match = ClickHandlers.getMatchAt(test_text, Overlay.link_regex, 115) expect(match[0]).toBe(""); expect(match[1]).toBeUndefined(); expect(match[2]).toBe("joplinapp.org"); expect(match[3]).toBeUndefined(); match = ClickHandlers.getMatchAt(test_text, Overlay.link_regex, 165) expect(match[0]).toBe("https://joplinapp.org"); expect(match[1]).toBeUndefined(); expect(match[2]).toBeUndefined(); expect(match[3]).toBe("https://joplinapp.org"); match = ClickHandlers.getMatchAt(test_text, Overlay.link_regex, 220) expect(match[0]).toBe("[page](https://joplinapp.org)"); expect(match[1]).toBe("https://joplinapp.org"); expect(match[2]).toBeUndefined(); expect(match[3]).toBeUndefined(); match = ClickHandlers.getMatchAt(test_text, Overlay.link_regex, 380) expect(match[0]).toBe("https://joplinapp.org/www(x=y)"); expect(match[1]).toBeUndefined(); expect(match[2]).toBeUndefined(); expect(match[3]).toBe("https://joplinapp.org/www(x=y)"); match = ClickHandlers.getMatchAt(test_text, Overlay.link_regex, 403) expect(match[0]).toBe("[even this](https://joplinapp.org/www(x=y))"); expect(match[1]).toBe("https://joplinapp.org/www(x=y)"); expect(match[2]).toBeUndefined(); expect(match[3]).toBeUndefined(); }); test("getMatchAt works on the boundary of a match (but not over)", () => { let match = ClickHandlers.getMatchAt(test_text, Overlay.link_regex, 42); expect(match).not.toBeNull(); match = ClickHandlers.getMatchAt(test_text, Overlay.link_regex, 41); expect(match).toBeNull(); match = ClickHandlers.getMatchAt(test_text, Overlay.link_regex, 70); expect(match).not.toBeNull(); match = ClickHandlers.getMatchAt(test_text, Overlay.link_regex, 71); expect(match).toBeNull(); }); test("reference link regexes are working okay", () => { let match = ClickHandlers.getMatchAt(test_text, Overlay.link_reference_regex, 290); expect(match).not.toBeNull(); match = ClickHandlers.getMatchAt(test_text, Overlay.link_label_regex, 290); expect(match).not.toBeNull(); }); ================================================ FILE: src/clickHandlers.ts ================================================ import * as Overlay from './overlay'; function normalizeCoord(coord: any) { if (coord.sticky && coord.sticky === "before") return { ch: coord.ch - 1, line: coord.line }; return coord; } export function isLink(event: MouseEvent) { if (!event.target) return false; const target = event.target as HTMLElement; return target.matches('.cm-rm-link *, .cm-rm-link') || target.matches('.cm-rm-link-label *, .cm-rm-link-label'); } export function isCheckbox(event: MouseEvent) { if (!event.target) return false; const target = event.target as HTMLElement; return target.matches('.cm-rm-checkbox *, .cm-rm-checkbox'); } // Joplin uses es2015 so we don't have matchAll export function getMatchAt(lineText: string, regex: RegExp, ch: number) { let match = null; regex.lastIndex = 0; if (!regex.global) { console.error("getMatchAt requires a global regex; Consider adding a `g` after ${regex}"); return null; } do { match = regex.exec(lineText); if (!match) break; const start = match.index; const end = start + match[0].length; if (start <= ch && ch <= end) return match; } while (match); return null; } export function getClickCoord(cm: any, event: MouseEvent) { return normalizeCoord(cm.coordsChar({left: event.clientX, top: event.clientY})); } export function clickAt(cm: any, coord: any) { if (!cm.state.richMarkdown) return; const settings = cm.state.richMarkdown.settings; if (settings.links) { const url = getLinkAt(cm, coord); if (url) return {name: 'followLink', url }; } if (settings.checkbox) { if (toggleCheckbox(cm, coord, '')) return null; } return null; } export enum TextItemType { Link = 'link', Checkbox = 'checkbox', Image = 'image', } export interface TextItem { type: TextItemType; coord: any; url?: string; } export function getItemsAt(cm:any, coord:any):TextItem[] { if (!cm.state.richMarkdown) return null; let items: TextItem[] = []; const url = getLinkAt(cm, coord); if (url) { items.push({ type: TextItemType.Link, url, coord }); } const checkboxInfo = getCheckboxInfo(cm, coord); if (checkboxInfo) { items.push({ type: TextItemType.Checkbox, coord }); } const imageUrl = getRegexAt(cm, coord, Overlay.html_full_image_regex, 2); if (imageUrl) { items.push({ type: TextItemType.Image, url: imageUrl, coord }); } return items; } function getLinkAt(cm: any, coord: any) { let { line, ch } = coord; const lineText = cm.getLine(line); const match = getMatchAt(lineText, Overlay.link_regex, ch); let url = ''; if (match) { for (let i = 1; i <= 4; i++) { url = url || match[i]; } } else { // This might be a reference link, check for that const reference_match = getMatchAt(lineText, Overlay.link_reference_regex, ch) if (!reference_match) return; const reference = reference_match[1] || reference_match[2]; const trimmedReference = reference.trim(); if (trimmedReference === '' || trimmedReference.toLowerCase() === 'x') return; // This is a checkbox // Skip Joplin directives or wiki style links such as [[toc]] that surface nested brackets if (trimmedReference.includes('[') || trimmedReference.includes(']')) return; const escapedReference = trimmedReference.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); const link_definition_regex = new RegExp(`\\[${escapedReference}\\]:\\s([^\\n]+)`, 'g'); for (let i = 0; i < cm.lineCount(); i++) { // a link reference definition can only be preceded by up to 3 // spaces, so we will be sure to find a match (if it exists) that // contains character 4 const definition_match = getMatchAt(cm.getLine(i), link_definition_regex, 4); if (definition_match) { url = definition_match[1]; break; }; } // No match found, just exit if (url === '') { alert(`No link defintion for [${trimmedReference}]. Press Esc to dismiss.`); return; } } // Special case, if this matches a link inside of an image, we will need to strip // the trailing ) if (url && url.endsWith(')')) { // this is not safe based on RFC 1738 (the ) character is allowd in URLS) // so we'll need to do an additional check if (getMatchAt(lineText, Overlay.image_regex, ch)) url = url.slice(0, url.length - 1); } // URLs inside html elements have a trailing quote character else if (url && (url.endsWith('"') || url.endsWith("'"))) { // Quotes are not allowed in URLs as per RFC 1738 // https://www.ietf.org/rfc/rfc1738.txt // Page 2 includes a list of unsafe characters url = url.slice(0, url.length - 1); } // Take the first element in case a title has been provided // [](https://link.ca "title") // spaces are not allowed in urls (RFC 1738) so this is safe url = url.split(' ')[0]; return url; } function getRegexAt(cm: any, coord: any, regex: RegExp, groupNum: number) { let { line, ch } = coord; const lineText = cm.getLine(line); const match = getMatchAt(lineText, regex, ch); if (match) { return match[groupNum]; } return ''; } function getCheckboxInfo(cm:any, coord:any) { const { line, ch } = coord; const lineText = cm.getLine(line); const match = getMatchAt(lineText, Overlay.checkbox_regex, ch); if (!match || !match[3]) return null; return { match, lineText, line }; } function toggleCheckboxInner(cm: any, coord: any, replacement: string) { const cursor = cm.getCursor(); const info = getCheckboxInfo(cm, coord); if (!info) return false; const { match, line, lineText } = info; let from = lineText.indexOf(match[3]) let to = from + match[3].length; let replace = replacement; if (replace === '') { replace = match[3][1] === ' ' ? '[x]' : '[ ]'; } cm.replaceRange(replace, { ch: from, line }, { ch: to, line }, '+input'); // This isn't exactly needed, but the replaceRange does move the cursor // to the end of the range if the cursor is withing the section that changes cm.setCursor(cursor, null, { scroll: false }); return true; } export function toggleCheckbox(cm: any, coord: any, replacement: string) { if (!cm.somethingSelected()) { return toggleCheckboxInner(cm, coord, replacement); } for (let selection of cm.listSelections()) { let start, end; if (selection.anchor.line > selection.head.line) { start = selection.head; end = selection.anchor; } else { start = selection.anchor; end = selection.head; } for (let i = start.line; i <= end.line; i++) { toggleCheckboxInner(cm, {line: i, ch: 0}, replacement); } } return true; } ================================================ FILE: src/cm6ListIndent.ts ================================================ import type { Decoration, DecorationSet, PluginValue } from '@codemirror/view'; import { require_codemirror_view, require_codemirror_state, require_codemirror_language } from "./cm6Requires"; function calculateIndent(indentStr, tabSize) { let width = 0; let hasTab = false; for (let i = 0; i < indentStr.length; i++) { if (indentStr[i] === '\t') { // Round up to next tab stop width = Math.ceil((width + 1) / tabSize) * tabSize; hasTab = true; } else { width += 1; } } return { width, hasTab }; } function createListIndentPlugin() { const { RangeSetBuilder } = require_codemirror_state(); const { Decoration, ViewPlugin } = require_codemirror_view(); const { getIndentUnit, syntaxTree } = require_codemirror_language(); const wrapIndent = (indent, hasTab) => Decoration.line({ attributes: { style: `text-indent: -${indent}ch; padding-left: ${indent}ch;` } }); const listMarkerRegex = /^(\s*)([-*+>](?:\s\[[Xx ]\])?|\d+[.)]|) /; return ViewPlugin.fromClass(class implements PluginValue { decorations!: DecorationSet; constructor(view) { this.decorations = this.buildDecorations(view); } update(update) { if (update.docChanged || update.viewportChanged) { this.decorations = this.buildDecorations(update.view); } } buildDecorations(view) { const builder = new RangeSetBuilder(); const tree = syntaxTree(view.state); const tabSize = view.state.tabSize || 6; tree.iterate({ enter: (node) => { if (node.name === "ListItem" || node.name == "Blockquote") { const line = view.state.doc.lineAt(node.from); const lineText = line.text; const match = listMarkerRegex.exec(lineText); if (match) { const indentStr = match[1]; const marker = match[2]; // Calculate visual width of indentation const { width, hasTab } = calculateIndent(indentStr, tabSize); // +1 for the space after the marker const markerWidth = marker.length + 1; // Chrome insists on using tab stops so line indentation needs to be a multiple // of the tab stop, which also means that we can't align the indented lines with // the marker :'( const totalIndent = width + (hasTab ? 0 : markerWidth); builder.add(line.from, line.from, wrapIndent(totalIndent, hasTab)); } } } }) return builder.finish(); } }, { decorations: v => v.decorations, }) } export const listIndent = () => { const { EditorView } = require_codemirror_view(); return [ EditorView.lineWrapping, createListIndentPlugin() ]; } ================================================ FILE: src/cm6Requires.ts ================================================ import type * as CodeMirrorView from '@codemirror/view'; import type * as CodeMirrorState from '@codemirror/state'; import type * as CodeMirrorLanguage from '@codemirror/language'; // Dynamically imports a CodeMirror 6 library. This is done // to allow the plugin to start in both CodeMirror 5 and CodeMirror 6 // without import failure errors. export function require_codemirror_view(): typeof CodeMirrorView { return require('@codemirror/view'); } export function require_codemirror_state(): typeof CodeMirrorState { return require('@codemirror/state'); } export function require_codemirror_language(): typeof CodeMirrorLanguage { return require('@codemirror/language'); } ================================================ FILE: src/imageData.ts ================================================ import joplin from 'api'; export async function imageToDataURL(filePath:string, mimeType:string) { const fs = joplin.require('fs-extra'); const fileBuffer = await fs.readFile(filePath); const base64String = fileBuffer.toString('base64'); return `data:${mimeType};base64,${base64String}`; } ================================================ FILE: src/images.test.ts ================================================ import * as ImageHandlers from './images'; const test_text = ` ![space-fish.png](:/40530199c558430d8ea01363748d9657){width=100%} ![おはいよ](https://www.inmoth.ca/images/envelope.png) ![red.png](file:///home/joplinuser/Pictures/red.png) ![](:/40530199c558430d8ea) ![おはいよ](https://www.inmoth.ca/images/envelope.png "title"){width=78px} ![name](https://url.com "title" this is all bad) some paragr ![i1.png](:/d9e191134dad42dda2d94ab3e98d3517) something![](:/f190a79a355e4bbb86990cb3b55bedb6)som [space-fish.png](:/40530199c558430d8ea01363748d9657) [おはいよ](https://www.inmoth.ca/images/envelope.png) [red.png](file:///home/joplinuser/Pictures/red.png) some paragr [i1.png](:/d9e191134dad42dda2d94ab3e98d3517) something! [](:/f190a79a355e4bbb86990cb3b55bedb6)som

69f775caf86ae2a8e86c3416fee6060d.png69f775caf86ae2a8e86c3416fee6060d.png something in a paragraphs 69f775caf86ae2a8e86c3416fee6060d.png which is supported by an image ` const test_html = `69f775caf86ae2a8e86c3416fee6060d.png Joplin Icon ` const line_cases = [ [1, "space-fish.png", ":/40530199c558430d8ea01363748d9657", null, "100%", "%"], [2, "おはいよ", "https://www.inmoth.ca/images/envelope.png", null, undefined, undefined], [3, "red.png", "file:///home/joplinuser/Pictures/red.png", null, undefined, undefined], [4, "", ":/40530199c558430d8ea", null, undefined, undefined], [5, "おはいよ", "https://www.inmoth.ca/images/envelope.png", " \"title\"", "78px", "px"], // The current regex correctly grabs the title, but it doesn't guard against extra bits [6, "name", "https://url.com", " \"title\" this is all bad", undefined, undefined], ] describe("Test image line regex matches images that own a line", () => { const lines = test_text.split("\n"); test.each(line_cases)( "Line %p matches ![%p](%p)", (line, name, url, title, width, unit) => { let match = ImageHandlers.image_line_regex.exec(lines[line]); expect(match).not.toBeNull(); const widthString = width ? `{width=${match[4]}}` : ''; expect(match[0]).toBe(`![${name}](${url}${title ? title : ''})${widthString}`); expect(match[1]).toBe(name); expect(match[2]).toBe(url); // match[3] is the full match of the {width=?} I won't bother checking it expect(match[4]).toBe(width); expect(match[5]).toBe(unit); } ); }); describe("Test image line regex does not match anything else", () => { const lines = test_text.split("\n").concat(test_html.split("\n")); const cases = Array.from({length: lines.length - line_cases.length}, (_, i) => i+line_cases.length+1) test.each(cases)( "Line %p is not a line image", (line: number) => { let match = ImageHandlers.image_line_regex.exec(lines[line]); expect(match).toBeNull(); } ); }); describe("Test image inline regex matches all images", () => { const cases = [ ["space-fish.png", ":/40530199c558430d8ea01363748d9657", null, "100%", "%"], ["おはいよ", "https://www.inmoth.ca/images/envelope.png", null, undefined, undefined], ["red.png", "file:///home/joplinuser/Pictures/red.png", null, undefined , undefined], ["", ":/40530199c558430d8ea", null, undefined, undefined], ["おはいよ", "https://www.inmoth.ca/images/envelope.png", " \"title\"", "78px", "px"], // The current regex correctly grabs the title, but it doesn't guard against extra bits ["name", "https://url.com", " \"title\" this is all bad", undefined, undefined], ["i1.png", ":/d9e191134dad42dda2d94ab3e98d3517", null, undefined, undefined], ["", ":/f190a79a355e4bbb86990cb3b55bedb6", null, undefined, undefined], ]; test.each(cases)( "Matches from ![%p](%p)", (name, url, title, width, unit) => { let match = ImageHandlers.image_inline_regex.exec(test_text); expect(match).not.toBeNull(); const widthString = width ? `{width=${match[4]}}` : ''; expect(match[0]).toBe(`![${name}](${url}${title ? title : ''})${widthString}`); expect(match[1]).toBe(name); expect(match[2]).toBe(url); expect(match[4]).toBe(width); expect(match[5]).toBe(unit); } ); test("There are no more matches", () => { let match = ImageHandlers.image_inline_regex.exec(test_text); expect(match).toBeNull(); }); }); describe("Test image html line regex only matches html images that own a line", () => { const text_lines = test_text.split("\n") test.each(text_lines)( "%p is not an html image on it's own line", (line: string) => { let match = ImageHandlers.html_image_line_regex.exec(line); expect(match).toBeNull(); } ); const html_lines = test_html.split("\n") test.each(html_lines)( "%p is an html image on it's own line", (line: string) => { let match = ImageHandlers.html_image_line_regex.exec(line); expect(match).not.toBeNull(); expect(match[0]).toBe(line); // This is less important because the entire tag is used to generate an image // So the rest of the match statement is ignored // In the future this might be changed // expect(match[1]).toBe(''); } ); }); const test_link_text = ` ![space-fish.png](:/40530199c558430d8ea01363748d9657){width=100%} [![おはいよ](https://www.inmoth.ca/images/envelope.png)](https://www.inmoth.ca) ![red.png](file:///home/joplinuser/Pictures/red.png) [![](:/40530199c558430d8ea)](https://joplinapp.org) [![おはいよ](https://www.inmoth.ca/images/envelope.png "title"){width=78px}](https://www.inmoth.ca) ![name](https://url.com "title" this is all bad) some paragr ![i1.png](:/d9e191134dad42dda2d94ab3e98d3517) something![](:/f190a79a355e4bbb86990cb3b55bedb6)som [space-fish.png](:/40530199c558430d8ea01363748d9657) [おはいよ](https://www.inmoth.ca/images/envelope.png) [red.png](file:///home/joplinuser/Pictures/red.png) some paragr [i1.png](:/d9e191134dad42dda2d94ab3e98d3517) something! [](:/f190a79a355e4bbb86990cb3b55bedb6)som ` const line_link_cases = [ [1, null], [2, "![おはいよ](https://www.inmoth.ca/images/envelope.png)"], [3, null], [4, "![](:/40530199c558430d8ea)"], [5, "![おはいよ](https://www.inmoth.ca/images/envelope.png \"title\"){width=78px}"], [6, null], [7, null], [8, null], [9, null], [10, null], [11, null], [12, null], ] describe("Test image line inside link regex matches only lines with images inside links", () => { const lines = test_link_text.split("\n"); test.each(line_link_cases)( "Line %p matches %p", (line, image) => { let match = ImageHandlers.image_line_link_regex.exec(lines[line]); if (image) { expect(match).not.toBeNull(); expect(match[1]).toBe(image); } else { expect(match).toBeNull(); } } ); }); ================================================ FILE: src/images.ts ================================================ import * as ClickHandlers from './clickHandlers'; import * as Overlay from './overlay'; import { require_codemirror_language } from "./cm6Requires"; export const image_line_regex = /^\s*!\[([^\]]*)\]\((<[^\)]+>|[^)\s]+)[^)]*\)({width=(\d+(px|%)?)})?\s*$/; export const image_line_link_regex = /^\[(!\[.*)\]\((.*)\)$/; export const image_inline_regex = /!\[([^\]]*)\]\((<[^\)]+>|[^)\s]+)[^)]*\)({width=(\d+(px|%)?)})?/g; export const html_image_line_regex = /^\s*]+?)\/?>\s*$/; // Used to quickly index widgets that will get updated let allWidgets = {}; export function isSupportedImageMimeType(mime:string) { return ['image/png', 'image/jpg', 'image/jpeg'].includes(mime.toLowerCase()); } export function isSupportedOcrMimeType(mime:string) { return ['application/pdf'].includes(mime.toLowerCase()) || isSupportedImageMimeType(mime); } export function onSourceChanged(cm: any, from: number, to: number, context: any) { if (!cm.state.richMarkdown) return; if (cm.state.richMarkdown.settings.inlineImages) { check_lines(cm, from, to, context); } } export function afterSourceChanges(cm: any) { if (!cm.state.richMarkdown) return; if (cm.state.richMarkdown.settings.imageHover) update_hover_widgets(cm); } function isLineInCodeBlock(editor, syntaxTree, lineNumber) { const doc = editor.state.doc const line = doc.line(lineNumber) const tree = syntaxTree(editor.state) let node = tree.resolveInner(line.from) // Walk up the tree to find if we're inside a code block while (node) { if (node.name === "FencedCode" || node.name === "CodeBlock") { return true } node = node.parent } return false } async function getImageData(cm: any, coord: any) { let { line, ch } = coord; const lineText = cm.getLine(line); const match = ClickHandlers.getMatchAt(lineText, image_inline_regex, ch); let img = null; if (match) { img = await createImage(match[2], match[1], cm.state.richMarkdown.path_from_id, match[4], match[5]); } else { const imgMatch = ClickHandlers.getMatchAt(lineText, Overlay.html_image_regex, ch); if (imgMatch) { img = await createImageFromImg(imgMatch[0], cm.state.richMarkdown.path_from_id); } } return img; } function open_widget(cm: any) { return async function(event: MouseEvent) { if (!event.target) return; if (!cm.state.richMarkdown) return; if (!cm.state.richMarkdown.settings.imageHover) return; // This shortcut is only enabled for the ctrl case because in the default case I would // prefer to accidentally display 2 images rather than not dislpay anything if (!cm.state.richMarkdown.settings.imageHoverCtrl && (!(event.ctrlKey || event.altKey) || cm.state.richMarkdown.isMouseHovering)) return; cm.state.richMarkdown.isMouseHovering = true; const target = event.target as HTMLElement; if (!target.offsetParent) return; // This already has the image rendered inline if (cm.state.richMarkdown.settings.inlineImages && target.parentNode.childNodes.length <= 3) return; const img = await getImageData(cm, ClickHandlers.getClickCoord(cm, event)); if (!img) return; // manual zoom if (img.style.zoom) { let zoom = parseFloat(img.style.zoom); if (img.style.zoom.endsWith('%')){ zoom /= 100 } if (zoom) { img.width *= zoom img.height *= zoom img.style.zoom = '100%'; } } img.style.visibility = 'hidden'; target.offsetParent.appendChild(img); img.style.position = 'absolute'; img.style.zIndex = '1000'; img.classList.add('rich-markdown-hover-image'); img.onload = function() { if (!cm.state.richMarkdown) return; if (!cm.state.richMarkdown.isMouseHovering) { img.remove(); return; } let im = this as HTMLElement; const par = target.offsetParent as HTMLElement; const { right, width } = par.getBoundingClientRect(); let x = 0; if (im.clientWidth < width) { x = Math.min(event.clientX, right - im.clientWidth); } const coords = cm.coordsChar({left: x, top: event.clientY}, 'page'); im.style.visibility = 'visible'; cm.addWidget(coords, img, false); } } } function clearHoverImages(cm: any) { const widgets = cm.getWrapperElement().getElementsByClassName('rich-markdown-hover-image'); // Removed widgets are simultaneously removed from this array // we need to iterate backwards to prevent the array from changing on us for (let i = widgets.length -1; i >= 0; i--) { widgets[i].remove(); } } function close_widget(cm: any) { return function(event: MouseEvent) { if (cm.state.richMarkdown) cm.state.richMarkdown.isMouseHovering = false; clearHoverImages(cm); } } function update_hover_widgets(cm: any) { if (!cm.state.richMarkdown) return; // If the image source is removed, this update funciton won't catch it // and the image will be stuck around forever joplin-rich-markdown/issues/69 // this prevents this from happening (by pre-emptively clearing the image) // but causes a flicker while editing and hovering. clearHoverImages(cm); const images = cm.getWrapperElement().getElementsByClassName("cm-rm-image"); for (let image of images) { image.onmouseenter = open_widget(cm); image.onmouseleave = close_widget(cm); if (!cm.state.richMarkdown.settings.imageHoverCtrl) { image.onmousemove = open_widget(cm); } } } async function check_lines(cm: any, from: number, to: number, context: any) { if (!cm.state.richMarkdown) return; const path_from_id = cm.state.richMarkdown.path_from_id; let needsRefresh = false; for (let i = from; i <= to; i++) { const line = cm.lineInfo(i); if (line.widgets) { for (const wid of line.widgets) { if (wid.className === 'rich-markdown-resource') wid.clear(); delete allWidgets[wid.node.id]; } } if (!line) { continue; } if (cm.cm6) { if (!cm.state.richMarkdown.language) { cm.state.richMarkdown.language = require_codemirror_language(); } const syntaxTree = cm.state.richMarkdown.language.syntaxTree; // cm6 uses 1 based indexing for line numbers, but cm5 uses 0 based // the line object we have here is emulated cm5, so it uses 0 based // but the checking function is cm6, so we need to adjust if (isLineInCodeBlock(cm.editor, syntaxTree, line.line + 1)) { continue; } } else { const state = cm.getStateAfter(i, true); // Don't render inline images inside of code blocks (not for cm5/legacy editor only) if (state?.outer && (state?.outer?.code || (state?.outer?.thisLine?.fencedCodeEnd))) { continue; } } // Special Case // If the line only contains a link wrapped around an image, we should match against that const line_link_match = line.text.match(image_line_link_regex); let lineText = line.text; let lineLink = ''; if (line_link_match) { lineText = line_link_match[1]; lineLink = line_link_match[2]; } const match = lineText.match(image_line_regex); let img = null; if (match) { img = await createImage(match[2], match[1], path_from_id, match[4], match[5], context, lineLink); } else { const imgMatch = line.text.match(html_image_line_regex); if (imgMatch) { img = await createImageFromImg(imgMatch[0], path_from_id); } } if (img) { const wid = cm.addLineWidget(i, img, { className: 'rich-markdown-resource' }); allWidgets[img.id] = wid; needsRefresh = true; } } if (needsRefresh) { cm.refresh(); } } async function createImageFromImg(imgTag: string, path_from_id: any) { const par = new DOMParser().parseFromString(imgTag, "text/html"); const img = par.body.firstChild as HTMLImageElement; img.style.height = img.style.height || 'auto'; img.style.maxWidth = img.style.maxWidth || '100%'; // Tags taken from // https://github.com/laurent22/joplin/blob/80b16dd17e227e3f538aa221d7b6cc2d81688e72/packages/renderer/htmlUtils.ts const disallowedTags = ['script', 'noscript', 'iframe', 'frameset', 'frame', 'object', 'base', 'embed', 'link', 'meta']; for (let i = 0; i < img.attributes.length; i++) { const name = img.attributes[i].name; if (disallowedTags.includes(name) || name.startsWith('on')) { img.attributes[i].value = ''; } } // Joplin resource paths get added on to the end of the local path for some reason if (img.src.length >= 34) { const id = img.src.substring(img.src.length - 34); if (id.startsWith(':/')) { img.src = await path_from_id(id.substring(2)); img.id = id.substring(2); } } return img; } async function createImage(path: string, alt: string, path_from_id: any, width?: string, unit?: string, context?: any, link?: string) { let id = path.substring(2) if (path.startsWith(':/') && path.length == 34) { path = await path_from_id(id); } if (path.startsWith('<') && path.endsWith('>')) { // <> quotes are not allowed in URLs as per RFC 1738 // https://www.ietf.org/rfc/rfc1738.txt // Page 2 includes a list of unsafe characters path = path.substring(1, path.length - 1); } const img = document.createElement('img'); img.src = path; img.alt = alt; img.style.maxWidth = '100%'; img.style.height = 'auto'; if (link && context) { img.onclick = () => { context.postMessage({ name: 'followLink', url: link }); }; } if (width) { img.style.width = width + (unit ? '' : 'px'); } // This will either contain the resource id or some gibberish path img.id = id; return img; } // Reload the specified resource on disk, this will be in response // to changes made by the user export function refreshResource(cm: any, id: string) { const timestamp = new Date().getTime(); let wid = allWidgets[id]; const path = wid.node.src.split("?t=")[0]; const height = wid.node.height; wid.node.onload = function() { let im = this as HTMLImageElement; // If the image is scrolled out of view (no need to refresh), it won't have a clientRect if (im.getClientRects().length == 0) { return; } if (im.height != height) { cm.refresh(); } }; wid.node.src = `${path}?t=${timestamp}`; } // Used on cleanup export function clearAllWidgets(cm: any) { clearHoverImages(cm); for (let id in allWidgets) { allWidgets[id].clear(); } allWidgets = {}; // Refresh codemirror to make sure everything is sized correctly cm.refresh(); } ================================================ FILE: src/indent.ts ================================================ // This module is modified from the CodeMirror indent wrap demo // https://codemirror.net/demo/indentwrap.html import { list_token_regex } from './overlay'; // These variables are cached when the plugin is loaded // This stores the width of a space in the current font let spaceWidth = 0; // This stores the width of a monospace character using the current monospace font let monoSpaceWidth = 0; // This stores the width of the > character in the current font let blockCharWidth = 0; // Must be called when the editor is mounted export function calculateSpaceWidth(cm: any) { spaceWidth = charWidth(cm, ' ', ''); monoSpaceWidth = charWidth(cm, ' ', 'cm-rm-monospace'); blockCharWidth = charWidth(cm, '>', ''); } // Adapted from codemirror/lib/codemirror.js function charWidth(cm: any, chr: string, cls: string) { let e = document.createElement('span'); if (cls) e.classList.add(cls); e.style.whiteSpace = "pre-wrap"; e.appendChild(document.createTextNode(chr.repeat(10))) const measure = cm.getWrapperElement().getElementsByClassName('CodeMirror-measure')[0]; if (measure.firstChild) measure.removeChild(measure.firstChild); measure.appendChild(e); const rect = e.getBoundingClientRect() const width = (rect.right - rect.left) / 10; return width || cm.defaultCharWidth(); } // Adapted from // https://github.com/codemirror/CodeMirror/blob/master/demo/indentwrap.html export function onRenderLine(cm: any, line: any, element: HTMLElement, CodeMirror: any) { if (!cm.state.richMarkdown) return; if (cm.state.richMarkdown.settings.alignIndent) { const matches = line.text.match(list_token_regex); if (!matches) return; let off = CodeMirror.countColumn(line.text, matches[0].length, cm.getOption("tabSize")) * spaceWidth; // Special case handling for checkboxes with monospace enabled if (cm.state.richMarkdown.settings.enforceMono && matches[0].indexOf('[') > 0) { // "- [ ] " is 6 characters off += monoSpaceWidth * 6 - spaceWidth * 6; } else if (cm.state.richMarkdown.settings.enforceMono && matches[0].indexOf('>') >= 0) { off += blockCharWidth - spaceWidth; } element.style.textIndent = "-" + off + "px"; element.style.paddingLeft = off + "px"; } } ================================================ FILE: src/index.ts ================================================ import joplin from 'api'; import { ContentScriptType, MenuItem, MenuItemLocation, ModelType } from 'api/types'; import { getAllSettings, registerAllSettings } from './settings'; // TODO: Waiting for https://github.com/laurent22/joplin/pull/4509 // import prettier = require('prettier/standalone'); // import markdown = require('prettier/parser-markdown'); import { TextItem, TextItemType } from './clickHandlers'; import { isSupportedImageMimeType, isSupportedOcrMimeType } from './images'; import { imageToDataURL } from './imageData'; const fs = joplin.require('fs-extra'); const { parseResourceUrl } = require('@joplin/lib/urlUtils'); const contentScriptId = 'richMarkdownEditor'; joplin.plugins.register({ onStart: async function() { // There is a bug (race condition?) where the perform action command // doesn't always work when first opening the app. Opening the keyboard // shortcuts will properly bind it and make it work. // Placing the command before registering settings also seems to fix it await joplin.commands.register({ name: 'editor.richMarkdown.clickAtCursor', label: 'Perform action', iconName: 'fas fa-link', execute: async () => { await joplin.commands.execute('editor.execCommand', { name: 'clickUnderCursor', }); }, }); await joplin.views.menuItems.create('richMarkdownClickAtCursor', 'editor.richMarkdown.clickAtCursor', MenuItemLocation.Note, { accelerator: 'Ctrl+Enter' }); // TODO: See about getting this same behaviour into the openItem function await joplin.commands.register({ name: 'app.richMarkdown.openItem', execute: async (url: string) => { // From RFC 1738 Page 1 a url is : // the below regex implements matching for the scheme (with support for uppercase) // urls without a scheme will be assumed http if (!url.startsWith(':/') && !url.match(/^(?:[a-zA-Z0-9\+\.\-])+:/)) { url = 'http://' + url; } await joplin.commands.execute('openItem', url); }, }); await joplin.commands.register({ name: 'editor.richMarkdown.toggleCheckbox', execute: async (coord: any) => { await joplin.commands.execute('editor.execCommand', { name: 'toggleCheckbox', args: [coord], }); }, }); await joplin.commands.register({ name: 'editor.richMarkdown.checkCheckbox', execute: async (coord: any) => { await joplin.commands.execute('editor.execCommand', { name: 'checkCheckbox', args: [coord], }); }, }); await joplin.commands.register({ name: 'editor.richMarkdown.uncheckCheckbox', execute: async (coord: any) => { await joplin.commands.execute('editor.execCommand', { name: 'uncheckCheckbox', args: [coord], }); }, }); await joplin.commands.register({ name: 'editor.richMarkdown.copyImage', execute: async (itemId: string) => { const resource = await joplin.data.get(['resources', itemId], { fields: ['mime'] }); const resourcePath = await joplin.data.resourcePath(itemId); const dataUrl = await imageToDataURL(resourcePath, resource.mime); await joplin.clipboard.writeImage(dataUrl); }, }); await joplin.commands.register({ name: 'editor.richMarkdown.viewOcrText', execute: async (itemId: string) => { const resource = await joplin.data.get(['resources', itemId], { fields: ['id', 'mime', 'ocr_text', 'ocr_status'] }); if (resource.ocr_status === 2) { // ResourceOcrStatus.Done const tempFilePath = `${await joplin.plugins.dataDir()}/${resource.id}_ocr.txt`; await fs.writeFile(tempFilePath, resource.ocr_text, 'utf8'); const fileUrl = `file://${tempFilePath.replace(/\\/g, '/')}`; await joplin.commands.execute('openItem', fileUrl); } else { console.info(`OCR of resource ${itemId} is not ready yet ${resource.ocr_status}`); } }, }); await joplin.commands.register({ name: 'editor.richMarkdown.copyPathToClipboard', execute: async (path: string) => { await joplin.clipboard.writeText(path); }, }); // Helper to build menu items for a resource (image or other attachment) const buildResourceMenuItems = async (resourceId: string, openUrl: string): Promise => { const items: MenuItem[] = []; const resource = await joplin.data.get(['resources', resourceId], { fields: ['mime'] }); items.push({ label: 'Open link', commandName: 'app.richMarkdown.openItem', commandArgs: [openUrl], }); items.push({ label: 'Reveal file in folder', commandName: 'revealResourceFile', commandArgs: [resourceId], }); if (isSupportedOcrMimeType(resource.mime)) { items.push({ label: 'View OCR text', commandName: 'editor.richMarkdown.viewOcrText', commandArgs: [resourceId], }); } if (isSupportedImageMimeType(resource.mime)) { items.push({ label: 'Copy image', commandName: 'editor.richMarkdown.copyImage', commandArgs: [resourceId], }); } const resourcePath = await joplin.data.resourcePath(resourceId); items.push({ label: 'Copy path to clipboard', commandName: 'editor.richMarkdown.copyPathToClipboard', commandArgs: [resourcePath], }); return items; }; // Helper to build menu items for a non-resource link const buildLinkMenuItems = (url: string): MenuItem[] => { return [ { label: 'Open link', commandName: 'app.richMarkdown.openItem', commandArgs: [url], }, { label: 'Copy link to clipboard', commandName: 'editor.richMarkdown.copyPathToClipboard', commandArgs: [url], }, ]; }; await joplin.workspace.filterEditorContextMenu(async (object: any) => { // Use context passed by Joplin if available (preferred method). // This correctly identifies what was right-clicked even when the cursor // is elsewhere (e.g., after switching from markdown editor to viewer). const context = object.context || {}; const contextResourceId = context.resourceId; const contextItemType = context.itemType; // Fall back to cursor-based detection for backward compatibility // and for items not covered by context (e.g., checkboxes) const textItems: TextItem[] = await joplin.commands.execute('editor.execCommand', { name: 'getItemsUnderCursor', }); const selection = await joplin.commands.execute('selectedText'); const newItems: MenuItem[] = []; // If context indicates an image/resource was right-clicked, use that if (contextResourceId && (contextItemType === 'image' || contextItemType === 'resource')) { try { const resourceItems = await buildResourceMenuItems(contextResourceId, `:/${contextResourceId}`); newItems.push(...resourceItems); } catch (error) { console.warn('Rich Markdown: Failed to get resource info from context', error); } } else if (textItems.length) { // Fall back to cursor-based detection for (const textItem of textItems) { if (textItem.type === TextItemType.Link || textItem.type === TextItemType.Image) { const info = parseResourceUrl(textItem.url); const itemId = info ? info.itemId : null; const itemType = itemId ? await joplin.data.itemType(itemId) : null; if (itemType === ModelType.Resource) { const resourceItems = await buildResourceMenuItems(itemId, textItem.url); newItems.push(...resourceItems); } else { const linkItems = buildLinkMenuItems(textItem.url); newItems.push(...linkItems); } } else if (textItem.type === TextItemType.Checkbox) { const newlineRegex = /[\r\n]/; if (newlineRegex.test(selection)) { newItems.push({ label: 'Toggle all', commandName: 'editor.richMarkdown.toggleCheckbox', commandArgs: [textItem.coord], }); newItems.push({ label: 'Uncheck all', commandName: 'editor.richMarkdown.uncheckCheckbox', commandArgs: [textItem.coord], }); newItems.push({ label: 'Check all', commandName: 'editor.richMarkdown.checkCheckbox', commandArgs: [textItem.coord], }); } else { newItems.push({ label: 'Toggle checkbox', commandName: 'editor.richMarkdown.toggleCheckbox', commandArgs: [textItem.coord], }); } } } } if (newItems.length) { newItems.splice(0, 0, { type: 'separator', }); object.items = object.items.concat(newItems); } return object; }); await registerAllSettings(); await joplin.contentScripts.register( ContentScriptType.CodeMirrorPlugin, contentScriptId, './richMarkdown.js' ); await joplin.contentScripts.onMessage(contentScriptId, async (message: any) => { if (message.name === 'getResourcePath') { return await joplin.data.resourcePath(message.id); } else if (message.name === 'getSettings') { return await getAllSettings(); } else if (message.name === 'followLink') { await joplin.commands.execute('app.richMarkdown.openItem', message.url); } return "Error: " + message + " is not a valid message"; }); await (joplin.workspace as any).onResourceChange(async (event: any) => { await joplin.commands.execute('editor.execCommand', { name: 'refreshResource', args: [event.id], }); }); // TODO: Waiting for https://github.com/laurent22/joplin/pull/4509 // await joplin.commands.register({ // name: 'editor.richMarkdown.prettifySelection', // label: 'Prettify Selection', // iconName: 'fas fa-rocket', // execute: async () => { // const text = await joplin.commands.execute('selectedText'); // const formatted = prettier.format(text, { parser: 'markdown', plugins: [markdown] }); // await joplin.commands.execute('replaceSelection', formatted); // }, // }); // await joplin.views.menuItems.create('prettifySelectionContext', 'editor.richMarkdown.prettifySelection', MenuItemLocation.EditorContextMenu); // await joplin.views.menuItems.create('prettifySelectionEdit', 'editor.richMarkdown.prettifySelection', MenuItemLocation.Edit); // await joplin.views.toolbarButtons.create('prettifySelectionToolbar', 'editor.richMarkdown.prettifySelection', ToolbarButtonLocation.EditorToolbar); }, }); ================================================ FILE: src/manifest.json ================================================ { "manifest_version": 1, "id": "plugin.calebjohn.rich-markdown", "app_min_version": "3.5.9", "version": "0.17.1", "name": "Rich Markdown", "description": "Helping you ditch the markdown viewer for good.", "author": "Caleb John", "homepage_url": "https://github.com/CalebJohn/joplin-rich-markdown#readme", "repository_url": "https://github.com/CalebJohn/joplin-rich-markdown", "keywords": [ "editor", "visual" ], "categories": [ "appearance", "editor" ], "screenshots": [ { "src": "examples/welcome.png", "label": "A demonstration of what a note can look like using this plugin." } ], "icons": { "16": "icons/16.png", "32": "icons/32.png", "48": "icons/48.png", "128": "icons/128.png" } } ================================================ FILE: src/overlay.test.ts ================================================ import * as Overlay from './overlay'; describe('link regex', () => { test('valid urls', () => { expect('[many](https://calebjohn.ca)').toMatch(Overlay.link_regex); expect('').toMatch(Overlay.link_regex); expect('https://joplinapp.org').toMatch(Overlay.link_regex); expect('https://joplinapp.org/www(x=y)').toMatch(Overlay.link_regex); expect('[even this](https://joplinapp.org/www(x=y))').toMatch(Overlay.link_regex); expect('[even this](https://joplinapp.org/www(x=y)skmfnsm)').toMatch(Overlay.link_regex); expect('[](test)').toMatch(Overlay.link_regex); expect('[ev]()').toMatch(Overlay.link_regex); expect('[](www.google.ca "soe")').toMatch(Overlay.link_regex); }); test('match groups', () => { let str = '[many](https://calebjohn.ca)'; Overlay.link_regex.lastIndex = 0; let match = Overlay.link_regex.exec(str); expect(match[0]).toBe(str); expect(match[1]).toBe('https://calebjohn.ca'); expect(match[2]).toBeUndefined(); expect(match[3]).toBeUndefined(); str = ''; Overlay.link_regex.lastIndex = 0; match = Overlay.link_regex.exec(str); expect(match[0]).toBe(str); expect(match[1]).toBeUndefined(); expect(match[2]).toBe('joplinapp.org'); expect(match[3]).toBeUndefined(); str = 'https://joplinapp.org'; Overlay.link_regex.lastIndex = 0; match = Overlay.link_regex.exec(str); expect(match[0]).toBe(str); expect(match[1]).toBeUndefined(); expect(match[2]).toBeUndefined(); expect(match[3]).toBe('https://joplinapp.org'); str = 'https://joplinapp.org/www(x=y)'; Overlay.link_regex.lastIndex = 0; match = Overlay.link_regex.exec(str); expect(match[0]).toBe(str); expect(match[1]).toBeUndefined(); expect(match[2]).toBeUndefined(); expect(match[3]).toBe('https://joplinapp.org/www(x=y)'); str = '[even this](https://joplinapp.org/www(x=y))'; Overlay.link_regex.lastIndex = 0; match = Overlay.link_regex.exec(str); expect(match[0]).toBe(str); expect(match[1]).toBe('https://joplinapp.org/www(x=y)'); expect(match[2]).toBeUndefined(); expect(match[3]).toBeUndefined(); str = '[even this](https://joplinapp.org/www(x=y)skmfnsm)'; Overlay.link_regex.lastIndex = 0; match = Overlay.link_regex.exec(str); expect(match[0]).toBe(str); expect(match[1]).toBe('https://joplinapp.org/www(x=y)skmfnsm'); expect(match[2]).toBeUndefined(); expect(match[3]).toBeUndefined(); // It's too difficult to support nested parens using regexes // let's hope we never face such a cursed url str = '[even this](https://joplinapp.org/www(x(=)y)skmfnsm)'; Overlay.link_regex.lastIndex = 0; match = Overlay.link_regex.exec(str); expect(match[0]).toBe('[even this](https://joplinapp.org/www(x(=)y)'); expect(match[1]).toBe('https://joplinapp.org/www(x(=)y'); expect(match[2]).toBeUndefined(); expect(match[3]).toBeUndefined(); str = '[](test)'; Overlay.link_regex.lastIndex = 0; match = Overlay.link_regex.exec(str); expect(match[0]).toBe(str); expect(match[1]).toBe('test'); expect(match[2]).toBeUndefined(); expect(match[3]).toBeUndefined(); str = '[](www.google.ca "soe")'; Overlay.link_regex.lastIndex = 0; match = Overlay.link_regex.exec(str); expect(match[0]).toBe(str); expect(match[1]).toBe('www.google.ca "soe"'); expect(match[2]).toBeUndefined(); expect(match[3]).toBeUndefined(); }); }); describe('highlight regex', () => { test('valid highlight', () => { expect('==highlight==').toMatch(Overlay.highlight_regex); expect('==high light==').toMatch(Overlay.highlight_regex); expect('==highlight=me==').toMatch(Overlay.highlight_regex); expect('==highlight=me=please==').toMatch(Overlay.highlight_regex); }); test('invalid highlight', () => { expect('\\==lowlight==').not.toMatch(Overlay.highlight_regex); expect('==lowlight\\==').not.toMatch(Overlay.highlight_regex); expect('== lowlight==').not.toMatch(Overlay.highlight_regex); expect('==lowlight ==').not.toMatch(Overlay.highlight_regex); expect('=lowlight=').not.toMatch(Overlay.highlight_regex); expect('lowlight== lowlight==').not.toMatch(Overlay.highlight_regex); }); }); describe('html full image regex', () => { test('valid html matches', () => { expect('').toMatch(Overlay.html_full_image_regex); expect('Grapefruit slice atop a pile of other slices').toMatch(Overlay.html_full_image_regex); expect('My image file description').toMatch(Overlay.html_full_image_regex); expect('A Penguin on a beach.').toMatch(Overlay.html_full_image_regex); expect('Visit the MDN site').toMatch(Overlay.html_full_image_regex); expect('MDN').toMatch(Overlay.html_full_image_regex); }); test('invalid html matches', () => { expect('').not.toMatch(Overlay.html_full_image_regex); expect('').not.toMatch(Overlay.html_full_image_regex); expect('Grapefruit slice atop a pile of other slices').not.toMatch(Overlay.html_full_image_regex); expect('A Penguin on a beach.').not.toMatch(Overlay.html_full_image_regex); }); }); ================================================ FILE: src/overlay.ts ================================================ // Taken from codemirror/addon/edit/continuelist.js export const checkbox_regex = /^(\s*)((?:[\*\+\-\#]|[\#]+) )(\[[Xx ]\])\s.*$/g; export const checkboxed_regex = /^(\s*)((?:[\*\+\-\#]|[\#]+) )(\[[Xx]\])\s.*$/g; export const checkbox_inner_regex = /(?<=\[)[Xx ](?=\])/g; export const checkbox_inner_checked_regex = /(?<=\[)[Xx](?=\])/g; // Last part of regex taken from https://stackoverflow.com/a/17773849/12245502 // This regex will match html tags tht somehow include a . in them // I've decided that this is an acceptable level of functionality export const link_regex = /(?\s]+\.[^>\s]+)>|((?:[a-zA-Z0-9\+\.\-])+:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|(?:[a-zA-Z0-9\+\.\-])+:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})|joplin:\/\/x-callback-url\/.*/g; export const link_reference_regex = /(?]+?)\/?>/g; // Used for clicking on images in editor, note: 2 extra capture groups to align with link regex export const html_full_image_regex = /]*?src\s*=\s*['"]([^'"]*)['"][^>]*?()())\/?>/g; export const highlight_regex = /(?]\s|(\d+)([.)]\s))(\s*)/g; // Taken from codemirror/mode/markdown/markdown.js export const hr_regex = /^([*\-_])(?:\s*\1){2,}\s*$/; export const blockquote_regex = /^\s*\>+\s/g; export const nbsp_regex = / /g; // TODO: Extend this to get better table highlighting export const table_regex = /^\|[^\n]+\|/g; function exec(query: RegExp, stream: any) { query.lastIndex = stream.pos; return query.exec(stream.string); } export function regexOverlay(name: string, regex: RegExp, requiredSettings: string[]) { // Hack to allow the backtick regexes in code blocks const allowedInCodeblock = name.indexOf("tick") > 0; return { name: "RichMarkdownOverlay-" + name, requiredSettings: requiredSettings, token: function(stream: any) { const match = exec(regex, stream); const baseToken = stream.baseToken(); if (baseToken?.type && ( // This baseToken stuff doesn't actually work in Joplin code blocks, but it does // work to prevent highlighting of the backtick tokens, so I need to add a special // case to allow them. It also works inside comments which I think is handy !allowedInCodeblock && ( baseToken.type.includes("jn-inline-code") || baseToken.type.includes("comment") ) || baseToken.type.includes("katex"))) { stream.pos += baseToken.size; } else if (match && match.index === stream.pos) { // advance stream.pos += match[0].length || 1; return name; } else if (match) { // jump to the next match stream.pos = match.index; } else { stream.skipToEnd(); } return null; }, }; } export const overlays = [ regexOverlay('rm-checkbox', checkbox_regex, []), regexOverlay('rm-checkboxed', checkboxed_regex, ['extraCSS']), regexOverlay('rm-checkbox-check', checkbox_inner_regex, ['extraCSS']), regexOverlay('rm-checkbox-checked', checkbox_inner_checked_regex, ['extraCSS']), regexOverlay('rm-link', link_regex, []), regexOverlay('rm-link-label', link_label_regex, []), regexOverlay('rm-image', image_regex, []), regexOverlay('rm-image', html_image_regex, []), regexOverlay('rm-list-token', list_token_regex, []), regexOverlay('rm-ins', insert_regex, ['insertHighlight']), regexOverlay('rm-sub', sub_regex, ['subHighlight']), regexOverlay('rm-sup', sup_regex, ['supHighlight']), regexOverlay('rm-header-token', header_regex, ['extraCSS']), regexOverlay('line-cm-rm-blockquote', blockquote_regex, ['extraCSS']), regexOverlay('rm-em-token', emph_star_regex, ['extraCSS']), regexOverlay('rm-em-token', emph_underline_regex, ['extraCSS']), regexOverlay('rm-strong-token', strong_star_regex, ['extraCSS']), regexOverlay('rm-strong-token', strong_underline_regex, ['extraCSS']), regexOverlay('rm-highlight', highlight_regex, ['markHighlight']), regexOverlay('rm-highlight-token', highlight_token_regex, ['extraCSS', 'markHighlight']), regexOverlay('rm-ins-token', insert_token_regex, ['extraCSS', 'insertHighlight']), regexOverlay('rm-sub-token', sub_token_regex, ['extraCSS', 'subHighlight']), regexOverlay('rm-sup-token', sup_token_regex, ['extraCSS', 'supHighlight']), regexOverlay('rm-strike-token', strike_token_regex, ['extraCSS']), regexOverlay('rm-backtick-token', backtick_token_regex, ['extraCSS']), regexOverlay('rm-triptick-token', backtick_block_token_regex, ['extraCSS']), regexOverlay('rm-hr line-cm-rm-hr', hr_regex, ['extraCSS']), regexOverlay('rm-admonition-token line-cm-rm-admonition line-background-cm-rm-admonition', admonition_token_regex, ['extraCSS']), regexOverlay('rm-admonition', admonition_line_regex, ['extraCSS']), regexOverlay('rm-nbsp', nbsp_regex, ['extraCSS']), ]; function validate(settings: any, values: string[]): boolean { for (let value of values) { if (!settings[value] && !(value === "extraCSS" && (settings.theme !== "none" || settings.extraFancy))) return false; } return true; } export function add(cm: any) { if (!cm.state.richMarkdown) return; for (let overlay of overlays) if (validate(cm.state.richMarkdown.settings, overlay.requiredSettings)) cm.addOverlay(overlay); } export function remove(cm: any) { for (let overlay of overlays) cm.removeOverlay(overlay); } ================================================ FILE: src/richMarkdown.ts ================================================ import * as ImageHandlers from './images'; import * as ClickHandlers from './clickHandlers'; import * as Overlay from './overlay'; import * as IndentHandlers from './indent'; import * as Stylesheets from './stylesheets'; import { RichMarkdownSettings } from './settings'; import { listIndent } from './cm6ListIndent'; module.exports = { default: function(context) { return { plugin: function(CodeMirror) { async function path_from_id(id: string) { return await context.postMessage({name:'getResourcePath', id: id}); } async function get_settings() { return await context.postMessage({name: 'getSettings'}); } function is_click_allowed(cm:any, event: MouseEvent) { const settings = cm.state.richMarkdown.settings; if (!settings.clickCtrl) return true; let allowed = false; if (settings.clickAlt) { allowed = allowed || event.altKey; } if (navigator.platform.toUpperCase().indexOf('MAC') >= 0) { allowed = allowed || event.metaKey; } else { allowed = allowed || event.ctrlKey; } return allowed; } CodeMirror.defineExtension('initializeRichMarkdown', function(settings: RichMarkdownSettings) { this.state.richMarkdown = { settings, path_from_id, }; this.on('change', on_change); this.on('update', on_update); this.on('mousedown', on_mousedown); this.on('renderLine', on_renderLine); try { JSON.parse(settings.regexOverlays).forEach((overlay: any) => { Overlay.overlays.push( Overlay.regexOverlay(overlay.name, new RegExp(overlay.regex, 'g'), ['extraCSS']) ); }); } catch (e) { console.error('Error parsing regexOverlays', e); } Overlay.add(this); IndentHandlers.calculateSpaceWidth(this); this.updateRichMarkdownSettings(settings); if (this.cm6 && this.state.richMarkdown.settings.alignIndent) { this.addExtension(listIndent()); } }); CodeMirror.defineExtension('updateRichMarkdownSettings', function(newSettings: RichMarkdownSettings) { if (!this.state.richMarkdown) return; this.state.richMarkdown.settings = newSettings; ImageHandlers.clearAllWidgets(this); ImageHandlers.onSourceChanged(this, this.firstLine(), this.lastLine(), context); ImageHandlers.afterSourceChanges(this); if (newSettings.activeLine) { this.setOption('styleActiveLine', { nonEmpty: true }); } else { this.setOption('styleActiveLine', false); } this.getWrapperElement().onmousemove = on_mousemove(this, newSettings); this.getWrapperElement().onmouseup = on_mouseup(this, newSettings); Stylesheets.refreshStylesheets(this); }); CodeMirror.defineExtension('clickUnderCursor', function() { const coord = this.getCursor('head'); const mes = ClickHandlers.clickAt(this, coord); if (mes) context.postMessage(mes); }); CodeMirror.defineExtension('getItemsUnderCursor', function(responsePromiseId:string) { const coord = this.getCursor('head'); return ClickHandlers.getItemsAt(this, coord); }); CodeMirror.defineExtension('toggleCheckbox', function(coord:any) { ClickHandlers.toggleCheckbox(this, coord, ''); }); CodeMirror.defineExtension('checkCheckbox', function(coord:any) { ClickHandlers.toggleCheckbox(this, coord, '[x]'); }); CodeMirror.defineExtension('uncheckCheckbox', function(coord:any) { ClickHandlers.toggleCheckbox(this, coord, '[ ]'); }); CodeMirror.defineExtension('refreshResource', function(resourceId:string) { ImageHandlers.refreshResource(this, resourceId); }); function on_renderLine(cm: any, line: any, element: HTMLElement) { IndentHandlers.onRenderLine(cm, line, element, CodeMirror); } function get_change_lines(change: any) { // change.from and change.to reflect the change location *before* the edit took place // when a larger scale edit happens they don't necessarily reflect the true scope of changes const from = change.from.line; let to = change.to.line; to -= change.removed.length; to += change.text.length; return { from, to }; } function on_change(cm: any, change: any) { const { from, to } = get_change_lines(change); ImageHandlers.onSourceChanged(cm, from, to, context); } function on_update(cm: any) { ImageHandlers.afterSourceChanges(cm); } async function on_mousedown(cm: any, event: MouseEvent) { if (!cm.state.richMarkdown) return; const clickAllowed = is_click_allowed(cm, event); if (clickAllowed && (ClickHandlers.isLink(event) || ClickHandlers.isCheckbox(event))) { const coord = ClickHandlers.getClickCoord(cm, event); const mes = ClickHandlers.clickAt(cm, coord); if (mes) context.postMessage(mes); event.preventDefault(); } } function update_cursor(cm:any, settings: RichMarkdownSettings, event: MouseEvent) { if (!event.target) return; let cursor = ''; if ((settings.links && ClickHandlers.isLink(event)) || (settings.checkbox && ClickHandlers.isCheckbox(event))) { cursor = is_click_allowed(cm, event) ? 'pointer' : cursor; } const target = event.target as HTMLElement; target.style.cursor = cursor; } function on_mousemove(cm:any, settings: RichMarkdownSettings) { return function(event: MouseEvent) { update_cursor(cm, settings, event); } } function on_mouseup(cm:any, settings: RichMarkdownSettings) { return function(event: MouseEvent) { update_cursor(cm, settings, event); } } CodeMirror.defineOption('enable-rich-mode', false, async function(cm, val, old) { // Cleanup if (old && old != CodeMirror.Init) { cm.off('change', on_change); cm.off('update', on_update); cm.off('mousedown', on_mousedown); cm.off('renderLine', on_renderLine); Overlay.remove(cm); cm.state.richMarkdown = null; ImageHandlers.clearAllWidgets(cm); } // setup if (val) { // There is a race condition in the Joplin initialization code // Sometimes the settings aren't ready yet and will return `undefined` // This code will perform an exponential backoff and poll settings // until something is returned async function backoff(timeout: number) { const settings = await get_settings(); if (!settings) { setTimeout(backoff, timeout * 2, timeout * 2); } else { cm.initializeRichMarkdown(settings); } }; // Set the first timeout to 50 because settings are usually ready immediately // Set the first backoff to (100*2) to give a little extra time setTimeout(backoff, 50, 100); } }); }, codeMirrorResources: ['addon/selection/active-line', 'addon/selection/mark-selection'], codeMirrorOptions: { 'enable-rich-mode': true, 'styleSelectedText': true }, assets: function() { return [ { mime: 'text/css', inline: true, text: `.cm-rm-monospace { font-family: monospace !important; } .cm-rm-ins { text-decoration: underline; } .cm-jn-code-block .cm-rm-ins { text-decoration: revert; } .cm-rm-sub { vertical-align: sub; font-size: smaller; } .cm-rm-sup { vertical-align: super; font-size: smaller; } .cm-jn-code-block .cm-rm-sub, .cm-jn-code-block .cm-rm-sup { vertical-align: revert; font-size: revert; } div.CodeMirror span.cm-overlay.cm-rm-highlight { background-color: var(--joplin-search-marker-background-color); color: var(--joplin-search-marker-color); } div.CodeMirror span.cm-rm-highlight { background-color: var(--joplin-search-marker-background-color); color: var(--joplin-search-marker-color); } .cm-jn-code-block .cm-rm-highlight, .cm-jn-code-block .cm-rm-highlight-token { color: revert; background-color: revert; } div.CodeMirror span.cm-comment.cm-jn-inline-code.cm-overlay.cm-rm-backtick-token:not(.cm-search-marker):not(.cm-fat-cursor-mark):not(.cm-search-marker-selected):not(.CodeMirror-selectedtext) { background-color: transparent; border: none; } div.CodeMirror span.cm-comment.cm-jn-inline-code.cm-rm-backtick-token:not(.cm-search-marker):not(.cm-fat-cursor-mark):not(.cm-search-marker-selected):not(.CodeMirror-selectedtext) { background-color: transparent; border: none; } .CodeMirror-selectedtext.cm-rm-highlight { background-color: #e5d3ce; } /* Needed for the renderLine indent hack to work */ .CodeMirror pre > * { text-indent: 0px; } .cm-rm-list-token .cm-ext-checkbox-toggle .content { text-indent: 0px; } ` } ]; }, } }, } ================================================ FILE: src/settings.ts ================================================ import joplin from 'api'; import { MenuItemLocation, SettingItemType } from 'api/types'; export interface RichMarkdownSettings { inlineImages: boolean; imageHover: boolean; imageHoverCtrl: boolean; markHighlight: boolean; insertHighlight: boolean; subHighlight: boolean; supHighlight: boolean; extraCSS: boolean; activeLine: boolean; alignIndent: boolean; checkbox: boolean; links: boolean; clickCtrl: boolean; clickAlt: boolean; focusMode: boolean; theme: string; extraFancy: string; cssPath: string; regexOverlays: string; } export async function getAllSettings(): Promise { return { inlineImages: await joplin.settings.value('inlineImages'), imageHover: await joplin.settings.value('imageHover'), imageHoverCtrl: await joplin.settings.value('imageHoverCtrl'), markHighlight: await joplin.settings.globalValue('markdown.plugin.mark'), insertHighlight: await joplin.settings.globalValue('markdown.plugin.insert'), subHighlight: await joplin.settings.globalValue('markdown.plugin.sub'), supHighlight: await joplin.settings.globalValue('markdown.plugin.sup'), extraCSS: await joplin.settings.value('extraCSS'), activeLine: await joplin.settings.value('activeLine'), alignIndent: await joplin.settings.value('alignIndent'), checkbox: await joplin.settings.value('checkbox'), links: await joplin.settings.value('links'), clickCtrl: await joplin.settings.value('clickCtrl'), clickAlt: await joplin.settings.value('clickAlt'), focusMode: await joplin.settings.value('focusMode'), theme: await joplin.settings.value('theme'), extraFancy: await joplin.settings.value('extraFancy'), cssPath: await joplin.plugins.installationDir(), regexOverlays: await joplin.settings.value('regexOverlays'), } } export async function registerAllSettings() { await joplin.settings.registerSection('settings.calebjohn.richmarkdown', { label: 'Rich Markdown', iconName: 'fas fa-rocket' }); await joplin.settings.registerSettings({ 'inlineImages': { value: false, type: SettingItemType.Bool, section: 'settings.calebjohn.richmarkdown', public: true, label: 'Render images below their markdown source (only for images on their own line)' }, 'imageHover': { value: true, type: SettingItemType.Bool, section: 'settings.calebjohn.richmarkdown', public: true, label: 'Show an image popup when hovering over the image source with Ctrl (or Opt) pressed' }, 'imageHoverCtrl': { value: false, type: SettingItemType.Bool, section: 'settings.calebjohn.richmarkdown', public: true, label: 'Enable image popup even when Ctrl (or Opt) is not pressed' }, 'alignIndent': { value: true, type: SettingItemType.Bool, section: 'settings.calebjohn.richmarkdown', public: true, label: 'Align wrapped list items to the indent level', }, 'extraCSS': { value: false, type: SettingItemType.Bool, section: 'settings.calebjohn.richmarkdown', public: true, label: 'Add additional CSS classes for enhanced customization', description: 'See https://github.com/CalebJohn/joplin-rich-markdown#extra-css for options', }, 'activeLine': { value: false, type: SettingItemType.Bool, section: 'settings.calebjohn.richmarkdown', public: true, label: 'Highlight the background of the current line', }, 'checkbox': { value: true, type: SettingItemType.Bool, section: 'settings.calebjohn.richmarkdown', public: true, label: 'Toggle checkboxes with Ctrl (or Cmd)+Click' }, 'links': { value: true, type: SettingItemType.Bool, section: 'settings.calebjohn.richmarkdown', public: true, label: 'Follow note links with Ctrl (or Cmd)+Click' }, 'clickCtrl': { value: true, type: SettingItemType.Bool, advanced: true, section: 'settings.calebjohn.richmarkdown', public: true, label: 'Require Ctrl (or Cmd) when clicking on elements (links and checkboxes)', description: 'It\'s recommended not to change this', }, 'clickAlt': { value: false, type: SettingItemType.Bool, advanced: true, section: 'settings.calebjohn.richmarkdown', public: true, label: 'Allow Alt (or Opt) in addition to Ctrl/Cmd when clicking on elements (links and checkboxes)', description: 'It\'s recommended not to change this', }, 'focusMode': { value: false, type: SettingItemType.Bool, section: 'settings.calebjohn.richmarkdown', public: true, label: 'Focus Mode', description: 'Fade everything that isn\'t the current paragraph.', }, 'theme': { label: 'Theme', value: 'none', type: SettingItemType.String, section: 'settings.calebjohn.richmarkdown', isEnum: true, public: true, options: { 'none': 'none', 'stylish': 'Stylish', }, description: 'Warning: Changing theme can change the settings above.', }, 'extraFancy': { value: false, type: SettingItemType.Bool, section: 'settings.calebjohn.richmarkdown', public: true, label: 'Hide Markdown Elements', description: 'Fades the markdown characters on other lines', }, 'regexOverlays': { value: '[]', type: SettingItemType.String, section: 'settings.calebjohn.richmarkdown', public: true, advanced: true, label: 'Custom classes JSON', description: 'Add custom classes in the format of [{"name": "className", "regex": string}] to create custom overlays referenced as "cm-className".', }, }); registerToggle('inlineImages', 'Toggle images in the markdown editor', 'fas fa-image'); registerToggle('focusMode', 'Toggle focus mode in the markdown editor', 'fas fa-eye'); } async function registerToggle(name: string, label: string, icon: string) { await joplin.commands.register({ name: `richMarkdown.${name}`, label: label, iconName: icon, execute: async () => { const enabled = await joplin.settings.value(name); joplin.settings.setValue(name, !enabled); const settings = await getAllSettings(); await joplin.commands.execute('editor.execCommand', { name: 'updateRichMarkdownSettings', args: [settings] }); }, }); await joplin.views.menuItems.create(`richMarkdown${name}`, `richMarkdown.${name}`, MenuItemLocation.View); } ================================================ FILE: src/style/extra_css_fixes.css ================================================ div.cm-editor .cm-inlineCode { background-color: var(--joplin-code-background-color); border-style: none; } ================================================ FILE: src/style/extra_fancy.css ================================================ /* Hide everything */ .cm-header.cm-rm-header-token, .cm-em.cm-rm-em-token, .cm-strong.cm-rm-strong-token, div.CodeMirror span.cm-overlay.cm-rm-highlight.cm-rm-highlight-token, div.CodeMirror span.cm-rm-highlight .cm-rm-highlight-token, .cm-strikethrough.cm-rm-strike-token, .cm-rm-ins.cm-rm-ins-token, .cm-rm-sub.cm-rm-sub-token, .cm-rm-sup.cm-rm-sup-token, .cm-quote.cm-rm-list-token, .cm-katex-marker-open, .cm-katex-marker-close, .cm-rm-backtick-token, .cm-rm-triptick-token, .cm-rm-nbsp, div.cm-editor .tok-meta { opacity: 0.2 !important; font-size: 0.9em; background: transparent; color: inherit; transition: opacity 0.3s; } /* Restore font-size inside of markdown tables because it messes up alignment */ div.cm-editor .cm-tableDelimiter .tok-meta { font-size: 1em; } /* The url string looks not great when it's faded all the way to 0.2*/ div.CodeMirror .cm-string.cm-url, div.cm-editor .cm-url { opacity: 0.5 !important; font-size: 0.9em; transition: opacity 0.3s; } div.CodeMirror .CodeMirror-activeline .cm-string.cm-url, .CodeMirror-activeline .cm-header.cm-rm-header-token, .CodeMirror-activeline .cm-em.cm-rm-em-token, .CodeMirror-activeline .cm-strong.cm-rm-strong-token, div.CodeMirror .CodeMirror-activeline span.cm-overlay.cm-rm-highlight.cm-rm-highlight-token, div.CodeMirror .CodeMirror-activeline span.cm-rm-highlight .cm-rm-highlight-token, .CodeMirror-activeline .cm-strikethrough.cm-rm-strike-token, .CodeMirror-activeline .cm-rm-ins.cm-rm-ins-token, .CodeMirror-activeline .cm-rm-sub.cm-rm-sub-token, .CodeMirror-activeline .cm-rm-sup.cm-rm-sup-token, .CodeMirror-activeline .cm-quote.cm-rm-list-token, .CodeMirror-activeline .cm-katex-marker-open, .CodeMirror-activeline .cm-katex-marker-close, .CodeMirror-activeline .cm-rm-backtick-token, .CodeMirror-activeline .cm-rm-triptick-token, .CodeMirror-activeline .cm-rm-nbsp, div.cm-editor .CodeMirror-activeline .tok-meta, div.cm-editor .cm-url { opacity: 0.7 !important; background: transparent; color: inherit; transition: opacity 0.5s; } /* Don't apply the fading in code blocks */ div.CodeMirror .cm-jn-code-block .cm-string.cm-url, .cm-jn-code-block .cm-header.cm-rm-header-token, .cm-jn-code-block .cm-em.cm-rm-em-token, .cm-jn-code-block .cm-strong.cm-rm-strong-token, .cm-jn-code-block .cm-rm-highlight.cm-rm-highlight-token, .cm-jn-code-block .cm-strikethrough.cm-rm-strike-token, .cm-jn-code-block .cm-rm-ins.cm-rm-ins-token, .cm-jn-code-block .cm-rm-sub.cm-rm-sub-token, .cm-jn-code-block .cm-rm-sup.cm-rm-sup-token, .cm-jn-code-block .cm-quote.cm-rm-list-token, .cm-jn-code-block .cm-katex-marker-open, .cm-jn-code-block .cm-katex-marker-close, .cm-jn-code-block .cm-rm-nbsp { opacity: revert !important; font-size: revert; } /* Revert background colour change on the activeline */ .CodeMirror .CodeMirror-activeline .CodeMirror-activeline-background:not(.cm-jn-code-block):not(.cm-jn-code-block-background), .cm-editor .CodeMirror-activeline-background, .cm-s-solarized.cm-s-light div.CodeMirror-activeline-background, .cm-s-solarized.cm-s-dark div.CodeMirror-activeline-background { background: inherit; } /* END Hide everything */ ================================================ FILE: src/style/focus.css ================================================ div.CodeMirror .CodeMirror-line, div.cm-editor .cm-line, div.CodeMirror img { opacity: 0.4; color: var(--joplin-color) !important; transition: opacity 0.2s; } div.CodeMirror .CodeMirror-activeline .CodeMirror-line, div.CodeMirror .CodeMirror-activeline img, div.CodeMirror .CodeMirror-activeline.cm-line, div.cm-editor .CodeMirror-activeline + .rich-markdown-resource.cm-line > img, div.cm-editor .CodeMirror-activeline + .rich-markdown-resource.cm-line { opacity: 1.0; transition: opacity 0.4s; } /* Revert background colour change on the activeline */ .CodeMirror .CodeMirror-activeline .CodeMirror-activeline-background:not(.cm-jn-code-block):not(.cm-jn-code-block-background), .cm-editor .CodeMirror-activeline-background:not(.cm-jn-code-block):not(.cm-jn-code-block-background), .cm-s-solarized.cm-s-light div.CodeMirror-activeline-background, .cm-s-solarized.cm-s-dark div.CodeMirror-activeline-background { background: inherit; } ================================================ FILE: src/style/stylish.css ================================================ /* * Adapted from the theme that uxamanda shared on the Joplin forum * https://discourse.joplinapp.org/t/plugin-rich-markdown/15053/108 * */ /* Horizontal Rule*/ div.CodeMirror span.cm-hr { border-top: 2px solid var(--joplin-divider-color); display: block; line-height: 1px; width: 100%; } /* END Horizontal Rule*/ /* Blockquotes */ pre.cm-rm-blockquote.CodeMirror-line, pre.cm-rm-blockquote.cm-line { border-left: 4px solid var(--joplin-code-border-color); opacity: 0.7; } pre.cm-rm-blockquote span.cm-quote + span.cm-quote { opacity: 1; } /* END Blockquotes */ /* Fade tokens*/ div.CodeMirror .cm-string.cm-url, div.CodeMirror .cm-header.cm-rm-header-token, div.CodeMirror .cm-overlay.cm-rm-highlight.cm-rm-highlight-token, div.CodeMirror .cm-rm-highlight .cm-rm-highlight-token, div.CodeMirror .cm-strikethrough.cm-rm-strike-token, div.CodeMirror .cm-rm-ins.cm-rm-ins-token, div.CodeMirror .cm-rm-sub.cm-rm-sub-token, div.CodeMirror .cm-rm-sup.cm-rm-sup-token, div.CodeMirror .cm-quote.cm-rm-list-token, div.CodeMirror .cm-rm-backtick-token, div.CodeMirror .cm-rm-triptick-token, div.CodeMirror .cm-em.cm-rm-em-token, div.CodeMirror .cm-katex-marker-open, div.CodeMirror .cm-katex-marker-close, div.CodeMirror .cm-strong.cm-rm-strong-token, div.CodeMirror .cm-rm-nbsp, div.cm-editor .tok-meta { opacity: 0.6; background: transparent; color: inherit; } /* Don't apply the fading in code blocks */ div.CodeMirror .cm-jn-code-block .cm-string.cm-url, div.CodeMirror .cm-jn-code-block .cm-header.cm-rm-header-token, div.CodeMirror .cm-jn-code-block .cm-em.cm-rm-em-token, div.CodeMirror .cm-jn-code-block .cm-strong.cm-rm-strong-token, div.CodeMirror .cm-jn-code-block .cm-rm-highlight.cm-rm-highlight-token, div.CodeMirror .cm-jn-code-block .cm-rm-highlight .cm-rm-highlight-token, div.CodeMirror .cm-jn-code-block .cm-strikethrough.cm-rm-strike-token, div.CodeMirror .cm-jn-code-block .cm-rm-ins.cm-rm-ins-token, div.CodeMirror .cm-jn-code-block .cm-rm-sub.cm-rm-sub-token, div.CodeMirror .cm-jn-code-block .cm-rm-sup.cm-rm-sup-token, div.CodeMirror .cm-jn-code-block .cm-quote.cm-rm-list-token, div.CodeMirror .cm-jn-code-block .cm-katex-marker-open, div.CodeMirror .cm-jn-code-block .cm-katex-marker-close, div.CodeMirror .cm-rm-nbsp, div.cm-editor .cm-jn-code-block .tok-meta { opacity: revert; color: inherit; } /* END Fade tokens*/ /* Headers */ div.CodeMirror .cm-header.cm-rm-header-token:not(.cm-whitespace-a, .cm-whitespace-b), div.cm-editor .cm-header > .cm-rm-header-token { font-family: monospace; font-size: 0.9em; /* Add some spacing for breathing room */ margin-right: 0.3ex; } div.CodeMirror .cm-rm-header-token.cm-header-1:not(.cm-whitespace-a, .cm-whitespace-b), div.cm-editor .cm-header.cm-h1 > .cm-rm-header-token { /* 1ex is the height of a lowercase x, it's an approximation for width */ /* We need 2ex to account for the space character */ margin-left: -2ex; } div.CodeMirror .cm-rm-header-token.cm-header-2:not(.cm-whitespace-a, .cm-whitespace-b), div.cm-editor .cm-header.cm-h2 > .cm-rm-header-token { margin-left: -3ex; } div.CodeMirror .cm-rm-header-token.cm-header-3:not(.cm-whitespace-a, .cm-whitespace-b), div.cm-editor .cm-header.cm-h3 > .cm-rm-header-token { margin-left: -4ex; } div.CodeMirror .cm-rm-header-token.cm-header-4:not(.cm-whitespace-a, .cm-whitespace-b), div.cm-editor .cm-header.cm-h4 > .cm-rm-header-token { margin-left: -5ex; } div.CodeMirror .cm-rm-header-token.cm-header-5:not(.cm-whitespace-a, .cm-whitespace-b), div.cm-editor .cm-header.cm-h5 > .cm-rm-header-token { margin-left: -6ex; } div.CodeMirror .cm-rm-header-token.cm-header-6:not(.cm-whitespace-a, .cm-whitespace-b), div.cm-editor .cm-header.cm-h6 > .cm-rm-header-token { margin-left: -7ex; } /* END Headers */ /* Checkbox */ /* strikeout and dim the text of a checked checkbox */ .cm-property + span.cm-rm-checkbox + span.cm-rm-checkbox ~ span.cm-rm-checkbox { text-decoration: line-through; opacity: 0.7; } /* Remove the strikethrough from the right edge of the checkbox */ div.CodeMirror span.cm-property.cm-rm-checkbox { text-decoration: none; opacity: 1; } /* END Checkbox */ ================================================ FILE: src/stylesheets.ts ================================================ // Hack to store created elements so that the can easily be removed (and re-added) const elements = [ 'focus', 'stylish', 'extra_fancy', 'extra_css_fixes', ]; const idPrefix = "content-script-richMarkdown-link-"; function createElement(name: string, path: string) { const element = document.createElement('link'); element.setAttribute('id', idPrefix + name); element.setAttribute('rel', 'stylesheet'); element.setAttribute('href', path + "/style/" + name + ".css"); return element; } export function refreshStylesheets(cm: any) { if (!cm.state.richMarkdown) return; cleanup(cm); const settings = cm.state.richMarkdown.settings; if (settings.focusMode) { cm.setOption('styleActiveLine', { nonEmpty: true }); const element = createElement('focus', settings.cssPath); document.head.appendChild(element); } if (settings.theme == "stylish") { const element = createElement('stylish', settings.cssPath); document.head.appendChild(element); } if (settings.extraFancy) { cm.setOption('styleActiveLine', { nonEmpty: true }); const element = createElement('extra_fancy', settings.cssPath); document.head.appendChild(element); } if (settings.extraCSS || settings.extraFancy || settings.theme == "stylish") { const element = createElement('extra_css_fixes', settings.cssPath); document.head.appendChild(element); } cm.refresh(); } function cleanup(cm: any) { if (!cm.state.richMarkdown) return; for (let element of elements) { const el = document.getElementById(idPrefix + element); if (el) { el.remove(); } } } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "outDir": "./dist/", "module": "commonjs", "target": "es2015", "jsx": "react", "allowJs": true, "baseUrl": "." } } ================================================ FILE: webpack.config.js ================================================ // ----------------------------------------------------------------------------- // This file is used to build the plugin file (.jpl) and plugin info (.json). It // is recommended not to edit this file as it would be overwritten when updating // the plugin framework. If you do make some changes, consider using an external // JS file and requiring it here to minimize the changes. That way when you // update, you can easily restore the functionality you've added. // ----------------------------------------------------------------------------- /* eslint-disable no-console */ const path = require('path'); const crypto = require('crypto'); const fs = require('fs-extra'); const chalk = require('chalk'); const CopyPlugin = require('copy-webpack-plugin'); const tar = require('tar'); const glob = require('glob'); const execSync = require('child_process').execSync; // AUTO-GENERATED by updateCategories const allPossibleCategories = [{ 'name': 'appearance' }, { 'name': 'developer tools' }, { 'name': 'productivity' }, { 'name': 'themes' }, { 'name': 'integrations' }, { 'name': 'viewer' }, { 'name': 'search' }, { 'name': 'tags' }, { 'name': 'editor' }, { 'name': 'files' }, { 'name': 'personal knowledge management' }]; // AUTO-GENERATED by updateCategories const rootDir = path.resolve(__dirname); const userConfigFilename = './plugin.config.json'; const userConfigPath = path.resolve(rootDir, userConfigFilename); const distDir = path.resolve(rootDir, 'dist'); const srcDir = path.resolve(rootDir, 'src'); const publishDir = path.resolve(rootDir, 'publish'); const userConfig = { extraScripts: [], ...(fs.pathExistsSync(userConfigPath) ? require(userConfigFilename) : {}), }; const manifestPath = `${srcDir}/manifest.json`; const packageJsonPath = `${rootDir}/package.json`; const allPossibleScreenshotsType = ['jpg', 'jpeg', 'png', 'gif', 'webp']; const manifest = readManifest(manifestPath); const pluginArchiveFilePath = path.resolve(publishDir, `${manifest.id}.jpl`); const pluginInfoFilePath = path.resolve(publishDir, `${manifest.id}.json`); const { builtinModules } = require('node:module'); // Webpack5 doesn't polyfill by default and displays a warning when attempting to require() built-in // node modules. Set these to false to prevent Webpack from warning about not polyfilling these modules. // We don't need to polyfill because the plugins run in Electron's Node environment. const moduleFallback = {}; for (const moduleName of builtinModules) { moduleFallback[moduleName] = false; } const getPackageJson = () => { return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); }; function validatePackageJson() { const content = getPackageJson(); if (!content.name || content.name.indexOf('joplin-plugin-') !== 0) { console.warn(chalk.yellow(`WARNING: To publish the plugin, the package name should start with "joplin-plugin-" (found "${content.name}") in ${packageJsonPath}`)); } if (!content.keywords || content.keywords.indexOf('joplin-plugin') < 0) { console.warn(chalk.yellow(`WARNING: To publish the plugin, the package keywords should include "joplin-plugin" (found "${JSON.stringify(content.keywords)}") in ${packageJsonPath}`)); } if (content.scripts && content.scripts.postinstall) { console.warn(chalk.yellow(`WARNING: package.json contains a "postinstall" script. It is recommended to use a "prepare" script instead so that it is executed before publish. In ${packageJsonPath}`)); } } function fileSha256(filePath) { const content = fs.readFileSync(filePath); return crypto.createHash('sha256').update(content).digest('hex'); } function currentGitInfo() { try { let branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe' }).toString().trim(); const commit = execSync('git rev-parse HEAD', { stdio: 'pipe' }).toString().trim(); if (branch === 'HEAD') branch = 'master'; return `${branch}:${commit}`; } catch (error) { const messages = error.message ? error.message.split('\n') : ['']; console.info(chalk.cyan('Could not get git commit (not a git repo?):', messages[0].trim())); console.info(chalk.cyan('Git information will not be stored in plugin info file')); return ''; } } function validateCategories(categories) { if (!categories) return null; if ((categories.length !== new Set(categories).size)) throw new Error('Repeated categories are not allowed'); // eslint-disable-next-line github/array-foreach -- Old code before rule was applied categories.forEach(category => { if (!allPossibleCategories.map(category => { return category.name; }).includes(category)) throw new Error(`${category} is not a valid category. Please make sure that the category name is lowercase. Valid categories are: \n${allPossibleCategories.map(category => { return category.name; })}\n`); }); } function validateScreenshots(screenshots) { if (!screenshots) return null; for (const screenshot of screenshots) { if (!screenshot.src) throw new Error('You must specify a src for each screenshot'); // Avoid attempting to download and verify URL screenshots. if (screenshot.src.startsWith('https://') || screenshot.src.startsWith('http://')) { continue; } const screenshotType = screenshot.src.split('.').pop(); if (!allPossibleScreenshotsType.includes(screenshotType)) throw new Error(`${screenshotType} is not a valid screenshot type. Valid types are: \n${allPossibleScreenshotsType}\n`); const screenshotPath = path.resolve(rootDir, screenshot.src); // Max file size is 1MB const fileMaxSize = 1024; const fileSize = fs.statSync(screenshotPath).size / 1024; if (fileSize > fileMaxSize) throw new Error(`Max screenshot file size is ${fileMaxSize}KB. ${screenshotPath} is ${fileSize}KB`); } } function readManifest(manifestPath) { const content = fs.readFileSync(manifestPath, 'utf8'); const output = JSON.parse(content); if (!output.id) throw new Error(`Manifest plugin ID is not set in ${manifestPath}`); validateCategories(output.categories); validateScreenshots(output.screenshots); return output; } function createPluginArchive(sourceDir, destPath) { const distFiles = glob.sync(`${sourceDir}/**/*`, { nodir: true, windowsPathsNoEscape: true }) .map(f => f.substr(sourceDir.length + 1)); if (!distFiles.length) throw new Error('Plugin archive was not created because the "dist" directory is empty'); fs.removeSync(destPath); tar.create( { strict: true, portable: true, file: destPath, cwd: sourceDir, sync: true, }, distFiles, ); console.info(chalk.cyan(`Plugin archive has been created in ${destPath}`)); } const writeManifest = (manifestPath, content) => { fs.writeFileSync(manifestPath, JSON.stringify(content, null, '\t'), 'utf8'); }; function createPluginInfo(manifestPath, destPath, jplFilePath) { const contentText = fs.readFileSync(manifestPath, 'utf8'); const content = JSON.parse(contentText); content._publish_hash = `sha256:${fileSha256(jplFilePath)}`; content._publish_commit = currentGitInfo(); writeManifest(destPath, content); } function onBuildCompleted() { try { fs.removeSync(path.resolve(publishDir, 'index.js')); createPluginArchive(distDir, pluginArchiveFilePath); createPluginInfo(manifestPath, pluginInfoFilePath, pluginArchiveFilePath); validatePackageJson(); } catch (error) { console.error(chalk.red(error.message)); } } const baseConfig = { mode: 'production', target: 'node', stats: 'errors-only', module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/, }, ], }, ...userConfig.webpackOverrides, }; const pluginConfig = { ...baseConfig, entry: './src/index.ts', resolve: { alias: { api: path.resolve(__dirname, 'api'), }, fallback: moduleFallback, // JSON files can also be required from scripts so we include this. // https://github.com/joplin/plugin-bibtex/pull/2 extensions: ['.js', '.tsx', '.ts', '.json'], }, output: { filename: 'index.js', path: distDir, }, plugins: [ new CopyPlugin({ patterns: [ { from: '**/*', context: path.resolve(__dirname, 'src'), to: path.resolve(__dirname, 'dist'), globOptions: { ignore: [ // All TypeScript files are compiled to JS and // already copied into /dist so we don't copy them. '**/*.ts', '**/*.tsx', ], }, }, ], }), ] }; // These libraries can be included with require(...) or // joplin.require(...) from content scripts. const externalContentScriptLibraries = [ '@codemirror/view', '@codemirror/state', '@codemirror/search', '@codemirror/language', '@codemirror/autocomplete', '@codemirror/commands', '@codemirror/highlight', '@codemirror/lint', '@codemirror/lang-html', '@codemirror/lang-markdown', '@codemirror/language-data', '@lezer/common', '@lezer/markdown', '@lezer/highlight', ]; const extraScriptExternals = {}; for (const library of externalContentScriptLibraries) { extraScriptExternals[library] = { commonjs: library }; } const extraScriptConfig = { ...baseConfig, resolve: { alias: { api: path.resolve(__dirname, 'api'), }, fallback: moduleFallback, extensions: ['.js', '.tsx', '.ts', '.json'], }, // We support requiring @codemirror/... libraries through require('@codemirror/...') externalsType: 'commonjs', externals: extraScriptExternals, }; const createArchiveConfig = { stats: 'errors-only', entry: './dist/index.js', resolve: { fallback: moduleFallback, }, output: { filename: 'index.js', path: publishDir, }, plugins: [{ apply(compiler) { compiler.hooks.done.tap('archiveOnBuildListener', onBuildCompleted); }, }], }; function resolveExtraScriptPath(name) { const relativePath = `./src/${name}`; const fullPath = path.resolve(`${rootDir}/${relativePath}`); if (!fs.pathExistsSync(fullPath)) throw new Error(`Could not find extra script: "${name}" at "${fullPath}"`); const s = name.split('.'); s.pop(); const nameNoExt = s.join('.'); return { entry: relativePath, output: { filename: `${nameNoExt}.js`, path: distDir, library: 'default', libraryTarget: 'commonjs', libraryExport: 'default', }, }; } function buildExtraScriptConfigs(userConfig) { if (!userConfig.extraScripts.length) return []; const output = []; for (const scriptName of userConfig.extraScripts) { const scriptPaths = resolveExtraScriptPath(scriptName); output.push({ ...extraScriptConfig, entry: scriptPaths.entry, output: scriptPaths.output }); } return output; } const increaseVersion = version => { try { const s = version.split('.'); const d = Number(s[s.length - 1]) + 1; s[s.length - 1] = `${d}`; return s.join('.'); } catch (error) { error.message = `Could not parse version number: ${version}: ${error.message}`; throw error; } }; const updateVersion = () => { const packageJson = getPackageJson(); packageJson.version = increaseVersion(packageJson.version); fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8'); const manifest = readManifest(manifestPath); manifest.version = increaseVersion(manifest.version); writeManifest(manifestPath, manifest); if (packageJson.version !== manifest.version) { console.warn(chalk.yellow(`Version numbers have been updated but they do not match: package.json (${packageJson.version}), manifest.json (${manifest.version}). Set them to the required values to get them in sync.`)); } else { console.info(packageJson.version); } }; function main(environ) { const configName = environ['joplin-plugin-config']; if (!configName) throw new Error('A config file must be specified via the --joplin-plugin-config flag'); // Webpack configurations run in parallel, while we need them to run in // sequence, and to do that it seems the only way is to run webpack multiple // times, with different config each time. const configs = { // Builds the main src/index.ts and copy the extra content from /src to // /dist including scripts, CSS and any other asset. buildMain: [pluginConfig], // Builds the extra scripts as defined in plugin.config.json. When doing // so, some JavaScript files that were copied in the previous might be // overwritten here by the compiled version. This is by design. The // result is that JS files that don't need compilation, are simply // copied to /dist, while those that do need it are correctly compiled. buildExtraScripts: buildExtraScriptConfigs(userConfig), // Ths config is for creating the .jpl, which is done via the plugin, so // it doesn't actually need an entry and output, however webpack won't // run without this. So we give it an entry that we know is going to // exist and output in the publish dir. Then the plugin will delete this // temporary file before packaging the plugin. createArchive: [createArchiveConfig], }; // If we are running the first config step, we clean up and create the build // directories. if (configName === 'buildMain') { fs.removeSync(distDir); fs.removeSync(publishDir); fs.mkdirpSync(publishDir); } if (configName === 'updateVersion') { updateVersion(); return []; } return configs[configName]; } module.exports = (env) => { let exportedConfigs = []; try { exportedConfigs = main(env); } catch (error) { console.error(error.message); process.exit(1); } if (!exportedConfigs.length) { // Nothing to do - for example where there are no external scripts to // compile. process.exit(0); } return exportedConfigs; };