2020-11-14 00:02:17 +00:00
|
|
|
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 { useEffect } from 'react';
|
|
|
|
|
import bridge from '../../../../../services/bridge';
|
2022-03-28 21:40:29 +05:30
|
|
|
import { ContextMenuOptions, ContextMenuItemType } from '../../../utils/contextMenuUtils';
|
|
|
|
|
import { menuItems } from '../../../utils/contextMenu';
|
2020-11-14 00:02:17 +00:00
|
|
|
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
|
|
|
|
import CommandService from '@joplin/lib/services/CommandService';
|
2025-04-09 06:40:11 -07:00
|
|
|
import type { ContextMenuParams, Event as ElectronEvent, MenuItemConstructorOptions } from 'electron';
|
2020-11-14 00:02:17 +00:00
|
|
|
|
2021-01-22 17:41:11 +00:00
|
|
|
import Resource from '@joplin/lib/models/Resource';
|
2023-02-15 10:59:32 -03:00
|
|
|
import { TinyMceEditorEvents } from './types';
|
2024-01-26 19:11:05 +00:00
|
|
|
import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from '../../../utils/types';
|
2024-11-08 07:32:05 -08:00
|
|
|
import { Editor } from 'tinymce';
|
2025-01-27 12:05:29 -08:00
|
|
|
import { EditDialogControl } from './useEditDialog';
|
|
|
|
|
import { Dispatch } from 'redux';
|
|
|
|
|
import { _ } from '@joplin/lib/locale';
|
2025-02-18 10:15:46 -08:00
|
|
|
import type { MenuItem as MenuItemType } from 'electron';
|
2025-05-19 15:00:15 -07:00
|
|
|
import isItemId from '@joplin/lib/models/utils/isItemId';
|
2020-11-14 00:02:17 +00:00
|
|
|
|
2024-11-08 07:32:05 -08:00
|
|
|
const Menu = bridge().Menu;
|
|
|
|
|
const MenuItem = bridge().MenuItem;
|
2020-11-14 00:02:17 +00:00
|
|
|
const menuUtils = new MenuUtils(CommandService.instance());
|
|
|
|
|
|
|
|
|
|
interface ContextMenuActionOptions {
|
|
|
|
|
current: ContextMenuOptions;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const contextMenuActionOptions: ContextMenuActionOptions = { current: null };
|
|
|
|
|
|
2025-01-27 12:05:29 -08:00
|
|
|
export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatch, htmlToMd: HtmlToMarkdownHandler, mdToHtml: MarkupToHtmlHandler, editDialog: EditDialogControl) {
|
2020-11-14 00:02:17 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!editor) return () => {};
|
|
|
|
|
|
2025-04-09 06:39:39 -07:00
|
|
|
const contextMenuItems = menuItems(dispatch);
|
2024-11-08 07:32:05 -08:00
|
|
|
const targetWindow = bridge().activeWindow();
|
2020-11-14 00:02:17 +00:00
|
|
|
|
2025-01-27 12:05:29 -08:00
|
|
|
const makeMainMenuItems = (element: Element) => {
|
2020-11-14 00:02:17 +00:00
|
|
|
let itemType: ContextMenuItemType = ContextMenuItemType.None;
|
|
|
|
|
let resourceId = '';
|
2025-06-06 02:32:35 -07:00
|
|
|
let linkUrl = null;
|
2020-11-14 00:02:17 +00:00
|
|
|
|
2025-05-19 15:00:15 -07:00
|
|
|
const pathToId = (path: string) => {
|
|
|
|
|
const id = Resource.pathToId(path);
|
|
|
|
|
return isItemId(id) ? id : '';
|
|
|
|
|
};
|
|
|
|
|
|
2020-11-14 00:02:17 +00:00
|
|
|
if (element.nodeName === 'IMG') {
|
|
|
|
|
itemType = ContextMenuItemType.Image;
|
2025-05-19 15:00:15 -07:00
|
|
|
resourceId = pathToId((element as HTMLImageElement).src);
|
2020-11-14 00:02:17 +00:00
|
|
|
} else if (element.nodeName === 'A') {
|
2025-05-19 15:00:15 -07:00
|
|
|
resourceId = pathToId((element as HTMLAnchorElement).href);
|
2020-11-14 00:02:17 +00:00
|
|
|
itemType = resourceId ? ContextMenuItemType.Resource : ContextMenuItemType.Link;
|
2025-06-06 02:32:35 -07:00
|
|
|
linkUrl = element.getAttribute('href') || '';
|
2020-11-14 00:02:17 +00:00
|
|
|
} else {
|
|
|
|
|
itemType = ContextMenuItemType.Text;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
contextMenuActionOptions.current = {
|
|
|
|
|
itemType,
|
|
|
|
|
resourceId,
|
2022-03-28 21:40:29 +05:30
|
|
|
filename: null,
|
|
|
|
|
mime: null,
|
2025-06-06 02:32:35 -07:00
|
|
|
linkToCopy: linkUrl,
|
|
|
|
|
linkToOpen: linkUrl,
|
2020-11-14 00:02:17 +00:00
|
|
|
textToCopy: null,
|
|
|
|
|
htmlToCopy: editor.selection ? editor.selection.getContent() : '',
|
|
|
|
|
insertContent: (content: string) => {
|
|
|
|
|
editor.insertContent(content);
|
|
|
|
|
},
|
|
|
|
|
isReadOnly: false,
|
2023-02-15 10:59:32 -03:00
|
|
|
fireEditorEvent: (event: TinyMceEditorEvents) => {
|
|
|
|
|
editor.fire(event);
|
|
|
|
|
},
|
2024-01-26 19:11:05 +00:00
|
|
|
htmlToMd,
|
|
|
|
|
mdToHtml,
|
2020-11-14 00:02:17 +00:00
|
|
|
};
|
|
|
|
|
|
2025-01-27 12:05:29 -08:00
|
|
|
const result = [];
|
2020-11-14 00:02:17 +00:00
|
|
|
for (const itemName in contextMenuItems) {
|
|
|
|
|
const item = contextMenuItems[itemName];
|
|
|
|
|
|
|
|
|
|
if (!item.isActive(itemType, contextMenuActionOptions.current)) continue;
|
|
|
|
|
|
2025-01-27 12:05:29 -08:00
|
|
|
result.push(new MenuItem({
|
2020-11-14 00:02:17 +00:00
|
|
|
label: item.label,
|
|
|
|
|
click: () => {
|
|
|
|
|
item.onAction(contextMenuActionOptions.current);
|
|
|
|
|
},
|
2024-11-08 07:32:05 -08:00
|
|
|
}));
|
2020-11-14 00:02:17 +00:00
|
|
|
}
|
2025-01-27 12:05:29 -08:00
|
|
|
return result;
|
|
|
|
|
};
|
2020-11-14 00:02:17 +00:00
|
|
|
|
2025-01-27 12:05:29 -08:00
|
|
|
const makeEditableMenuItems = (element: Element) => {
|
|
|
|
|
if (editDialog.isEditable(element)) {
|
|
|
|
|
return [
|
|
|
|
|
new MenuItem({
|
|
|
|
|
type: 'normal',
|
|
|
|
|
label: _('Edit'),
|
|
|
|
|
click: () => {
|
|
|
|
|
editDialog.editExisting(element);
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
new MenuItem({ type: 'separator' }),
|
|
|
|
|
];
|
2020-11-14 00:02:17 +00:00
|
|
|
}
|
2025-01-27 12:05:29 -08:00
|
|
|
return [];
|
|
|
|
|
};
|
2020-11-14 00:02:17 +00:00
|
|
|
|
2025-04-09 06:40:11 -07:00
|
|
|
const showContextMenu = (element: HTMLElement, misspelledWord: string|null, dictionarySuggestions: string[]) => {
|
2025-01-27 12:05:29 -08:00
|
|
|
const menu = new Menu();
|
2025-02-18 10:15:46 -08:00
|
|
|
const menuItems: MenuItemType[] = [];
|
|
|
|
|
const toMenuItems = (specs: MenuItemConstructorOptions[]) => {
|
|
|
|
|
return specs.map(spec => new MenuItem(spec));
|
|
|
|
|
};
|
2020-11-14 00:02:17 +00:00
|
|
|
|
2025-01-27 12:05:29 -08:00
|
|
|
menuItems.push(...makeEditableMenuItems(element));
|
|
|
|
|
menuItems.push(...makeMainMenuItems(element));
|
2025-04-09 06:40:11 -07:00
|
|
|
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(misspelledWord, dictionarySuggestions);
|
2025-02-18 10:15:46 -08:00
|
|
|
menuItems.push(
|
|
|
|
|
...toMenuItems(spellCheckerMenuItems),
|
|
|
|
|
);
|
|
|
|
|
menuItems.push(
|
|
|
|
|
...toMenuItems(menuUtils.pluginContextMenuItems(plugins, MenuItemLocation.EditorContextMenu)),
|
|
|
|
|
);
|
2025-01-27 12:05:29 -08:00
|
|
|
|
|
|
|
|
for (const item of menuItems) {
|
|
|
|
|
menu.append(item);
|
|
|
|
|
}
|
2024-11-08 07:32:05 -08:00
|
|
|
menu.popup({ window: targetWindow });
|
2025-04-09 06:40:11 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let lastTarget: EventTarget|null = null;
|
|
|
|
|
const onElectronContextMenu = (event: ElectronEvent, params: ContextMenuParams) => {
|
|
|
|
|
if (!lastTarget) return;
|
|
|
|
|
const element = lastTarget as HTMLElement;
|
|
|
|
|
lastTarget = null;
|
|
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
showContextMenu(element, params.misspelledWord, params.dictionarySuggestions);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onBrowserContextMenu = (event: PointerEvent) => {
|
|
|
|
|
const isKeyboard = event.buttons === 0;
|
|
|
|
|
if (isKeyboard) {
|
|
|
|
|
// Context menu events from the keyboard seem to always use <body> 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();
|
|
|
|
|
showContextMenu(lastTarget as HTMLElement, null, []);
|
|
|
|
|
lastTarget = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
2020-11-14 00:02:17 +00:00
|
|
|
|
2025-04-09 06:40:11 -07:00
|
|
|
targetWindow.webContents.prependListener('context-menu', onElectronContextMenu);
|
|
|
|
|
editor.on('contextmenu', onBrowserContextMenu);
|
2020-11-14 00:02:17 +00:00
|
|
|
|
|
|
|
|
return () => {
|
2025-04-09 06:40:11 -07:00
|
|
|
editor.off('contextmenu', onBrowserContextMenu);
|
2024-11-08 07:32:05 -08:00
|
|
|
if (!targetWindow.isDestroyed() && targetWindow?.webContents?.off) {
|
2025-04-09 06:40:11 -07:00
|
|
|
targetWindow.webContents.off('context-menu', onElectronContextMenu);
|
2020-11-14 00:02:17 +00:00
|
|
|
}
|
|
|
|
|
};
|
2025-01-27 12:05:29 -08:00
|
|
|
}, [editor, plugins, dispatch, htmlToMd, mdToHtml, editDialog]);
|
2020-11-14 00:02:17 +00:00
|
|
|
}
|