1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Desktop: Resolves #6100: Allow saving a Mermaid graph as a PNG or SVG via context menu (#6126)

This commit is contained in:
asrient 2022-03-28 21:40:29 +05:30 committed by GitHub
parent 37d51c3b58
commit a62e1fba96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 321 additions and 49 deletions

View File

@ -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.d.ts
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js.map 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.d.ts
packages/app-desktop/gui/NoteEditor/utils/index.js packages/app-desktop/gui/NoteEditor/utils/index.js
packages/app-desktop/gui/NoteEditor/utils/index.js.map 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.d.ts
packages/app-desktop/gui/utils/loadScript.js packages/app-desktop/gui/utils/loadScript.js
packages/app-desktop/gui/utils/loadScript.js.map 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.d.ts
packages/app-desktop/plugins/GotoAnything.js packages/app-desktop/plugins/GotoAnything.js
packages/app-desktop/plugins/GotoAnything.js.map packages/app-desktop/plugins/GotoAnything.js.map

9
.gitignore vendored
View File

@ -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.d.ts
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js.map 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.d.ts
packages/app-desktop/gui/NoteEditor/utils/index.js packages/app-desktop/gui/NoteEditor/utils/index.js
packages/app-desktop/gui/NoteEditor/utils/index.js.map 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.d.ts
packages/app-desktop/gui/utils/loadScript.js packages/app-desktop/gui/utils/loadScript.js
packages/app-desktop/gui/utils/loadScript.js.map 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.d.ts
packages/app-desktop/plugins/GotoAnything.js packages/app-desktop/plugins/GotoAnything.js
packages/app-desktop/plugins/GotoAnything.js.map packages/app-desktop/plugins/GotoAnything.js.map

View File

@ -3,7 +3,8 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService'; import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
import { useEffect } from 'react'; import { useEffect } from 'react';
import bridge from '../../../../../services/bridge'; 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 MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService'; import CommandService from '@joplin/lib/services/CommandService';
import convertToScreenCoordinates from '../../../../utils/convertToScreenCoordinates'; import convertToScreenCoordinates from '../../../../utils/convertToScreenCoordinates';
@ -67,6 +68,8 @@ export default function(editor: any, plugins: PluginStates, dispatch: Function)
contextMenuActionOptions.current = { contextMenuActionOptions.current = {
itemType, itemType,
resourceId, resourceId,
filename: null,
mime: null,
linkToCopy, linkToCopy,
textToCopy: null, textToCopy: null,
htmlToCopy: editor.selection ? editor.selection.getContent() : '', htmlToCopy: editor.selection ? editor.selection.getContent() : '',

View File

@ -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 = [
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve">test</svg>',
'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<string> = [
'data:image/svg+xml;base64,error',
'invalid',
];
for (const testCase of testCases) {
await expect(svgUriToPng(document, testCase)).rejects.toBeInstanceOf(Error);
}
console.error = consoleError;
});
});

View File

@ -2,6 +2,7 @@ import ResourceEditWatcher from '@joplin/lib/services/ResourceEditWatcher/index'
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import { copyHtmlToClipboard } from './clipboardUtils'; import { copyHtmlToClipboard } from './clipboardUtils';
import bridge from '../../../services/bridge'; import bridge from '../../../services/bridge';
import { ContextMenuItemType, ContextMenuOptions, ContextMenuItems, resourceInfo, textToDataUri, svgUriToPng } from './contextMenuUtils';
const Menu = bridge().Menu; const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem; const MenuItem = bridge().MenuItem;
import Resource from '@joplin/lib/models/Resource'; import Resource from '@joplin/lib/models/Resource';
@ -10,43 +11,10 @@ import BaseModel from '@joplin/lib/BaseModel';
import { processPastedHtml } from './resourceHandling'; import { processPastedHtml } from './resourceHandling';
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types'; import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
const fs = require('fs-extra'); const fs = require('fs-extra');
const { writeFile } = require('fs-extra');
const { clipboard } = require('electron'); const { clipboard } = require('electron');
const { toSystemSlashes } = require('@joplin/lib/path-utils'); 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<any> {
const resource = options.resourceId ? await Resource.load(options.resourceId) : null;
const resourcePath = resource ? Resource.fullPath(resource) : '';
return { resource, resourcePath };
}
function handleCopyToClipboard(options: ContextMenuOptions) { function handleCopyToClipboard(options: ContextMenuOptions) {
if (options.textToCopy) { if (options.textToCopy) {
clipboard.writeText(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 = '') { export async function openItemById(itemId: string, dispatch: Function, hash: string = '') {
const item = await BaseItem.loadItemById(itemId); const item = await BaseItem.loadItemById(itemId);
@ -100,7 +74,7 @@ export function menuItems(dispatch: Function): ContextMenuItems {
onAction: async (options: ContextMenuOptions) => { onAction: async (options: ContextMenuOptions) => {
await openItemById(options.resourceId, dispatch); 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: { saveAs: {
label: _('Save as...'), label: _('Save as...'),
@ -112,7 +86,32 @@ export function menuItems(dispatch: Function): ContextMenuItems {
if (!filePath) return; if (!filePath) return;
await fs.copy(resourcePath, filePath); 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: { revealInFolder: {
label: _('Reveal file in folder'), label: _('Reveal file in folder'),
@ -120,13 +119,20 @@ export function menuItems(dispatch: Function): ContextMenuItems {
const { resourcePath } = await resourceInfo(options); const { resourcePath } = await resourceInfo(options);
bridge().showItemInFolder(resourcePath); 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: { copyPathToClipboard: {
label: _('Copy path to clipboard'), label: _('Copy path to clipboard'),
onAction: async (options: ContextMenuOptions) => { onAction: async (options: ContextMenuOptions) => {
let path = '';
if (options.textToCopy && options.mime) {
path = textToDataUri(options.textToCopy, options.mime);
} else {
const { resourcePath } = await resourceInfo(options); const { resourcePath } = await resourceInfo(options);
clipboard.writeText(toSystemSlashes(resourcePath)); if (resourcePath) path = toSystemSlashes(resourcePath);
}
if (!path) return;
clipboard.writeText(path);
}, },
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource, isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
}, },
@ -137,7 +143,7 @@ export function menuItems(dispatch: Function): ContextMenuItems {
const image = bridge().createImageFromPath(resourcePath); const image = bridge().createImageFromPath(resourcePath);
clipboard.writeImage(image); clipboard.writeImage(image);
}, },
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image, isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && itemType === ContextMenuItemType.Image,
}, },
cut: { cut: {
label: _('Cut'), label: _('Cut'),
@ -145,14 +151,14 @@ export function menuItems(dispatch: Function): ContextMenuItems {
handleCopyToClipboard(options); handleCopyToClipboard(options);
options.insertContent(''); 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: { copy: {
label: _('Copy'), label: _('Copy'),
onAction: async (options: ContextMenuOptions) => { onAction: async (options: ContextMenuOptions) => {
handleCopyToClipboard(options); handleCopyToClipboard(options);
}, },
isActive: (_itemType: ContextMenuItemType, options: ContextMenuOptions) => !!options.textToCopy || !!options.htmlToCopy, isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => itemType != ContextMenuItemType.Image && (!!options.textToCopy || !!options.htmlToCopy),
}, },
paste: { paste: {
label: _('Paste'), label: _('Paste'),
@ -184,7 +190,6 @@ export default async function contextMenu(options: ContextMenuOptions, dispatch:
const items = menuItems(dispatch); const items = menuItems(dispatch);
if (!('readyOnly' in options)) options.isReadOnly = true; if (!('readyOnly' in options)) options.isReadOnly = true;
for (const itemKey in items) { for (const itemKey in items) {
const item = items[itemKey]; const item = items[itemKey];

View File

@ -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<any> {
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<Uint8Array>((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;
});
};

View File

@ -35,6 +35,8 @@ export default function useMessageHandler(scrollWhenReady: any, setScrollWhenRea
const menu = await contextMenu({ const menu = await contextMenu({
itemType: arg0 && arg0.type, itemType: arg0 && arg0.type,
resourceId: arg0.resourceId, resourceId: arg0.resourceId,
filename: arg0.filename,
mime: arg0.mime,
textToCopy: arg0.textToCopy, textToCopy: arg0.textToCopy,
linkToCopy: arg0.linkToCopy || null, linkToCopy: arg0.linkToCopy || null,
htmlToCopy: '', htmlToCopy: '',

View File

@ -586,9 +586,24 @@
})); }));
document.addEventListener('contextmenu', webviewLib.logEnabledEventHandler(event => { document.addEventListener('contextmenu', webviewLib.logEnabledEventHandler(event => {
// To handle right clicks on resource icons
let element = event.target; let element = event.target;
// To handle right clicks on resource icons // Mermaid svgs are wrapped inside a <pre> 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')) element = element.parentElement;
if (element && element.getAttribute('data-resource-id')) { if (element && element.getAttribute('data-resource-id')) {

View File

@ -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);
}
}

View File

@ -116,6 +116,7 @@
"app-builder-bin": "^1.9.11", "app-builder-bin": "^1.9.11",
"babel-cli": "^6.26.0", "babel-cli": "^6.26.0",
"babel-preset-react": "^6.24.1", "babel-preset-react": "^6.24.1",
"canvas": "^2.9.0",
"electron": "14.1.0", "electron": "14.1.0",
"electron-builder": "^22.11.7", "electron-builder": "^22.11.7",
"electron-notarize": "^1.0.0", "electron-notarize": "^1.0.0",

View File

@ -2910,6 +2910,7 @@ __metadata:
async-mutex: ^0.1.3 async-mutex: ^0.1.3
babel-cli: ^6.26.0 babel-cli: ^6.26.0
babel-preset-react: ^6.24.1 babel-preset-react: ^6.24.1
canvas: ^2.9.0
codemirror: ^5.56.0 codemirror: ^5.56.0
color: ^3.1.2 color: ^3.1.2
compare-versions: ^3.2.1 compare-versions: ^3.2.1
@ -4339,6 +4340,25 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@mrmlnc/readdir-enhanced@npm:^2.2.1":
version: 2.2.1 version: 2.2.1
resolution: "@mrmlnc/readdir-enhanced@npm:2.2.1" resolution: "@mrmlnc/readdir-enhanced@npm:2.2.1"
@ -8915,6 +8935,18 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "capital-case@npm:^1.0.4":
version: 1.0.4 version: 1.0.4
resolution: "capital-case@npm:1.0.4" resolution: "capital-case@npm:1.0.4"
@ -14886,6 +14918,23 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "gauge@npm:^4.0.0":
version: 4.0.0 version: 4.0.0
resolution: "gauge@npm:4.0.0" resolution: "gauge@npm:4.0.0"
@ -20230,7 +20279,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"make-dir@npm:^3.0.0": "make-dir@npm:^3.0.0, make-dir@npm:^3.1.0":
version: 3.1.0 version: 3.1.0
resolution: "make-dir@npm:3.1.0" resolution: "make-dir@npm:3.1.0"
dependencies: dependencies:
@ -21751,7 +21800,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"nan@npm:^2.12.1": "nan@npm:^2.12.1, nan@npm:^2.15.0":
version: 2.15.0 version: 2.15.0
resolution: "nan@npm:2.15.0" resolution: "nan@npm:2.15.0"
dependencies: dependencies:
@ -22019,6 +22068,20 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "node-gyp-build@npm:^4.2.1":
version: 4.3.0 version: 4.3.0
resolution: "node-gyp-build@npm:4.3.0" resolution: "node-gyp-build@npm:4.3.0"
@ -22473,6 +22536,18 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "npmlog@npm:^6.0.0":
version: 6.0.0 version: 6.0.0
resolution: "npmlog@npm:6.0.0" resolution: "npmlog@npm:6.0.0"
@ -28646,7 +28721,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 6.1.11
resolution: "tar@npm:6.1.11" resolution: "tar@npm:6.1.11"
dependencies: dependencies: