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(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 { + 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) {