diff --git a/packages/editor-ui/src/composables/__tests__/useCanvasPanning.test.ts b/packages/editor-ui/src/composables/__tests__/useCanvasPanning.test.ts new file mode 100644 index 0000000000..e45a3e6463 --- /dev/null +++ b/packages/editor-ui/src/composables/__tests__/useCanvasPanning.test.ts @@ -0,0 +1,97 @@ +import type { Ref } from 'vue'; +import { ref } from 'vue'; +import { useCanvasPanning } from '@/composables/useCanvasPanning'; +import { getMousePosition } from '@/utils/nodeViewUtils'; +import { useUIStore } from '@/stores/ui.store'; + +vi.mock('@/stores/ui.store', () => ({ + useUIStore: vi.fn(() => ({ + nodeViewOffsetPosition: [0, 0], + nodeViewMoveInProgress: false, + isActionActive: vi.fn(), + })), +})); + +vi.mock('@/utils/nodeViewUtils', () => ({ + getMousePosition: vi.fn(() => [0, 0]), +})); + +describe('useCanvasPanning()', () => { + let element: HTMLElement; + let elementRef: Ref; + + beforeEach(() => { + element = document.createElement('div'); + element.id = 'node-view'; + elementRef = ref(element); + document.body.appendChild(element); + }); + + afterEach(() => { + document.body.removeChild(element); + }); + + describe('onMouseDown()', () => { + it('should attach mousemove event listener on mousedown', () => { + const addEventListenerSpy = vi.spyOn(element, 'addEventListener'); + const { onMouseDown, onMouseMove } = useCanvasPanning(elementRef); + const mouseEvent = new MouseEvent('mousedown', { button: 1 }); + + onMouseDown(mouseEvent, true); + + expect(addEventListenerSpy).toHaveBeenCalledWith('mousemove', onMouseMove); + }); + }); + + describe('onMouseMove()', () => { + it('should update node view position on mousemove', () => { + vi.mocked(getMousePosition).mockReturnValueOnce([0, 0]).mockReturnValueOnce([100, 100]); + const { onMouseDown, onMouseMove, moveLastPosition } = useCanvasPanning(elementRef); + + expect(moveLastPosition.value).toEqual([0, 0]); + + onMouseDown(new MouseEvent('mousedown', { button: 1 }), true); + onMouseMove(new MouseEvent('mousemove', { buttons: 4 })); + + expect(moveLastPosition.value).toEqual([100, 100]); + }); + }); + + describe('onMouseUp()', () => { + it('should remove mousemove event listener on mouseup', () => { + vi.mocked(useUIStore).mockReturnValueOnce({ + nodeViewOffsetPosition: [0, 0], + nodeViewMoveInProgress: true, + isActionActive: vi.fn(), + } as unknown as ReturnType); + + const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener'); + const { onMouseDown, onMouseMove, onMouseUp } = useCanvasPanning(elementRef); + + onMouseDown(new MouseEvent('mousedown', { button: 1 }), true); + onMouseUp(new MouseEvent('mouseup')); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('mousemove', onMouseMove); + }); + }); + + describe('panCanvas()', () => { + it('should update node view offset position correctly', () => { + vi.mocked(getMousePosition).mockReturnValueOnce([100, 100]); + + const { panCanvas } = useCanvasPanning(elementRef); + const [x, y] = panCanvas(new MouseEvent('mousemove')); + + expect(x).toEqual(100); + expect(y).toEqual(100); + }); + + it('should not update offset position if mouse is not moved', () => { + const { panCanvas } = useCanvasPanning(elementRef); + const [x, y] = panCanvas(new MouseEvent('mousemove')); + + expect(x).toEqual(0); + expect(y).toEqual(0); + }); + }); +}); diff --git a/packages/editor-ui/src/composables/useCanvasPanning.ts b/packages/editor-ui/src/composables/useCanvasPanning.ts new file mode 100644 index 0000000000..b11b92b1f9 --- /dev/null +++ b/packages/editor-ui/src/composables/useCanvasPanning.ts @@ -0,0 +1,126 @@ +import type { Ref } from 'vue'; +import { ref, unref } from 'vue'; + +import { getMousePosition } from '@/utils/nodeViewUtils'; +import { useUIStore } from '@/stores/ui.store'; +import { useDeviceSupport } from 'n8n-design-system'; +import { MOUSE_EVENT_BUTTON, MOUSE_EVENT_BUTTONS } from '@/constants'; + +/** + * Composable for handling canvas panning interactions - it facilitates the movement of the + * canvas element in response to mouse events + */ +export function useCanvasPanning( + elementRef: Ref, + options: { + // @TODO To be refactored (unref) when migrating NodeView to composition API + onMouseMoveEnd?: Ref void)>; + } = {}, +) { + const uiStore = useUIStore(); + const moveLastPosition = ref([0, 0]); + const deviceSupport = useDeviceSupport(); + + /** + * Updates the canvas offset position based on the mouse movement + */ + function panCanvas(e: MouseEvent) { + const offsetPosition = uiStore.nodeViewOffsetPosition; + + const [x, y] = getMousePosition(e); + + const nodeViewOffsetPositionX = offsetPosition[0] + (x - moveLastPosition.value[0]); + const nodeViewOffsetPositionY = offsetPosition[1] + (y - moveLastPosition.value[1]); + uiStore.nodeViewOffsetPosition = [nodeViewOffsetPositionX, nodeViewOffsetPositionY]; + + // Update the last position + moveLastPosition.value = [x, y]; + + return [nodeViewOffsetPositionX, nodeViewOffsetPositionY]; + } + + /** + * Initiates the panning process when specific conditions are met (middle mouse or ctrl key pressed) + */ + function onMouseDown(e: MouseEvent, moveButtonPressed: boolean) { + if (!(deviceSupport.isCtrlKeyPressed(e) || moveButtonPressed)) { + // We only care about it when the ctrl key is pressed at the same time. + // So we exit when it is not pressed. + return; + } + + if (uiStore.isActionActive('dragActive')) { + // If a node does currently get dragged we do not activate the selection + return; + } + + // Prevent moving canvas on anything but middle button + if (e.button !== MOUSE_EVENT_BUTTON.MIDDLE) { + uiStore.nodeViewMoveInProgress = true; + } + + const [x, y] = getMousePosition(e); + + moveLastPosition.value = [x, y]; + + const element = unref(elementRef); + element?.addEventListener('mousemove', onMouseMove); + } + + /** + * Ends the panning process and removes the mousemove event listener + */ + function onMouseUp(_: MouseEvent) { + if (!uiStore.nodeViewMoveInProgress) { + // If it is not active return directly. + // Else normal node dragging will not work. + return; + } + + const element = unref(elementRef); + element?.removeEventListener('mousemove', onMouseMove); + + uiStore.nodeViewMoveInProgress = false; + + // Nothing else to do. Simply leave the node view at the current offset + } + + /** + * Handles the actual movement of the canvas during a mouse drag, + * updating the position based on the current mouse position + */ + function onMouseMove(e: MouseEvent) { + const element = unref(elementRef); + if (e.target && !(element === e.target || element?.contains(e.target as Node))) { + return; + } + + if (uiStore.isActionActive('dragActive')) { + return; + } + + // Signal that moving canvas is active if middle button is pressed and mouse is moved + if (e.buttons === MOUSE_EVENT_BUTTONS.MIDDLE) { + uiStore.nodeViewMoveInProgress = true; + } + + if (e.buttons === MOUSE_EVENT_BUTTONS.NONE) { + // Mouse button is not pressed anymore so stop selection mode + // Happens normally when mouse leave the view pressed and then + // comes back unpressed. + const onMouseMoveEnd = unref(options.onMouseMoveEnd); + onMouseMoveEnd?.(e); + return; + } + + panCanvas(e); + } + + return { + moveLastPosition, + onMouseDown, + onMouseUp, + onMouseMove, + panCanvas, + }; +} diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 34c23c0d3c..251bbef520 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -703,3 +703,37 @@ export const TIME = { }; export const SUGGESTED_TEMPLATES_FLAG = 'SHOW_N8N_SUGGESTED_TEMPLATES'; + +/** + * Mouse button codes + */ + +/** + * Mapping for the MouseEvent.button property that indicates which button was pressed + * on the mouse to trigger the event. + * + * @docs https://www.w3.org/TR/uievents/#dom-mouseevent-button + */ +export const MOUSE_EVENT_BUTTON = { + PRIMARY: 0, + MIDDLE: 1, + SECONDARY: 2, + BROWSER_BACK: 3, + BROWSER_FORWARD: 4, +} as const; + +/** + * Mapping for the MouseEvent.buttons property that indicates which buttons are pressed + * on the mouse when a mouse event is triggered. If multiple buttons are pressed, + * the values are added together to produce a new number. + * + * @docs https://www.w3.org/TR/uievents/#dom-mouseevent-buttons + */ +export const MOUSE_EVENT_BUTTONS = { + NONE: 0, + PRIMARY: 1, + SECONDARY: 2, + MIDDLE: 4, + BROWSER_BACK: 8, + BROWSER_FORWARD: 16, +} as const; diff --git a/packages/editor-ui/src/mixins/moveNodeWorkflow.ts b/packages/editor-ui/src/mixins/moveNodeWorkflow.ts deleted file mode 100644 index 8e91af0ab0..0000000000 --- a/packages/editor-ui/src/mixins/moveNodeWorkflow.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { defineComponent } from 'vue'; -import { mapStores } from 'pinia'; - -import { getMousePosition } from '@/utils/nodeViewUtils'; -import { useUIStore } from '@/stores/ui.store'; -import { useDeviceSupport } from 'n8n-design-system'; - -export const moveNodeWorkflow = defineComponent({ - data() { - return { - moveLastPosition: [0, 0], - }; - }, - computed: { - ...mapStores(useUIStore), - }, - methods: { - moveWorkflow(e: MouseEvent) { - const offsetPosition = this.uiStore.nodeViewOffsetPosition; - - const [x, y] = getMousePosition(e); - - const nodeViewOffsetPositionX = offsetPosition[0] + (x - this.moveLastPosition[0]); - const nodeViewOffsetPositionY = offsetPosition[1] + (y - this.moveLastPosition[1]); - this.uiStore.nodeViewOffsetPosition = [nodeViewOffsetPositionX, nodeViewOffsetPositionY]; - - // Update the last position - this.moveLastPosition[0] = x; - this.moveLastPosition[1] = y; - }, - mouseDownMoveWorkflow(e: MouseEvent, moveButtonPressed: boolean) { - const deviceSupport = useDeviceSupport(); - - if (!deviceSupport.isCtrlKeyPressed(e) && !moveButtonPressed) { - // We only care about it when the ctrl key is pressed at the same time. - // So we exit when it is not pressed. - return; - } - - if (this.uiStore.isActionActive('dragActive')) { - // If a node does currently get dragged we do not activate the selection - return; - } - - // Prevent moving canvas on anything but middle button - if (e.button !== 1) { - this.uiStore.nodeViewMoveInProgress = true; - } - - const [x, y] = getMousePosition(e); - - this.moveLastPosition[0] = x; - this.moveLastPosition[1] = y; - - // @ts-ignore - this.$el.addEventListener('mousemove', this.mouseMoveNodeWorkflow); - }, - mouseUpMoveWorkflow(e: MouseEvent) { - if (!this.uiStore.nodeViewMoveInProgress) { - // If it is not active return directly. - // Else normal node dragging will not work. - return; - } - - // @ts-ignore - this.$el.removeEventListener('mousemove', this.mouseMoveNodeWorkflow); - - this.uiStore.nodeViewMoveInProgress = false; - - // Nothing else to do. Simply leave the node view at the current offset - }, - mouseMoveNodeWorkflow(e: MouseEvent) { - // @ts-ignore - if (e.target && !e.target.id.includes('node-view')) { - return; - } - - if (this.uiStore.isActionActive('dragActive')) { - return; - } - - // Signal that moving canvas is active if middle button is pressed and mouse is moved - if (e.buttons === 4) { - this.uiStore.nodeViewMoveInProgress = true; - } - - if (e.buttons === 0) { - // Mouse button is not pressed anymore so stop selection mode - // Happens normally when mouse leave the view pressed and then - // comes back unpressed. - // @ts-ignore - this.mouseUp(e); - return; - } - - this.moveWorkflow(e); - }, - }, -}); diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index eaf7dbc10f..dd8aa17b3a 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -1,5 +1,5 @@