From 7e06b31a5f3de8f073e7ef42be24e04899091486 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 10 Oct 2023 11:07:58 +0200 Subject: [PATCH] fix(editor): Implement canvas zoom UX improvements (#7376) - Fix pinch-to-zoom - Support command + scroll to zoom - Improve accuracy of zooming (scroll more = zoom more) - Zoom limits - Zoom relative to mouse position --- cypress/e2e/12-canvas.cy.ts | 27 ++++++- cypress/pages/workflow.ts | 29 ++++--- packages/editor-ui/package.json | 1 - packages/editor-ui/src/Interface.ts | 1 + .../src/composables/useCanvasMouseSelect.ts | 20 +---- .../src/declarations/normalize-wheel.d.ts | 10 --- packages/editor-ui/src/stores/canvas.store.ts | 65 ++++++++++----- packages/editor-ui/src/utils/canvasUtils.ts | 79 +++++++++++-------- packages/editor-ui/vite.config.ts | 1 - pnpm-lock.yaml | 7 -- 10 files changed, 141 insertions(+), 99 deletions(-) delete mode 100644 packages/editor-ui/src/declarations/normalize-wheel.d.ts diff --git a/cypress/e2e/12-canvas.cy.ts b/cypress/e2e/12-canvas.cy.ts index 01386d8e0c..75e05fc712 100644 --- a/cypress/e2e/12-canvas.cy.ts +++ b/cypress/e2e/12-canvas.cy.ts @@ -14,8 +14,11 @@ const WorkflowPage = new WorkflowPageClass(); const DEFAULT_ZOOM_FACTOR = 1; const ZOOM_IN_X1_FACTOR = 1.25; // Zoom in factor after one click const ZOOM_IN_X2_FACTOR = 1.5625; // Zoom in factor after two clicks -const ZOOM_OUT_X1_FACTOR = 0.8; -const ZOOM_OUT_X2_FACTOR = 0.64; +const ZOOM_OUT_X1_FACTOR = 0.75; +const ZOOM_OUT_X2_FACTOR = 0.5625; + +const PINCH_ZOOM_IN_FACTOR = 1.32; +const PINCH_ZOOM_OUT_FACTOR = 0.4752; const RENAME_NODE_NAME = 'Something else'; describe('Canvas Node Manipulation and Navigation', () => { @@ -203,6 +206,26 @@ describe('Canvas Node Manipulation and Navigation', () => { ); }); + it('should zoom using pinch to zoom', () => { + WorkflowPage.actions.pinchToZoom(2, 'zoomIn'); + WorkflowPage.getters + .nodeView() + .should( + 'have.css', + 'transform', + `matrix(${PINCH_ZOOM_IN_FACTOR}, 0, 0, ${PINCH_ZOOM_IN_FACTOR}, 0, 0)`, + ); + + WorkflowPage.actions.pinchToZoom(4, 'zoomOut'); + WorkflowPage.getters + .nodeView() + .should( + 'have.css', + 'transform', + `matrix(${PINCH_ZOOM_OUT_FACTOR}, 0, 0, ${PINCH_ZOOM_OUT_FACTOR}, 0, 0)`, + ); + }); + it('should reset zoom', () => { // Reset zoom should not appear until zoom level changed WorkflowPage.getters.resetZoomButton().should('not.exist'); diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index a0c990ba70..ec5c791b44 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -143,11 +143,14 @@ export class WorkflowPage extends BasePage { this.getters.nodeCreatorSearchBar().type('{enter}'); if (opts?.action) { // Expand actions category if it's collapsed - nodeCreator.getters.getCategoryItem('Actions').parent().then(($el) => { - if ($el.attr('data-category-collapsed') === 'true') { - nodeCreator.getters.getCategoryItem('Actions').click(); - } - }); + nodeCreator.getters + .getCategoryItem('Actions') + .parent() + .then(($el) => { + if ($el.attr('data-category-collapsed') === 'true') { + nodeCreator.getters.getCategoryItem('Actions').click(); + } + }); nodeCreator.getters.getCreatorItem(opts.action).click(); } else if (!opts?.keepNdvOpen) { cy.get('body').type('{esc}'); @@ -249,6 +252,17 @@ export class WorkflowPage extends BasePage { zoomToFit: () => { cy.getByTestId('zoom-to-fit').click(); }, + pinchToZoom: (steps: number, mode: 'zoomIn' | 'zoomOut' = 'zoomIn') => { + // Pinch-to-zoom simulates a 'wheel' event with ctrlKey: true (same as zooming by scrolling) + this.getters.nodeViewBackground().trigger('wheel', { + force: true, + bubbles: true, + ctrlKey: true, + pageX: cy.window().innerWidth / 2, + pageY: cy.window().innerHeight / 2, + deltaY: mode === 'zoomOut' ? 16 * steps : -16 * steps, + }); + }, hitUndo: () => { cy.get('body').type(META_KEY, { delay: 500, release: false }).type('z'); }, @@ -311,10 +325,7 @@ export class WorkflowPage extends BasePage { this.getters.stickies().dblclick().find('textarea').clear().type(content).type('{esc}'); }, shouldHaveWorkflowName: (name: string) => { - this.getters - .workflowNameInputContainer() - .invoke('attr', 'title') - .should('include', name); + this.getters.workflowNameInputContainer().invoke('attr', 'title').should('include', name); }, }; } diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 7b04b20b6e..4ba42cd562 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -64,7 +64,6 @@ "luxon": "^3.3.0", "n8n-design-system": "workspace:*", "n8n-workflow": "workspace:*", - "normalize-wheel": "^1.0.1", "pinia": "^2.1.6", "prettier": "^3.0.3", "qrcode.vue": "^3.3.4", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 28018f9256..ecd71cd7ad 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1258,6 +1258,7 @@ export interface IRestApiContext { export interface IZoomConfig { scale: number; offset: XYPosition; + origin?: XYPosition; } export interface IBounds { diff --git a/packages/editor-ui/src/composables/useCanvasMouseSelect.ts b/packages/editor-ui/src/composables/useCanvasMouseSelect.ts index 23d2c1a5a9..843cab09a9 100644 --- a/packages/editor-ui/src/composables/useCanvasMouseSelect.ts +++ b/packages/editor-ui/src/composables/useCanvasMouseSelect.ts @@ -3,12 +3,7 @@ import type { INodeUi, XYPosition } from '@/Interface'; import useDeviceSupport from './useDeviceSupport'; import { useUIStore } from '@/stores/ui.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; -import { - getMousePosition, - getRelativePosition, - SIDEBAR_WIDTH, - SIDEBAR_WIDTH_EXPANDED, -} from '@/utils/nodeViewUtils'; +import { getMousePosition, getRelativePosition } from '@/utils/nodeViewUtils'; import { ref, onMounted, computed } from 'vue'; import { useCanvasStore } from '@/stores/canvas.store'; @@ -179,18 +174,9 @@ export default function useCanvasMouseSelect() { } function getMousePositionWithinNodeView(event: MouseEvent | TouchEvent): XYPosition { - const [mouseX, mouseY] = getMousePosition(event); + const mousePosition = getMousePosition(event); - const sidebarWidth = canvasStore.isDemo - ? 0 - : uiStore.sidebarMenuCollapsed - ? SIDEBAR_WIDTH - : SIDEBAR_WIDTH_EXPANDED; - - const relativeX = mouseX - sidebarWidth; - const relativeY = canvasStore.isDemo - ? mouseY - : mouseY - uiStore.bannersHeight - uiStore.headerHeight; + const [relativeX, relativeY] = canvasStore.canvasPositionFromPagePosition(mousePosition); const nodeViewScale = canvasStore.nodeViewScale; const nodeViewOffsetPosition = uiStore.nodeViewOffsetPosition; diff --git a/packages/editor-ui/src/declarations/normalize-wheel.d.ts b/packages/editor-ui/src/declarations/normalize-wheel.d.ts deleted file mode 100644 index c6f249fef3..0000000000 --- a/packages/editor-ui/src/declarations/normalize-wheel.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -declare module 'normalize-wheel' { - function normalizeWheel(e: WheelEvent): { - spinX: number; - spinY: number; - pixelX: number; - pixelY: number; - }; - - export = normalizeWheel; -} diff --git a/packages/editor-ui/src/stores/canvas.store.ts b/packages/editor-ui/src/stores/canvas.store.ts index 08a4c7e913..2bbab329c3 100644 --- a/packages/editor-ui/src/stores/canvas.store.ts +++ b/packages/editor-ui/src/stores/canvas.store.ts @@ -1,7 +1,6 @@ import { computed, ref, watch } from 'vue'; import { defineStore } from 'pinia'; import { v4 as uuid } from 'uuid'; -import normalizeWheel from 'normalize-wheel'; import { useWorkflowsStore, useNodeTypesStore, @@ -10,7 +9,14 @@ import { useSourceControlStore, } from '@/stores'; import type { INodeUi, XYPosition } from '@/Interface'; -import { scaleBigger, scaleReset, scaleSmaller } from '@/utils'; +import { + applyScale, + getScaleFromWheelEventDelta, + normalizeWheelEventDelta, + scaleBigger, + scaleReset, + scaleSmaller, +} from '@/utils'; import { START_NODE_TYPE } from '@/constants'; import type { BeforeStartEventParams, @@ -31,6 +37,9 @@ import { CONNECTOR_PAINT_STYLE_DEFAULT, CONNECTOR_PAINT_STYLE_PRIMARY, CONNECTOR_ARROW_OVERLAYS, + getMousePosition, + SIDEBAR_WIDTH, + SIDEBAR_WIDTH_EXPANDED, } from '@/utils/nodeViewUtils'; import type { PointXY } from '@jsplumb/util'; @@ -64,7 +73,7 @@ export const useCanvasStore = defineStore('canvas', () => { }); const setRecenteredCanvasAddButtonPosition = (offset?: XYPosition) => { - const position = getMidCanvasPosition(nodeViewScale.value, offset || [0, 0]); + const position = getMidCanvasPosition(nodeViewScale.value, offset ?? [0, 0]); position[0] -= PLACEHOLDER_TRIGGER_NODE_SIZE / 2; position[1] -= PLACEHOLDER_TRIGGER_NODE_SIZE / 2; @@ -85,6 +94,21 @@ export const useCanvasStore = defineStore('canvas', () => { const getNodesWithPlaceholderNode = (): INodeUi[] => triggerNodes.value.length > 0 ? nodes.value : [getPlaceholderTriggerNodeUI(), ...nodes.value]; + const canvasPositionFromPagePosition = (position: XYPosition): XYPosition => { + const sidebarWidth = isDemo.value + ? 0 + : uiStore.sidebarMenuCollapsed + ? SIDEBAR_WIDTH + : SIDEBAR_WIDTH_EXPANDED; + + const relativeX = position[0] - sidebarWidth; + const relativeY = isDemo.value + ? position[1] + : position[1] - uiStore.bannersHeight - uiStore.headerHeight; + + return [relativeX, relativeY]; + }; + const setZoomLevel = (zoomLevel: number, offset: XYPosition) => { nodeViewScale.value = zoomLevel; jsPlumbInstanceRef.value?.setZoom(zoomLevel); @@ -95,6 +119,7 @@ export const useCanvasStore = defineStore('canvas', () => { const { scale, offset } = scaleReset({ scale: nodeViewScale.value, offset: uiStore.nodeViewOffsetPosition, + origin: canvasPositionFromPagePosition([window.innerWidth / 2, window.innerHeight / 2]), }); setZoomLevel(scale, offset); }; @@ -103,6 +128,7 @@ export const useCanvasStore = defineStore('canvas', () => { const { scale, offset } = scaleBigger({ scale: nodeViewScale.value, offset: uiStore.nodeViewOffsetPosition, + origin: canvasPositionFromPagePosition([window.innerWidth / 2, window.innerHeight / 2]), }); setZoomLevel(scale, offset); }; @@ -111,6 +137,7 @@ export const useCanvasStore = defineStore('canvas', () => { const { scale, offset } = scaleSmaller({ scale: nodeViewScale.value, offset: uiStore.nodeViewOffsetPosition, + origin: canvasPositionFromPagePosition([window.innerWidth / 2, window.innerHeight / 2]), }); setZoomLevel(scale, offset); }; @@ -125,29 +152,30 @@ export const useCanvasStore = defineStore('canvas', () => { setZoomLevel(zoomLevel, offset); }; - const wheelMoveWorkflow = (e: WheelEvent) => { - const normalized = normalizeWheel(e); + const wheelMoveWorkflow = (deltaX: number, deltaY: number, shiftKeyPressed = false) => { const offsetPosition = uiStore.nodeViewOffsetPosition; - const nodeViewOffsetPositionX = - offsetPosition[0] - (e.shiftKey ? normalized.pixelY : normalized.pixelX); - const nodeViewOffsetPositionY = - offsetPosition[1] - (e.shiftKey ? normalized.pixelX : normalized.pixelY); + const nodeViewOffsetPositionX = offsetPosition[0] - (shiftKeyPressed ? deltaY : deltaX); + const nodeViewOffsetPositionY = offsetPosition[1] - (shiftKeyPressed ? deltaX : deltaY); uiStore.nodeViewOffsetPosition = [nodeViewOffsetPositionX, nodeViewOffsetPositionY]; }; const wheelScroll = (e: WheelEvent) => { - //* Control + scroll zoom - if (e.ctrlKey) { - if (e.deltaY > 0) { - zoomOut(); - } else { - zoomIn(); - } + // Prevent browser back/forward gesture, default pinch to zoom etc. + e.preventDefault(); - e.preventDefault(); + const { deltaX, deltaY } = normalizeWheelEventDelta(e); + + if (e.ctrlKey || e.metaKey) { + const scaleFactor = getScaleFromWheelEventDelta(deltaY); + const { scale, offset } = applyScale(scaleFactor)({ + scale: nodeViewScale.value, + offset: uiStore.nodeViewOffsetPosition, + origin: canvasPositionFromPagePosition(getMousePosition(e)), + }); + setZoomLevel(scale, offset); return; } - wheelMoveWorkflow(e); + wheelMoveWorkflow(deltaX, deltaY, e.shiftKey); }; function initInstance(container: Element) { @@ -268,6 +296,7 @@ export const useCanvasStore = defineStore('canvas', () => { jsPlumbInstance, setRecenteredCanvasAddButtonPosition, getNodesWithPlaceholderNode, + canvasPositionFromPagePosition, setZoomLevel, resetZoom, zoomIn, diff --git a/packages/editor-ui/src/utils/canvasUtils.ts b/packages/editor-ui/src/utils/canvasUtils.ts index 37b42122fe..c10b8bbd37 100644 --- a/packages/editor-ui/src/utils/canvasUtils.ts +++ b/packages/editor-ui/src/utils/canvasUtils.ts @@ -13,47 +13,42 @@ import type { Route } from 'vue-router'; '@/utils'. */ -export const scaleSmaller = ({ scale, offset: [xOffset, yOffset] }: IZoomConfig): IZoomConfig => { - scale /= 1.25; - xOffset /= 1.25; - yOffset /= 1.25; - xOffset += window.innerWidth / 10; - yOffset += window.innerHeight / 10; +const SCALE_INCREASE_FACTOR = 1.25; +const SCALE_DECREASE_FACTOR = 0.75; +const MIN_SCALE = 0.2; +const MAX_SCALE = 5; - return { - scale, - offset: [xOffset, yOffset], - }; +const clamp = (min: number, max: number) => (num: number) => { + return Math.max(min, Math.min(max, num)); }; -export const scaleBigger = ({ scale, offset: [xOffset, yOffset] }: IZoomConfig): IZoomConfig => { - scale *= 1.25; - xOffset -= window.innerWidth / 10; - yOffset -= window.innerHeight / 10; - xOffset *= 1.25; - yOffset *= 1.25; +const clampScale = clamp(MIN_SCALE, MAX_SCALE); - return { - scale, - offset: [xOffset, yOffset], +export const applyScale = + (scale: number) => + ({ scale: initialScale, offset: [xOffset, yOffset], origin }: IZoomConfig): IZoomConfig => { + const newScale = clampScale(initialScale * scale); + const scaleChange = newScale / initialScale; + + const xOrigin = origin?.[0] ?? window.innerWidth / 2; + const yOrigin = origin?.[1] ?? window.innerHeight / 2; + + // Calculate the new offsets based on the zoom origin + xOffset = xOrigin - scaleChange * (xOrigin - xOffset); + yOffset = yOrigin - scaleChange * (yOrigin - yOffset); + + return { + scale: newScale, + offset: [xOffset, yOffset], + }; }; -}; + +export const scaleBigger = applyScale(SCALE_INCREASE_FACTOR); + +export const scaleSmaller = applyScale(SCALE_DECREASE_FACTOR); export const scaleReset = (config: IZoomConfig): IZoomConfig => { - if (config.scale > 1) { - // zoomed in - while (config.scale > 1) { - config = scaleSmaller(config); - } - } else { - while (config.scale < 1) { - config = scaleBigger(config); - } - } - - config.scale = 1; - - return config; + return applyScale(1 / config.scale)(config); }; export const closestNumberDivisibleBy = (inputNumber: number, divisibleBy: number): number => { @@ -112,3 +107,19 @@ export const getConnectionInfo = ( } return null; }; + +export const normalizeWheelEventDelta = (event: WheelEvent): { deltaX: number; deltaY: number } => { + const factorByMode: Record = { + [WheelEvent.DOM_DELTA_PIXEL]: 1, + [WheelEvent.DOM_DELTA_LINE]: 8, + [WheelEvent.DOM_DELTA_PAGE]: 24, + }; + + const factor = factorByMode[event.deltaMode] ?? 1; + + return { deltaX: event.deltaX * factor, deltaY: event.deltaY * factor }; +}; + +export const getScaleFromWheelEventDelta = (delta: number): number => { + return 1 - delta / 100; +}; diff --git a/packages/editor-ui/vite.config.ts b/packages/editor-ui/vite.config.ts index aaf4cd92ff..34ce3cd51d 100644 --- a/packages/editor-ui/vite.config.ts +++ b/packages/editor-ui/vite.config.ts @@ -12,7 +12,6 @@ const vendorChunks = ['vue', 'vue-router']; const n8nChunks = ['n8n-workflow', 'n8n-design-system']; const ignoreChunks = [ '@fontsource/open-sans', - 'normalize-wheel', '@vueuse/components', // TODO: remove this. It's currently required by xml2js in NodeErrors 'stream-browserify', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e701ba990..1f2a8330ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -899,9 +899,6 @@ importers: n8n-workflow: specifier: workspace:* version: link:../workflow - normalize-wheel: - specifier: ^1.0.1 - version: 1.0.1 pinia: specifier: ^2.1.6 version: 2.1.6(typescript@5.2.2)(vue@3.3.4) @@ -16975,10 +16972,6 @@ packages: resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} dev: false - /normalize-wheel@1.0.1: - resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==} - dev: false - /now-and-later@2.0.1: resolution: {integrity: sha512-KGvQ0cB70AQfg107Xvs/Fbu+dGmZoTRJp2TaPwcwQm3/7PteUyN2BCgk8KBMPGBUXZdVwyWS8fDCGFygBm19UQ==} engines: {node: '>= 0.10'}