feat(editor): Add node context menu (#7620)

This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`useContextMenu > should return the correct actions opening the menu from the button 1`] = `
|
||||
[
|
||||
{
|
||||
"id": "open",
|
||||
"label": "Open node...",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"↵",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "execute",
|
||||
"label": "Execute node",
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"id": "rename",
|
||||
"label": "Rename node",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"F2",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"id": "toggle_activation",
|
||||
"label": "Deactivate node",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"D",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"id": "toggle_pin",
|
||||
"label": "Pin node",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"p",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "copy",
|
||||
"label": "Copy node",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"C",
|
||||
],
|
||||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"id": "duplicate",
|
||||
"label": "Duplicate node",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"D",
|
||||
],
|
||||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
"id": "select_all",
|
||||
"label": "Select all",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"A",
|
||||
],
|
||||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"id": "deselect_all",
|
||||
"label": "Clear selection",
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"divided": true,
|
||||
"id": "delete",
|
||||
"label": "Delete node",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"Del",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`useContextMenu > should return the correct actions when right clicking a Node 1`] = `
|
||||
[
|
||||
{
|
||||
"id": "open",
|
||||
"label": "Open node...",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"↵",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "execute",
|
||||
"label": "Execute node",
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"id": "rename",
|
||||
"label": "Rename node",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"F2",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"id": "toggle_activation",
|
||||
"label": "Deactivate node",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"D",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"id": "toggle_pin",
|
||||
"label": "Pin node",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"p",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "copy",
|
||||
"label": "Copy node",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"C",
|
||||
],
|
||||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"id": "duplicate",
|
||||
"label": "Duplicate node",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"D",
|
||||
],
|
||||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
"id": "select_all",
|
||||
"label": "Select all",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"A",
|
||||
],
|
||||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"id": "deselect_all",
|
||||
"label": "Clear selection",
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"divided": true,
|
||||
"id": "delete",
|
||||
"label": "Delete node",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"Del",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`useContextMenu > should return the correct actions when right clicking a sticky 1`] = `
|
||||
[
|
||||
{
|
||||
"id": "open",
|
||||
"label": "Edit sticky note",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"↵",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "copy",
|
||||
"label": "Copy sticky note",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"C",
|
||||
],
|
||||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"id": "duplicate",
|
||||
"label": "Duplicate sticky note",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"D",
|
||||
],
|
||||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
"id": "select_all",
|
||||
"label": "Select all",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"A",
|
||||
],
|
||||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"id": "deselect_all",
|
||||
"label": "Clear selection",
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"divided": true,
|
||||
"id": "delete",
|
||||
"label": "Delete sticky note",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"Del",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`useContextMenu > should support opening and closing (default = right click on canvas) 1`] = `
|
||||
[
|
||||
{
|
||||
"disabled": true,
|
||||
"id": "toggle_activation",
|
||||
"label": "Deactivate 2 nodes",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"D",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"id": "toggle_pin",
|
||||
"label": "Pin 2 nodes",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"p",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "copy",
|
||||
"label": "Copy 2 nodes",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"C",
|
||||
],
|
||||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"id": "duplicate",
|
||||
"label": "Duplicate 2 nodes",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"D",
|
||||
],
|
||||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
"id": "select_all",
|
||||
"label": "Select all",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"A",
|
||||
],
|
||||
"metaKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"id": "deselect_all",
|
||||
"label": "Clear selection",
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"divided": true,
|
||||
"id": "delete",
|
||||
"label": "Delete 2 nodes",
|
||||
"shortcut": {
|
||||
"keys": [
|
||||
"Del",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
`;
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { useContextMenu } from '@/composables/useContextMenu';
|
||||
import { NO_OP_NODE_TYPE, STICKY_NODE_TYPE, STORES } from '@/constants';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
|
||||
const nodeFactory = (data: Partial<INodeUi> = {}): INodeUi => ({
|
||||
id: faker.string.uuid(),
|
||||
name: faker.word.words(3),
|
||||
parameters: {},
|
||||
position: [faker.number.int(), faker.number.int()],
|
||||
type: NO_OP_NODE_TYPE,
|
||||
typeVersion: 1,
|
||||
...data,
|
||||
});
|
||||
|
||||
describe('useContextMenu', () => {
|
||||
const nodes = [nodeFactory(), nodeFactory(), nodeFactory()];
|
||||
const selectedNodes = nodes.slice(0, 2);
|
||||
|
||||
beforeAll(() => {
|
||||
setActivePinia(
|
||||
createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.UI]: { selectedNodes },
|
||||
[STORES.WORKFLOWS]: { workflow: { nodes } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useContextMenu().close();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockEvent = new MouseEvent('contextmenu', { clientX: 500, clientY: 300 });
|
||||
|
||||
it('should support opening and closing (default = right click on canvas)', () => {
|
||||
const { open, close, isOpen, actions, position, target, targetNodes } = useContextMenu();
|
||||
expect(isOpen.value).toBe(false);
|
||||
expect(actions.value).toEqual([]);
|
||||
expect(position.value).toEqual([0, 0]);
|
||||
expect(targetNodes.value).toEqual([]);
|
||||
|
||||
open(mockEvent);
|
||||
expect(isOpen.value).toBe(true);
|
||||
expect(useContextMenu().isOpen.value).toEqual(true);
|
||||
expect(actions.value).toMatchSnapshot();
|
||||
expect(position.value).toEqual([500, 300]);
|
||||
expect(target.value).toEqual({ source: 'canvas' });
|
||||
expect(targetNodes.value).toEqual(selectedNodes);
|
||||
|
||||
close();
|
||||
expect(isOpen.value).toBe(false);
|
||||
expect(useContextMenu().isOpen.value).toEqual(false);
|
||||
expect(actions.value).toEqual([]);
|
||||
expect(position.value).toEqual([0, 0]);
|
||||
expect(targetNodes.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return the correct actions when right clicking a sticky', () => {
|
||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
||||
const sticky = nodeFactory({ type: STICKY_NODE_TYPE });
|
||||
open(mockEvent, { source: 'node-right-click', node: sticky });
|
||||
|
||||
expect(isOpen.value).toBe(true);
|
||||
expect(actions.value).toMatchSnapshot();
|
||||
expect(targetNodes.value).toEqual([sticky]);
|
||||
});
|
||||
|
||||
it('should return the correct actions when right clicking a Node', () => {
|
||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
||||
const node = nodeFactory();
|
||||
open(mockEvent, { source: 'node-right-click', node });
|
||||
|
||||
expect(isOpen.value).toBe(true);
|
||||
expect(actions.value).toMatchSnapshot();
|
||||
expect(targetNodes.value).toEqual([node]);
|
||||
});
|
||||
|
||||
it('should return the correct actions opening the menu from the button', () => {
|
||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
||||
const node = nodeFactory();
|
||||
open(mockEvent, { source: 'node-button', node });
|
||||
|
||||
expect(isOpen.value).toBe(true);
|
||||
expect(actions.value).toMatchSnapshot();
|
||||
expect(targetNodes.value).toEqual([node]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
export { default as useCanvasMouseSelect } from './useCanvasMouseSelect';
|
||||
export * from './useCopyToClipboard';
|
||||
export * from './useDebounce';
|
||||
export { default as useDeviceSupport } from './useDeviceSupport';
|
||||
export * from './useExternalHooks';
|
||||
export * from './useExternalSecretsProvider';
|
||||
export { default as useGlobalLinkActions } from './useGlobalLinkActions';
|
||||
@@ -15,3 +14,4 @@ export * from './useToast';
|
||||
export * from './useNodeSpecificationValues';
|
||||
export * from './useDataSchema';
|
||||
export * from './useExecutionDebugging';
|
||||
export * from './useContextMenu';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { INodeUi, XYPosition } from '@/Interface';
|
||||
|
||||
import useDeviceSupport from './useDeviceSupport';
|
||||
import { useDeviceSupport } from 'n8n-design-system';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { getMousePosition, getRelativePosition } from '@/utils/nodeViewUtils';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useContextMenu } from './useContextMenu';
|
||||
|
||||
interface ExtendedHTMLSpanElement extends HTMLSpanElement {
|
||||
x: number;
|
||||
@@ -20,6 +21,7 @@ export default function useCanvasMouseSelect() {
|
||||
const uiStore = useUIStore();
|
||||
const canvasStore = useCanvasStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const { isOpen: isContextMenuOpen } = useContextMenu();
|
||||
|
||||
function _setSelectBoxStyle(styles: Record<string, string>) {
|
||||
Object.assign(selectBox.value.style, styles);
|
||||
@@ -127,6 +129,9 @@ export default function useCanvasMouseSelect() {
|
||||
}
|
||||
|
||||
function mouseUpMouseSelect(e: MouseEvent) {
|
||||
// Ignore right-click
|
||||
if (e.button === 2 || isContextMenuOpen.value) return;
|
||||
|
||||
if (!selectActive.value) {
|
||||
if (isTouchDevice && e.target instanceof HTMLElement) {
|
||||
if (e.target && e.target.id.includes('node-view')) {
|
||||
@@ -156,7 +161,7 @@ export default function useCanvasMouseSelect() {
|
||||
_hideSelectBox();
|
||||
}
|
||||
function mouseDownMouseSelect(e: MouseEvent, moveButtonPressed: boolean) {
|
||||
if (isCtrlKeyPressed(e) || moveButtonPressed) {
|
||||
if (isCtrlKeyPressed(e) || moveButtonPressed || e.button === 2) {
|
||||
// We only care about it when the ctrl key is not pressed at the same time.
|
||||
// So we exit when it is pressed.
|
||||
return;
|
||||
|
||||
242
packages/editor-ui/src/composables/useContextMenu.ts
Normal file
242
packages/editor-ui/src/composables/useContextMenu.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import type { XYPosition } from '@/Interface';
|
||||
import {
|
||||
NOT_DUPLICATABE_NODE_TYPES,
|
||||
PIN_DATA_NODE_TYPES_DENYLIST,
|
||||
STICKY_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
import { useNodeTypesStore, useSourceControlStore, useUIStore, useWorkflowsStore } from '@/stores';
|
||||
import type { IActionDropdownItem } from 'n8n-design-system/src/components/N8nActionDropdown/ActionDropdown.vue';
|
||||
import type { INode, INodeTypeDescription } from 'n8n-workflow';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { getMousePosition } from '../utils/nodeViewUtils';
|
||||
import { useI18n } from './useI18n';
|
||||
import { useDataSchema } from './useDataSchema';
|
||||
|
||||
export type ContextMenuTarget =
|
||||
| { source: 'canvas' }
|
||||
| { source: 'node-right-click'; node: INode }
|
||||
| { source: 'node-button'; node: INode };
|
||||
export type ContextMenuAction =
|
||||
| 'open'
|
||||
| 'copy'
|
||||
| 'toggle_activation'
|
||||
| 'duplicate'
|
||||
| 'execute'
|
||||
| 'rename'
|
||||
| 'toggle_pin'
|
||||
| 'delete'
|
||||
| 'select_all'
|
||||
| 'deselect_all'
|
||||
| 'add_node'
|
||||
| 'add_sticky';
|
||||
|
||||
const position = ref<XYPosition>([0, 0]);
|
||||
const isOpen = ref(false);
|
||||
const target = ref<ContextMenuTarget>({ source: 'canvas' });
|
||||
const actions = ref<IActionDropdownItem[]>([]);
|
||||
|
||||
export const useContextMenu = () => {
|
||||
const uiStore = useUIStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const { getInputDataWithPinned } = useDataSchema();
|
||||
const i18n = useI18n();
|
||||
|
||||
const isReadOnly = computed(
|
||||
() => sourceControlStore.preferences.branchReadOnly || uiStore.isReadOnlyView,
|
||||
);
|
||||
|
||||
const targetNodes = computed(() => {
|
||||
if (!isOpen.value) return [];
|
||||
const selectedNodes = uiStore.selectedNodes.map((node) =>
|
||||
workflowsStore.getNodeByName(node.name),
|
||||
) as INode[];
|
||||
const currentTarget = target.value;
|
||||
if (currentTarget.source === 'canvas') {
|
||||
return selectedNodes;
|
||||
} else if (currentTarget.source === 'node-right-click') {
|
||||
const isNodeInSelection = selectedNodes.some((node) => node.name === currentTarget.node.name);
|
||||
return isNodeInSelection ? selectedNodes : [currentTarget.node];
|
||||
}
|
||||
|
||||
return [currentTarget.node];
|
||||
});
|
||||
|
||||
const canAddNodeOfType = (nodeType: INodeTypeDescription) => {
|
||||
const sameTypeNodes = workflowsStore.allNodes.filter((n) => n.type === nodeType.name);
|
||||
return nodeType.maxNodes === undefined || sameTypeNodes.length < nodeType.maxNodes;
|
||||
};
|
||||
|
||||
const canDuplicateNode = (node: INode): boolean => {
|
||||
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||
if (!nodeType) return false;
|
||||
if (NOT_DUPLICATABE_NODE_TYPES.includes(nodeType.name)) return false;
|
||||
|
||||
return canAddNodeOfType(nodeType);
|
||||
};
|
||||
|
||||
const canPinNode = (node: INode): boolean => {
|
||||
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||
const dataToPin = getInputDataWithPinned(node);
|
||||
if (!nodeType || dataToPin.length === 0) return false;
|
||||
return nodeType.outputs.length === 1 && !PIN_DATA_NODE_TYPES_DENYLIST.includes(node.type);
|
||||
};
|
||||
|
||||
const hasPinData = (node: INode): boolean => {
|
||||
return !!workflowsStore.pinDataByNodeName(node.name);
|
||||
};
|
||||
const close = () => {
|
||||
target.value = { source: 'canvas' };
|
||||
isOpen.value = false;
|
||||
actions.value = [];
|
||||
position.value = [0, 0];
|
||||
};
|
||||
|
||||
const open = (event: MouseEvent, menuTarget: ContextMenuTarget = { source: 'canvas' }) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (isOpen.value && menuTarget.source === target.value.source) {
|
||||
// Close context menu, let browser open native context menu
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
target.value = menuTarget;
|
||||
position.value = getMousePosition(event);
|
||||
isOpen.value = true;
|
||||
|
||||
const nodes = targetNodes.value;
|
||||
const onlyStickies = nodes.every((node) => node.type === STICKY_NODE_TYPE);
|
||||
const i18nOptions = {
|
||||
adjustToNumber: nodes.length,
|
||||
interpolate: {
|
||||
subject: onlyStickies
|
||||
? i18n.baseText('contextMenu.sticky', { adjustToNumber: nodes.length })
|
||||
: i18n.baseText('contextMenu.node', { adjustToNumber: nodes.length }),
|
||||
},
|
||||
};
|
||||
|
||||
const selectionActions = [
|
||||
{
|
||||
id: 'select_all',
|
||||
divided: true,
|
||||
label: i18n.baseText('contextMenu.selectAll'),
|
||||
shortcut: { metaKey: true, keys: ['A'] },
|
||||
disabled: nodes.length === workflowsStore.allNodes.length,
|
||||
},
|
||||
{
|
||||
id: 'deselect_all',
|
||||
label: i18n.baseText('contextMenu.deselectAll'),
|
||||
disabled: nodes.length === 0,
|
||||
},
|
||||
];
|
||||
|
||||
if (nodes.length === 0) {
|
||||
actions.value = [
|
||||
{
|
||||
id: 'add_node',
|
||||
shortcut: { keys: ['Tab'] },
|
||||
label: i18n.baseText('contextMenu.addNode'),
|
||||
disabled: isReadOnly.value,
|
||||
},
|
||||
{
|
||||
id: 'add_sticky',
|
||||
shortcut: { shiftKey: true, keys: ['s'] },
|
||||
label: i18n.baseText('contextMenu.addSticky'),
|
||||
disabled: isReadOnly.value,
|
||||
},
|
||||
...selectionActions,
|
||||
];
|
||||
} else {
|
||||
const menuActions: IActionDropdownItem[] = [
|
||||
!onlyStickies && {
|
||||
id: 'toggle_activation',
|
||||
label: nodes.every((node) => node.disabled)
|
||||
? i18n.baseText('contextMenu.activate', i18nOptions)
|
||||
: i18n.baseText('contextMenu.deactivate', i18nOptions),
|
||||
shortcut: { keys: ['D'] },
|
||||
disabled: isReadOnly.value,
|
||||
},
|
||||
!onlyStickies && {
|
||||
id: 'toggle_pin',
|
||||
label: nodes.every((node) => hasPinData(node))
|
||||
? i18n.baseText('contextMenu.unpin', i18nOptions)
|
||||
: i18n.baseText('contextMenu.pin', i18nOptions),
|
||||
shortcut: { keys: ['p'] },
|
||||
disabled: isReadOnly.value || !nodes.every(canPinNode),
|
||||
},
|
||||
{
|
||||
id: 'copy',
|
||||
label: i18n.baseText('contextMenu.copy', i18nOptions),
|
||||
shortcut: { metaKey: true, keys: ['C'] },
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: i18n.baseText('contextMenu.duplicate', i18nOptions),
|
||||
shortcut: { metaKey: true, keys: ['D'] },
|
||||
disabled: isReadOnly.value || !nodes.every(canDuplicateNode),
|
||||
},
|
||||
...selectionActions,
|
||||
{
|
||||
id: 'delete',
|
||||
divided: true,
|
||||
label: i18n.baseText('contextMenu.delete', i18nOptions),
|
||||
shortcut: { keys: ['Del'] },
|
||||
disabled: isReadOnly.value,
|
||||
},
|
||||
].filter(Boolean) as IActionDropdownItem[];
|
||||
|
||||
if (nodes.length === 1) {
|
||||
const singleNodeActions = onlyStickies
|
||||
? [
|
||||
{
|
||||
id: 'open',
|
||||
label: i18n.baseText('contextMenu.editSticky'),
|
||||
shortcut: { keys: ['↵'] },
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: 'open',
|
||||
label: i18n.baseText('contextMenu.open'),
|
||||
shortcut: { keys: ['↵'] },
|
||||
},
|
||||
{
|
||||
id: 'execute',
|
||||
label: i18n.baseText('contextMenu.execute'),
|
||||
},
|
||||
{
|
||||
id: 'rename',
|
||||
label: i18n.baseText('contextMenu.rename'),
|
||||
shortcut: { keys: ['F2'] },
|
||||
disabled: isReadOnly.value,
|
||||
},
|
||||
];
|
||||
// Add actions only available for a single node
|
||||
menuActions.unshift(...singleNodeActions);
|
||||
}
|
||||
|
||||
actions.value = menuActions;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => uiStore.nodeViewOffsetPosition,
|
||||
() => {
|
||||
close();
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
position,
|
||||
target,
|
||||
actions,
|
||||
targetNodes,
|
||||
open,
|
||||
close,
|
||||
};
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface DeviceSupportHelpers {
|
||||
isTouchDevice: boolean;
|
||||
isMacOs: boolean;
|
||||
controlKeyCode: string;
|
||||
isCtrlKeyPressed: (e: MouseEvent | KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
export default function useDeviceSupportHelpers(): DeviceSupportHelpers {
|
||||
const isTouchDevice = ref('ontouchstart' in window || navigator.maxTouchPoints > 0);
|
||||
const userAgent = ref(navigator.userAgent.toLowerCase());
|
||||
const isMacOs = ref(
|
||||
userAgent.value.includes('macintosh') ||
|
||||
userAgent.value.includes('ipad') ||
|
||||
userAgent.value.includes('iphone') ||
|
||||
userAgent.value.includes('ipod'),
|
||||
);
|
||||
const controlKeyCode = ref(isMacOs.value ? 'Meta' : 'Control');
|
||||
|
||||
function isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean {
|
||||
if (isTouchDevice.value && e instanceof MouseEvent) {
|
||||
return true;
|
||||
}
|
||||
if (isMacOs.value) {
|
||||
return (e as KeyboardEvent).metaKey;
|
||||
}
|
||||
return (e as KeyboardEvent).ctrlKey;
|
||||
}
|
||||
|
||||
return {
|
||||
isTouchDevice: isTouchDevice.value,
|
||||
isMacOs: isMacOs.value,
|
||||
controlKeyCode: controlKeyCode.value,
|
||||
isCtrlKeyPressed,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useUIStore } from '@/stores/ui.store';
|
||||
|
||||
import { ref, onMounted, onUnmounted, nextTick, getCurrentInstance } from 'vue';
|
||||
import { useDebounceHelper } from './useDebounce';
|
||||
import useDeviceSupportHelpers from './useDeviceSupport';
|
||||
import { useDeviceSupport } from 'n8n-design-system';
|
||||
import { getNodeViewTab } from '@/utils';
|
||||
import type { Route } from 'vue-router';
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useHistoryHelper(activeRoute: Route) {
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const { callDebounced } = useDebounceHelper();
|
||||
const { isCtrlKeyPressed } = useDeviceSupportHelpers();
|
||||
const { isCtrlKeyPressed } = useDeviceSupport();
|
||||
|
||||
const isNDVOpen = ref<boolean>(ndvStore.activeNodeName !== null);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user