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
This commit is contained in:
Elias Meire 2023-10-10 11:07:58 +02:00 committed by GitHub
parent 0847623f85
commit 7e06b31a5f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 141 additions and 99 deletions

View file

@ -14,8 +14,11 @@ const WorkflowPage = new WorkflowPageClass();
const DEFAULT_ZOOM_FACTOR = 1; const DEFAULT_ZOOM_FACTOR = 1;
const ZOOM_IN_X1_FACTOR = 1.25; // Zoom in factor after one click 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_IN_X2_FACTOR = 1.5625; // Zoom in factor after two clicks
const ZOOM_OUT_X1_FACTOR = 0.8; const ZOOM_OUT_X1_FACTOR = 0.75;
const ZOOM_OUT_X2_FACTOR = 0.64; 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'; const RENAME_NODE_NAME = 'Something else';
describe('Canvas Node Manipulation and Navigation', () => { 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', () => { it('should reset zoom', () => {
// Reset zoom should not appear until zoom level changed // Reset zoom should not appear until zoom level changed
WorkflowPage.getters.resetZoomButton().should('not.exist'); WorkflowPage.getters.resetZoomButton().should('not.exist');

View file

@ -143,11 +143,14 @@ export class WorkflowPage extends BasePage {
this.getters.nodeCreatorSearchBar().type('{enter}'); this.getters.nodeCreatorSearchBar().type('{enter}');
if (opts?.action) { if (opts?.action) {
// Expand actions category if it's collapsed // Expand actions category if it's collapsed
nodeCreator.getters.getCategoryItem('Actions').parent().then(($el) => { nodeCreator.getters
if ($el.attr('data-category-collapsed') === 'true') { .getCategoryItem('Actions')
nodeCreator.getters.getCategoryItem('Actions').click(); .parent()
} .then(($el) => {
}); if ($el.attr('data-category-collapsed') === 'true') {
nodeCreator.getters.getCategoryItem('Actions').click();
}
});
nodeCreator.getters.getCreatorItem(opts.action).click(); nodeCreator.getters.getCreatorItem(opts.action).click();
} else if (!opts?.keepNdvOpen) { } else if (!opts?.keepNdvOpen) {
cy.get('body').type('{esc}'); cy.get('body').type('{esc}');
@ -249,6 +252,17 @@ export class WorkflowPage extends BasePage {
zoomToFit: () => { zoomToFit: () => {
cy.getByTestId('zoom-to-fit').click(); 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: () => { hitUndo: () => {
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('z'); 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}'); this.getters.stickies().dblclick().find('textarea').clear().type(content).type('{esc}');
}, },
shouldHaveWorkflowName: (name: string) => { shouldHaveWorkflowName: (name: string) => {
this.getters this.getters.workflowNameInputContainer().invoke('attr', 'title').should('include', name);
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('include', name);
}, },
}; };
} }

View file

@ -64,7 +64,6 @@
"luxon": "^3.3.0", "luxon": "^3.3.0",
"n8n-design-system": "workspace:*", "n8n-design-system": "workspace:*",
"n8n-workflow": "workspace:*", "n8n-workflow": "workspace:*",
"normalize-wheel": "^1.0.1",
"pinia": "^2.1.6", "pinia": "^2.1.6",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"qrcode.vue": "^3.3.4", "qrcode.vue": "^3.3.4",

View file

@ -1258,6 +1258,7 @@ export interface IRestApiContext {
export interface IZoomConfig { export interface IZoomConfig {
scale: number; scale: number;
offset: XYPosition; offset: XYPosition;
origin?: XYPosition;
} }
export interface IBounds { export interface IBounds {

View file

@ -3,12 +3,7 @@ import type { INodeUi, XYPosition } from '@/Interface';
import useDeviceSupport from './useDeviceSupport'; import useDeviceSupport from './useDeviceSupport';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { import { getMousePosition, getRelativePosition } from '@/utils/nodeViewUtils';
getMousePosition,
getRelativePosition,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_EXPANDED,
} from '@/utils/nodeViewUtils';
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useCanvasStore } from '@/stores/canvas.store'; import { useCanvasStore } from '@/stores/canvas.store';
@ -179,18 +174,9 @@ export default function useCanvasMouseSelect() {
} }
function getMousePositionWithinNodeView(event: MouseEvent | TouchEvent): XYPosition { function getMousePositionWithinNodeView(event: MouseEvent | TouchEvent): XYPosition {
const [mouseX, mouseY] = getMousePosition(event); const mousePosition = getMousePosition(event);
const sidebarWidth = canvasStore.isDemo const [relativeX, relativeY] = canvasStore.canvasPositionFromPagePosition(mousePosition);
? 0
: uiStore.sidebarMenuCollapsed
? SIDEBAR_WIDTH
: SIDEBAR_WIDTH_EXPANDED;
const relativeX = mouseX - sidebarWidth;
const relativeY = canvasStore.isDemo
? mouseY
: mouseY - uiStore.bannersHeight - uiStore.headerHeight;
const nodeViewScale = canvasStore.nodeViewScale; const nodeViewScale = canvasStore.nodeViewScale;
const nodeViewOffsetPosition = uiStore.nodeViewOffsetPosition; const nodeViewOffsetPosition = uiStore.nodeViewOffsetPosition;

View file

@ -1,10 +0,0 @@
declare module 'normalize-wheel' {
function normalizeWheel(e: WheelEvent): {
spinX: number;
spinY: number;
pixelX: number;
pixelY: number;
};
export = normalizeWheel;
}

View file

@ -1,7 +1,6 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import normalizeWheel from 'normalize-wheel';
import { import {
useWorkflowsStore, useWorkflowsStore,
useNodeTypesStore, useNodeTypesStore,
@ -10,7 +9,14 @@ import {
useSourceControlStore, useSourceControlStore,
} from '@/stores'; } from '@/stores';
import type { INodeUi, XYPosition } from '@/Interface'; 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 { START_NODE_TYPE } from '@/constants';
import type { import type {
BeforeStartEventParams, BeforeStartEventParams,
@ -31,6 +37,9 @@ import {
CONNECTOR_PAINT_STYLE_DEFAULT, CONNECTOR_PAINT_STYLE_DEFAULT,
CONNECTOR_PAINT_STYLE_PRIMARY, CONNECTOR_PAINT_STYLE_PRIMARY,
CONNECTOR_ARROW_OVERLAYS, CONNECTOR_ARROW_OVERLAYS,
getMousePosition,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_EXPANDED,
} from '@/utils/nodeViewUtils'; } from '@/utils/nodeViewUtils';
import type { PointXY } from '@jsplumb/util'; import type { PointXY } from '@jsplumb/util';
@ -64,7 +73,7 @@ export const useCanvasStore = defineStore('canvas', () => {
}); });
const setRecenteredCanvasAddButtonPosition = (offset?: XYPosition) => { 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[0] -= PLACEHOLDER_TRIGGER_NODE_SIZE / 2;
position[1] -= PLACEHOLDER_TRIGGER_NODE_SIZE / 2; position[1] -= PLACEHOLDER_TRIGGER_NODE_SIZE / 2;
@ -85,6 +94,21 @@ export const useCanvasStore = defineStore('canvas', () => {
const getNodesWithPlaceholderNode = (): INodeUi[] => const getNodesWithPlaceholderNode = (): INodeUi[] =>
triggerNodes.value.length > 0 ? nodes.value : [getPlaceholderTriggerNodeUI(), ...nodes.value]; 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) => { const setZoomLevel = (zoomLevel: number, offset: XYPosition) => {
nodeViewScale.value = zoomLevel; nodeViewScale.value = zoomLevel;
jsPlumbInstanceRef.value?.setZoom(zoomLevel); jsPlumbInstanceRef.value?.setZoom(zoomLevel);
@ -95,6 +119,7 @@ export const useCanvasStore = defineStore('canvas', () => {
const { scale, offset } = scaleReset({ const { scale, offset } = scaleReset({
scale: nodeViewScale.value, scale: nodeViewScale.value,
offset: uiStore.nodeViewOffsetPosition, offset: uiStore.nodeViewOffsetPosition,
origin: canvasPositionFromPagePosition([window.innerWidth / 2, window.innerHeight / 2]),
}); });
setZoomLevel(scale, offset); setZoomLevel(scale, offset);
}; };
@ -103,6 +128,7 @@ export const useCanvasStore = defineStore('canvas', () => {
const { scale, offset } = scaleBigger({ const { scale, offset } = scaleBigger({
scale: nodeViewScale.value, scale: nodeViewScale.value,
offset: uiStore.nodeViewOffsetPosition, offset: uiStore.nodeViewOffsetPosition,
origin: canvasPositionFromPagePosition([window.innerWidth / 2, window.innerHeight / 2]),
}); });
setZoomLevel(scale, offset); setZoomLevel(scale, offset);
}; };
@ -111,6 +137,7 @@ export const useCanvasStore = defineStore('canvas', () => {
const { scale, offset } = scaleSmaller({ const { scale, offset } = scaleSmaller({
scale: nodeViewScale.value, scale: nodeViewScale.value,
offset: uiStore.nodeViewOffsetPosition, offset: uiStore.nodeViewOffsetPosition,
origin: canvasPositionFromPagePosition([window.innerWidth / 2, window.innerHeight / 2]),
}); });
setZoomLevel(scale, offset); setZoomLevel(scale, offset);
}; };
@ -125,29 +152,30 @@ export const useCanvasStore = defineStore('canvas', () => {
setZoomLevel(zoomLevel, offset); setZoomLevel(zoomLevel, offset);
}; };
const wheelMoveWorkflow = (e: WheelEvent) => { const wheelMoveWorkflow = (deltaX: number, deltaY: number, shiftKeyPressed = false) => {
const normalized = normalizeWheel(e);
const offsetPosition = uiStore.nodeViewOffsetPosition; const offsetPosition = uiStore.nodeViewOffsetPosition;
const nodeViewOffsetPositionX = const nodeViewOffsetPositionX = offsetPosition[0] - (shiftKeyPressed ? deltaY : deltaX);
offsetPosition[0] - (e.shiftKey ? normalized.pixelY : normalized.pixelX); const nodeViewOffsetPositionY = offsetPosition[1] - (shiftKeyPressed ? deltaX : deltaY);
const nodeViewOffsetPositionY =
offsetPosition[1] - (e.shiftKey ? normalized.pixelX : normalized.pixelY);
uiStore.nodeViewOffsetPosition = [nodeViewOffsetPositionX, nodeViewOffsetPositionY]; uiStore.nodeViewOffsetPosition = [nodeViewOffsetPositionX, nodeViewOffsetPositionY];
}; };
const wheelScroll = (e: WheelEvent) => { const wheelScroll = (e: WheelEvent) => {
//* Control + scroll zoom // Prevent browser back/forward gesture, default pinch to zoom etc.
if (e.ctrlKey) { e.preventDefault();
if (e.deltaY > 0) {
zoomOut();
} else {
zoomIn();
}
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; return;
} }
wheelMoveWorkflow(e); wheelMoveWorkflow(deltaX, deltaY, e.shiftKey);
}; };
function initInstance(container: Element) { function initInstance(container: Element) {
@ -268,6 +296,7 @@ export const useCanvasStore = defineStore('canvas', () => {
jsPlumbInstance, jsPlumbInstance,
setRecenteredCanvasAddButtonPosition, setRecenteredCanvasAddButtonPosition,
getNodesWithPlaceholderNode, getNodesWithPlaceholderNode,
canvasPositionFromPagePosition,
setZoomLevel, setZoomLevel,
resetZoom, resetZoom,
zoomIn, zoomIn,

View file

@ -13,47 +13,42 @@ import type { Route } from 'vue-router';
'@/utils'. '@/utils'.
*/ */
export const scaleSmaller = ({ scale, offset: [xOffset, yOffset] }: IZoomConfig): IZoomConfig => { const SCALE_INCREASE_FACTOR = 1.25;
scale /= 1.25; const SCALE_DECREASE_FACTOR = 0.75;
xOffset /= 1.25; const MIN_SCALE = 0.2;
yOffset /= 1.25; const MAX_SCALE = 5;
xOffset += window.innerWidth / 10;
yOffset += window.innerHeight / 10;
return { const clamp = (min: number, max: number) => (num: number) => {
scale, return Math.max(min, Math.min(max, num));
offset: [xOffset, yOffset],
};
}; };
export const scaleBigger = ({ scale, offset: [xOffset, yOffset] }: IZoomConfig): IZoomConfig => { const clampScale = clamp(MIN_SCALE, MAX_SCALE);
scale *= 1.25;
xOffset -= window.innerWidth / 10;
yOffset -= window.innerHeight / 10;
xOffset *= 1.25;
yOffset *= 1.25;
return { export const applyScale =
scale, (scale: number) =>
offset: [xOffset, yOffset], ({ 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 => { export const scaleReset = (config: IZoomConfig): IZoomConfig => {
if (config.scale > 1) { return applyScale(1 / config.scale)(config);
// zoomed in
while (config.scale > 1) {
config = scaleSmaller(config);
}
} else {
while (config.scale < 1) {
config = scaleBigger(config);
}
}
config.scale = 1;
return config;
}; };
export const closestNumberDivisibleBy = (inputNumber: number, divisibleBy: number): number => { export const closestNumberDivisibleBy = (inputNumber: number, divisibleBy: number): number => {
@ -112,3 +107,19 @@ export const getConnectionInfo = (
} }
return null; return null;
}; };
export const normalizeWheelEventDelta = (event: WheelEvent): { deltaX: number; deltaY: number } => {
const factorByMode: Record<number, number> = {
[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;
};

View file

@ -12,7 +12,6 @@ const vendorChunks = ['vue', 'vue-router'];
const n8nChunks = ['n8n-workflow', 'n8n-design-system']; const n8nChunks = ['n8n-workflow', 'n8n-design-system'];
const ignoreChunks = [ const ignoreChunks = [
'@fontsource/open-sans', '@fontsource/open-sans',
'normalize-wheel',
'@vueuse/components', '@vueuse/components',
// TODO: remove this. It's currently required by xml2js in NodeErrors // TODO: remove this. It's currently required by xml2js in NodeErrors
'stream-browserify', 'stream-browserify',

View file

@ -899,9 +899,6 @@ importers:
n8n-workflow: n8n-workflow:
specifier: workspace:* specifier: workspace:*
version: link:../workflow version: link:../workflow
normalize-wheel:
specifier: ^1.0.1
version: 1.0.1
pinia: pinia:
specifier: ^2.1.6 specifier: ^2.1.6
version: 2.1.6(typescript@5.2.2)(vue@3.3.4) version: 2.1.6(typescript@5.2.2)(vue@3.3.4)
@ -16975,10 +16972,6 @@ packages:
resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
dev: false dev: false
/normalize-wheel@1.0.1:
resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==}
dev: false
/now-and-later@2.0.1: /now-and-later@2.0.1:
resolution: {integrity: sha512-KGvQ0cB70AQfg107Xvs/Fbu+dGmZoTRJp2TaPwcwQm3/7PteUyN2BCgk8KBMPGBUXZdVwyWS8fDCGFygBm19UQ==} resolution: {integrity: sha512-KGvQ0cB70AQfg107Xvs/Fbu+dGmZoTRJp2TaPwcwQm3/7PteUyN2BCgk8KBMPGBUXZdVwyWS8fDCGFygBm19UQ==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}