mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
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:
parent
0847623f85
commit
7e06b31a5f
|
@ -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');
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -1258,6 +1258,7 @@ export interface IRestApiContext {
|
|||
export interface IZoomConfig {
|
||||
scale: number;
|
||||
offset: XYPosition;
|
||||
origin?: XYPosition;
|
||||
}
|
||||
|
||||
export interface IBounds {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
declare module 'normalize-wheel' {
|
||||
function normalizeWheel(e: WheelEvent): {
|
||||
spinX: number;
|
||||
spinY: number;
|
||||
pixelX: number;
|
||||
pixelY: number;
|
||||
};
|
||||
|
||||
export = normalizeWheel;
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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<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;
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'}
|
||||
|
|
Loading…
Reference in a new issue