From 6ca41ddf80d3f4f6d5588b5f875dee8cbaa64228 Mon Sep 17 00:00:00 2001
From: Laurent Cozic <laurent@cozic.net>
Date: Sat, 9 May 2020 19:18:41 +0100
Subject: [PATCH] Desktop: WYSIWYG: Enable context menu on resources, links and
 text

---
 .eslintignore                                 |   1 +
 .gitignore                                    |   1 +
 .../NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx   |  54 ++++++++
 .../gui/NoteEditor/utils/contextMenu.ts       | 115 ++++++++++++++++++
 .../gui/NoteEditor/utils/useMessageHandler.ts |  81 +-----------
 5 files changed, 177 insertions(+), 75 deletions(-)
 create mode 100644 ElectronClient/gui/NoteEditor/utils/contextMenu.ts

diff --git a/.eslintignore b/.eslintignore
index ea5dc3d8c..28b8dfcbb 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -72,6 +72,7 @@ ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
 ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
 ElectronClient/gui/NoteEditor/NoteEditor.js
 ElectronClient/gui/NoteEditor/styles/index.js
+ElectronClient/gui/NoteEditor/utils/contextMenu.js
 ElectronClient/gui/NoteEditor/utils/index.js
 ElectronClient/gui/NoteEditor/utils/resourceHandling.js
 ElectronClient/gui/NoteEditor/utils/types.js
diff --git a/.gitignore b/.gitignore
index 269e4da07..2139aafdd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,6 +62,7 @@ ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
 ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
 ElectronClient/gui/NoteEditor/NoteEditor.js
 ElectronClient/gui/NoteEditor/styles/index.js
+ElectronClient/gui/NoteEditor/utils/contextMenu.js
 ElectronClient/gui/NoteEditor/utils/index.js
 ElectronClient/gui/NoteEditor/utils/resourceHandling.js
 ElectronClient/gui/NoteEditor/utils/types.js
diff --git a/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx b/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx
index cb42ca186..16baf848c 100644
--- a/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx
+++ b/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx
@@ -3,11 +3,13 @@ import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHand
 import { ScrollOptions, ScrollOptionTypes, EditorCommand, NoteBodyEditorProps } from '../../utils/types';
 import { resourcesStatus } from '../../utils/resourceHandling';
 import useScroll from './utils/useScroll';
+import { menuItems, ContextMenuOptions, ContextMenuItemType } from '../../utils/contextMenu';
 const { MarkupToHtml } = require('lib/joplin-renderer');
 const taboverride = require('taboverride');
 const { reg } = require('lib/registry.js');
 const { _ } = require('lib/locale');
 const BaseItem = require('lib/models/BaseItem');
+const Resource = require('lib/models/Resource');
 const { themeStyle, buildStyle } = require('../../../../theme.js');
 const { clipboard } = require('electron');
 
@@ -147,6 +149,8 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => {
 	const props_onMessage = useRef(null);
 	props_onMessage.current = props.onMessage;
 
+	const contextMenuActionOptions = useRef<ContextMenuOptions>(null);
+
 	const markupToHtml = useRef(null);
 	markupToHtml.current = props.markupToHtml;
 
@@ -434,7 +438,19 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => {
 
 		loadedAssetFiles_ = [];
 
+		function contextMenuItemNameWithNamespace(name:string) {
+			// For unknown reasons, TinyMCE converts all context menu names to
+			// lowercase when setting them in the init method, so we need to
+			// make them lowercase too, to make sure that the update() method
+			// addContextMenu is triggered.
+			return (`joplin${name}`).toLowerCase();
+		}
+
 		const loadEditor = async () => {
+			const contextMenuItems = menuItems();
+			const contextMenuItemNames = [];
+			for (const name in contextMenuItems) contextMenuItemNames.push(contextMenuItemNameWithNamespace(name));
+
 			const editors = await (window as any).tinymce.init({
 				selector: `#${rootIdRef.current}`,
 				width: '100%',
@@ -454,6 +470,7 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => {
 				language: props.locale,
 				toolbar: 'bold italic | link joplinInlineCode joplinCodeBlock joplinAttach | numlist bullist joplinChecklist | h1 h2 h3 hr blockquote table joplinInsertDateTime',
 				localization_function: _,
+				contextmenu: contextMenuItemNames.join(' '),
 				setup: (editor:any) => {
 
 					function openEditDialog(editable:any) {
@@ -573,6 +590,43 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => {
 						},
 					});
 
+					for (const itemName in contextMenuItems) {
+						const item = contextMenuItems[itemName];
+
+						const itemNameNS = contextMenuItemNameWithNamespace(itemName);
+
+						editor.ui.registry.addMenuItem(itemNameNS, {
+							text: item.label,
+							onAction: () => {
+								item.onAction(contextMenuActionOptions.current);
+							},
+						});
+
+						editor.ui.registry.addContextMenu(itemNameNS, {
+							update: function(element:any) {
+								let itemType:ContextMenuItemType = ContextMenuItemType.None;
+								let resourceId = '';
+								let textToCopy = '';
+
+								if (element.nodeName === 'IMG') {
+									itemType = ContextMenuItemType.Image;
+									resourceId = Resource.pathToId(element.src);
+								} else if (element.nodeName === 'A') {
+									resourceId = Resource.pathToId(element.href);
+									itemType = resourceId ? ContextMenuItemType.Resource : ContextMenuItemType.Link;
+								} else {
+									itemType = ContextMenuItemType.Text;
+									textToCopy = editor.selection.getContent({ format: 'text' });
+								}
+
+								contextMenuActionOptions.current = { itemType, resourceId, textToCopy };
+
+
+								return item.isActive(itemType) ? itemNameNS : '';
+							},
+						});
+					}
+
 					// TODO: remove event on unmount?
 					editor.on('DblClick', (event:any) => {
 						const editable = findEditableContainer(event.target);
diff --git a/ElectronClient/gui/NoteEditor/utils/contextMenu.ts b/ElectronClient/gui/NoteEditor/utils/contextMenu.ts
new file mode 100644
index 000000000..23f6c1d03
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/utils/contextMenu.ts
@@ -0,0 +1,115 @@
+const { bridge } = require('electron').remote.require('./bridge');
+const Menu = bridge().Menu;
+const MenuItem = bridge().MenuItem;
+const Resource = require('lib/models/Resource.js');
+const fs = require('fs-extra');
+const { clipboard } = require('electron');
+const { toSystemSlashes } = require('lib/path-utils');
+const { _ } = require('lib/locale');
+
+export enum ContextMenuItemType {
+	None = '',
+	Image = 'image',
+	Resource = 'resource',
+	Text = 'text',
+	Link = 'link',
+}
+
+export interface ContextMenuOptions {
+	itemType: ContextMenuItemType,
+	resourceId: string,
+	textToCopy: string,
+}
+
+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 };
+}
+
+export function menuItems():ContextMenuItems {
+	return {
+		open: {
+			label: _('Open...'),
+			onAction: async (options:ContextMenuOptions) => {
+				const { resourcePath } = await resourceInfo(options);
+				const ok = bridge().openExternal(`file://${resourcePath}`);
+				if (!ok) bridge().showErrorMessageBox(_('This file could not be opened: %s', resourcePath));
+			},
+			isActive: (itemType:ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
+		},
+		saveAs: {
+			label: _('Save as...'),
+			onAction: async (options:ContextMenuOptions) => {
+				const { resourcePath, resource } = await resourceInfo(options);
+				const filePath = bridge().showSaveDialog({
+					defaultPath: resource.filename ? resource.filename : resource.title,
+				});
+				if (!filePath) return;
+				await fs.copy(resourcePath, filePath);
+			},
+			isActive: (itemType:ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
+		},
+		revealInFolder: {
+			label: _('Reveal file in folder'),
+			onAction: async (options:ContextMenuOptions) => {
+				const { resourcePath } = await resourceInfo(options);
+				bridge().showItemInFolder(resourcePath);
+			},
+			isActive: (itemType:ContextMenuItemType) => 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));
+			},
+			isActive: (itemType:ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
+		},
+		copy: {
+			label: _('Copy'),
+			onAction: async (options:ContextMenuOptions) => {
+				clipboard.writeText(options.textToCopy);
+			},
+			isActive: (itemType:ContextMenuItemType) => itemType === ContextMenuItemType.Text,
+		},
+		copyLinkUrl: {
+			label: _('Copy Link Address'),
+			onAction: async (options:ContextMenuOptions) => {
+				clipboard.writeText(options.textToCopy);
+			},
+			isActive: (itemType:ContextMenuItemType) => itemType === ContextMenuItemType.Link,
+		},
+	};
+}
+
+export default async function contextMenu(options:ContextMenuOptions) {
+	const menu = new Menu();
+
+	const items = menuItems();
+
+	for (const itemKey in items) {
+		const item = items[itemKey];
+
+		if (!item.isActive(options.itemType)) continue;
+
+		menu.append(new MenuItem({
+			label: item.label,
+			click: () => {
+				item.onAction(options);
+			},
+		}));
+	}
+
+	return menu;
+}
diff --git a/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts b/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts
index 4bc337322..23b6cabf1 100644
--- a/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts
+++ b/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts
@@ -1,5 +1,6 @@
 import { useCallback } from 'react';
 import { FormNote } from './types';
+import contextMenu from './contextMenu';
 const BaseItem = require('lib/models/BaseItem');
 const { _ } = require('lib/locale');
 const BaseModel = require('lib/BaseModel.js');
@@ -8,11 +9,6 @@ const { bridge } = require('electron').remote.require('./bridge');
 const { urlDecode } = require('lib/string-utils');
 const urlUtils = require('lib/urlUtils');
 const ResourceFetcher = require('lib/services/ResourceFetcher.js');
-const Menu = bridge().Menu;
-const MenuItem = bridge().MenuItem;
-const fs = require('fs-extra');
-const { clipboard } = require('electron');
-const { toSystemSlashes } = require('lib/path-utils');
 const { reg } = require('lib/registry.js');
 
 export default function useMessageHandler(scrollWhenReady:any, setScrollWhenReady:Function, editorRef:any, setLocalSearchResultCount:Function, dispatch:Function, formNote:FormNote) {
@@ -40,76 +36,11 @@ export default function useMessageHandler(scrollWhenReady:any, setScrollWhenRead
 			if (s.length < 2) throw new Error(`Invalid message: ${msg}`);
 			ResourceFetcher.instance().markForDownload(s[1]);
 		} else if (msg === 'contextMenu') {
-			const itemType = arg0 && arg0.type;
-
-			const menu = new Menu();
-
-			if (itemType === 'image' || itemType === 'resource') {
-				const resource = await Resource.load(arg0.resourceId);
-				const resourcePath = Resource.fullPath(resource);
-
-				menu.append(
-					new MenuItem({
-						label: _('Open...'),
-						click: async () => {
-							const ok = bridge().openExternal(`file://${resourcePath}`);
-							if (!ok) bridge().showErrorMessageBox(_('This file could not be opened: %s', resourcePath));
-						},
-					})
-				);
-
-				menu.append(
-					new MenuItem({
-						label: _('Save as...'),
-						click: async () => {
-							const filePath = bridge().showSaveDialog({
-								defaultPath: resource.filename ? resource.filename : resource.title,
-							});
-							if (!filePath) return;
-							await fs.copy(resourcePath, filePath);
-						},
-					})
-				);
-
-				menu.append(
-					new MenuItem({
-						label: _('Reveal file in folder'),
-						click: async () => {
-							bridge().showItemInFolder(resourcePath);
-						},
-					})
-				);
-
-				menu.append(
-					new MenuItem({
-						label: _('Copy path to clipboard'),
-						click: async () => {
-							clipboard.writeText(toSystemSlashes(resourcePath));
-						},
-					})
-				);
-			} else if (itemType === 'text') {
-				menu.append(
-					new MenuItem({
-						label: _('Copy'),
-						click: async () => {
-							clipboard.writeText(arg0.textToCopy);
-						},
-					})
-				);
-			} else if (itemType === 'link') {
-				menu.append(
-					new MenuItem({
-						label: _('Copy Link Address'),
-						click: async () => {
-							clipboard.writeText(arg0.textToCopy);
-						},
-					})
-				);
-			} else {
-				reg.logger().error(`Unhandled item type: ${itemType}`);
-				return;
-			}
+			const menu = await contextMenu({
+				itemType: arg0 && arg0.type,
+				resourceId: arg0.resourceId,
+				textToCopy: arg0.textToCopy,
+			});
 
 			menu.popup(bridge().window());
 		} else if (msg.indexOf('joplin://') === 0) {