();
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

something in a paragraphs
which is supported by an image
`
const test_html = `
`
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*
]+?)\/?>\s*$/;
// Used to quickly index widgets that will get updated
let allWidgets = {};
export function isSupportedImageMimeType(mime:string) {
return ['image/png', 'image/jpg', 'image/jpeg'].includes(mime.toLowerCase());
}
export function isSupportedOcrMimeType(mime:string) {
return ['application/pdf'].includes(mime.toLowerCase()) || isSupportedImageMimeType(mime);
}
export function onSourceChanged(cm: any, from: number, to: number, context: any) {
if (!cm.state.richMarkdown) return;
if (cm.state.richMarkdown.settings.inlineImages) {
check_lines(cm, from, to, context);
}
}
export function afterSourceChanges(cm: any) {
if (!cm.state.richMarkdown) return;
if (cm.state.richMarkdown.settings.imageHover)
update_hover_widgets(cm);
}
function isLineInCodeBlock(editor, syntaxTree, lineNumber) {
const doc = editor.state.doc
const line = doc.line(lineNumber)
const tree = syntaxTree(editor.state)
let node = tree.resolveInner(line.from)
// Walk up the tree to find if we're inside a code block
while (node) {
if (node.name === "FencedCode" || node.name === "CodeBlock") {
return true
}
node = node.parent
}
return false
}
async function getImageData(cm: any, coord: any) {
let { line, ch } = coord;
const lineText = cm.getLine(line);
const match = ClickHandlers.getMatchAt(lineText, image_inline_regex, ch);
let img = null;
if (match) {
img = await createImage(match[2], match[1], cm.state.richMarkdown.path_from_id, match[4], match[5]);
}
else {
const imgMatch = ClickHandlers.getMatchAt(lineText, Overlay.html_image_regex, ch);
if (imgMatch) {
img = await createImageFromImg(imgMatch[0], cm.state.richMarkdown.path_from_id);
}
}
return img;
}
function open_widget(cm: any) {
return async function(event: MouseEvent) {
if (!event.target) return;
if (!cm.state.richMarkdown) return;
if (!cm.state.richMarkdown.settings.imageHover) return;
// This shortcut is only enabled for the ctrl case because in the default case I would
// prefer to accidentally display 2 images rather than not dislpay anything
if (!cm.state.richMarkdown.settings.imageHoverCtrl &&
(!(event.ctrlKey || event.altKey) || cm.state.richMarkdown.isMouseHovering)) return;
cm.state.richMarkdown.isMouseHovering = true;
const target = event.target as HTMLElement;
if (!target.offsetParent) return;
// This already has the image rendered inline
if (cm.state.richMarkdown.settings.inlineImages && target.parentNode.childNodes.length <= 3)
return;
const img = await getImageData(cm, ClickHandlers.getClickCoord(cm, event));
if (!img) return;
// manual zoom
if (img.style.zoom) {
let zoom = parseFloat(img.style.zoom);
if (img.style.zoom.endsWith('%')){
zoom /= 100
}
if (zoom) {
img.width *= zoom
img.height *= zoom
img.style.zoom = '100%';
}
}
img.style.visibility = 'hidden';
target.offsetParent.appendChild(img);
img.style.position = 'absolute';
img.style.zIndex = '1000';
img.classList.add('rich-markdown-hover-image');
img.onload = function() {
if (!cm.state.richMarkdown) return;
if (!cm.state.richMarkdown.isMouseHovering) {
img.remove();
return;
}
let im = this as HTMLElement;
const par = target.offsetParent as HTMLElement;
const { right, width } = par.getBoundingClientRect();
let x = 0;
if (im.clientWidth < width) {
x = Math.min(event.clientX, right - im.clientWidth);
}
const coords = cm.coordsChar({left: x, top: event.clientY}, 'page');
im.style.visibility = 'visible';
cm.addWidget(coords, img, false);
}
}
}
function clearHoverImages(cm: any) {
const widgets = cm.getWrapperElement().getElementsByClassName('rich-markdown-hover-image');
// Removed widgets are simultaneously removed from this array
// we need to iterate backwards to prevent the array from changing on us
for (let i = widgets.length -1; i >= 0; i--) {
widgets[i].remove();
}
}
function close_widget(cm: any) {
return function(event: MouseEvent) {
if (cm.state.richMarkdown)
cm.state.richMarkdown.isMouseHovering = false;
clearHoverImages(cm);
}
}
function update_hover_widgets(cm: any) {
if (!cm.state.richMarkdown) return;
// If the image source is removed, this update funciton won't catch it
// and the image will be stuck around forever joplin-rich-markdown/issues/69
// this prevents this from happening (by pre-emptively clearing the image)
// but causes a flicker while editing and hovering.
clearHoverImages(cm);
const images = cm.getWrapperElement().getElementsByClassName("cm-rm-image");
for (let image of images) {
image.onmouseenter = open_widget(cm);
image.onmouseleave = close_widget(cm);
if (!cm.state.richMarkdown.settings.imageHoverCtrl) {
image.onmousemove = open_widget(cm);
}
}
}
async function check_lines(cm: any, from: number, to: number, context: any) {
if (!cm.state.richMarkdown) return;
const path_from_id = cm.state.richMarkdown.path_from_id;
let needsRefresh = false;
for (let i = from; i <= to; i++) {
const line = cm.lineInfo(i);
if (line.widgets) {
for (const wid of line.widgets) {
if (wid.className === 'rich-markdown-resource')
wid.clear();
delete allWidgets[wid.node.id];
}
}
if (!line) { continue; }
if (cm.cm6) {
if (!cm.state.richMarkdown.language) {
cm.state.richMarkdown.language = require_codemirror_language();
}
const syntaxTree = cm.state.richMarkdown.language.syntaxTree;
// cm6 uses 1 based indexing for line numbers, but cm5 uses 0 based
// the line object we have here is emulated cm5, so it uses 0 based
// but the checking function is cm6, so we need to adjust
if (isLineInCodeBlock(cm.editor, syntaxTree, line.line + 1)) {
continue;
}
} else {
const state = cm.getStateAfter(i, true);
// Don't render inline images inside of code blocks (not for cm5/legacy editor only)
if (state?.outer && (state?.outer?.code || (state?.outer?.thisLine?.fencedCodeEnd))) {
continue;
}
}
// Special Case
// If the line only contains a link wrapped around an image, we should match against that
const line_link_match = line.text.match(image_line_link_regex);
let lineText = line.text;
let lineLink = '';
if (line_link_match) {
lineText = line_link_match[1];
lineLink = line_link_match[2];
}
const match = lineText.match(image_line_regex);
let img = null;
if (match) {
img = await createImage(match[2], match[1], path_from_id, match[4], match[5], context, lineLink);
}
else {
const imgMatch = line.text.match(html_image_line_regex);
if (imgMatch) {
img = await createImageFromImg(imgMatch[0], path_from_id);
}
}
if (img) {
const wid = cm.addLineWidget(i, img, { className: 'rich-markdown-resource' });
allWidgets[img.id] = wid;
needsRefresh = true;
}
}
if (needsRefresh) {
cm.refresh();
}
}
async function createImageFromImg(imgTag: string, path_from_id: any) {
const par = new DOMParser().parseFromString(imgTag, "text/html");
const img = par.body.firstChild as HTMLImageElement;
img.style.height = img.style.height || 'auto';
img.style.maxWidth = img.style.maxWidth || '100%';
// Tags taken from
// https://github.com/laurent22/joplin/blob/80b16dd17e227e3f538aa221d7b6cc2d81688e72/packages/renderer/htmlUtils.ts
const disallowedTags = ['script', 'noscript', 'iframe', 'frameset', 'frame', 'object', 'base', 'embed', 'link', 'meta'];
for (let i = 0; i < img.attributes.length; i++) {
const name = img.attributes[i].name;
if (disallowedTags.includes(name) || name.startsWith('on')) {
img.attributes[i].value = '';
}
}
// Joplin resource paths get added on to the end of the local path for some reason
if (img.src.length >= 34) {
const id = img.src.substring(img.src.length - 34);
if (id.startsWith(':/')) {
img.src = await path_from_id(id.substring(2));
img.id = id.substring(2);
}
}
return img;
}
async function createImage(path: string, alt: string, path_from_id: any, width?: string, unit?: string, context?: any, link?: string) {
let id = path.substring(2)
if (path.startsWith(':/') && path.length == 34) {
path = await path_from_id(id);
}
if (path.startsWith('<') && path.endsWith('>')) {
// <> quotes are not allowed in URLs as per RFC 1738
// https://www.ietf.org/rfc/rfc1738.txt
// Page 2 includes a list of unsafe characters
path = path.substring(1, path.length - 1);
}
const img = document.createElement('img');
img.src = path;
img.alt = alt;
img.style.maxWidth = '100%';
img.style.height = 'auto';
if (link && context) {
img.onclick = () => {
context.postMessage({ name: 'followLink', url: link });
};
}
if (width) {
img.style.width = width + (unit ? '' : 'px');
}
// This will either contain the resource id or some gibberish path
img.id = id;
return img;
}
// Reload the specified resource on disk, this will be in response
// to changes made by the user
export function refreshResource(cm: any, id: string) {
const timestamp = new Date().getTime();
let wid = allWidgets[id];
const path = wid.node.src.split("?t=")[0];
const height = wid.node.height;
wid.node.onload = function() {
let im = this as HTMLImageElement;
// If the image is scrolled out of view (no need to refresh), it won't have a clientRect
if (im.getClientRects().length == 0) { return; }
if (im.height != height) {
cm.refresh();
}
};
wid.node.src = `${path}?t=${timestamp}`;
}
// Used on cleanup
export function clearAllWidgets(cm: any) {
clearHoverImages(cm);
for (let id in allWidgets) {
allWidgets[id].clear();
}
allWidgets = {};
// Refresh codemirror to make sure everything is sized correctly
cm.refresh();
}
================================================
FILE: src/indent.ts
================================================
// This module is modified from the CodeMirror indent wrap demo
// https://codemirror.net/demo/indentwrap.html
import { list_token_regex } from './overlay';
// These variables are cached when the plugin is loaded
// This stores the width of a space in the current font
let spaceWidth = 0;
// This stores the width of a monospace character using the current monospace font
let monoSpaceWidth = 0;
// This stores the width of the > character in the current font
let blockCharWidth = 0;
// Must be called when the editor is mounted
export function calculateSpaceWidth(cm: any) {
spaceWidth = charWidth(cm, ' ', '');
monoSpaceWidth = charWidth(cm, ' ', 'cm-rm-monospace');
blockCharWidth = charWidth(cm, '>', '');
}
// Adapted from codemirror/lib/codemirror.js
function charWidth(cm: any, chr: string, cls: string) {
let e = document.createElement('span');
if (cls)
e.classList.add(cls);
e.style.whiteSpace = "pre-wrap";
e.appendChild(document.createTextNode(chr.repeat(10)))
const measure = cm.getWrapperElement().getElementsByClassName('CodeMirror-measure')[0];
if (measure.firstChild)
measure.removeChild(measure.firstChild);
measure.appendChild(e);
const rect = e.getBoundingClientRect()
const width = (rect.right - rect.left) / 10;
return width || cm.defaultCharWidth();
}
// Adapted from
// https://github.com/codemirror/CodeMirror/blob/master/demo/indentwrap.html
export function onRenderLine(cm: any, line: any, element: HTMLElement, CodeMirror: any) {
if (!cm.state.richMarkdown) return;
if (cm.state.richMarkdown.settings.alignIndent) {
const matches = line.text.match(list_token_regex);
if (!matches) return;
let off = CodeMirror.countColumn(line.text, matches[0].length, cm.getOption("tabSize")) * spaceWidth;
// Special case handling for checkboxes with monospace enabled
if (cm.state.richMarkdown.settings.enforceMono && matches[0].indexOf('[') > 0) {
// "- [ ] " is 6 characters
off += monoSpaceWidth * 6 - spaceWidth * 6;
}
else if (cm.state.richMarkdown.settings.enforceMono && matches[0].indexOf('>') >= 0) {
off += blockCharWidth - spaceWidth;
}
element.style.textIndent = "-" + off + "px";
element.style.paddingLeft = off + "px";
}
}
================================================
FILE: src/index.ts
================================================
import joplin from 'api';
import { ContentScriptType, MenuItem, MenuItemLocation, ModelType } from 'api/types';
import { getAllSettings, registerAllSettings } from './settings';
// TODO: Waiting for https://github.com/laurent22/joplin/pull/4509
// import prettier = require('prettier/standalone');
// import markdown = require('prettier/parser-markdown');
import { TextItem, TextItemType } from './clickHandlers';
import { isSupportedImageMimeType, isSupportedOcrMimeType } from './images';
import { imageToDataURL } from './imageData';
const fs = joplin.require('fs-extra');
const { parseResourceUrl } = require('@joplin/lib/urlUtils');
const contentScriptId = 'richMarkdownEditor';
joplin.plugins.register({
onStart: async function() {
// There is a bug (race condition?) where the perform action command
// doesn't always work when first opening the app. Opening the keyboard
// shortcuts will properly bind it and make it work.
// Placing the command before registering settings also seems to fix it
await joplin.commands.register({
name: 'editor.richMarkdown.clickAtCursor',
label: 'Perform action',
iconName: 'fas fa-link',
execute: async () => {
await joplin.commands.execute('editor.execCommand', {
name: 'clickUnderCursor',
});
},
});
await joplin.views.menuItems.create('richMarkdownClickAtCursor', 'editor.richMarkdown.clickAtCursor', MenuItemLocation.Note, { accelerator: 'Ctrl+Enter' });
// TODO: See about getting this same behaviour into the openItem function
await joplin.commands.register({
name: 'app.richMarkdown.openItem',
execute: async (url: string) => {
// From RFC 1738 Page 1 a url is :
// the below regex implements matching for the scheme (with support for uppercase)
// urls without a scheme will be assumed http
if (!url.startsWith(':/') && !url.match(/^(?:[a-zA-Z0-9\+\.\-])+:/)) {
url = 'http://' + url;
}
await joplin.commands.execute('openItem', url);
},
});
await joplin.commands.register({
name: 'editor.richMarkdown.toggleCheckbox',
execute: async (coord: any) => {
await joplin.commands.execute('editor.execCommand', {
name: 'toggleCheckbox',
args: [coord],
});
},
});
await joplin.commands.register({
name: 'editor.richMarkdown.checkCheckbox',
execute: async (coord: any) => {
await joplin.commands.execute('editor.execCommand', {
name: 'checkCheckbox',
args: [coord],
});
},
});
await joplin.commands.register({
name: 'editor.richMarkdown.uncheckCheckbox',
execute: async (coord: any) => {
await joplin.commands.execute('editor.execCommand', {
name: 'uncheckCheckbox',
args: [coord],
});
},
});
await joplin.commands.register({
name: 'editor.richMarkdown.copyImage',
execute: async (itemId: string) => {
const resource = await joplin.data.get(['resources', itemId], { fields: ['mime'] });
const resourcePath = await joplin.data.resourcePath(itemId);
const dataUrl = await imageToDataURL(resourcePath, resource.mime);
await joplin.clipboard.writeImage(dataUrl);
},
});
await joplin.commands.register({
name: 'editor.richMarkdown.viewOcrText',
execute: async (itemId: string) => {
const resource = await joplin.data.get(['resources', itemId], { fields: ['id', 'mime', 'ocr_text', 'ocr_status'] });
if (resource.ocr_status === 2) { // ResourceOcrStatus.Done
const tempFilePath = `${await joplin.plugins.dataDir()}/${resource.id}_ocr.txt`;
await fs.writeFile(tempFilePath, resource.ocr_text, 'utf8');
const fileUrl = `file://${tempFilePath.replace(/\\/g, '/')}`;
await joplin.commands.execute('openItem', fileUrl);
} else {
console.info(`OCR of resource ${itemId} is not ready yet ${resource.ocr_status}`);
}
},
});
await joplin.commands.register({
name: 'editor.richMarkdown.copyPathToClipboard',
execute: async (path: string) => {
await joplin.clipboard.writeText(path);
},
});
// Helper to build menu items for a resource (image or other attachment)
const buildResourceMenuItems = async (resourceId: string, openUrl: string): Promise