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

This commit is contained in:
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user