Showing preview only (219K chars total). Download the full file or copy to clipboard to get everything.
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!

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.
```

```
================================================
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)
*
* <span class="platform-desktop">desktop</span>
*/
require(_path: string): any;
versionInfo(): Promise<import("./types").VersionInfo>;
}
================================================
FILE: api/JoplinClipboard.d.ts
================================================
export default class JoplinClipboard {
private electronClipboard_;
private electronNativeImage_;
constructor(electronClipboard: any, electronNativeImage: any);
readText(): Promise<string>;
writeText(text: string): Promise<void>;
/** <span class="platform-desktop">desktop</span> */
readHtml(): Promise<string>;
/** <span class="platform-desktop">desktop</span> */
writeHtml(html: string): Promise<void>;
/**
* Returns the image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format.
*
* <span class="platform-desktop">desktop</span>
*/
readImage(): Promise<string>;
/**
* Takes an image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format.
*
* <span class="platform-desktop">desktop</span>
*/
writeImage(dataUrl: string): Promise<void>;
/**
* Returns the list available formats (mime types).
*
* For example [ 'text/plain', 'text/html' ]
*/
availableFormats(): Promise<string[]>;
}
================================================
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<any | void>;
/**
* 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<void>;
}
================================================
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<void>;
/**
* 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<void>;
}
================================================
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<any>;
post(path: Path, query?: any, body?: any, files?: any[]): Promise<any>;
put(path: Path, query?: any, body?: any, files?: any[]): Promise<any>;
delete(path: Path, query?: any): Promise<any>;
itemType(itemId: string): Promise<ModelType>;
resourcePath(resourceId: string): Promise<string>;
/**
* 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<T>(itemType: ModelType, itemId: string, key: string): Promise<T>;
/**
* Sets a note user data. See {@link JoplinData.userDataGet} for more details.
*/
userDataSet<T>(itemType: ModelType, itemId: string, key: string, value: T): Promise<void>;
/**
* Deletes a note user data. See {@link JoplinData.userDataGet} for more details.
*/
userDataDelete(itemType: ModelType, itemId: string, key: string): Promise<void>;
}
================================================
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<void>;
off(name: string, callback: FilterHandler): Promise<void>;
}
================================================
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<unknown>;
createFromPdf: (path: string, options: CreateFromPdfOptions) => Promise<unknown[]>;
getPdfInfo: (path: string) => Promise<PdfInfo>;
}
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)
*
* <span class="platform-desktop">desktop</span>
*/
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<Handle>;
createFromResource(resourceId: string): Promise<Handle>;
createFromPdfPath(path: string, options?: CreateFromPdfOptions): Promise<Handle[]>;
createFromPdfResource(resourceId: string, options?: CreateFromPdfOptions): Promise<Handle[]>;
getPdfInfoFromPath(path: string): Promise<PdfInfo>;
getPdfInfoFromResource(resourceId: string): Promise<PdfInfo>;
getSize(handle: Handle): Promise<any>;
resize(handle: Handle, options?: ResizeOptions): Promise<string>;
crop(handle: Handle, rectangle: Rectangle): Promise<string>;
toPngFile(handle: Handle, filePath: string): Promise<void>;
/**
* Quality is between 0 and 100
*/
toJpgFile(handle: Handle, filePath: string, quality?: number): Promise<void>;
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<import("../../database/types").ResourceEntity>;
/**
* Creates a new Joplin resource from the image data. The image will be
* first converted to a PNG.
*/
toPngResource(handle: Handle, resourceProps: any): Promise<import("../../database/types").ResourceEntity>;
/**
* 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<void>;
}
================================================
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
*
* <span class="platform-desktop">desktop</span>: 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<void>;
registerImportModule(module: ImportModule): Promise<void>;
}
================================================
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<void>;
/**
* @deprecated Use joplin.contentScripts.register()
*/
registerContentScript(type: ContentScriptType, id: string, scriptPath: string): Promise<void>;
/**
* 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<string>;
/**
* 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<string>;
/**
* @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<string, SettingItem>): Promise<void>;
/**
* @deprecated Use joplin.settings.registerSettings()
*
* Registers a new setting.
*/
registerSetting(key: string, settingItem: SettingItem): Promise<void>;
/**
* 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<void>;
/**
* Gets setting values (only applies to setting you registered from your plugin)
*/
values(keys: string[] | string): Promise<Record<string, unknown>>;
/**
* @deprecated Use joplin.settings.values()
*
* Gets a setting value (only applies to setting you registered from your plugin)
*/
value(key: string): Promise<any>;
/**
* Sets a setting value (only applies to setting you registered from your plugin)
*/
setValue(key: string, value: any): Promise<void>;
/**
* 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<any>;
/**
* 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<void>;
}
================================================
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<ViewHandle>;
/**
* 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<number>;
/**
* 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
*
* <span class="platform-desktop">desktop</span>
*/
showOpenDialog(options: any): Promise<any>;
/**
* Sets the dialog HTML content
*/
setHtml(handle: ViewHandle, html: string): Promise<string>;
/**
* Adds and loads a new JS or CSS files into the dialog.
*/
addScript(handle: ViewHandle, scriptPath: string): Promise<void>;
/**
* Sets the dialog buttons.
*/
setButtons(handle: ViewHandle, buttons: ButtonSpec[]): Promise<ButtonSpec[]>;
/**
* Opens the dialog.
*
* On desktop, this closes any copies of the dialog open in different windows.
*/
open(handle: ViewHandle): Promise<DialogResult>;
/**
* 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<boolean>;
}
================================================
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<ViewHandle>;
/**
* Sets the editor HTML content
*/
setHtml(handle: ViewHandle, html: string): Promise<string>;
/**
* Adds and loads a new JS or CSS file into the panel.
*/
addScript(handle: ViewHandle, scriptPath: string): Promise<void>;
/**
* See [[JoplinViewPanels]]
*/
onMessage(handle: ViewHandle, callback: Function): Promise<void>;
/**
* 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<void>;
/**
* 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<void>;
/**
* See [[JoplinViewPanels]]
*/
postMessage(handle: ViewHandle, message: any): void;
/**
* Tells whether the editor is active or not.
*/
isActive(handle: ViewHandle): Promise<boolean>;
/**
* 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<boolean>;
}
================================================
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)
*
* <span class="platform-desktop">desktop</span>
*/
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<void>;
}
================================================
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)
*
* <span class="platform-desktop">desktop</span>
*/
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<void>;
}
================================================
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
*
* <img width="250px" src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/note_list/TopToBottom.png"/>
*
* ### Left to right with thumbnails
*
* <img width="250px" src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/note_list/LeftToRight_Thumbnails.png"/>
*
* ### Top to bottom with editable title
*
* <img width="250px" src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/note_list/TopToBottom_Editable.png"/>
*
* <span class="platform-desktop">desktop</span>
*/
export default class JoplinViewsNoteList {
private plugin_;
private store_;
constructor(plugin: Plugin, store: Store);
registerRenderer(renderer: ListRenderer): Promise<void>;
}
================================================
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<ViewHandle>;
/**
* Sets the panel webview HTML
*/
setHtml(handle: ViewHandle, html: string): Promise<string>;
/**
* Adds and loads a new JS or CSS files into the panel.
*/
addScript(handle: ViewHandle, scriptPath: string): Promise<void>;
/**
* 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<void>;
/**
* 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<void>;
/**
* Hides the panel
*/
hide(handle: ViewHandle): Promise<void>;
/**
* Tells whether the panel is visible or not
*/
visible(handle: ViewHandle): Promise<boolean>;
isActive(handle: ViewHandle): Promise<boolean>;
}
================================================
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<void>;
}
================================================
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.
*
* <span class="platform-desktop">desktop</span>
*/
loadChromeCssFile(filePath: string): Promise<void>;
/**
* 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.
*
* <span class="platform-desktop">desktop</span>
*/
loadNoteCssFile(filePath: string): Promise<void>;
}
================================================
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<EventType> = (event: EventType) => void;
type ItemChangeHandler = WorkspaceEventHandler<ItemChangeEvent>;
type SyncStartHandler = () => void;
type ResourceChangeHandler = WorkspaceEventHandler<ResourceChangeEvent>;
/**
* 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<NoteSelectionChangeEvent>): Promise<Disposable>;
/**
* 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<NoteContentChangeEvent>): Promise<void>;
/**
* Called when the content of the current note changes.
*/
onNoteChange(handler: ItemChangeHandler): Promise<Disposable>;
/**
* Called when a resource is changed. Currently this handled will not be
* called when a resource is added or deleted.
*/
onResourceChange(handler: ResourceChangeHandler): Promise<void>;
/**
* Called when an alarm associated with a to-do is triggered.
*/
onNoteAlarmTrigger(handler: WorkspaceEventHandler<NoteAlarmTriggerEvent>): Promise<Disposable>;
/**
* Called when the synchronisation process is starting.
*/
onSyncStart(handler: SyncStartHandler): Promise<Disposable>;
/**
* Called when the synchronisation process has finished.
*/
onSyncComplete(callback: WorkspaceEventHandler<SyncCompleteEvent>): Promise<Disposable>;
/**
* Called just before the editor context menu is about to open. Allows
* adding items to it.
*
* <span class="platform-desktop">desktop</span>
*/
filterEditorContextMenu(handler: FilterHandler<EditContextMenuFilterObject>): void;
/**
* Gets the currently selected note. Will be `null` if no note is selected.
*/
selectedNote(): Promise<any>;
/**
* 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<FolderEntity>;
/**
* 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<string[]>;
}
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<string, any>;
export interface OnChangeEvent {
elementId: string;
value: any;
noteId: string;
}
export interface OnClickEvent {
elementId: string;
}
export type OnRenderNoteHandler = (props: any) => Promise<RenderNoteView>;
export type OnChangeHandler = (event: OnChangeEvent) => Promise<void>;
export type OnClickHandler = (event: OnClickEvent) => Promise<void>;
/**
* 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 `<input type='checkbox' ... />` 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<string, string>;
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
* `<span>{{value}}</span>` 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
* `
* <div>
* Raw timestamp: {{updatedTimeMs}} <!-- This is **not** auto-rendered ->
* Formatted time: {{note.user_updated_time}} <!-- This is -->
* </div>
* `,
*
* ```
*
* 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 `<span>{{value}}</span>`, the final template will be
* `<span>{{formattedDate}}</span>`.
*
* 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<string>;
/**
* 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
* `
* <div>
* Title: {{note.title}}<br/>
* Date: {{formattedDate}}
* </div>
* `,
*
* 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
* <div>
* <input type="text" value="{{note.title}}" data-id="noteTitleInput"/>
* </div>
* ```
*
* 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:
*
* `<input data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}>`
*/
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<string, any>;
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<RenderNoteView>;
export type OnChangeHandler = (event: OnChangeEvent)=> Promise<void>;
export type OnClickHandler = (event: OnClickEvent)=> Promise<void>;
/**
* 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 `<input type='checkbox' ... />` 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<string, string>;
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
* `<span>{{value}}</span>` 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
* `
* <div>
* Raw timestamp: {{updatedTimeMs}} <!-- This is **not** auto-rendered ->
* Formatted time: {{note.user_updated_time}} <!-- This is -->
* </div>
* `,
*
* ```
*
* 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 `<span>{{value}}</span>`, the final template will be
* `<span>{{formattedDate}}</span>`.
*
* 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<string>;
/**
* 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
* `
* <div>
* Title: {{note.title}}<br/>
* Date: {{formattedDate}}
* </div>
* `,
*
* 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
* <div>
* <input type="text" value="{{note.title}}" data-id="noteTitleInput"/>
* </div>
* ```
*
* 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:
*
* `<input data-id="todo-checkbox" type="checkbox" {{#note.todo_completed}}checked="checked"{{/note.todo_completed}}>`
*/
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<any | void>;
/**
* 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<void>;
/**
* 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<void>;
/**
* 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<void>;
/**
* Called when the export process is done.
*/
onClose(context: ExportContext): Promise<void>;
}
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<void>;
}
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<void>;
}
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.
*
* <span class="platform-desktop">desktop</span>
*/
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<boolean>;
export type UpdateCallback = ()=> Promise<void>;
export type VisibleHandler = ()=> Promise<void>;
export interface EditContextMenuFilterObject {
items: MenuItem[];
}
export interface EditorActivationCheckFilterObject {
activatedEditors: {
pluginId: string;
viewId: string;
isActive: boolean;
}[];
}
export type FilterHandler<T> = (object: T)=> Promise<T>;
// =================================================================
// 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<any, any>;
/**
* 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<any>;
/**
* 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<ContentScriptModule, 'plugin'> {
// 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<ContentScriptModule, 'plugin'> {
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
* <div class="joplin-editable">
* ...your html...
* </div>
* ```
* 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
* <div class="joplin-editable">
* <pre
* class="joplin-source"
* data-joplin-language="foo"
* data-joplin-source-open="```foo
"
* data-joplin-source-close="```"
* > ... original source here ... </pre>
* ... rendered HTML here ...
* </div>
* ```
*
* 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 <nixpkgs> {} }:
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 <joplinapp.org> 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("<joplinapp.org>");
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<Decoration>();
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 = `
{width=100%}



{width=78px}

some paragr  somethingsom
[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
<img>
<p src=":/5d1ac6e676094f4f908c1d0a65694eff" alt="69f775caf86ae2a8e86c3416fee6060d.png" width="163" height="161" class="jop-noMdConv" style="zoom: 50%;">
<img src=":/5d1ac6e676094f4f908c1d0a65694eff" alt="69f775caf86ae2a8e86c3416fee6060d.png" width="135" height="135" class="jop-noMdConv" style="zoom: 0.5;"><img src=":/5d1ac6e676094f4f908c1d0a65694eff" alt="69f775caf86ae2a8e86c3416fee6060d.png" width="135" height="135" class="jop-noMdConv" style="transform: scale(0.5);">
something in a paragraphs <img src=":/5d1ac6e676094f4f908c1d0a65694eff" alt="69f775caf86ae2a8e86c3416fee6060d.png" width="163" height="161" class="jop-noMdConv" style="zoom: 50%;"> which is supported by an image
`
const test_html = `<img src=":/5d1ac6e676094f4f908c1d0a65694eff" alt="69f775caf86ae2a8e86c3416fee6060d.png" width="163" height="161" class="jop-noMdConv" style="zoom: 50%;">
<img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/LinuxIcons/256x256.png" alt="Joplin Icon" width="331" height="331" class="jop-noMdConv">
<img src="test" onerror="alert('123')"/>`
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 ",
(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(`${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 ",
(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(`${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 = `
{width=100%}
[](https://www.inmoth.ca)

[](https://joplinapp.org)
[{width=78px}](https://www.inmoth.ca)

some paragr  somethingsom
[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, ""],
[3, null],
[4, ""],
[5, "{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*<img([^>]+?)\/?>\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 <scheme>:<scheme specific part>
// 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<MenuItem[]> => {
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('<joplinapp.org>').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 = '<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]).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('<img src=":/8be52e7f659b4cd29f1fe962e9e42b1d" alt="" width="547" height="420" class="jop-noMdConv">').toMatch(Overlay.html_full_image_regex);
expect('<img class="fit-picture" src="/shared-assets/images/examples/grapefruit-slice.jpg" alt="Grapefruit slice atop a pile of other slices" />').toMatch(Overlay.html_full_image_regex);
expect('<img src="image-file.png" alt="My image file description" attributionsrc="https://a.example/register-source https://b.example/register-source" />').toMatch(Overlay.html_full_image_regex);
expect('<img alt="A Penguin on a beach." src="penguin.jpg" />').toMatch(Overlay.html_full_image_regex);
expect('<a href="https://developer.mozilla.org"><img src="/shared-assets/images/examples/favicon144.png" alt="Visit the MDN site" /></a>').toMatch(Overlay.html_full_image_regex);
expect('<img src="/shared-assets/images/examples/favicon72.png" alt="MDN" srcset="/shared-assets/images/examples/favicon144.png 2x" />').toMatch(Overlay.html_full_image_regex);
});
test('invalid html matches', () => {
expect('<imgsrc=":/8be52e7f659b4cd29f1fe962e9e42b1d" alt="" width="547" height="420" class="jop-noMdConv">').not.toMatch(Overlay.html_full_image_regex);
expect('<img >').not.toMatch(Overlay.html_full_image_regex);
expect('<img class="fit-picture" alt="Grapefruit slice atop a pile of other slices" />').not.toMatch(Overlay.html_full_image_regex);
expect('<img alt="A Penguin on a beach." src="penguin.jpg" /').not.toMatch(Overlay.html_full_image_regex);
expect('<img alt="MDN" srcset="/shared-assets/images/examples/favicon144.png 2x" />').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 = /(?<![\\])\[([^\]]*)\](?!\()|\]\[([^\]]*)\]\s/g;
export const link_label_regex = /((?<![\\])\[(?:[^\sxX]|(?:[^\]][^\]]+))\])/g;
export const image_regex = /!\[[^\]]*\]\([^\(]+\)/g;
// Modified from https://stackoverflow.com/a/18665138/12245502
export const html_image_regex = /<img([^>]+?)\/?>/g;
// Used for clicking on images in editor, note: 2 extra capture groups to align with link regex
export const html_full_image_regex = /<img\s([^>]*?src\s*=\s*['"]([^'"]*)['"][^>]*?()())\/?>/g;
export const highlight_regex = /(?<!\\)==(?=[^\s])(?:[^=]=?)*[^=\s\\]==/g;
export const insert_regex = /(?<!\\)\+\+(?=[^\s])[^\+]*[^\+\s\\]\+\+/g;
export const sub_regex = /(?<![\\~])~(?=[^\s])[^~]*[^~\s\\]~/g;
export const sup_regex = /(?<![\\[])\^(?=[^\s])[^\^]*[^\^\s\\[]\^/g;
export const emph_star_regex = /(?<![\\\*])\*(?!\*)/g;
export const emph_underline_regex = /(?<![\\\_])\_(?!\_)/g;
export const strong_star_regex = /(?<![\\\*])\*\*(?!\*)/g;
export const strong_underline_regex = /(?<![\\\_])\_\_(?!\_)/g;
export const highlight_token_regex = /(?<![\\=])==(?!=)/g;
export const insert_token_regex = /(?<![\\\+])\+\+(?!\+)/g;
export const sub_token_regex = /(?<![\\~])~(?!~)/g;
export const sup_token_regex = /(?<![\\\^])\^(?!\^)/g;
export const strike_token_regex = /(?<![\\~])~~(?!~~)/g;
export const backtick_token_regex = /(?<![\\`])`(?!`)/g;
export const backtick_block_token_regex = /^```\S*$/g;
export const admonition_token_regex = /^!!!/g;
export const admonition_line_regex = /^!!!.*$/g;
export const header_regex = /^\s*#+\s/g;
// Taken from codemirror/addon/edit/continuelist.js
export const list_token_regex = /^(\s*)([*+-] \[[Xx ]\]\s|[*+->]\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<RichMarkdownSettings> {
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-toke
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
SYMBOL INDEX (189 symbols across 35 files)
FILE: api/Global.d.ts
class Global (line 9) | class Global {
FILE: api/Joplin.d.ts
class Joplin (line 27) | class Joplin {
FILE: api/JoplinClipboard.d.ts
class JoplinClipboard (line 1) | class JoplinClipboard {
FILE: api/JoplinCommands.d.ts
class JoplinCommands (line 58) | class JoplinCommands {
FILE: api/JoplinContentScripts.d.ts
class JoplinContentScripts (line 3) | class JoplinContentScripts {
FILE: api/JoplinData.d.ts
class JoplinData (line 40) | class JoplinData {
FILE: api/JoplinFilters.d.ts
class JoplinFilters (line 8) | class JoplinFilters {
FILE: api/JoplinImaging.d.ts
type CreateFromBufferOptions (line 2) | interface CreateFromBufferOptions {
type CreateFromPdfOptions (line 7) | interface CreateFromPdfOptions {
type PdfInfo (line 23) | interface PdfInfo {
type Implementation (line 26) | interface Implementation {
type ResizeOptions (line 31) | interface ResizeOptions {
type Handle (line 36) | type Handle = string;
class JoplinImaging (line 50) | class JoplinImaging {
FILE: api/JoplinInterop.d.ts
class JoplinInterop (line 17) | class JoplinInterop {
FILE: api/JoplinPlugins.d.ts
class JoplinPlugins (line 6) | class JoplinPlugins {
FILE: api/JoplinSettings.d.ts
type ChangeEvent (line 3) | interface ChangeEvent {
type ChangeHandler (line 9) | type ChangeHandler = (event: ChangeEvent) => void;
class JoplinSettings (line 19) | class JoplinSettings {
FILE: api/JoplinViews.d.ts
class JoplinViews (line 15) | class JoplinViews {
FILE: api/JoplinViewsDialogs.d.ts
class JoplinViewsDialogs (line 32) | class JoplinViewsDialogs {
FILE: api/JoplinViewsEditor.d.ts
class JoplinViewsEditors (line 40) | class JoplinViewsEditors {
FILE: api/JoplinViewsMenuItems.d.ts
class JoplinViewsMenuItems (line 10) | class JoplinViewsMenuItems {
FILE: api/JoplinViewsMenus.d.ts
class JoplinViewsMenus (line 10) | class JoplinViewsMenus {
FILE: api/JoplinViewsNoteList.d.ts
class JoplinViewsNoteList (line 37) | class JoplinViewsNoteList {
FILE: api/JoplinViewsPanels.d.ts
class JoplinViewsPanels (line 17) | class JoplinViewsPanels {
FILE: api/JoplinViewsToolbarButtons.d.ts
class JoplinViewsToolbarButtons (line 8) | class JoplinViewsToolbarButtons {
FILE: api/JoplinWindow.d.ts
class JoplinWindow (line 2) | class JoplinWindow {
FILE: api/JoplinWorkspace.d.ts
type ItemChangeEventType (line 4) | enum ItemChangeEventType {
type ItemChangeEvent (line 9) | interface ItemChangeEvent {
type ResourceChangeEvent (line 13) | interface ResourceChangeEvent {
type NoteContentChangeEvent (line 16) | interface NoteContentChangeEvent {
type NoteSelectionChangeEvent (line 19) | interface NoteSelectionChangeEvent {
type NoteAlarmTriggerEvent (line 22) | interface NoteAlarmTriggerEvent {
type SyncCompleteEvent (line 25) | interface SyncCompleteEvent {
type WorkspaceEventHandler (line 28) | type WorkspaceEventHandler<EventType> = (event: EventType) => void;
type ItemChangeHandler (line 29) | type ItemChangeHandler = WorkspaceEventHandler<ItemChangeEvent>;
type SyncStartHandler (line 30) | type SyncStartHandler = () => void;
type ResourceChangeHandler (line 31) | type ResourceChangeHandler = WorkspaceEventHandler<ResourceChangeEvent>;
class JoplinWorkspace (line 40) | class JoplinWorkspace {
FILE: api/noteListType.d.ts
type ListRendererDatabaseDependency (line 2) | type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.de...
type ItemFlow (line 3) | enum ItemFlow {
type RenderNoteView (line 7) | type RenderNoteView = Record<string, any>;
type OnChangeEvent (line 8) | interface OnChangeEvent {
type OnClickEvent (line 13) | interface OnClickEvent {
type OnRenderNoteHandler (line 16) | type OnRenderNoteHandler = (props: any) => Promise<RenderNoteView>;
type OnChangeHandler (line 17) | type OnChangeHandler = (event: OnChangeEvent) => Promise<void>;
type OnClickHandler (line 18) | type OnClickHandler = (event: OnClickEvent) => Promise<void>;
type ListRendererDependency (line 33) | type ListRendererDependency = ListRendererDatabaseDependency | 'item.ind...
type ListRendererItemValueTemplates (line 34) | type ListRendererItemValueTemplates = Record<string, string>;
type ColumnName (line 36) | type ColumnName = typeof columnNames[number];
type ListRenderer (line 37) | interface ListRenderer {
type NoteListColumn (line 247) | interface NoteListColumn {
type NoteListColumns (line 251) | type NoteListColumns = NoteListColumn[];
FILE: api/noteListType.ts
type ListRendererDatabaseDependency (line 6) | type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.de...
type ItemFlow (line 9) | enum ItemFlow {
type RenderNoteView (line 15) | type RenderNoteView = Record<string, any>;
type OnChangeEvent (line 17) | interface OnChangeEvent {
type OnClickEvent (line 24) | interface OnClickEvent {
type OnRenderNoteHandler (line 29) | type OnRenderNoteHandler = (props: any)=> Promise<RenderNoteView>;
type OnChangeHandler (line 30) | type OnChangeHandler = (event: OnChangeEvent)=> Promise<void>;
type OnClickHandler (line 31) | type OnClickHandler = (event: OnClickEvent)=> Promise<void>;
type ListRendererDependency (line 47) | type ListRendererDependency =
type ListRendererItemValueTemplates (line 59) | type ListRendererItemValueTemplates = Record<string, string>;
type ColumnName (line 75) | type ColumnName = typeof columnNames[number];
type ListRenderer (line 77) | interface ListRenderer {
type NoteListColumn (line 299) | interface NoteListColumn {
type NoteListColumns (line 304) | type NoteListColumns = NoteListColumn[];
FILE: api/types.ts
type Command (line 7) | interface Command {
type FileSystemItem (line 66) | enum FileSystemItem {
type ImportModuleOutputFormat (line 71) | enum ImportModuleOutputFormat {
type ExportModule (line 83) | interface ExportModule {
type ImportModule (line 134) | interface ImportModule {
type ExportOptions (line 173) | interface ExportOptions {
type ExportContext (line 182) | interface ExportContext {
type ImportContext (line 193) | interface ImportContext {
type Script (line 204) | interface Script {
type Disposable (line 209) | interface Disposable {
type ModelType (line 213) | enum ModelType {
type VersionInfo (line 232) | interface VersionInfo {
type CreateMenuItemOptions (line 244) | interface CreateMenuItemOptions {
type MenuItemLocation (line 248) | enum MenuItemLocation {
function isContextMenuItemLocation (line 290) | function isContextMenuItemLocation(location: MenuItemLocation): boolean {
type MenuItem (line 300) | interface MenuItem {
type ButtonSpec (line 339) | interface ButtonSpec {
type ButtonId (line 345) | type ButtonId = string;
type ToolbarButtonLocation (line 347) | enum ToolbarButtonLocation {
type ViewHandle (line 361) | type ViewHandle = string;
type EditorCommand (line 363) | interface EditorCommand {
type DialogResult (line 369) | interface DialogResult {
type Size (line 375) | interface Size {
type Rectangle (line 380) | interface Rectangle {
type ActivationCheckCallback (line 387) | type ActivationCheckCallback = ()=> Promise<boolean>;
type UpdateCallback (line 389) | type UpdateCallback = ()=> Promise<void>;
type VisibleHandler (line 391) | type VisibleHandler = ()=> Promise<void>;
type EditContextMenuFilterObject (line 393) | interface EditContextMenuFilterObject {
type EditorActivationCheckFilterObject (line 397) | interface EditorActivationCheckFilterObject {
type FilterHandler (line 405) | type FilterHandler<T> = (object: T)=> Promise<T>;
type SettingItemType (line 411) | enum SettingItemType {
type SettingItemSubType (line 420) | enum SettingItemSubType {
type AppType (line 426) | enum AppType {
type SettingStorage (line 432) | enum SettingStorage {
type SettingItem (line 439) | interface SettingItem {
type SettingSection (line 511) | interface SettingSection {
type Path (line 529) | type Path = string[];
type PostMessageHandler (line 536) | type PostMessageHandler = (message: any)=> Promise<any>;
type ContentScriptContext (line 541) | interface ContentScriptContext {
type ContentScriptModuleLoadedEvent (line 558) | interface ContentScriptModuleLoadedEvent {
type ContentScriptModule (line 563) | interface ContentScriptModule {
type MarkdownItContentScriptModule (line 570) | interface MarkdownItContentScriptModule extends Omit<ContentScriptModule...
type EditorCommandCallback (line 576) | type EditorCommandCallback = (...args: any[])=> any;
type CodeMirrorControl (line 578) | interface CodeMirrorControl {
type MarkdownEditorContentScriptModule (line 615) | interface MarkdownEditorContentScriptModule extends Omit<ContentScriptMo...
type ContentScriptType (line 619) | enum ContentScriptType {
FILE: src/clickHandlers.ts
function normalizeCoord (line 3) | function normalizeCoord(coord: any) {
function isLink (line 10) | function isLink(event: MouseEvent) {
function isCheckbox (line 17) | function isCheckbox(event: MouseEvent) {
function getMatchAt (line 25) | function getMatchAt(lineText: string, regex: RegExp, ch: number) {
function getClickCoord (line 50) | function getClickCoord(cm: any, event: MouseEvent) {
function clickAt (line 54) | function clickAt(cm: any, coord: any) {
type TextItemType (line 73) | enum TextItemType {
type TextItem (line 79) | interface TextItem {
function getItemsAt (line 85) | function getItemsAt(cm:any, coord:any):TextItem[] {
function getLinkAt (line 108) | function getLinkAt(cm: any, coord: any) {
function getRegexAt (line 180) | function getRegexAt(cm: any, coord: any, regex: RegExp, groupNum: number) {
function getCheckboxInfo (line 194) | function getCheckboxInfo(cm:any, coord:any) {
function toggleCheckboxInner (line 205) | function toggleCheckboxInner(cm: any, coord: any, replacement: string) {
function toggleCheckbox (line 230) | function toggleCheckbox(cm: any, coord: any, replacement: string) {
FILE: src/cm6ListIndent.ts
function calculateIndent (line 4) | function calculateIndent(indentStr, tabSize) {
function createListIndentPlugin (line 19) | function createListIndentPlugin() {
FILE: src/cm6Requires.ts
function require_codemirror_view (line 8) | function require_codemirror_view(): typeof CodeMirrorView {
function require_codemirror_state (line 12) | function require_codemirror_state(): typeof CodeMirrorState {
function require_codemirror_language (line 16) | function require_codemirror_language(): typeof CodeMirrorLanguage {
FILE: src/imageData.ts
function imageToDataURL (line 3) | async function imageToDataURL(filePath:string, mimeType:string) {
FILE: src/images.ts
function isSupportedImageMimeType (line 14) | function isSupportedImageMimeType(mime:string) {
function isSupportedOcrMimeType (line 18) | function isSupportedOcrMimeType(mime:string) {
function onSourceChanged (line 22) | function onSourceChanged(cm: any, from: number, to: number, context: any) {
function afterSourceChanges (line 30) | function afterSourceChanges(cm: any) {
function isLineInCodeBlock (line 37) | function isLineInCodeBlock(editor, syntaxTree, lineNumber) {
function getImageData (line 55) | async function getImageData(cm: any, coord: any) {
function open_widget (line 77) | function open_widget(cm: any) {
function clearHoverImages (line 141) | function clearHoverImages(cm: any) {
function close_widget (line 151) | function close_widget(cm: any) {
function update_hover_widgets (line 160) | function update_hover_widgets(cm: any) {
function check_lines (line 180) | async function check_lines(cm: any, from: number, to: number, context: a...
function createImageFromImg (line 254) | async function createImageFromImg(imgTag: string, path_from_id: any) {
function createImage (line 282) | async function createImage(path: string, alt: string, path_from_id: any,...
function refreshResource (line 316) | function refreshResource(cm: any, id: string) {
function clearAllWidgets (line 336) | function clearAllWidgets(cm: any) {
FILE: src/indent.ts
function calculateSpaceWidth (line 15) | function calculateSpaceWidth(cm: any) {
function charWidth (line 22) | function charWidth(cm: any, chr: string, cls: string) {
function onRenderLine (line 44) | function onRenderLine(cm: any, line: any, element: HTMLElement, CodeMirr...
FILE: src/overlay.ts
function exec (line 46) | function exec(query: RegExp, stream: any) {
function regexOverlay (line 51) | function regexOverlay(name: string, regex: RegExp, requiredSettings: str...
function validate (line 123) | function validate(settings: any, values: string[]): boolean {
function add (line 132) | function add(cm: any) {
function remove (line 140) | function remove(cm: any) {
FILE: src/richMarkdown.ts
function path_from_id (line 13) | async function path_from_id(id: string) {
function get_settings (line 16) | async function get_settings() {
function is_click_allowed (line 20) | function is_click_allowed(cm:any, event: MouseEvent) {
function on_renderLine (line 114) | function on_renderLine(cm: any, line: any, element: HTMLElement) {
function get_change_lines (line 118) | function get_change_lines(change: any) {
function on_change (line 129) | function on_change(cm: any, change: any) {
function on_update (line 134) | function on_update(cm: any) {
function on_mousedown (line 138) | async function on_mousedown(cm: any, event: MouseEvent) {
function update_cursor (line 155) | function update_cursor(cm:any, settings: RichMarkdownSettings, event: Mo...
function on_mousemove (line 169) | function on_mousemove(cm:any, settings: RichMarkdownSettings) {
function on_mouseup (line 175) | function on_mouseup(cm:any, settings: RichMarkdownSettings) {
function backoff (line 199) | async function backoff(timeout: number) {
FILE: src/settings.ts
type RichMarkdownSettings (line 4) | interface RichMarkdownSettings {
function getAllSettings (line 26) | async function getAllSettings(): Promise<RichMarkdownSettings> {
function registerAllSettings (line 50) | async function registerAllSettings() {
function registerToggle (line 188) | async function registerToggle(name: string, label: string, icon: string) {
FILE: src/stylesheets.ts
function createElement (line 11) | function createElement(name: string, path: string) {
function refreshStylesheets (line 20) | function refreshStylesheets(cm: any) {
function cleanup (line 47) | function cleanup(cm: any) {
FILE: webpack.config.js
function validatePackageJson (line 57) | function validatePackageJson() {
function fileSha256 (line 72) | function fileSha256(filePath) {
function currentGitInfo (line 77) | function currentGitInfo() {
function validateCategories (line 91) | function validateCategories(categories) {
function validateScreenshots (line 100) | function validateScreenshots(screenshots) {
function readManifest (line 122) | function readManifest(manifestPath) {
function createPluginArchive (line 131) | function createPluginArchive(sourceDir, destPath) {
function createPluginInfo (line 156) | function createPluginInfo(manifestPath, destPath, jplFilePath) {
function onBuildCompleted (line 164) | function onBuildCompleted() {
method apply (line 276) | apply(compiler) {
function resolveExtraScriptPath (line 282) | function resolveExtraScriptPath(name) {
function buildExtraScriptConfigs (line 304) | function buildExtraScriptConfigs(userConfig) {
function main (line 346) | function main(environ) {
Condensed preview — 56 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (225K chars).
[
{
"path": ".gitignore",
"chars": 44,
"preview": "dist/\nnode_modules/\npublish/*.json\npublish/\n"
},
{
"path": ".npmignore",
"chars": 71,
"preview": "*.md\n!README.md\n/*.jpl\n/api\n/src\n/dist\ntsconfig.json\nwebpack.config.js\n"
},
{
"path": "GENERATOR_DOC.md",
"chars": 4603,
"preview": "# Plugin development\n\nThis documentation describes how to create a plugin, and how to work with the plugin builder frame"
},
{
"path": "LICENSE",
"chars": 1067,
"preview": "MIT License\n\nCopyright (c) 2021 Caleb John\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
},
{
"path": "README.md",
"chars": 6415,
"preview": "# Rich Markdown\n\nA plugin that will finally allow you to ditch the markdown viewer, saving space and making your life ea"
},
{
"path": "TIPS.md",
"chars": 3970,
"preview": "# Tips\n\nNote: Most of these have been wrapped up in to the themes (available in the plugin settings). So make sure to ch"
},
{
"path": "api/Global.d.ts",
"chars": 270,
"preview": "import Plugin from '../Plugin';\nimport Joplin from './Joplin';\n/**\n * @ignore\n */\n/**\n * @ignore\n */\nexport default clas"
},
{
"path": "api/Joplin.d.ts",
"chars": 2965,
"preview": "import Plugin from '../Plugin';\nimport JoplinData from './JoplinData';\nimport JoplinPlugins from './JoplinPlugins';\nimpo"
},
{
"path": "api/JoplinClipboard.d.ts",
"chars": 1090,
"preview": "export default class JoplinClipboard {\n private electronClipboard_;\n private electronNativeImage_;\n constructor"
},
{
"path": "api/JoplinCommands.d.ts",
"chars": 4114,
"preview": "import { Command } from './types';\nimport Plugin from '../Plugin';\n/**\n * This class allows executing or registering new"
},
{
"path": "api/JoplinContentScripts.d.ts",
"chars": 2540,
"preview": "import Plugin from '../Plugin';\nimport { ContentScriptType } from './types';\nexport default class JoplinContentScripts {"
},
{
"path": "api/JoplinData.d.ts",
"chars": 4397,
"preview": "import { ModelType } from '../../../BaseModel';\nimport Plugin from '../Plugin';\nimport { Path } from './types';\n/**\n * T"
},
{
"path": "api/JoplinFilters.d.ts",
"chars": 326,
"preview": "import { FilterHandler } from '../../../eventManager';\n/**\n * @ignore\n *\n * Not sure if it's the best way to hook into t"
},
{
"path": "api/JoplinImaging.d.ts",
"chars": 3536,
"preview": "import { Rectangle } from './types';\nexport interface CreateFromBufferOptions {\n width?: number;\n height?: number;"
},
{
"path": "api/JoplinInterop.d.ts",
"chars": 1092,
"preview": "import { ExportModule, ImportModule } from './types';\n/**\n * Provides a way to create modules to import external data in"
},
{
"path": "api/JoplinPlugins.d.ts",
"chars": 1551,
"preview": "import Plugin from '../Plugin';\nimport { ContentScriptType, Script } from './types';\n/**\n * This class provides access t"
},
{
"path": "api/JoplinSettings.d.ts",
"chars": 3038,
"preview": "import Plugin from '../Plugin';\nimport { SettingItem, SettingSection } from './types';\nexport interface ChangeEvent {\n "
},
{
"path": "api/JoplinViews.d.ts",
"chars": 1442,
"preview": "import Plugin from '../Plugin';\nimport JoplinViewsDialogs from './JoplinViewsDialogs';\nimport JoplinViewsMenuItems from "
},
{
"path": "api/JoplinViewsDialogs.d.ts",
"chars": 2945,
"preview": "import Plugin from '../Plugin';\nimport { ButtonSpec, ViewHandle, DialogResult } from './types';\n/**\n * Allows creating a"
},
{
"path": "api/JoplinViewsEditor.d.ts",
"chars": 4226,
"preview": "import Plugin from '../Plugin';\nimport { ActivationCheckCallback, ViewHandle, UpdateCallback } from './types';\n/**\n * Al"
},
{
"path": "api/JoplinViewsMenuItems.d.ts",
"chars": 762,
"preview": "import { CreateMenuItemOptions, MenuItemLocation } from './types';\nimport Plugin from '../Plugin';\n/**\n * Allows creatin"
},
{
"path": "api/JoplinViewsMenus.d.ts",
"chars": 776,
"preview": "import { MenuItem, MenuItemLocation } from './types';\nimport Plugin from '../Plugin';\n/**\n * Allows creating menus.\n *\n "
},
{
"path": "api/JoplinViewsNoteList.d.ts",
"chars": 1821,
"preview": "import { Store } from 'redux';\nimport Plugin from '../Plugin';\nimport { ListRenderer } from './noteListType';\n/**\n * Thi"
},
{
"path": "api/JoplinViewsPanels.d.ts",
"chars": 2974,
"preview": "import Plugin from '../Plugin';\nimport { ViewHandle } from './types';\n/**\n * Allows creating and managing view panels. V"
},
{
"path": "api/JoplinViewsToolbarButtons.d.ts",
"chars": 590,
"preview": "import { ToolbarButtonLocation } from './types';\nimport Plugin from '../Plugin';\n/**\n * Allows creating and managing too"
},
{
"path": "api/JoplinWindow.d.ts",
"chars": 1065,
"preview": "import Plugin from '../Plugin';\nexport default class JoplinWindow {\n private store_;\n constructor(_plugin: Plugin,"
},
{
"path": "api/JoplinWorkspace.d.ts",
"chars": 3578,
"preview": "import Plugin from '../Plugin';\nimport { FolderEntity } from '../../database/types';\nimport { Disposable, EditContextMen"
},
{
"path": "api/index.ts",
"chars": 91,
"preview": "import type Joplin from './Joplin';\n\ndeclare const joplin: Joplin;\n\nexport default joplin;\n"
},
{
"path": "api/noteListType.d.ts",
"chars": 12116,
"preview": "import { Size } from './types';\ntype ListRendererDatabaseDependency = 'folder.created_time' | 'folder.deleted_time' | 'f"
},
{
"path": "api/noteListType.ts",
"chars": 12113,
"preview": "/* eslint-disable multiline-comment-style */\n\nimport { Size } from './types';\n\n// AUTO-GENERATED by generate-database-ty"
},
{
"path": "api/types.ts",
"chars": 26406,
"preview": "/* eslint-disable multiline-comment-style */\n\n// =================================================================\n// Co"
},
{
"path": "jest.config.js",
"chars": 128,
"preview": "/** @type {import('jest').Config} */\nconst config = {\n\tpreset: 'ts-jest',\n\ttestEnvironment: 'node',\n};\n\nmodule.exports ="
},
{
"path": "package.json",
"chars": 1655,
"preview": "{\n \"name\": \"joplin-plugin-rich-markdown\",\n \"version\": \"0.17.1\",\n \"scripts\": {\n \"dist\": \"webpack --env joplin-plugi"
},
{
"path": "plugin.config.json",
"chars": 134,
"preview": "{\n\t\"extraScripts\": [\n\t\t\"richMarkdown.ts\",\n\t\t\"images.ts\",\n\t\t\"indent.ts\",\n\t\t\"clickHandlers.ts\",\n\t\t\"stylesheets.ts\",\n\t\t\"ove"
},
{
"path": "shell.nix",
"chars": 188,
"preview": "{ pkgs ? import <nixpkgs> {} }:\npkgs.mkShell rec {\n name = \"nodejs\";\n\n NODE_OPTIONS=\"--openssl-legacy-provider\";\n\n bu"
},
{
"path": "src/clickHandlers.test.ts",
"chars": 2896,
"preview": "import * as ClickHandlers from './clickHandlers';\nimport * as Overlay from './overlay';\n\nconst test_text = `\nsoemthing\nt"
},
{
"path": "src/clickHandlers.ts",
"chars": 6455,
"preview": "import * as Overlay from './overlay';\n\nfunction normalizeCoord(coord: any) {\n\tif (coord.sticky && coord.sticky === \"befo"
},
{
"path": "src/cm6ListIndent.ts",
"chars": 2626,
"preview": "import type { Decoration, DecorationSet, PluginValue } from '@codemirror/view';\nimport { require_codemirror_view, requir"
},
{
"path": "src/cm6Requires.ts",
"chars": 681,
"preview": "import type * as CodeMirrorView from '@codemirror/view';\nimport type * as CodeMirrorState from '@codemirror/state';\nimpo"
},
{
"path": "src/imageData.ts",
"chars": 295,
"preview": "import joplin from 'api';\n\nexport async function imageToDataURL(filePath:string, mimeType:string) {\n\tconst fs = joplin.r"
},
{
"path": "src/images.test.ts",
"chars": 7291,
"preview": "import * as ImageHandlers from './images';\n\nconst test_text = `\n{wi"
},
{
"path": "src/images.ts",
"chars": 10191,
"preview": "import * as ClickHandlers from './clickHandlers';\nimport * as Overlay from './overlay';\n\nimport { require_codemirror_lan"
},
{
"path": "src/indent.ts",
"chars": 2217,
"preview": "// This module is modified from the CodeMirror indent wrap demo\n// https://codemirror.net/demo/indentwrap.html\n\nimport {"
},
{
"path": "src/index.ts",
"chars": 10279,
"preview": "import joplin from 'api';\nimport { ContentScriptType, MenuItem, MenuItemLocation, ModelType } from 'api/types';\nimport {"
},
{
"path": "src/manifest.json",
"chars": 730,
"preview": "{\n\t\"manifest_version\": 1,\n\t\"id\": \"plugin.calebjohn.rich-markdown\",\n\t\"app_min_version\": \"3.5.9\",\n\t\"version\": \"0.17.1\",\n\t\""
},
{
"path": "src/overlay.test.ts",
"chars": 5876,
"preview": "import * as Overlay from './overlay';\n\n\ndescribe('link regex', () => {\n\ttest('valid urls', () => {\n\t\texpect('[many](http"
},
{
"path": "src/overlay.ts",
"chars": 6783,
"preview": "\n// Taken from codemirror/addon/edit/continuelist.js\nexport const checkbox_regex = /^(\\s*)((?:[\\*\\+\\-\\#]|[\\#]+) )(\\[[Xx "
},
{
"path": "src/richMarkdown.ts",
"chars": 9198,
"preview": "import * as ImageHandlers from './images';\nimport * as ClickHandlers from './clickHandlers';\nimport * as Overlay from '."
},
{
"path": "src/settings.ts",
"chars": 6226,
"preview": "import joplin from 'api';\nimport { MenuItemLocation, SettingItemType } from 'api/types';\n\nexport interface RichMarkdownS"
},
{
"path": "src/style/extra_css_fixes.css",
"chars": 112,
"preview": "div.cm-editor .cm-inlineCode {\n background-color: var(--joplin-code-background-color);\n border-style: none;\n}\n"
},
{
"path": "src/style/extra_fancy.css",
"chars": 3142,
"preview": "/* Hide everything */\n.cm-header.cm-rm-header-token, .cm-em.cm-rm-em-token, .cm-strong.cm-rm-strong-token, div.CodeMirro"
},
{
"path": "src/style/focus.css",
"chars": 950,
"preview": "div.CodeMirror .CodeMirror-line,\ndiv.cm-editor .cm-line,\ndiv.CodeMirror img {\n opacity: 0.4;\n color: var(--joplin-colo"
},
{
"path": "src/style/stylish.css",
"chars": 4137,
"preview": "/*\n * Adapted from the theme that uxamanda shared on the Joplin forum\n * https://discourse.joplinapp.org/t/plugin-rich-m"
},
{
"path": "src/stylesheets.ts",
"chars": 1561,
"preview": "\n// Hack to store created elements so that the can easily be removed (and re-added)\nconst elements = [\n\t'focus',\n\t'styli"
},
{
"path": "tsconfig.json",
"chars": 152,
"preview": "{\n\t\"compilerOptions\": {\n\t\t\"outDir\": \"./dist/\",\n\t\t\"module\": \"commonjs\",\n\t\t\"target\": \"es2015\",\n\t\t\"jsx\": \"react\",\n\t\t\"allowJ"
},
{
"path": "webpack.config.js",
"chars": 13489,
"preview": "// -----------------------------------------------------------------------------\n// This file is used to build the plugi"
}
]
About this extraction
This page contains the full source code of the CalebJohn/joplin-rich-markdown GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 56 files (204.5 KB), approximately 54.4k tokens, and a symbol index with 189 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.