一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
185 行
6.5 KiB
TypeScript
185 行
6.5 KiB
TypeScript
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 <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();
|
|
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]);
|
|
}
|