diff --git a/diagrams-demo-gallery/demos/1_SimpleUsage.stories.tsx b/diagrams-demo-gallery/demos/1_SimpleUsage.stories.tsx index a4f0da4..4f30945 100644 --- a/diagrams-demo-gallery/demos/1_SimpleUsage.stories.tsx +++ b/diagrams-demo-gallery/demos/1_SimpleUsage.stories.tsx @@ -14,6 +14,7 @@ import demo_listeners from './demo-listeners'; import demo_zoom from './demo-zoom-to-fit'; import demo_zoom_nodes from './demo-zoom-to-fit-nodes'; import demo_canvas_drag from './demo-canvas-drag'; +import demo_pan_and_zoom from './demo-pan-and-zoom'; import demo_dynamic_ports from './demo-dynamic-ports'; import demo_labels from './demo-labelled-links'; @@ -26,5 +27,6 @@ export const EventsAndListeners = demo_listeners; export const ZoomToFit = demo_zoom; export const ZoomToFitSelectNodes = demo_zoom_nodes; export const CanvasDrag = demo_canvas_drag; +export const CanvasPanAndZoom = demo_pan_and_zoom; export const DynamicPorts = demo_dynamic_ports; export const LinksWithLabels = demo_labels; diff --git a/diagrams-demo-gallery/demos/demo-pan-and-zoom/index.tsx b/diagrams-demo-gallery/demos/demo-pan-and-zoom/index.tsx new file mode 100644 index 0000000..db4f98c --- /dev/null +++ b/diagrams-demo-gallery/demos/demo-pan-and-zoom/index.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import createEngine, { DiagramModel, DefaultNodeModel } from '@projectstorm/react-diagrams'; +import { DemoButton, DemoWorkspaceWidget } from '../helpers/DemoWorkspaceWidget'; +import { CanvasWidget } from '@projectstorm/react-canvas-core'; +import { DemoCanvasWidget } from '../helpers/DemoCanvasWidget'; + +/** + * Tests the pan and zoom action, which is intended as a trackpad/mobile + * alternative to the standard ZoomCanvasAction + */ +class CanvasPanAndZoomToggle extends React.Component { + render() { + const { engine } = this.props; + return ( + + + + ); + } +} + +export default () => { + /** + * 1) setup the diagram engine + * PandAndZoomCanvasAction and ZoomCanvasAction are mutually exclusive + * If both are enabled, ZoomCanvasAction will override. + */ + var engine = createEngine({ + registerDefaultPanAndZoomCanvasAction: true, + registerDefaultZoomCanvasAction: false + }); + + //2) setup the diagram model + var model = new DiagramModel(); + + //3-A) create a default node + var node1 = new DefaultNodeModel('Node 1', 'rgb(0,192,255)'); + var port1 = node1.addOutPort('Out'); + node1.setPosition(100, 100); + + //3-B) create another default node + var node2 = new DefaultNodeModel('Node 2', 'rgb(192,255,0)'); + var port2 = node2.addInPort('In'); + node2.setPosition(400, 100); + + //3-C) link the 2 nodes together + var link1 = port1.link(port2); + + //4) add the models to the root graph + model.addAll(node1, node2, link1); + + //5) load model into engine + engine.setModel(model); + + //6) render the diagram! + return ; +}; diff --git a/packages/react-canvas-core/src/CanvasEngine.ts b/packages/react-canvas-core/src/CanvasEngine.ts index 67b793d..dfa71b2 100644 --- a/packages/react-canvas-core/src/CanvasEngine.ts +++ b/packages/react-canvas-core/src/CanvasEngine.ts @@ -8,6 +8,7 @@ import { MouseEvent } from 'react'; import { BaseModel } from './core-models/BaseModel'; import { Point } from '@projectstorm/geometry'; import { ActionEventBus } from './core-actions/ActionEventBus'; +import { PanAndZoomCanvasAction } from './actions/PanAndZoomCanvasAction'; import { ZoomCanvasAction } from './actions/ZoomCanvasAction'; import { DeleteItemsAction } from './actions/DeleteItemsAction'; import { StateMachine } from './core-state/StateMachine'; @@ -25,6 +26,7 @@ export interface CanvasEngineListener extends BaseListener { */ export interface CanvasEngineOptions { registerDefaultDeleteItemsAction?: boolean; + registerDefaultPanAndZoomCanvasAction?: boolean; registerDefaultZoomCanvasAction?: boolean; /** * Defines the debounce wait time in milliseconds if > 0 @@ -62,6 +64,8 @@ export class CanvasEngine< }; if (this.options.registerDefaultZoomCanvasAction === true) { this.eventBus.registerAction(new ZoomCanvasAction()); + } else if (this.options.registerDefaultPanAndZoomCanvasAction === true) { + this.eventBus.registerAction(new PanAndZoomCanvasAction()); } if (this.options.registerDefaultDeleteItemsAction === true) { this.eventBus.registerAction(new DeleteItemsAction()); diff --git a/packages/react-canvas-core/src/actions/PanAndZoomCanvasAction.ts b/packages/react-canvas-core/src/actions/PanAndZoomCanvasAction.ts new file mode 100644 index 0000000..95605c7 --- /dev/null +++ b/packages/react-canvas-core/src/actions/PanAndZoomCanvasAction.ts @@ -0,0 +1,64 @@ +import { WheelEvent } from 'react'; +import { Action, ActionEvent, InputType } from '../core-actions/Action'; + +export interface PanAndZoomCanvasActionOptions { + inverseZoom?: boolean; +} + +export class PanAndZoomCanvasAction extends Action { + constructor(options: PanAndZoomCanvasActionOptions = {}) { + super({ + type: InputType.MOUSE_WHEEL, + fire: (actionEvent: ActionEvent) => { + const { event } = actionEvent; + // we can block layer rendering because we are only targeting the transforms + for (let layer of this.engine.getModel().getLayers()) { + layer.allowRepaint(false); + } + + const model = this.engine.getModel(); + event.stopPropagation(); + if (event.ctrlKey) { + // Pinch and zoom gesture + const oldZoomFactor = this.engine.getModel().getZoomLevel() / 100; + + let scrollDelta = options.inverseZoom ? event.deltaY : -event.deltaY; + scrollDelta /= 3; + + if (model.getZoomLevel() + scrollDelta > 10) { + model.setZoomLevel(model.getZoomLevel() + scrollDelta); + } + + const zoomFactor = model.getZoomLevel() / 100; + + const boundingRect = event.currentTarget.getBoundingClientRect(); + const clientWidth = boundingRect.width; + const clientHeight = boundingRect.height; + // compute difference between rect before and after scroll + const widthDiff = clientWidth * zoomFactor - clientWidth * oldZoomFactor; + const heightDiff = clientHeight * zoomFactor - clientHeight * oldZoomFactor; + // compute mouse coords relative to canvas + const clientX = event.clientX - boundingRect.left; + const clientY = event.clientY - boundingRect.top; + + // compute width and height increment factor + const xFactor = (clientX - model.getOffsetX()) / oldZoomFactor / clientWidth; + const yFactor = (clientY - model.getOffsetY()) / oldZoomFactor / clientHeight; + + model.setOffset(model.getOffsetX() - widthDiff * xFactor, model.getOffsetY() - heightDiff * yFactor); + } else { + // Pan gesture + let yDelta = options.inverseZoom ? -event.deltaY : event.deltaY; + let xDelta = options.inverseZoom ? -event.deltaX : event.deltaX; + model.setOffset(model.getOffsetX() - xDelta, model.getOffsetY() - yDelta); + } + this.engine.repaintCanvas(); + + // re-enable rendering + for (let layer of this.engine.getModel().getLayers()) { + layer.allowRepaint(true); + } + } + }); + } +} diff --git a/packages/react-canvas-core/src/index.ts b/packages/react-canvas-core/src/index.ts index 9cfb7c0..adb4751 100644 --- a/packages/react-canvas-core/src/index.ts +++ b/packages/react-canvas-core/src/index.ts @@ -41,3 +41,4 @@ export * from './states/MoveItemsState'; export * from './actions/DeleteItemsAction'; export * from './actions/ZoomCanvasAction'; +export * from './actions/PanAndZoomCanvasAction';