feat(editor): Add context menu to canvas v2 (no-changelog) (#10088)
This commit is contained in:
@@ -56,90 +56,97 @@ describe('useContextMenu', () => {
|
||||
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();
|
||||
const { open, close, isOpen, actions, position, target, targetNodeIds } = useContextMenu();
|
||||
expect(isOpen.value).toBe(false);
|
||||
expect(actions.value).toEqual([]);
|
||||
expect(position.value).toEqual([0, 0]);
|
||||
expect(targetNodes.value).toEqual([]);
|
||||
expect(targetNodeIds.value).toEqual([]);
|
||||
|
||||
open(mockEvent);
|
||||
const nodeIds = selectedNodes.map((n) => n.id);
|
||||
open(mockEvent, { source: 'canvas', nodeIds });
|
||||
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);
|
||||
expect(target.value).toEqual({ source: 'canvas', nodeIds });
|
||||
expect(targetNodeIds.value).toEqual(nodeIds);
|
||||
|
||||
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([]);
|
||||
expect(targetNodeIds.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return the correct actions when right clicking a sticky', () => {
|
||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
||||
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
||||
const sticky = nodeFactory({ type: STICKY_NODE_TYPE });
|
||||
open(mockEvent, { source: 'node-right-click', node: sticky });
|
||||
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(sticky);
|
||||
open(mockEvent, { source: 'node-right-click', nodeId: sticky.id });
|
||||
|
||||
expect(isOpen.value).toBe(true);
|
||||
expect(actions.value).toMatchSnapshot();
|
||||
expect(targetNodes.value).toEqual([sticky]);
|
||||
expect(targetNodeIds.value).toEqual([sticky.id]);
|
||||
});
|
||||
|
||||
it('should disable pinning for node that has other inputs then "main"', () => {
|
||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
||||
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
||||
const basicChain = nodeFactory({ type: BASIC_CHAIN_NODE_TYPE });
|
||||
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(basicChain);
|
||||
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue(['main', 'ai_languageModel']);
|
||||
open(mockEvent, { source: 'node-right-click', node: basicChain });
|
||||
open(mockEvent, { source: 'node-right-click', nodeId: basicChain.id });
|
||||
|
||||
expect(isOpen.value).toBe(true);
|
||||
expect(actions.value.find((action) => action.id === 'toggle_pin')?.disabled).toBe(true);
|
||||
expect(targetNodes.value).toEqual([basicChain]);
|
||||
expect(targetNodeIds.value).toEqual([basicChain.id]);
|
||||
});
|
||||
|
||||
it('should return the correct actions when right clicking a Node', () => {
|
||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
||||
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
||||
const node = nodeFactory();
|
||||
open(mockEvent, { source: 'node-right-click', node });
|
||||
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node);
|
||||
open(mockEvent, { source: 'node-right-click', nodeId: node.id });
|
||||
|
||||
expect(isOpen.value).toBe(true);
|
||||
expect(actions.value).toMatchSnapshot();
|
||||
expect(targetNodes.value).toEqual([node]);
|
||||
expect(targetNodeIds.value).toEqual([node.id]);
|
||||
});
|
||||
|
||||
it('should return the correct actions opening the menu from the button', () => {
|
||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
||||
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
||||
const node = nodeFactory();
|
||||
open(mockEvent, { source: 'node-button', node });
|
||||
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node);
|
||||
open(mockEvent, { source: 'node-button', nodeId: node.id });
|
||||
|
||||
expect(isOpen.value).toBe(true);
|
||||
expect(actions.value).toMatchSnapshot();
|
||||
expect(targetNodes.value).toEqual([node]);
|
||||
expect(targetNodeIds.value).toEqual([node.id]);
|
||||
});
|
||||
|
||||
describe('Read-only mode', () => {
|
||||
it('should return the correct actions when right clicking a sticky', () => {
|
||||
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(true);
|
||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
||||
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
||||
const sticky = nodeFactory({ type: STICKY_NODE_TYPE });
|
||||
open(mockEvent, { source: 'node-right-click', node: sticky });
|
||||
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(sticky);
|
||||
open(mockEvent, { source: 'node-right-click', nodeId: sticky.id });
|
||||
|
||||
expect(isOpen.value).toBe(true);
|
||||
expect(actions.value).toMatchSnapshot();
|
||||
expect(targetNodes.value).toEqual([sticky]);
|
||||
expect(targetNodeIds.value).toEqual([sticky.id]);
|
||||
});
|
||||
|
||||
it('should return the correct actions when right clicking a Node', () => {
|
||||
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(true);
|
||||
const { open, isOpen, actions, targetNodes } = useContextMenu();
|
||||
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
||||
const node = nodeFactory();
|
||||
open(mockEvent, { source: 'node-right-click', node });
|
||||
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node);
|
||||
open(mockEvent, { source: 'node-right-click', nodeId: node.id });
|
||||
|
||||
expect(isOpen.value).toBe(true);
|
||||
expect(actions.value).toMatchSnapshot();
|
||||
expect(targetNodes.value).toEqual([node]);
|
||||
expect(targetNodeIds.value).toEqual([node.id]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { renderComponent } from '@/__tests__/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { useKeybindings } from '../useKeybindings';
|
||||
|
||||
const renderTestComponent = async (...args: Parameters<typeof useKeybindings>) => {
|
||||
return renderComponent(
|
||||
defineComponent({
|
||||
setup() {
|
||||
useKeybindings(...args);
|
||||
return () => h('div', [h('input')]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe('useKeybindings', () => {
|
||||
it('should trigger case-insensitive keyboard shortcuts', async () => {
|
||||
const saveSpy = vi.fn();
|
||||
const saveAllSpy = vi.fn();
|
||||
await renderTestComponent({ Ctrl_s: saveSpy, ctrl_Shift_S: saveAllSpy });
|
||||
await userEvent.keyboard('{Control>}s');
|
||||
expect(saveSpy).toHaveBeenCalled();
|
||||
expect(saveAllSpy).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.keyboard('{Control>}{Shift>}s');
|
||||
expect(saveAllSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not trigger shortcuts when an input element has focus', async () => {
|
||||
const saveSpy = vi.fn();
|
||||
const saveAllSpy = vi.fn();
|
||||
const { getByRole } = await renderTestComponent({ Ctrl_s: saveSpy, ctrl_Shift_S: saveAllSpy });
|
||||
|
||||
getByRole('textbox').focus();
|
||||
|
||||
await userEvent.keyboard('{Control>}s');
|
||||
await userEvent.keyboard('{Control>}{Shift>}s');
|
||||
|
||||
expect(saveSpy).not.toHaveBeenCalled();
|
||||
expect(saveAllSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,6 @@
|
||||
* @TODO Remove this notice when Canvas V2 is the only one in use
|
||||
*/
|
||||
|
||||
import { CanvasConnectionMode } from '@/types';
|
||||
import type { CanvasConnectionCreateData, CanvasNode, CanvasConnection } from '@/types';
|
||||
import type {
|
||||
AddedNodesAndConnections,
|
||||
INodeUi,
|
||||
@@ -13,6 +11,14 @@ import type {
|
||||
IWorkflowDb,
|
||||
XYPosition,
|
||||
} from '@/Interface';
|
||||
import { useDataSchema } from '@/composables/useDataSchema';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { usePinnedData, type PinDataSource } from '@/composables/usePinnedData';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import {
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
QUICKSTART_NOTE_NAME,
|
||||
@@ -20,11 +26,6 @@ import {
|
||||
UPDATE_WEBHOOK_ID_NODE_TYPES,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useHistoryStore } from '@/stores/history.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import {
|
||||
AddNodeCommand,
|
||||
MoveNodeCommand,
|
||||
@@ -32,7 +33,20 @@ import {
|
||||
RemoveNodeCommand,
|
||||
RenameNodeCommand,
|
||||
} from '@/models/history';
|
||||
import type { Connection } from '@vue-flow/core';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
import { useHistoryStore } from '@/stores/history.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { CanvasConnection, CanvasConnectionCreateData, CanvasNode } from '@/types';
|
||||
import { CanvasConnectionMode } from '@/types';
|
||||
import {
|
||||
createCanvasConnectionHandleString,
|
||||
getUniqueNodeName,
|
||||
@@ -40,10 +54,15 @@ import {
|
||||
mapLegacyConnectionsToCanvasConnections,
|
||||
parseCanvasConnectionHandleString,
|
||||
} from '@/utils/canvasUtilsV2';
|
||||
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||
import { isPresent } from '@/utils/typesUtils';
|
||||
import type { Connection } from '@vue-flow/core';
|
||||
import type {
|
||||
ConnectionTypes,
|
||||
IConnection,
|
||||
IConnections,
|
||||
INode,
|
||||
INodeConnections,
|
||||
INodeInputConfiguration,
|
||||
INodeOutputConfiguration,
|
||||
@@ -51,31 +70,14 @@ import type {
|
||||
INodeTypeNameVersion,
|
||||
ITelemetryTrackProperties,
|
||||
IWorkflowBase,
|
||||
Workflow,
|
||||
INode,
|
||||
NodeParameterValueType,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType, NodeHelpers, TelemetryHelpers } from 'n8n-workflow';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { Ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import type { useRouter } from 'vue-router';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { usePinnedData } from '@/composables/usePinnedData';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||
|
||||
type AddNodeData = Partial<INodeUi> & {
|
||||
type: string;
|
||||
@@ -218,6 +220,12 @@ export function useCanvasOperations({
|
||||
trackDeleteNode(id);
|
||||
}
|
||||
|
||||
function deleteNodes(ids: string[]) {
|
||||
historyStore.startRecordingUndo();
|
||||
ids.forEach((id) => deleteNode(id, { trackHistory: true, trackBulk: false }));
|
||||
historyStore.stopRecordingUndo();
|
||||
}
|
||||
|
||||
function revertDeleteNode(node: INodeUi) {
|
||||
workflowsStore.addNode(node);
|
||||
}
|
||||
@@ -284,16 +292,33 @@ export function useCanvasOperations({
|
||||
uiStore.lastSelectedNode = node.name;
|
||||
}
|
||||
|
||||
function toggleNodeDisabled(
|
||||
id: string,
|
||||
function toggleNodesDisabled(
|
||||
ids: string[],
|
||||
{ trackHistory = true }: { trackHistory?: boolean } = {},
|
||||
) {
|
||||
const node = workflowsStore.getNodeById(id);
|
||||
if (!node) {
|
||||
return;
|
||||
const nodes = ids.map((id) => workflowsStore.getNodeById(id)).filter(isPresent);
|
||||
nodeHelpers.disableNodes(nodes, trackHistory);
|
||||
}
|
||||
|
||||
function toggleNodesPinned(ids: string[], source: PinDataSource) {
|
||||
historyStore.startRecordingUndo();
|
||||
|
||||
const nodes = ids.map((id) => workflowsStore.getNodeById(id)).filter(isPresent);
|
||||
const nextStatePinned = nodes.some((node) => !workflowsStore.pinDataByNodeName(node.name));
|
||||
|
||||
for (const node of nodes) {
|
||||
const pinnedDataForNode = usePinnedData(node);
|
||||
if (nextStatePinned) {
|
||||
const dataToPin = useDataSchema().getInputDataWithPinned(node);
|
||||
if (dataToPin.length !== 0) {
|
||||
pinnedDataForNode.setData(dataToPin, source);
|
||||
}
|
||||
} else {
|
||||
pinnedDataForNode.unsetData(source);
|
||||
}
|
||||
}
|
||||
|
||||
nodeHelpers.disableNodes([node], trackHistory);
|
||||
historyStore.stopRecordingUndo();
|
||||
}
|
||||
|
||||
async function addNodes(
|
||||
@@ -1403,11 +1428,13 @@ export function useCanvasOperations({
|
||||
setNodeActive,
|
||||
setNodeActiveByName,
|
||||
setNodeSelected,
|
||||
toggleNodesDisabled,
|
||||
toggleNodesPinned,
|
||||
setNodeParameters,
|
||||
toggleNodeDisabled,
|
||||
renameNode,
|
||||
revertRenameNode,
|
||||
deleteNode,
|
||||
deleteNodes,
|
||||
revertDeleteNode,
|
||||
addConnections,
|
||||
createConnection,
|
||||
|
||||
@@ -9,12 +9,14 @@ import { computed, ref, watch } from 'vue';
|
||||
import { getMousePosition } from '../utils/nodeViewUtils';
|
||||
import { useI18n } from './useI18n';
|
||||
import { usePinnedData } from './usePinnedData';
|
||||
import { isPresent } from '../utils/typesUtils';
|
||||
|
||||
export type ContextMenuTarget =
|
||||
| { source: 'canvas' }
|
||||
| { source: 'node-right-click'; node: INode }
|
||||
| { source: 'node-button'; node: INode };
|
||||
export type ContextMenuActionCallback = (action: ContextMenuAction, targets: INode[]) => void;
|
||||
| { source: 'canvas'; nodeIds: string[] }
|
||||
| { source: 'node-right-click'; nodeId: string }
|
||||
| { source: 'node-button'; nodeId: string };
|
||||
export type ContextMenuActionCallback = (action: ContextMenuAction, nodeIds: string[]) => void;
|
||||
|
||||
export type ContextMenuAction =
|
||||
| 'open'
|
||||
| 'copy'
|
||||
@@ -32,7 +34,7 @@ export type ContextMenuAction =
|
||||
|
||||
const position = ref<XYPosition>([0, 0]);
|
||||
const isOpen = ref(false);
|
||||
const target = ref<ContextMenuTarget>({ source: 'canvas' });
|
||||
const target = ref<ContextMenuTarget>();
|
||||
const actions = ref<ActionDropdownItem[]>([]);
|
||||
const actionCallback = ref<ContextMenuActionCallback>(() => {});
|
||||
|
||||
@@ -48,22 +50,17 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
||||
() => 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];
|
||||
}
|
||||
const targetNodeIds = computed(() => {
|
||||
if (!isOpen.value || !target.value) return [];
|
||||
|
||||
return [currentTarget.node];
|
||||
const currentTarget = target.value;
|
||||
return currentTarget.source === 'canvas' ? currentTarget.nodeIds : [currentTarget.nodeId];
|
||||
});
|
||||
|
||||
const targetNodes = computed(() =>
|
||||
targetNodeIds.value.map((nodeId) => workflowsStore.getNodeById(nodeId)).filter(isPresent),
|
||||
);
|
||||
|
||||
const canAddNodeOfType = (nodeType: INodeTypeDescription) => {
|
||||
const sameTypeNodes = workflowsStore.allNodes.filter((n) => n.type === nodeType.name);
|
||||
return nodeType.maxNodes === undefined || sameTypeNodes.length < nodeType.maxNodes;
|
||||
@@ -80,17 +77,18 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
||||
const hasPinData = (node: INode): boolean => {
|
||||
return !!workflowsStore.pinDataByNodeName(node.name);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
target.value = { source: 'canvas' };
|
||||
target.value = undefined;
|
||||
isOpen.value = false;
|
||||
actions.value = [];
|
||||
position.value = [0, 0];
|
||||
};
|
||||
|
||||
const open = (event: MouseEvent, menuTarget: ContextMenuTarget = { source: 'canvas' }) => {
|
||||
const open = (event: MouseEvent, menuTarget: ContextMenuTarget) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (isOpen.value && menuTarget.source === target.value.source) {
|
||||
if (isOpen.value && menuTarget.source === target.value?.source) {
|
||||
// Close context menu, let browser open native context menu
|
||||
close();
|
||||
return;
|
||||
@@ -225,8 +223,8 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
||||
}
|
||||
};
|
||||
|
||||
const _dispatchAction = (action: ContextMenuAction) => {
|
||||
actionCallback.value(action, targetNodes.value);
|
||||
const _dispatchAction = (a: ContextMenuAction) => {
|
||||
actionCallback.value(a, targetNodeIds.value);
|
||||
};
|
||||
|
||||
watch(
|
||||
@@ -241,7 +239,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
||||
position,
|
||||
target,
|
||||
actions,
|
||||
targetNodes,
|
||||
targetNodeIds,
|
||||
open,
|
||||
close,
|
||||
_dispatchAction,
|
||||
|
||||
77
packages/editor-ui/src/composables/useKeybindings.ts
Normal file
77
packages/editor-ui/src/composables/useKeybindings.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useActiveElement, useEventListener } from '@vueuse/core';
|
||||
import { useDeviceSupport } from 'n8n-design-system';
|
||||
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
|
||||
|
||||
type KeyMap = Record<string, (event: KeyboardEvent) => void>;
|
||||
|
||||
export const useKeybindings = (keymap: MaybeRefOrGetter<KeyMap>) => {
|
||||
const activeElement = useActiveElement();
|
||||
const { isCtrlKeyPressed } = useDeviceSupport();
|
||||
|
||||
const ignoreKeyPresses = computed(() => {
|
||||
if (!activeElement.value) return false;
|
||||
|
||||
const active = activeElement.value;
|
||||
const isInput = ['INPUT', 'TEXTAREA'].includes(active.tagName);
|
||||
const isContentEditable = active.closest('[contenteditable]') !== null;
|
||||
const isIgnoreClass = active.closest('.ignore-key-press') !== null;
|
||||
|
||||
return isInput || isContentEditable || isIgnoreClass;
|
||||
});
|
||||
|
||||
const normalizedKeymap = computed(() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(toValue(keymap))
|
||||
.map(([shortcut, handler]) => {
|
||||
const shortcuts = shortcut.split('|');
|
||||
return shortcuts.map((s) => [normalizeShortcutString(s), handler]);
|
||||
})
|
||||
.flat(),
|
||||
),
|
||||
);
|
||||
|
||||
function normalizeShortcutString(shortcut: string) {
|
||||
return shortcut
|
||||
.split(/[+_-]/)
|
||||
.map((key) => key.toLowerCase())
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join('+');
|
||||
}
|
||||
|
||||
function toShortcutString(event: KeyboardEvent) {
|
||||
const { shiftKey, altKey } = event;
|
||||
const ctrlKey = isCtrlKeyPressed(event);
|
||||
const keys = [event.key];
|
||||
const modifiers: string[] = [];
|
||||
|
||||
if (shiftKey) {
|
||||
modifiers.push('shift');
|
||||
}
|
||||
|
||||
if (ctrlKey) {
|
||||
modifiers.push('ctrl');
|
||||
}
|
||||
|
||||
if (altKey) {
|
||||
modifiers.push('alt');
|
||||
}
|
||||
|
||||
return normalizeShortcutString([...modifiers, ...keys].join('+'));
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (ignoreKeyPresses.value) return;
|
||||
|
||||
const shortcutString = toShortcutString(event);
|
||||
|
||||
const handler = normalizedKeymap.value[shortcutString];
|
||||
|
||||
if (handler) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener(document, 'keydown', onKeyDown);
|
||||
};
|
||||
Reference in New Issue
Block a user