From a62e1fba96998ab26c432426f2f9005d165cb136 Mon Sep 17 00:00:00 2001 From: asrient <44570278+asrient@users.noreply.github.com> Date: Mon, 28 Mar 2022 21:40:29 +0530 Subject: [PATCH] Desktop: Resolves #6100: Allow saving a Mermaid graph as a PNG or SVG via context menu (#6126) --- .eslintignore | 9 ++ .gitignore | 9 ++ .../NoteBody/TinyMCE/utils/useContextMenu.ts | 5 +- .../gui/NoteEditor/utils/contextMenu.test.ts | 41 +++++++++ .../gui/NoteEditor/utils/contextMenu.ts | 91 +++++++++--------- .../gui/NoteEditor/utils/contextMenuUtils.ts | 92 +++++++++++++++++++ .../gui/NoteEditor/utils/useMessageHandler.ts | 2 + .../app-desktop/gui/note-viewer/index.html | 19 +++- packages/app-desktop/loadResources.testEnv.ts | 20 ++++ packages/app-desktop/package.json | 1 + yarn.lock | 81 +++++++++++++++- 11 files changed, 321 insertions(+), 49 deletions(-) create mode 100644 packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.ts create mode 100644 packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.ts create mode 100644 packages/app-desktop/loadResources.testEnv.ts diff --git a/.eslintignore b/.eslintignore index bf3913992c..acbedd7466 100644 --- a/.eslintignore +++ b/.eslintignore @@ -490,6 +490,12 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js.map packages/app-desktop/gui/NoteEditor/utils/contextMenu.d.ts packages/app-desktop/gui/NoteEditor/utils/contextMenu.js packages/app-desktop/gui/NoteEditor/utils/contextMenu.js.map +packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.d.ts +packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.js +packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.js.map +packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.d.ts +packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js +packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js.map packages/app-desktop/gui/NoteEditor/utils/index.d.ts packages/app-desktop/gui/NoteEditor/utils/index.js packages/app-desktop/gui/NoteEditor/utils/index.js.map @@ -724,6 +730,9 @@ packages/app-desktop/gui/utils/convertToScreenCoordinates.js.map packages/app-desktop/gui/utils/loadScript.d.ts packages/app-desktop/gui/utils/loadScript.js packages/app-desktop/gui/utils/loadScript.js.map +packages/app-desktop/loadResources.testEnv.d.ts +packages/app-desktop/loadResources.testEnv.js +packages/app-desktop/loadResources.testEnv.js.map packages/app-desktop/plugins/GotoAnything.d.ts packages/app-desktop/plugins/GotoAnything.js packages/app-desktop/plugins/GotoAnything.js.map diff --git a/.gitignore b/.gitignore index abdb4efbb5..305dcbf09c 100644 --- a/.gitignore +++ b/.gitignore @@ -480,6 +480,12 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js.map packages/app-desktop/gui/NoteEditor/utils/contextMenu.d.ts packages/app-desktop/gui/NoteEditor/utils/contextMenu.js packages/app-desktop/gui/NoteEditor/utils/contextMenu.js.map +packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.d.ts +packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.js +packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.js.map +packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.d.ts +packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js +packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js.map packages/app-desktop/gui/NoteEditor/utils/index.d.ts packages/app-desktop/gui/NoteEditor/utils/index.js packages/app-desktop/gui/NoteEditor/utils/index.js.map @@ -714,6 +720,9 @@ packages/app-desktop/gui/utils/convertToScreenCoordinates.js.map packages/app-desktop/gui/utils/loadScript.d.ts packages/app-desktop/gui/utils/loadScript.js packages/app-desktop/gui/utils/loadScript.js.map +packages/app-desktop/loadResources.testEnv.d.ts +packages/app-desktop/loadResources.testEnv.js +packages/app-desktop/loadResources.testEnv.js.map packages/app-desktop/plugins/GotoAnything.d.ts packages/app-desktop/plugins/GotoAnything.js packages/app-desktop/plugins/GotoAnything.js.map diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.ts index 1b74e62c73..6ccae1aba1 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.ts @@ -3,7 +3,8 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer'; import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService'; import { useEffect } from 'react'; import bridge from '../../../../../services/bridge'; -import { menuItems, ContextMenuOptions, ContextMenuItemType } from '../../../utils/contextMenu'; +import { ContextMenuOptions, ContextMenuItemType } from '../../../utils/contextMenuUtils'; +import { menuItems } from '../../../utils/contextMenu'; import MenuUtils from '@joplin/lib/services/commands/MenuUtils'; import CommandService from '@joplin/lib/services/CommandService'; import convertToScreenCoordinates from '../../../../utils/convertToScreenCoordinates'; @@ -67,6 +68,8 @@ export default function(editor: any, plugins: PluginStates, dispatch: Function) contextMenuActionOptions.current = { itemType, resourceId, + filename: null, + mime: null, linkToCopy, textToCopy: null, htmlToCopy: editor.selection ? editor.selection.getContent() : '', diff --git a/packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.ts b/packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.ts new file mode 100644 index 0000000000..9110adc97a --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/utils/contextMenu.test.ts @@ -0,0 +1,41 @@ +/** @jest-environment ./loadResources.testEnv */ +// eslint-disable-next-line strict, lines-around-directive +'use strict'; +// use strict is necessary here so that typescript doesn't place "use strict" above the jest docblock +// https://github.com/microsoft/TypeScript/issues/15819#issuecomment-782235619 + +import { textToDataUri, svgUriToPng } from './contextMenuUtils'; + +jest.mock('@joplin/lib/models/Resource'); + +describe('contextMenu', () => { + it('should provide proper copy path', async () => { + const testCase = [ + 'test', + 'image/svg+xml', + ]; + const expectedText = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4bWw6c3BhY2U9InByZXNlcnZlIj50ZXN0PC9zdmc+'; + expect(textToDataUri(testCase[0], testCase[1])).toBe(expectedText); + }); + + it('should convert to png binary', async () => { + const testCase = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4PSIwcHgiIHk9IjBweCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDEwMCAxMDAiIHhtbDpzcGFjZT0icHJlc2VydmUiIGhlaWdodD0iMTAwcHgiIHdpZHRoPSIxMDBweCI+CjxnPgoJPHBhdGggZD0iTTI4LjEsMzYuNmM0LjYsMS45LDEyLjIsMS42LDIwLjksMS4xYzguOS0wLjQsMTktMC45LDI4LjksMC45YzYuMywxLjIsMTEuOSwzLjEsMTYuOCw2Yy0xLjUtMTIuMi03LjktMjMuNy0xOC42LTMxLjMgICBjLTQuOS0wLjItOS45LDAuMy0xNC44LDEuNEM0Ny44LDE3LjksMzYuMiwyNS42LDI4LjEsMzYuNnoiLz4KCTxwYXRoIGQ9Ik03MC4zLDkuOEM1Ny41LDMuNCw0Mi44LDMuNiwzMC41LDkuNWMtMyw2LTguNCwxOS42LTUuMywyNC45YzguNi0xMS43LDIwLjktMTkuOCwzNS4yLTIzLjFDNjMuNywxMC41LDY3LDEwLDcwLjMsOS44eiIvPgoJPHBhdGggZD0iTTE2LjUsNTEuM2MwLjYtMS43LDEuMi0zLjQsMi01LjFjLTMuOC0zLjQtNy41LTctMTEtMTAuOGMtMi4xLDYuMS0yLjgsMTIuNS0yLjMsMTguN0M5LjYsNTEuMSwxMy40LDUwLjIsMTYuNSw1MS4zeiIvPgoJPHBhdGggZD0iTTksMzEuNmMzLjUsMy45LDcuMiw3LjYsMTEuMSwxMS4xYzAuOC0xLjYsMS43LTMuMSwyLjYtNC42YzAuMS0wLjIsMC4zLTAuNCwwLjQtMC42Yy0yLjktMy4zLTMuMS05LjItMC42LTE3LjYgICBjMC44LTIuNywxLjgtNS4zLDIuNy03LjRjLTUuMiwzLjQtOS44LDgtMTMuMywxMy43QzEwLjgsMjcuOSw5LjgsMjkuNyw5LDMxLjZ6Ii8+Cgk8cGF0aCBkPSJNMTUuNCw1NC43Yy0yLjYtMS02LjEsMC43LTkuNywzLjRjMS4yLDYuNiwzLjksMTMsOCwxOC41QzEzLDY5LjMsMTMuNSw2MS44LDE1LjQsNTQuN3oiLz4KCTxwYXRoIGQ9Ik0zOS44LDU3LjZDNTQuMyw2Ni43LDcwLDczLDg2LjUsNzYuNGMwLjYtMC44LDEuMS0xLjYsMS43LTIuNWM0LjgtNy43LDctMTYuMyw2LjgtMjQuOGMtMTMuOC05LjMtMzEuMy04LjQtNDUuOC03LjcgICBjLTkuNSwwLjUtMTcuOCwwLjktMjMuMi0xLjdjLTAuMSwwLjEtMC4yLDAuMy0wLjMsMC40Yy0xLDEuNy0yLDMuNC0yLjksNS4xQzI4LjIsNDkuNywzMy44LDUzLjksMzkuOCw1Ny42eiIvPgoJPHBhdGggZD0iTTI2LjIsODguMmMzLjMsMiw2LjcsMy42LDEwLjIsNC43Yy0zLjUtNi4yLTYuMy0xMi42LTguOC0xOC41Yy0zLjEtNy4yLTUuOC0xMy41LTktMTcuMmMtMS45LDgtMiwxNi40LTAuMywyNC43ICAgQzIwLjYsODQuMiwyMy4yLDg2LjMsMjYuMiw4OC4yeiIvPgoJPHBhdGggZD0iTTMwLjksNzNjMi45LDYuOCw2LjEsMTQuNCwxMC41LDIxLjJjMTUuNiwzLDMyLTIuMyw0Mi42LTE0LjZDNjcuNyw3Niw1Mi4yLDY5LjYsMzcuOSw2MC43QzMyLDU3LDI2LjUsNTMsMjEuMyw0OC42ICAgYy0wLjYsMS41LTEuMiwzLTEuNyw0LjZDMjQuMSw1Ny4xLDI3LjMsNjQuNSwzMC45LDczeiIvPgo8L2c+Cjwvc3ZnPg=='; + const png = await svgUriToPng(document, testCase); + expect(png).toBeInstanceOf(Uint8Array); + }); + + it('should throw error on invalid svg uri', async () => { + // We are mocking console.error since jsdom throws errors to console when we try to load an invalid img + // https://github.com/facebook/jest/pull/5267#issuecomment-356605468 + const consoleError = console.error; + console.error = jest.fn(); + const testCases: Array = [ + 'data:image/svg+xml;base64,error', + 'invalid', + ]; + for (const testCase of testCases) { + await expect(svgUriToPng(document, testCase)).rejects.toBeInstanceOf(Error); + } + console.error = consoleError; + }); +}); diff --git a/packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts b/packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts index b9516a41e5..c2796dfe22 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts @@ -2,6 +2,7 @@ import ResourceEditWatcher from '@joplin/lib/services/ResourceEditWatcher/index' import { _ } from '@joplin/lib/locale'; import { copyHtmlToClipboard } from './clipboardUtils'; import bridge from '../../../services/bridge'; +import { ContextMenuItemType, ContextMenuOptions, ContextMenuItems, resourceInfo, textToDataUri, svgUriToPng } from './contextMenuUtils'; const Menu = bridge().Menu; const MenuItem = bridge().MenuItem; import Resource from '@joplin/lib/models/Resource'; @@ -10,43 +11,10 @@ import BaseModel from '@joplin/lib/BaseModel'; import { processPastedHtml } from './resourceHandling'; import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types'; const fs = require('fs-extra'); +const { writeFile } = require('fs-extra'); const { clipboard } = require('electron'); const { toSystemSlashes } = require('@joplin/lib/path-utils'); -export enum ContextMenuItemType { - None = '', - Image = 'image', - Resource = 'resource', - Text = 'text', - Link = 'link', -} - -export interface ContextMenuOptions { - itemType: ContextMenuItemType; - resourceId: string; - linkToCopy: string; - textToCopy: string; - htmlToCopy: string; - insertContent: Function; - isReadOnly?: boolean; -} - -interface ContextMenuItem { - label: string; - onAction: Function; - isActive: Function; -} - -interface ContextMenuItems { - [key: string]: ContextMenuItem; -} - -async function resourceInfo(options: ContextMenuOptions): Promise { - const resource = options.resourceId ? await Resource.load(options.resourceId) : null; - const resourcePath = resource ? Resource.fullPath(resource) : ''; - return { resource, resourcePath }; -} - function handleCopyToClipboard(options: ContextMenuOptions) { if (options.textToCopy) { clipboard.writeText(options.textToCopy); @@ -55,6 +23,12 @@ function handleCopyToClipboard(options: ContextMenuOptions) { } } +async function saveFileData(data: any, filename: string) { + const newFilePath = await bridge().showSaveDialog({ defaultPath: filename }); + if (!newFilePath) return; + await writeFile(newFilePath, data); +} + export async function openItemById(itemId: string, dispatch: Function, hash: string = '') { const item = await BaseItem.loadItemById(itemId); @@ -100,7 +74,7 @@ export function menuItems(dispatch: Function): ContextMenuItems { onAction: async (options: ContextMenuOptions) => { await openItemById(options.resourceId, dispatch); }, - isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource, + isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource), }, saveAs: { label: _('Save as...'), @@ -112,7 +86,32 @@ export function menuItems(dispatch: Function): ContextMenuItems { if (!filePath) return; await fs.copy(resourcePath, filePath); }, - isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource, + // We handle images received as text seperately as it can be saved in multiple formats + isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource), + }, + saveAsSvg: { + label: _('Save as SVG'), + onAction: async (options: ContextMenuOptions) => { + await saveFileData(options.textToCopy, options.filename); + }, + isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !!options.textToCopy && itemType === ContextMenuItemType.Image && options.mime?.startsWith('image/svg'), + }, + saveAsPng: { + label: _('Save as PNG'), + onAction: async (options: ContextMenuOptions) => { + // First convert it to png then save + if (options.mime != 'image/svg+xml') { + throw new Error(`Unsupported image type: ${options.mime}`); + } + if (!options.filename) { + throw new Error('Filename is needed to save as png'); + } + const dataUri = textToDataUri(options.textToCopy, options.mime); + const png = await svgUriToPng(document, dataUri); + const filename = options.filename.replace('.svg', '.png'); + await saveFileData(png, filename); + }, + isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !!options.textToCopy && itemType === ContextMenuItemType.Image && options.mime?.startsWith('image/svg'), }, revealInFolder: { label: _('Reveal file in folder'), @@ -120,13 +119,20 @@ export function menuItems(dispatch: Function): ContextMenuItems { const { resourcePath } = await resourceInfo(options); bridge().showItemInFolder(resourcePath); }, - isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource, + isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource, }, copyPathToClipboard: { label: _('Copy path to clipboard'), onAction: async (options: ContextMenuOptions) => { - const { resourcePath } = await resourceInfo(options); - clipboard.writeText(toSystemSlashes(resourcePath)); + let path = ''; + if (options.textToCopy && options.mime) { + path = textToDataUri(options.textToCopy, options.mime); + } else { + const { resourcePath } = await resourceInfo(options); + if (resourcePath) path = toSystemSlashes(resourcePath); + } + if (!path) return; + clipboard.writeText(path); }, isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource, }, @@ -137,7 +143,7 @@ export function menuItems(dispatch: Function): ContextMenuItems { const image = bridge().createImageFromPath(resourcePath); clipboard.writeImage(image); }, - isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image, + isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && itemType === ContextMenuItemType.Image, }, cut: { label: _('Cut'), @@ -145,14 +151,14 @@ export function menuItems(dispatch: Function): ContextMenuItems { handleCopyToClipboard(options); options.insertContent(''); }, - isActive: (_itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.isReadOnly && (!!options.textToCopy || !!options.htmlToCopy), + isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => itemType != ContextMenuItemType.Image && (!options.isReadOnly && (!!options.textToCopy || !!options.htmlToCopy)), }, copy: { label: _('Copy'), onAction: async (options: ContextMenuOptions) => { handleCopyToClipboard(options); }, - isActive: (_itemType: ContextMenuItemType, options: ContextMenuOptions) => !!options.textToCopy || !!options.htmlToCopy, + isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => itemType != ContextMenuItemType.Image && (!!options.textToCopy || !!options.htmlToCopy), }, paste: { label: _('Paste'), @@ -184,7 +190,6 @@ export default async function contextMenu(options: ContextMenuOptions, dispatch: const items = menuItems(dispatch); if (!('readyOnly' in options)) options.isReadOnly = true; - for (const itemKey in items) { const item = items[itemKey]; diff --git a/packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.ts b/packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.ts new file mode 100644 index 0000000000..4884c0db9c --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.ts @@ -0,0 +1,92 @@ +import Resource from '@joplin/lib/models/Resource'; + +export enum ContextMenuItemType { + None = '', + Image = 'image', + Resource = 'resource', + Text = 'text', + Link = 'link', +} + +export interface ContextMenuOptions { + itemType: ContextMenuItemType; + resourceId: string; + mime: string; + filename: string; + linkToCopy: string; + textToCopy: string; + htmlToCopy: string; + insertContent: Function; + isReadOnly?: boolean; +} + +export interface ContextMenuItem { + label: string; + onAction: Function; + isActive: Function; +} + +export interface ContextMenuItems { + [key: string]: ContextMenuItem; +} + +export async function resourceInfo(options: ContextMenuOptions): Promise { + const resource = options.resourceId ? await Resource.load(options.resourceId) : null; + const filePath = resource ? Resource.fullPath(resource) : null; + const filename = resource ? (resource.filename ? resource.filename : resource.title) : options.filename ? options.filename : ''; + return { resource, filePath, filename }; +} + +export function textToDataUri(text: string, mime: string): string { + return `data:${mime};base64,${Buffer.from(text).toString('base64')}`; +} + +export const svgUriToPng = (document: Document, svg: string) => { + return new Promise((resolve, reject) => { + let canvas: HTMLCanvasElement; + let img: HTMLImageElement; + + const cleanUpAndReject = (e: Error) => { + if (canvas) canvas.remove(); + if (img) img.remove(); + return reject(e); + }; + + try { + img = document.createElement('img'); + if (!img) throw new Error('Failed to create img element'); + } catch (e) { + return cleanUpAndReject(e); + } + + img.onload = function() { + try { + canvas = document.createElement('canvas'); + if (!canvas) throw new Error('Failed to create canvas element'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Failed to get context'); + ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, img.width, img.height); + const pngUri = canvas.toDataURL('image/png'); + if (!pngUri) throw new Error('Failed to generate png uri'); + const pngBase64 = pngUri.split(',')[1]; + const byteString = atob(pngBase64); + // write the bytes of the string to a typed array + const buff = new Uint8Array(byteString.length); + for (let i = 0; i < byteString.length; i++) { + buff[i] = byteString.charCodeAt(i); + } + canvas.remove(); + img.remove(); + resolve(buff); + } catch (err) { + cleanUpAndReject(err); + } + }; + img.onerror = function(e) { + cleanUpAndReject(new Error(e.toString())); + }; + img.src = svg; + }); +}; diff --git a/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts b/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts index cdc94e5ad2..a26123e518 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts @@ -35,6 +35,8 @@ export default function useMessageHandler(scrollWhenReady: any, setScrollWhenRea const menu = await contextMenu({ itemType: arg0 && arg0.type, resourceId: arg0.resourceId, + filename: arg0.filename, + mime: arg0.mime, textToCopy: arg0.textToCopy, linkToCopy: arg0.linkToCopy || null, htmlToCopy: '', diff --git a/packages/app-desktop/gui/note-viewer/index.html b/packages/app-desktop/gui/note-viewer/index.html index 66cded6760..6559dc9d75 100644 --- a/packages/app-desktop/gui/note-viewer/index.html +++ b/packages/app-desktop/gui/note-viewer/index.html @@ -586,9 +586,24 @@ })); document.addEventListener('contextmenu', webviewLib.logEnabledEventHandler(event => { - let element = event.target; - // To handle right clicks on resource icons + let element = event.target; + + // Mermaid svgs are wrapped inside a
 with class "mermaid"
+			let mermaidElement = element.closest(".mermaid")?.children[0];
+			if (mermaidElement) {
+				const svgString = new XMLSerializer().serializeToString(mermaidElement);
+				if (!!svgString) {
+					ipcProxySendToHost('contextMenu', {
+						type: 'image',
+						textToCopy: svgString,
+						mime: 'image/svg+xml',
+						filename: mermaidElement.id + '.svg',
+					});
+				}
+				return;
+			}
+
 			if (element && !element.getAttribute('data-resource-id')) element = element.parentElement;
 
 			if (element && element.getAttribute('data-resource-id')) {
diff --git a/packages/app-desktop/loadResources.testEnv.ts b/packages/app-desktop/loadResources.testEnv.ts
new file mode 100644
index 0000000000..9fc4853ae5
--- /dev/null
+++ b/packages/app-desktop/loadResources.testEnv.ts
@@ -0,0 +1,20 @@
+/**
+ * A Jest custom test Environment to load the resources for the tests.
+ * Use this test envirenment when you work with resources like images, files.
+ * See gui/NoteEditor/utils/contextMenu.test.ts for an example.
+ */
+
+const JSDOMEnvironment = require('jest-environment-jsdom');
+import type { EnvironmentContext } from '@jest/environment';
+import type { Config } from '@jest/types';
+
+
+export default class CustomEnvironment extends JSDOMEnvironment {
+	constructor(config: Config.ProjectConfig, context?: EnvironmentContext) {
+		// Resources is set to 'usable' to enable fetching of resources like images and fonts while testing
+		// Which does not happen by default in jest
+		// https://stackoverflow.com/a/49482563
+		config.testEnvironmentOptions.resources = 'usable';
+		super(config, context);
+	}
+}
diff --git a/packages/app-desktop/package.json b/packages/app-desktop/package.json
index 6fa9856636..0151818590 100644
--- a/packages/app-desktop/package.json
+++ b/packages/app-desktop/package.json
@@ -116,6 +116,7 @@
     "app-builder-bin": "^1.9.11",
     "babel-cli": "^6.26.0",
     "babel-preset-react": "^6.24.1",
+    "canvas": "^2.9.0",
     "electron": "14.1.0",
     "electron-builder": "^22.11.7",
     "electron-notarize": "^1.0.0",
diff --git a/yarn.lock b/yarn.lock
index cf7dabb1b3..8d67c2a642 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2910,6 +2910,7 @@ __metadata:
     async-mutex: ^0.1.3
     babel-cli: ^6.26.0
     babel-preset-react: ^6.24.1
+    canvas: ^2.9.0
     codemirror: ^5.56.0
     color: ^3.1.2
     compare-versions: ^3.2.1
@@ -4339,6 +4340,25 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@mapbox/node-pre-gyp@npm:^1.0.0":
+  version: 1.0.8
+  resolution: "@mapbox/node-pre-gyp@npm:1.0.8"
+  dependencies:
+    detect-libc: ^1.0.3
+    https-proxy-agent: ^5.0.0
+    make-dir: ^3.1.0
+    node-fetch: ^2.6.5
+    nopt: ^5.0.0
+    npmlog: ^5.0.1
+    rimraf: ^3.0.2
+    semver: ^7.3.5
+    tar: ^6.1.11
+  bin:
+    node-pre-gyp: bin/node-pre-gyp
+  checksum: 29a38f39575107fa1665edf14defcfdf62e12bb38e9c27f7457ba42be84060125015171d12b8de3065155a465992f1854a363e2985f071fcbea9ff0701362b05
+  languageName: node
+  linkType: hard
+
 "@mrmlnc/readdir-enhanced@npm:^2.2.1":
   version: 2.2.1
   resolution: "@mrmlnc/readdir-enhanced@npm:2.2.1"
@@ -8915,6 +8935,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"canvas@npm:^2.9.0":
+  version: 2.9.0
+  resolution: "canvas@npm:2.9.0"
+  dependencies:
+    "@mapbox/node-pre-gyp": ^1.0.0
+    nan: ^2.15.0
+    node-gyp: latest
+    simple-get: ^3.0.3
+  checksum: 376ccd47340a46c04d5cabafd6feb1b7ae82c92dc3ae6db68c9cbac17ec1c43d2bf6aab60019690e9d49bf40b41bee3e4e0a8901f39b53e993789698e77e2699
+  languageName: node
+  linkType: hard
+
 "capital-case@npm:^1.0.4":
   version: 1.0.4
   resolution: "capital-case@npm:1.0.4"
@@ -14886,6 +14918,23 @@ __metadata:
   languageName: node
   linkType: hard
 
+"gauge@npm:^3.0.0":
+  version: 3.0.2
+  resolution: "gauge@npm:3.0.2"
+  dependencies:
+    aproba: ^1.0.3 || ^2.0.0
+    color-support: ^1.1.2
+    console-control-strings: ^1.0.0
+    has-unicode: ^2.0.1
+    object-assign: ^4.1.1
+    signal-exit: ^3.0.0
+    string-width: ^4.2.3
+    strip-ansi: ^6.0.1
+    wide-align: ^1.1.2
+  checksum: 81296c00c7410cdd48f997800155fbead4f32e4f82109be0719c63edc8560e6579946cc8abd04205297640691ec26d21b578837fd13a4e96288ab4b40b1dc3e9
+  languageName: node
+  linkType: hard
+
 "gauge@npm:^4.0.0":
   version: 4.0.0
   resolution: "gauge@npm:4.0.0"
@@ -20230,7 +20279,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"make-dir@npm:^3.0.0":
+"make-dir@npm:^3.0.0, make-dir@npm:^3.1.0":
   version: 3.1.0
   resolution: "make-dir@npm:3.1.0"
   dependencies:
@@ -21751,7 +21800,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"nan@npm:^2.12.1":
+"nan@npm:^2.12.1, nan@npm:^2.15.0":
   version: 2.15.0
   resolution: "nan@npm:2.15.0"
   dependencies:
@@ -22019,6 +22068,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"node-fetch@npm:^2.6.5":
+  version: 2.6.7
+  resolution: "node-fetch@npm:2.6.7"
+  dependencies:
+    whatwg-url: ^5.0.0
+  peerDependencies:
+    encoding: ^0.1.0
+  peerDependenciesMeta:
+    encoding:
+      optional: true
+  checksum: 8d816ffd1ee22cab8301c7756ef04f3437f18dace86a1dae22cf81db8ef29c0bf6655f3215cb0cdb22b420b6fe141e64b26905e7f33f9377a7fa59135ea3e10b
+  languageName: node
+  linkType: hard
+
 "node-gyp-build@npm:^4.2.1":
   version: 4.3.0
   resolution: "node-gyp-build@npm:4.3.0"
@@ -22473,6 +22536,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"npmlog@npm:^5.0.1":
+  version: 5.0.1
+  resolution: "npmlog@npm:5.0.1"
+  dependencies:
+    are-we-there-yet: ^2.0.0
+    console-control-strings: ^1.1.0
+    gauge: ^3.0.0
+    set-blocking: ^2.0.0
+  checksum: 516b2663028761f062d13e8beb3f00069c5664925871a9b57989642ebe09f23ab02145bf3ab88da7866c4e112cafff72401f61a672c7c8a20edc585a7016ef5f
+  languageName: node
+  linkType: hard
+
 "npmlog@npm:^6.0.0":
   version: 6.0.0
   resolution: "npmlog@npm:6.0.0"
@@ -28646,7 +28721,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"tar@npm:^6.0.2, tar@npm:^6.0.5, tar@npm:^6.1.2":
+"tar@npm:^6.0.2, tar@npm:^6.0.5, tar@npm:^6.1.11, tar@npm:^6.1.2":
   version: 6.1.11
   resolution: "tar@npm:6.1.11"
   dependencies: