import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types'; import { PluginStates } from '@joplin/lib/services/plugins/reducer'; import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService'; import { useContext, useEffect } from 'react'; import bridge from '../../../../../services/bridge'; import { ContextMenuOptions, ContextMenuItemType, buildMenuItems } from '../../../utils/contextMenuUtils'; import { menuItems } from '../../../utils/contextMenu'; import MenuUtils from '@joplin/lib/services/commands/MenuUtils'; import CommandService from '@joplin/lib/services/CommandService'; import type { ContextMenuParams, Event as ElectronEvent, MenuItemConstructorOptions } from 'electron'; import Resource from '@joplin/lib/models/Resource'; import { TinyMceEditorEvents } from './types'; import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from '../../../utils/types'; import { Editor } from 'tinymce'; import { EditDialogControl } from './useEditDialog'; import { Dispatch } from 'redux'; import { _ } from '@joplin/lib/locale'; import type { MenuItem as MenuItemType } from 'electron'; import isItemId from '@joplin/lib/models/utils/isItemId'; import { WindowIdContext } from '../../../../NewWindowOrIFrame'; import Logger from '@joplin/utils/Logger'; const logger = Logger.create('useContextMenu'); const Menu = bridge().Menu; const MenuItem = bridge().MenuItem; const menuUtils = new MenuUtils(CommandService.instance()); interface ContextMenuActionOptions { current: ContextMenuOptions; } const contextMenuActionOptions: ContextMenuActionOptions = { current: null }; export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatch, htmlToMd: HtmlToMarkdownHandler, mdToHtml: MarkupToHtmlHandler, editDialog: EditDialogControl) { const windowId = useContext(WindowIdContext); useEffect(() => { if (!editor) return () => {}; const targetWindow = bridge().windowById(windowId); if (!targetWindow) return () => {}; const contextMenuItems = menuItems(dispatch); const makeMainMenuItems = async (element: Element) => { let itemType: ContextMenuItemType = ContextMenuItemType.None; let resourceId = ''; let linkUrl = null; const pathToId = (path: string) => { const id = Resource.pathToId(path); return isItemId(id) ? id : ''; }; if (element.nodeName === 'IMG') { itemType = ContextMenuItemType.Image; resourceId = pathToId((element as HTMLImageElement).src); } else if (element.nodeName === 'A') { resourceId = pathToId((element as HTMLAnchorElement).href); itemType = resourceId ? ContextMenuItemType.Resource : ContextMenuItemType.Link; linkUrl = element.getAttribute('href') || ''; } else { itemType = ContextMenuItemType.Text; } contextMenuActionOptions.current = { itemType, resourceId, filename: null, mime: null, linkToCopy: linkUrl, linkToOpen: linkUrl, textToCopy: null, htmlToCopy: editor.selection ? editor.selection.getContent() : '', insertContent: (content: string) => { editor.insertContent(content); }, isReadOnly: false, fireEditorEvent: (event: TinyMceEditorEvents) => { editor.fire(event); }, htmlToMd, mdToHtml, }; return buildMenuItems(contextMenuItems, contextMenuActionOptions.current); }; const makeEditableMenuItems = (element: Element) => { if (editDialog.isEditable(element)) { return [ new MenuItem({ type: 'normal', label: _('Edit'), click: () => { editDialog.editExisting(element); }, }), new MenuItem({ type: 'separator' }), ]; } return []; }; const showContextMenu = async (element: HTMLElement, misspelledWord: string|null, dictionarySuggestions: string[]) => { const menu = new Menu(); const menuItems: MenuItemType[] = []; const toMenuItems = (specs: MenuItemConstructorOptions[]) => { return specs.map(spec => new MenuItem(spec)); }; menuItems.push(...makeEditableMenuItems(element)); menuItems.push(...(await makeMainMenuItems(element))); const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(misspelledWord, dictionarySuggestions); menuItems.push( ...toMenuItems(spellCheckerMenuItems), ); menuItems.push( ...toMenuItems(menuUtils.pluginContextMenuItems(plugins, MenuItemLocation.EditorContextMenu)), ); for (const item of menuItems) { menu.append(item); } menu.popup({ window: targetWindow }); }; let lastTarget: EventTarget|null = null; const onElectronContextMenu = async (event: ElectronEvent, params: ContextMenuParams) => { if (!lastTarget) return; const element = lastTarget as HTMLElement; lastTarget = null; event.preventDefault(); await showContextMenu(element, params.misspelledWord, params.dictionarySuggestions); }; const onBrowserContextMenu = async (event: PointerEvent) => { const isKeyboard = event.buttons === 0; if (isKeyboard) { // Context menu events from the keyboard seem to always use as the // event target. Since which context menu is displayed depends on what the // target is, using event.target for keyboard-triggered contextmenu events // would prevent keyboard-only users from accessing certain functionality. // To fix this, use the selection instead. lastTarget = editor.selection.getNode(); } else { lastTarget = event.target; } // Plugins in the Rich Text Editor (e.g. the mermaid renderer) can sometimes // create custom right-click events. These don't trigger the Electron 'context-menu' // event. As such, the context menu must be shown manually. const isFromPlugin = !event.isTrusted; if (isFromPlugin) { event.preventDefault(); await showContextMenu(lastTarget as HTMLElement, null, []); lastTarget = null; } }; try { targetWindow.webContents.prependListener('context-menu', onElectronContextMenu); } catch (error) { logger.error('Failed to register context menu', error); } editor.on('contextmenu', onBrowserContextMenu); return () => { editor.off('contextmenu', onBrowserContextMenu); try { if (!targetWindow.isDestroyed() && targetWindow?.webContents?.off) { targetWindow.webContents.off('context-menu', onElectronContextMenu); } } catch (error) { // This can happen if the window closes after the isDestroyed check, but before webContents.off // finishes running. logger.error('Error removing context menu listener', error); } }; }, [editor, plugins, dispatch, htmlToMd, mdToHtml, editDialog, windowId]); }