From ea3123ec0334aa2a8c2b867cfa20c212e8b65692 Mon Sep 17 00:00:00 2001 From: CTomlyn Date: Mon, 6 Nov 2023 15:19:38 -0800 Subject: [PATCH 1/3] #1615 Refactor zoom code into a utility file (for consistency) (#1616) --- .../src/common-canvas/common-canvas-utils.js | 11 +- .../src/common-canvas/svg-canvas-d3.js | 4 +- .../src/common-canvas/svg-canvas-renderer.js | 900 +++--------------- .../svg-canvas-utils-external.js | 10 +- .../common-canvas/svg-canvas-utils-zoom.js | 780 +++++++++++++++ 5 files changed, 939 insertions(+), 766 deletions(-) create mode 100644 canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-zoom.js diff --git a/canvas_modules/common-canvas/src/common-canvas/common-canvas-utils.js b/canvas_modules/common-canvas/src/common-canvas/common-canvas-utils.js index d3a684ac62..aa974f6f24 100644 --- a/canvas_modules/common-canvas/src/common-canvas/common-canvas-utils.js +++ b/canvas_modules/common-canvas/src/common-canvas/common-canvas-utils.js @@ -863,9 +863,16 @@ export default class CanvasUtils { } // Returns an array of selected object IDs for nodes, comments and links - // that are within the region provided. Links are only included if + // that are within the region (inReg) provided. Links are only included if // includeLinks is truthy. - static selectInRegion(region, pipeline, includeLinks, linkType, enableAssocLinkType) { + static selectInRegion(inReg, pipeline, includeLinks, linkType, enableAssocLinkType) { + const region = { + x1: inReg.x, + y1: inReg.y, + x2: inReg.x + inReg.width, + y2: inReg.y + inReg.height + }; + var regionSelections = []; for (const node of pipeline.nodes) { if (!this.isSuperBindingNode(node) && // Don't include binding nodes in select diff --git a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-d3.js b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-d3.js index 7b37e2c06e..894f3919e1 100644 --- a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-d3.js +++ b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-d3.js @@ -107,9 +107,7 @@ export default class SVGCanvasD3 { } convertPageCoordsToSnappedCanvasCoords(pos) { - let positon = this.renderer.convertPageCoordsToCanvasCoords(pos.x, pos.y); - positon = this.renderer.getMousePosSnapToGrid(positon); - return positon; + return this.renderer.convertPageCoordsToSnappedCanvasCoords(pos); } nodeTemplateDragStart(nodeTemplate) { diff --git a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-renderer.js b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-renderer.js index 0311d544ea..8477ae9672 100644 --- a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-renderer.js +++ b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-renderer.js @@ -19,12 +19,9 @@ /* eslint no-lonely-if: "off" */ // Import just the D3 modules that are needed and combine them as the 'd3' object. -import * as d3Drag from "d3-drag"; -import * as d3Ease from "d3-ease"; import * as d3Selection from "d3-selection"; import * as d3Fetch from "d3-fetch"; -import * as d3Zoom from "./d3-zoom-extension/src"; -const d3 = Object.assign({}, d3Drag, d3Ease, d3Selection, d3Fetch, d3Zoom); +const d3 = Object.assign({}, d3Selection, d3Fetch); const markdownIt = require("markdown-it")({ html: false, // Don't allow HTML to be executed in comments. @@ -41,7 +38,6 @@ import { ASSOC_RIGHT_SIDE_CURVE, ASSOCIATION_LINK, NODE_LINK, COMMENT_LINK, CONTEXT_MENU_BUTTON, DEC_LINK, DEC_NODE, LEFT_ARROW_ICON, EDIT_ICON, NODE_MENU_ICON, SUPER_NODE_EXPAND_ICON, PORT_OBJECT_IMAGE, TIP_TYPE_NODE, TIP_TYPE_PORT, TIP_TYPE_DEC, TIP_TYPE_LINK, - INTERACTION_MOUSE, INTERACTION_TRACKPAD, INTERACTION_CARBON, USE_DEFAULT_ICON, USE_DEFAULT_EXT_ICON, SUPER_NODE, SNAP_TO_GRID_AFTER, SNAP_TO_GRID_DURING, NORTH, SOUTH, EAST, WEST } @@ -60,6 +56,7 @@ import SvgCanvasTextArea from "./svg-canvas-utils-textarea.js"; import SvgCanvasDragObject from "./svg-canvas-utils-drag-objects.js"; import SvgCanvasDragNewLink from "./svg-canvas-utils-drag-new-link.js"; import SvgCanvasDragDetLink from "./svg-canvas-utils-drag-det-link.js"; +import SvgCanvasZoom from "./svg-canvas-utils-zoom.js"; import SVGCanvasPipeline from "./svg-canvas-pipeline"; const NINETY_DEGREES = 90; @@ -94,6 +91,7 @@ export default class SVGCanvasRenderer { this.dragObjectUtils = new SvgCanvasDragObject(this); this.dragNewLinkUtils = new SvgCanvasDragNewLink(this); this.dragDetLinkUtils = new SvgCanvasDragDetLink(this); + this.zoomUtils = new SvgCanvasZoom(this); this.externalUtils = new SvgCanvasExternal(this); this.svgCanvasTextArea = new SvgCanvasTextArea( this.config, @@ -111,13 +109,6 @@ export default class SVGCanvasRenderer { this.dispUtils.setDisplayState(); this.logger.log(this.dispUtils.getDisplayStateMsg()); - // Initialize zoom variables - this.initializeZoomVariables(); - - // Dimensions for extent of canvas scaling - this.minScaleExtent = 0.2; - this.maxScaleExtent = 1.8; - // The data link a node is currently being dragged over. It will be null // when the node being dragged is not over a data link. this.dragOverLink = null; @@ -137,69 +128,38 @@ export default class SVGCanvasRenderer { // option is switched on. this.dragNewLinkOverNode = null; - // Flag to indicate when the space key is down (used when dragging). - this.spaceKeyPressed = false; - - // Flag to indicate when a zoom is invoked programmatically. - this.zoomingAction = false; - - // Keep track of when the context menu has been closed, so we don't remove - // selections when a context menu is closed during a zoom gesture. - this.contextMenuClosedOnZoom = false; - - // Keep track of when text editing has been closed, so we don't remove - // selections when that happens during a zoom gesture. - this.textEditingClosedOnZoom = false; - - // Used to monitor the region selection rectangle. - this.regionSelect = false; - - // Used to track the start of the zoom. - this.zoomStartPoint = { x: 0, y: 0, k: 0, startX: 0, startY: 0 }; - - // I was not able to figure out how to use the zoom filter method to - // allow mousedown and mousemove messages to go through to the canvas to - // do region selection. Therefore I had to implement region selection in - // the zoom methods. This has the side effect that, when a region is - // selected, d3Event.transform.x and d3Event.transform.y are incremented - // even though the objects in the canvas have not moved. The values below - // are used to store the current transform x and y amounts at the beginning - // of the region selection and then restore those amounts at the end of - // the region selection. - this.regionStartTransformX = 0; - this.regionStartTransformY = 0; - - // Create a zoom object for use with the canvas. - this.zoom = - d3.zoom() - .trackpad(this.config.enableInteractionType === INTERACTION_TRACKPAD) - .preventBackGesture(true) - .wheelDelta((d3Event) => -d3Event.deltaY * (this.config.enableInteractionType === INTERACTION_TRACKPAD ? 0.02 : 0.002)) - .scaleExtent([this.minScaleExtent, this.maxScaleExtent]) - .on("start", this.zoomStart.bind(this)) - .on("zoom", this.zoomAction.bind(this)) - .on("end", this.zoomEnd.bind(this)); - this.initializeGhostDiv(); this.canvasSVG = this.createCanvasSVG(); this.canvasDefs = this.canvasSVG.selectChildren("defs"); - this.canvasGrp = this.createCanvasGroup(this.canvasSVG, "d3-canvas-group"); // Group to contain all canvas objects - this.canvasUnderlay = this.createCanvasUnderlay(this.canvasGrp, "d3-canvas-underlay"); // Put underlay rectangle under comments, nodes and links - this.commentsGrp = this.createCanvasGroup(this.canvasGrp, "d3-comments-group"); // Group to always position comments under nodes and links - this.nodesLinksGrp = this.createCanvasGroup(this.canvasGrp, "d3-nodes-links-group"); // Group to position nodes and links over comments - this.boundingRectsGrp = this.createBoundingRectanglesGrp(this.canvasGrp, "d3-bounding-rect-group"); // Group to optionally add bounding rectangles over all objects + + // Group to contain all canvas objects + this.canvasGrp = this.createCanvasGroup(this.canvasSVG, "d3-canvas-group"); + + // Put underlay rectangle under comments, nodes and links + this.canvasUnderlay = this.createCanvasUnderlay(this.canvasGrp, "d3-canvas-underlay"); + + // Group to always position comments under nodes and links + this.commentsGrp = this.createCanvasGroup(this.canvasGrp, "d3-comments-group"); + + // Group to position nodes and links over comments + this.nodesLinksGrp = this.createCanvasGroup(this.canvasGrp, "d3-nodes-links-group"); + + // Group to optionally add bounding rectangles over all objects + this.boundingRectsGrp = this.createBoundingRectanglesGrp(this.canvasGrp, "d3-bounding-rect-group"); this.resetCanvasSVGBehaviors(); this.displayCanvas(); if (this.dispUtils.isDisplayingFullPage()) { - this.restoreZoom(); + this.zoomUtils.restoreZoom(); } - // If we are showing a sub-flow in full screen mode, or the options is - // switched on to always display it, show the 'back to parent' control. + // Show the 'Back to Parent' control, if we are showing a sub-flow + // in full screen mode or, the alwaysDisplayBackToParentFlow option is + // switched on to always display it (used by apps that manage their own + // supernodes/sub-flows). if (this.dispUtils.isDisplayingSubFlowFullPage() || this.canvasLayout.alwaysDisplayBackToParentFlow) { this.addBackToParentFlowArrow(this.canvasSVG); @@ -223,30 +183,62 @@ export default class SVGCanvasRenderer { this.logger.logEndTimer("constructor" + pipelineId.substring(0, 5)); } + // Sets the pressed state of the space bar. This is called + // from outside canvas via svg-canvas-d3. setSpaceKeyPressed(state) { - this.spaceKeyPressed = state; + this.zoomUtils.setSpaceKeyPressed(state); this.resetCanvasCursor(); } // Returns true if the space bar is pressed and held down. This is called - // from outside canvas via svg-canvas-d3 as well as internally. + // from outside canvas via svg-canvas-d3. isSpaceKeyPressed() { - return this.spaceKeyPressed; + return this.zoomUtils.isSpaceKeyPressed(); } - // Returns true if the event indicates that a drag is in action. This means - // with regular Mouse interation that the space bar is pressed or with - // legacy interation it means the shift key is NOT pressed. - isDragActivated(d3Event) { - if (this.config.enableInteractionType === INTERACTION_CARBON) { - return this.isSpaceKeyPressed(); - } - return (d3Event && d3Event.sourceEvent && !d3Event.sourceEvent.shiftKey); + zoomTo(zoomObject) { + this.zoomUtils.zoomTo(zoomObject); + } + + translateBy(x, y, animateTime) { + this.zoomUtils.translateBy(x, y, animateTime); + } + + zoomIn() { + this.zoomUtils.zoomIn(); + } + + zoomOut() { + this.zoomUtils.zoomOut(); + } + + zoomToFit() { + this.zoomUtils.zoomToFit(); + } + + isZoomedToMax() { + return this.zoomUtils.isZoomedToMax(); + } + + isZoomedToMin() { + return this.zoomUtils.isZoomedToMin(); + } + + getZoomToReveal(objectIDs, xPos, yPos) { + return this.zoomUtils.getZoomToReveal(objectIDs, xPos, yPos); + } + + getZoom() { + return this.zoomUtils.getZoom(); + } + + getTransformedViewportDimensions() { + return this.zoomUtils.getTransformedViewportDimensions(); } // Returns the data object for the parent supernode that references the // active pipeline (managed by this renderer). We get the supernode by - // looking through the overall canvas info objects. + // looking through the overall canvas info object. // Don't be tempted into thinking you can retrieve the supernode datum by // calling the parent renderer because there is no parent renderer when we // are showing a sub-flow in full page mode. @@ -264,15 +256,8 @@ export default class SVGCanvasRenderer { return node; } - getZoomTransform() { - return this.zoomTransform; - } - - initializeZoomVariables() { - // Allows us to record the current zoom amounts. - this.zoomTransform = d3.zoomIdentity.translate(0, 0).scale(1); - } - + // Provides new canvas info data to this renderer and other display and layout info + // so the canvas can be redisplayed. setCanvasInfoRenderer(canvasInfo, selectionInfo, breadcrumbs, nodeLayout, canvasLayout, config) { this.logger.logStartTimer("setCanvasInfoRenderer" + this.pipelineId.substring(0, 5)); this.config = config; @@ -365,7 +350,7 @@ export default class SVGCanvasRenderer { } clearCanvas() { - this.initializeZoomVariables(); + this.zoomUtils.resetZoomTransform(); this.canvasSVG.remove(); } @@ -411,25 +396,27 @@ export default class SVGCanvasRenderer { this.displaySVGToFitSupernode(); } - // The supernode will not have any calculated port positions when the - // subflow is being displayed full screen, so calculate them first. - if (this.dispUtils.isDisplayingSubFlowFullPage()) { - this.displayPortsForSubFlowFullPage(); - } - this.displayCanvasAccoutrements(); this.logger.logEndTimer("displayCanvas"); } + // Displays zoom and size dependent canvas elements. displayCanvasAccoutrements() { if (this.config.enableBoundingRectangles) { this.displayBoundingRectangles(); } - if (this.config.enableCanvasUnderlay !== "None" && this.dispUtils.isDisplayingPrimaryFlowFullPage()) { + if (this.config.enableCanvasUnderlay !== "None" && + this.dispUtils.isDisplayingPrimaryFlowFullPage()) { this.setCanvasUnderlaySize(); } + + // The supernode will not have any calculated port positions when the + // subflow is being displayed full screen, so calculate them first. + if (this.dispUtils.isDisplayingSubFlowFullPage()) { + this.displayPortsForSubFlowFullPage(); + } } // Ensures the binding ports for a full-page sub-flow are calculated @@ -487,7 +474,7 @@ export default class SVGCanvasRenderer { // Moves the binding nodes in a sub-flow, which map to nodes in the parent // supernode, to the edge of the SVG area. moveSuperBindingNodes() { - const transformedSVGRect = this.getTransformedViewportDimensions(); + const transformedSVGRect = this.zoomUtils.getTransformedViewportDimensions(); // this.logger.log("transformedSVGRect" + // " x = " + transformedSVGRect.x + @@ -554,10 +541,10 @@ export default class SVGCanvasRenderer { if (!this.activePipeline) { return; } - const svgRect = this.getViewportDimensions(); - const transformedSVGRect = this.getTransformedRect(svgRect, 1); - const canv = this.getCanvasDimensionsAdjustedForScale(1); - const canvWithPadding = this.getCanvasDimensionsAdjustedForScale(1, this.getZoomToFitPadding()); + const svgRect = this.zoomUtils.getViewportDimensions(); + const transformedSVGRect = this.zoomUtils.getTransformedRect(svgRect, 1); + const canv = this.zoomUtils.getCanvasDimensions(); + const canvWithPadding = this.zoomUtils.getCanvasDimensionsWithPadding(); this.boundingRectsGrp.selectChildren(".d3-bounding").remove(); @@ -611,6 +598,16 @@ export default class SVGCanvasRenderer { } } + // Selects any objects in the region provided where region is { x, y, width, height } + selectObjsInRegion(region) { + const selections = + CanvasUtils.selectInRegion(region, this.activePipeline, + this.config.enableLinkSelection !== LINK_SELECTION_NONE, + this.config.enableLinkType, + this.config.enableAssocLinkType); + this.canvasController.setSelections(selections, this.activePipeline.id); + } + // Returns true when we are editing text. Called by svg-canvas-d3. isEditingText() { if (this.svgCanvasTextArea.isEditingText()) { @@ -728,7 +725,7 @@ export default class SVGCanvasRenderer { // transformation amounts based on the local SVG -- that is, if we're // displaying a sub-flow it is based on the SVG in the supernode. getTransformedMousePos(d3Event) { - return this.transformPos(this.getMousePos(d3Event, this.canvasSVG)); + return this.zoomUtils.transformPos(this.getMousePos(d3Event, this.canvasSVG)); } // Returns the current mouse position based on the D3 SVG selection object @@ -747,45 +744,19 @@ export default class SVGCanvasRenderer { return { x: 0, y: 0 }; } + // Returns the page position passed in snapped to the grid in canvas + // coordinates. Called externally via svg-canvas-d3. + convertPageCoordsToSnappedCanvasCoords(pos) { + let positon = this.convertPageCoordsToCanvasCoords(pos.x, pos.y); + positon = this.getMousePosSnapToGrid(positon); + return positon; + } + // Convert coordinates from the page (based on the page top left corner) to // canvas coordinates based on the canvas coordinate system. convertPageCoordsToCanvasCoords(x, y) { const svgRect = this.canvasSVG.node().getBoundingClientRect(); - return this.transformPos({ x: x - Math.round(svgRect.left), y: y - Math.round(svgRect.top) }); - } - - // Transforms the x and y fields passed in by the current zoom - // transformation amounts to convert a coordinate position in screen pixels - // to a canvas coordinate position. - transformPos(pos) { - return { - x: (pos.x - this.zoomTransform.x) / this.zoomTransform.k, - y: (pos.y - this.zoomTransform.y) / this.zoomTransform.k - }; - } - - // Transforms the x and y fields passed in by the current zoom - // transformation amounts to convert a canvas coordinate position - // to a coordinate position in screen pixels. - unTransformPos(pos) { - return { - x: (pos.x * this.zoomTransform.k) + this.zoomTransform.x, - y: (pos.y * this.zoomTransform.k) + this.zoomTransform.y - }; - } - - // Transforms the x, y, height and width fields of the object passed in by the - // current zoom transformation amounts to convert coordinate positions and - // dimensions in screen pixels to coordinate positions and dimensions in - // zoomed pixels. - getTransformedRect(svgRect, pad) { - const transPad = (pad / this.zoomTransform.k); - return { - x: (-this.zoomTransform.x / this.zoomTransform.k) + transPad, - y: (-this.zoomTransform.y / this.zoomTransform.k) + transPad, - height: (svgRect.height / this.zoomTransform.k) - (2 * transPad), - width: (svgRect.width / this.zoomTransform.k) - (2 * transPad) - }; + return this.zoomUtils.transformPos({ x: x - Math.round(svgRect.left), y: y - Math.round(svgRect.top) }); } // Creates the div which contains the ghost node for drag and @@ -822,6 +793,7 @@ export default class SVGCanvasRenderer { getGhostNode(node) { const that = this; const ghostDivSel = this.getGhostDivSel(); + const zoomScale = this.zoomUtils.getZoomScale(); // Calculate the ghost area width which is the maximum of either the node // label or the default node width. @@ -837,8 +809,8 @@ export default class SVGCanvasRenderer { // Create a new SVG area for the ghost area. const ghostAreaSVG = ghostDivSel .append("svg") - .attr("width", ghostAreaWidth * this.zoomTransform.k) - .attr("height", (50 + node.height) * this.zoomTransform.k) // Add some extra pixels, in case label is below label bottom + .attr("width", ghostAreaWidth * zoomScale) + .attr("height", (50 + node.height) * zoomScale) // Add some extra pixels, in case label is below label bottom .attr("x", 0) .attr("y", 0) .attr("class", "d3-ghost-svg"); @@ -900,7 +872,7 @@ export default class SVGCanvasRenderer { // Next calculate the amount, if any, the label protrudes beyond the edge // of the node width and move the ghost group by that amount. - xOffset = Math.max(0, (labelDisplayLength - node.width) / 2) * this.zoomTransform.k; + xOffset = Math.max(0, (labelDisplayLength - node.width) / 2) * zoomScale; // If the label is center justified, restrict the label width to the // display amount and adjust the x coordinate to compensate for the change @@ -915,7 +887,7 @@ export default class SVGCanvasRenderer { } } - ghostGrp.attr("transform", `translate(${xOffset}, 0) scale(${this.zoomTransform.k})`); + ghostGrp.attr("transform", `translate(${xOffset}, 0) scale(${zoomScale})`); // Get the amount the actual browser page is 'zoomed'. This is differet // to the zoom amount for the canvas objects. @@ -923,8 +895,8 @@ export default class SVGCanvasRenderer { // Calculate the center of the node area for positioning the mouse pointer // on the image when it is being dragged. - const centerX = (xOffset + ((node.width / 2) * this.zoomTransform.k)) * browserZoom; - const centerY = ((node.height / 2) * this.zoomTransform.k) * browserZoom; + const centerX = (xOffset + ((node.width / 2) * zoomScale)) * browserZoom; + const centerY = ((node.height / 2) * zoomScale) * browserZoom; return { element: ghostDivSel.node(), @@ -1234,15 +1206,15 @@ export default class SVGCanvasRenderer { // means the factors will multiply as they percolate up to the top flow. setMaxZoomExtent(factor) { if (this.dispUtils.isDisplayingFullPage()) { - const newMaxExtent = this.maxScaleExtent * factor; - - this.zoom.scaleExtent([this.minScaleExtent, newMaxExtent]); + this.zoomUtils.setMaxZoomExtent(factor); } else { - const newFactor = Number(factor) * 1 / this.zoomTransform.k; + const newFactor = Number(factor) * 1 / this.zoomUtils.getZoomScale(); this.supernodeInfo.renderer.setMaxZoomExtent(newFactor); } } + // Returns a new canvas SVG object with all the behavior expected of a great + // SVG canvas object. createCanvasSVG() { this.logger.log("Create Canvas SVG."); @@ -1273,14 +1245,14 @@ export default class SVGCanvasRenderer { .attr("x", dims.x) .attr("y", dims.y) .on("mouseenter", (d3Event, d) => { - // If we are a sub-flow (i.e we have a parent renderer) set the max + // If we are a sub-flow (i.e. we have a parent renderer) set the max // zoom extent with a factor calculated from our zoom amount. if (this.supernodeInfo.renderer && this.config.enableZoomIntoSubFlows) { - this.supernodeInfo.renderer.setMaxZoomExtent(1 / this.zoomTransform.k); + this.supernodeInfo.renderer.setMaxZoomExtent(1 / this.zoomUtils.getZoomScale()); } }) .on("mouseleave", (d3Event, d) => { - // If we are a sub-flow (i.e we have a parent renderer) set the max + // If we are a sub-flow (i.e. we have a parent renderer) set the max // zoom extent with a factor of 1. if (this.supernodeInfo.renderer && this.config.enableZoomIntoSubFlows) { this.supernodeInfo.renderer.setMaxZoomExtent(1); @@ -1338,7 +1310,7 @@ export default class SVGCanvasRenderer { if (!this.activePipeline.isEmptyOrBindingsOnly() && this.dispUtils.isDisplayingFullPage()) { this.canvasSVG - .call(this.zoom); + .call(this.zoomUtils.getZoomHandler()); } // These behaviors will be applied to SVG areas at the top level and @@ -1357,6 +1329,7 @@ export default class SVGCanvasRenderer { } }) .on("click.zoom", (d3Event) => { + // Control comes here after the zoomClick action has been perfoemd in zoomUtils. this.logger.log("Canvas - click-zoom"); this.canvasController.clickActionHandler({ @@ -1381,7 +1354,7 @@ export default class SVGCanvasRenderer { // Resets the pointer cursor on the background rectangle in the Canvas SVG area. resetCanvasCursor() { const selector = ".d3-svg-background[data-pipeline-id='" + this.activePipeline.id + "']"; - this.canvasSVG.select(selector).style("cursor", this.isDragActivated() && this.dispUtils.isDisplayingFullPage() ? "grab" : "default"); + this.canvasSVG.select(selector).style("cursor", this.zoomUtils.isDragActivated() && this.dispUtils.isDisplayingFullPage() ? "grab" : "default"); } createCanvasGroup(canvasObj, className) { @@ -1403,7 +1376,7 @@ export default class SVGCanvasRenderer { } setCanvasUnderlaySize(x = 0, y = 0) { - const canv = this.getCanvasDimensionsAdjustedForScale(1, this.getZoomToFitPadding()); + const canv = this.zoomUtils.getCanvasDimensionsWithPadding(); if (canv) { this.canvasUnderlay .attr("x", canv.left - 50) @@ -1505,6 +1478,7 @@ export default class SVGCanvasRenderer { }); } } + setNodeLabelEditingMode(nodeId, pipelineId) { if (this.pipelineId === pipelineId) { const node = this.activePipeline.getNode(nodeId); @@ -1556,435 +1530,9 @@ export default class SVGCanvasRenderer { } } - // Restores the zoom of the canvas, if it has changed, based on the type - // of 'save zoom' specified in the configuration and, if no saved zoom, was - // provided pans the canvas area so it is always visible. - restoreZoom() { - let newZoom = this.canvasController.getSavedZoom(this.pipelineId); - - // If there's no saved zoom, and enablePanIntoViewOnOpen is set, pan so - // the canvas area (containing nodes and comments) is visible in the viewport. - if (!newZoom && this.config.enablePanIntoViewOnOpen) { - const canvWithPadding = this.getCanvasDimensionsAdjustedForScale(1, this.getZoomToFitPadding()); - if (canvWithPadding) { - newZoom = { x: -canvWithPadding.left, y: -canvWithPadding.top, k: 1 }; - } - } - - // If there's no saved zoom and we have some initial pan amounts provided use them. - if (!newZoom && this.canvasLayout.initialPanX && this.canvasLayout.initialPanY) { - newZoom = { x: this.canvasLayout.initialPanX, y: this.canvasLayout.initialPanY, k: 1 }; - } - - // If new zoom is different to the current zoom amount, apply it. - if (newZoom && - (newZoom.k !== this.zoomTransform.k || - newZoom.x !== this.zoomTransform.x || - newZoom.y !== this.zoomTransform.y)) { - this.zoomCanvasInvokeZoomBehavior(newZoom); - } - } - - // Zooms the canvas to the amount specified in newZoomTransform. Zooming the - // canvas in this way will invoke the zoom behavior methods: zoomStart, - // zoomAction and zoomEnd. It does not perform a zoom if newZoomTransform - // is the same as the current zoom transform. - zoomCanvasInvokeZoomBehavior(newZoomTransform, animateTime) { - if (isFinite(newZoomTransform.x) && - isFinite(newZoomTransform.y) && - isFinite(newZoomTransform.k) && - this.zoomHasChanged(newZoomTransform)) { - this.zoomingAction = true; - const zoomTransform = d3.zoomIdentity.translate(newZoomTransform.x, newZoomTransform.y).scale(newZoomTransform.k); - if (animateTime) { - this.canvasSVG.call(this.zoom).transition() - .duration(animateTime) - .call(this.zoom.transform, zoomTransform); - } else { - this.canvasSVG.call(this.zoom.transform, zoomTransform); - } - this.zoomingAction = false; - } - } - - // Return true if the new zoom transform passed in is different from the - // current zoom transform. - zoomHasChanged(newZoomTransform) { - return newZoomTransform.k !== this.zoomTransform.k || - newZoomTransform.x !== this.zoomTransform.x || - newZoomTransform.y !== this.zoomTransform.y; - } - - zoomToFit() { - const padding = this.getZoomToFitPadding(); - const canvasDimensions = this.getCanvasDimensionsAdjustedForScale(1, padding); - const viewPortDimensions = this.getViewportDimensions(); - - if (canvasDimensions) { - const xRatio = viewPortDimensions.width / canvasDimensions.width; - const yRatio = viewPortDimensions.height / canvasDimensions.height; - const newScale = Math.min(xRatio, yRatio, 1); // Don't let the canvas be scaled more than 1 in either direction - - let x = (viewPortDimensions.width - (canvasDimensions.width * newScale)) / 2; - let y = (viewPortDimensions.height - (canvasDimensions.height * newScale)) / 2; - - x -= newScale * canvasDimensions.left; - y -= newScale * canvasDimensions.top; - - this.zoomCanvasInvokeZoomBehavior({ x: x, y: y, k: newScale }); - } - } - - // Returns the padding space for the canvas objects to be zoomed which takes - // into account any connections that need to be made to/from any sub-flow - // binding nodes plus any space needed for the binding nodes ports. - getZoomToFitPadding() { - let padding = this.canvasLayout.zoomToFitPadding; - - if (this.dispUtils.isDisplayingSubFlow()) { - // Allocate some space for connecting lines and the binding node ports - const newPadding = this.getMaxZoomToFitPaddingForConnections() + (2 * this.canvasLayout.supernodeBindingPortRadius); - padding = Math.max(padding, newPadding); - } - return padding; - } - - zoomTo(zoomObject) { - const animateTime = 500; - this.zoomCanvasInvokeZoomBehavior(zoomObject, animateTime); - } - - getZoom() { - return { x: this.zoomTransform.x, y: this.zoomTransform.y, k: this.zoomTransform.k }; - } - - translateBy(x, y, animateTime) { - const z = this.getZoomTransform(); - const zoomObject = d3.zoomIdentity.translate(z.x + x, z.y + y).scale(z.k); - this.zoomCanvasInvokeZoomBehavior(zoomObject, animateTime); - } - - zoomIn() { - if (this.zoomTransform.k < this.maxScaleExtent) { - const newScale = Math.min(this.zoomTransform.k * 1.1, this.maxScaleExtent); - this.canvasSVG.call(this.zoom.scaleTo, newScale); - } - } - - zoomOut() { - if (this.zoomTransform.k > this.minScaleExtent) { - const newScale = Math.max(this.zoomTransform.k / 1.1, this.minScaleExtent); - this.canvasSVG.call(this.zoom.scaleTo, newScale); - } - } - - isZoomedToMax() { - return this.zoomTransform ? this.zoomTransform.k === this.maxScaleExtent : false; - } - - isZoomedToMin() { - return this.zoomTransform ? this.zoomTransform.k === this.minScaleExtent : false; - } - - getZoomToReveal(objectIDs, xPos, yPos) { - const transformedSVGRect = this.getTransformedViewportDimensions(); - const nodes = this.activePipeline.getNodes(objectIDs); - const comments = this.activePipeline.getComments(objectIDs); - const links = this.activePipeline.getLinks(objectIDs); - - if (nodes.length > 0 || comments.length > 0 || links.length > 0) { - const canvasDimensions = CanvasUtils.getCanvasDimensions(nodes, comments, links, 0, 0, true); - const canv = this.convertCanvasDimensionsAdjustedForScaleWithPadding(canvasDimensions, 1, 10); - const xPosInt = parseInt(xPos, 10); - const yPosInt = typeof yPos === "undefined" ? xPosInt : parseInt(yPos, 10); - - if (canv) { - let xOffset; - let yOffset; - - if (!Number.isNaN(xPosInt) && !Number.isNaN(yPosInt)) { - xOffset = transformedSVGRect.x + (transformedSVGRect.width * (xPosInt / 100)) - (canv.left + (canv.width / 2)); - yOffset = transformedSVGRect.y + (transformedSVGRect.height * (yPosInt / 100)) - (canv.top + (canv.height / 2)); - - } else { - if (canv.right > transformedSVGRect.x + transformedSVGRect.width) { - xOffset = transformedSVGRect.x + transformedSVGRect.width - canv.right; - } - if (canv.left < transformedSVGRect.x) { - xOffset = transformedSVGRect.x - canv.left; - } - if (canv.bottom > transformedSVGRect.y + transformedSVGRect.height) { - yOffset = transformedSVGRect.y + transformedSVGRect.height - canv.bottom; - } - if (canv.top < transformedSVGRect.y) { - yOffset = transformedSVGRect.y - canv.top; - } - } - - if (typeof xOffset !== "undefined" || typeof yOffset !== "undefined") { - const x = this.zoomTransform.x + ((xOffset || 0)) * this.zoomTransform.k; - const y = this.zoomTransform.y + ((yOffset || 0)) * this.zoomTransform.k; - return { x: x || 0, y: y || 0, k: this.zoomTransform.k }; - } - } - } - - return null; - } - - // Returns an object representing the viewport dimensions which have been - // transformed for the current zoom amount. - getTransformedViewportDimensions() { - const svgRect = this.getViewportDimensions(); - return this.getTransformedRect(svgRect, 0); - } - - // Returns the dimensions of the SVG area. When we are displaying a sub-flow - // we can use the supernode's dimensions. If not we are displaying - // full-page so we can use getBoundingClientRect() to get the dimensions - // (for some reason that method doesn't return correct values with embedded SVG areas). - getViewportDimensions() { - let viewportDimensions = {}; - - if (this.dispUtils.isDisplayingSubFlowInPlace()) { - const dims = this.getParentSupernodeSVGDimensions(); - viewportDimensions.width = dims.width; - viewportDimensions.height = dims.height; - - } else { - if (this.canvasSVG && this.canvasSVG.node()) { - viewportDimensions = this.canvasSVG.node().getBoundingClientRect(); - } else { - viewportDimensions = { x: 0, y: 0, width: 1100, height: 640 }; // Return a sensible default (for Jest tests) - } - } - return viewportDimensions; - } - - zoomStart(d3Event) { - this.logger.log("zoomStart - " + JSON.stringify(d3Event.transform)); - - // Ensure any open tip is closed before starting a zoom operation. - this.canvasController.closeTip(); - - // Close the context menu, if it's open, before panning or zooming. - // If the context menu is opened inside the expanded supernode (in-place - // subflow), when the user zooms the canvas, the full page flow is handling - // that zoom, which causes a refresh in the subflow, so the full page flow - // will take care of closing the context menu. This means the in-place - // subflow doesn’t need to do anything on zoom, - // hence: !this.dispUtils.isDisplayingSubFlowInPlace() - if (this.canvasController.isContextMenuDisplayed() && - !this.dispUtils.isDisplayingSubFlowInPlace()) { - this.canvasController.closeContextMenu(); - this.contextMenuClosedOnZoom = true; - } - - // Any text editing in progress will be closed by the textarea's blur event - // if the user clicks on the canvas background. So we set this flag to - // prevent the selection being lost in the zoomEnd (mouseup) event. - if (this.svgCanvasTextArea.isEditingText()) { - this.textEditingClosedOnZoom = true; - } - - this.regionSelect = this.shouldDoRegionSelect(d3Event); - - if (this.regionSelect) { - // Add a delay so, if the user just clicks, they don't see the crosshair. - // This will be cleared in zoomEnd if the user's click takes less than 200 ms. - this.addingCursorOverlay = setTimeout(() => this.addTempCursorOverlay("crosshair"), 200); - this.regionStartTransformX = d3Event.transform.x; - this.regionStartTransformY = d3Event.transform.y; - - } else { - if (this.isDragActivated(d3Event)) { - this.addingCursorOverlay = setTimeout(() => this.addTempCursorOverlay("grabbing"), 200); - } else { - this.addingCursorOverlay = setTimeout(() => this.addTempCursorOverlay("default"), 200); - } - } - - const transPos = this.getTransformedMousePos(d3Event); - this.zoomStartPoint = { x: d3Event.transform.x, y: d3Event.transform.y, k: d3Event.transform.k, startX: transPos.x, startY: transPos.y }; - this.previousD3Event = { x: d3Event.transform.x, y: d3Event.transform.y, k: d3Event.transform.k }; - // Calculate the canvas dimensions here, so we don't have to recalculate - // them for every zoom action event. - this.zoomCanvasDimensions = CanvasUtils.getCanvasDimensions( - this.activePipeline.nodes, this.activePipeline.comments, - this.activePipeline.links, this.canvasLayout.commentHighlightGap); - } - - zoomAction(d3Event) { - this.logger.log("zoomAction - " + JSON.stringify(d3Event.transform)); - - // If the scale amount is the same we are not zooming, so we must be panning. - if (d3Event.transform.k === this.zoomStartPoint.k) { - if (this.regionSelect) { - this.drawRegionSelector(d3Event); - - } else { - this.zoomCanvasBackground(d3Event); - } - } else { - this.addTempCursorOverlay("default"); - this.zoomCanvasBackground(d3Event); - this.zoomCommentToolbar(); - } - } - - zoomEnd(d3Event) { - this.logger.log("zoomEnd - " + JSON.stringify(d3Event.transform)); - - // Clears the display of the cursor overlay if the user clicks within 200 ms - clearTimeout(this.addingCursorOverlay); - - const transPos = this.getTransformedMousePos(d3Event); - - // The user just clicked -- with no drag. - if (transPos.x === this.zoomStartPoint.startX && - transPos.y === this.zoomStartPoint.startY && - !this.zoomChanged()) { - this.zoomClick(d3Event); - - } else if (this.regionSelect) { - this.zoomEndRegionSelect(d3Event); - - } else if (this.dispUtils.isDisplayingFullPage() && this.zoomChanged()) { - this.zoomSave(); - } - - // Remove the cursor overlay and reset the SVG background rectangle - // cursor style, which was set in the zoom start method. - this.resetCanvasCursor(d3Event); - this.removeTempCursorOverlay(); - this.contextMenuClosedOnZoom = false; - this.textEditingClosedOnZoom = false; - this.regionSelect = false; - } - - // Returns true if the region select gesture is requested by the user. - shouldDoRegionSelect(d3Event) { - // The this.zoomingAction flag indicates zooming is being invoked - // programmatically. - if (this.zoomingAction) { - return false; - - } else if (this.config.enableInteractionType === INTERACTION_MOUSE && - (d3Event && d3Event.sourceEvent && d3Event.sourceEvent.shiftKey)) { - return true; - - } else if (this.config.enableInteractionType === INTERACTION_CARBON && - !this.isSpaceKeyPressed(d3Event)) { - return true; - - } else if (this.config.enableInteractionType === INTERACTION_TRACKPAD && - (d3Event.sourceEvent && d3Event.sourceEvent.buttons === 1) && // Main button is pressed - !this.spaceKeyPressed) { - return true; - } - - return false; - } - - // Returns true if the current zoom transform is different from the - // zoom values at the beginning of the zoom action. - zoomChanged() { - return (this.zoomTransform.k !== this.zoomStartPoint.k || - this.zoomTransform.x !== this.zoomStartPoint.x || - this.zoomTransform.y !== this.zoomStartPoint.y); - } - - zoomCanvasBackground(d3Event) { - this.regionSelect = false; - - if (this.dispUtils.isDisplayingPrimaryFlowFullPage()) { - const incTransform = this.getTransformIncrement(d3Event); - this.zoomTransform = this.zoomConstrainRegular(incTransform, this.getViewportDimensions(), this.zoomCanvasDimensions); - } else { - this.zoomTransform = d3.zoomIdentity.translate(d3Event.transform.x, d3Event.transform.y).scale(d3Event.transform.k); - } - - this.canvasGrp.attr("transform", this.zoomTransform); - - if (this.config.enableBoundingRectangles) { - this.displayBoundingRectangles(); - } - - if (this.config.enableCanvasUnderlay !== "None" && - this.dispUtils.isDisplayingPrimaryFlowFullPage()) { - this.setCanvasUnderlaySize(); - } - - // The supernode will not have any calculated port positions when the - // subflow is being displayed full screen, so calculate them first. - if (this.dispUtils.isDisplayingSubFlowFullPage()) { - this.displayPortsForSubFlowFullPage(); - } - } - - // Handles a zoom operation that is just a click on the canvas background. - zoomClick(d3Event) { - // Only clear selections under these conditions: - // * if the click was on the canvas of the current active pipeline. (This - // is because clicking on the canvas background of an expanded supernode - // should select that node.) - // * if we have not just closed a context menu - // * if we have not just closed text editing - // * if editing actions are enabled OR the target is the canvas background. - // (This condition is necessary because when editing actions are disabled, - // for a read-only canvas, the mouse-up over a node can cause this method - // to be called.) - if (this.dispUtils.isDisplayingCurrentPipeline() && - !this.contextMenuClosedOnZoom && - !this.textEditingClosedOnZoom && - (this.config.enableEditingActions || - (d3Event.sourceEvent && - d3Event.sourceEvent.target.classList.contains("d3-svg-background")))) { - this.canvasController.clearSelections(); - } - } - - // Handles the behavior when the user stops doing a region select. - zoomEndRegionSelect(d3Event) { - this.removeRegionSelector(); - - // Reset the transform x and y to what they were before the region - // selection action was started. This directly sets the x and y values - // in the __zoom property of the svgCanvas DOM object. - d3Event.transform.x = this.regionStartTransformX; - d3Event.transform.y = this.regionStartTransformY; - - const { startX, startY, width, height } = this.getRegionDimensions(d3Event); - - const region = { x1: startX, y1: startY, x2: startX + width, y2: startY + height }; - const selections = - CanvasUtils.selectInRegion(region, this.activePipeline, - this.config.enableLinkSelection !== LINK_SELECTION_NONE, - this.config.enableLinkType, - this.config.enableAssocLinkType); - this.canvasController.setSelections(selections, this.activePipeline.id); - } - - // Save the zoom amount. The canvas controller/object model will decide - // how this info is saved. - zoomSave() { - // Set the internal zoom value for canvasSVG used by D3. This will be - // used by d3Event next time a zoom action is initiated. - this.canvasSVG.property("__zoom", this.zoomTransform); - - const data = { - editType: "setZoom", - editSource: "canvas", - zoom: this.zoomTransform, - pipelineId: this.activePipeline.id - }; - this.canvasController.editActionHandler(data); - - } - // Repositions the comment toolbar so it is always over the top of the // comment being edited. - zoomCommentToolbar() { + repositionCommentToolbar() { if (this.config.enableMarkdownInComments && this.dispUtils.isDisplayingFullPage() && this.svgCanvasTextArea.isEditingText()) { @@ -2000,130 +1548,13 @@ export default class SVGCanvasRenderer { // Returns a position object that describes the position in page coordinates // of the comment toolbar so that it is positioned above the comment being edited. getCommentToolbarPos(com) { - const pos = this.unTransformPos({ x: com.x_pos, y: com.y_pos }); + const pos = this.zoomUtils.unTransformPos({ x: com.x_pos, y: com.y_pos }); return { x: pos.x + this.canvasLayout.commentToolbarPosX, y: pos.y + this.canvasLayout.commentToolbarPosY }; } - // Returns a new zoom which is the result of incrementing the current zoom - // by the amount since the previous d3Event transform amount. - // We calculate increments because d3Event.transform is not based on - // the constrained zoom position (which is very annoying) so we keep track - // of the current constraind zoom amount in this.zoomTransform. - getTransformIncrement(d3Event) { - const xInc = d3Event.transform.x - this.previousD3Event.x; - const yInc = d3Event.transform.y - this.previousD3Event.y; - - const newTransform = { x: this.zoomTransform.x + xInc, y: this.zoomTransform.y + yInc, k: d3Event.transform.k }; - this.previousD3Event = { x: d3Event.transform.x, y: d3Event.transform.y, k: d3Event.transform.k }; - return newTransform; - } - - // Returns a modifed transform object so that the canvas area (the area - // containing nodes and comments) is constrained such that it never totally - // disappears from the view port. - zoomConstrainRegular(transform, viewPort, canvasDimensions) { - if (!canvasDimensions) { - return this.zoomTransform; - } - - const k = transform.k; - let x = transform.x; - let y = transform.y; - - const canv = - this.convertCanvasDimensionsAdjustedForScaleWithPadding(canvasDimensions, k, this.getZoomToFitPadding()); - - const rightOffsetLimit = viewPort.width - Math.min((viewPort.width * 0.25), (canv.width * 0.25)); - const leftOffsetLimit = -(Math.max((canv.width - (viewPort.width * 0.25)), (canv.width * 0.75))); - - const bottomOffsetLimit = viewPort.height - Math.min((viewPort.height * 0.25), (canv.height * 0.25)); - const topOffsetLimit = -(Math.max((canv.height - (viewPort.height * 0.25)), (canv.height * 0.75))); - - if (x > -canv.left + rightOffsetLimit) { - x = -canv.left + rightOffsetLimit; - - } else if (x < -canv.left + leftOffsetLimit) { - x = -canv.left + leftOffsetLimit; - } - - if (y > -canv.top + bottomOffsetLimit) { - y = -canv.top + bottomOffsetLimit; - - } else if (y < -canv.top + topOffsetLimit) { - y = -canv.top + topOffsetLimit; - } - - return d3.zoomIdentity.translate(x, y).scale(k); - } - - // Returns the dimensions in SVG coordinates of the canvas area. This is - // based on the position and width and height of the nodes and comments. It - // does not include the 'super binding nodes' which are the binding nodes in - // a sub-flow that map to a port in the containing supernode. The dimensions - // are scaled by k and padded by pad (if provided). - getCanvasDimensionsAdjustedForScale(k, pad) { - const gap = this.canvasLayout.commentHighlightGap; - const canvasDimensions = this.activePipeline.getCanvasDimensions(gap); - return this.convertCanvasDimensionsAdjustedForScaleWithPadding(canvasDimensions, k, pad); - } - - convertCanvasDimensionsAdjustedForScaleWithPadding(canv, k, pad) { - const padding = pad || 0; - if (canv) { - return { - left: (canv.left * k) - padding, - top: (canv.top * k) - padding, - right: (canv.right * k) + padding, - bottom: (canv.bottom * k) + padding, - width: (canv.width * k) + (2 * padding), - height: (canv.height * k) + (2 * padding) - }; - } - return null; - } - - drawRegionSelector(d3Event) { - this.removeRegionSelector(); - const { startX, startY, width, height } = this.getRegionDimensions(d3Event); - - this.canvasGrp - .append("rect") - .attr("width", width) - .attr("height", height) - .attr("x", startX) - .attr("y", startY) - .attr("class", "d3-region-selector"); - } - - removeRegionSelector() { - this.canvasGrp.selectAll(".d3-region-selector").remove(); - } - - // Returns the startX, startY, width and height of the selection region - // where startX and startY are always the top left corner of the region - // and width and height are therefore always positive. - getRegionDimensions(d3Event) { - const transPos = this.getTransformedMousePos(d3Event); - let startX = this.zoomStartPoint.startX; - let startY = this.zoomStartPoint.startY; - let width = transPos.x - startX; - let height = transPos.y - startY; - - if (width < 0) { - width = Math.abs(width); - startX -= width; - } - if (height < 0) { - height = Math.abs(height); - startY -= height; - } - - return { startX, startY, width, height }; - } - // Returns the snap-to-grid position of the object positioned at objPos.x // and objPos.y. The grid that is snapped to is defined by this.snapToGridXPx // and this.snapToGridYPx values which are pixel values. @@ -2688,7 +2119,7 @@ export default class SVGCanvasRenderer { labelSel .attr("x", this.nodeUtils.getNodeLabelHoverPosX(d)) .attr("width", this.nodeUtils.getNodeLabelHoverWidth(d)) - .attr("height", this.nodeUtils.getNodeLabelHoverHeight(d, spanSel.node(), this.zoomTransform.k)); + .attr("height", this.nodeUtils.getNodeLabelHoverHeight(d, spanSel.node(), this.zoomUtils.getZoomScale())); spanSel.classed("d3-label-full", true); } }) @@ -2820,7 +2251,7 @@ export default class SVGCanvasRenderer { const nodeObj = foreignObj.parentElement; const nodeGrpSel = d3.select(nodeObj); const transform = this.nodeUtils.getNodeLabelEditIconTranslate(node, spanObj, - this.zoomTransform.k, this.config.enableDisplayFullLabelOnHover); + this.zoomUtils.getZoomScale(), this.config.enableDisplayFullLabelOnHover); this.displayEditIcon(spanObj, nodeGrpSel, transform, (d3Event, d) => this.displayNodeLabelTextArea(d, d3Event.currentTarget.parentNode)); @@ -2834,7 +2265,7 @@ export default class SVGCanvasRenderer { const decObj = foreignObj.parentElement; const decGrpSel = d3.select(decObj); const transform = this.decUtils.getDecLabelEditIconTranslate( - dec, obj, objType, spanObj, this.zoomTransform.k); + dec, obj, objType, spanObj, this.zoomUtils.getZoomScale()); this.displayEditIcon(spanObj, decGrpSel, transform, (d3Event, d) => this.displayDecLabelTextArea(dec, obj, objType, d3Event.currentTarget.parentNode)); @@ -3353,50 +2784,6 @@ export default class SVGCanvasRenderer { return expandedSupernodeHaveStyledNodes; } - // Returns the maximum amount for padding, when zooming to fit the canvas - // objects within a subflow, to allow the connection lines to be displayed - // without them doubling back on themselves. - getMaxZoomToFitPaddingForConnections() { - const paddingForInputBinding = this.getMaxPaddingForConnectionsFromInputBindingNodes(); - const paddingForOutputBinding = this.getMaxPaddingForConnectionsToOutputBindingNodes(); - const padding = Math.max(paddingForInputBinding, paddingForOutputBinding); - return padding; - } - - // Returns the maximum amount for padding, when zooming to fit the canvas - // objects within a subflow, to allow the connection lines (from input binding - // nodes to other sub-flow nodes) to be displayed without them doubling back - // on themselves. - getMaxPaddingForConnectionsFromInputBindingNodes() { - let maxPadding = 0; - const inputBindingNodes = this.activePipeline.nodes.filter((n) => n.isSupernodeInputBinding); - - inputBindingNodes.forEach((n) => { - const nodePadding = CanvasUtils.getNodePaddingToTargetNodes(n, this.activePipeline.nodes, - this.activePipeline.links, this.canvasLayout.linkType); - maxPadding = Math.max(maxPadding, nodePadding); - }); - - return maxPadding; - } - - // Returns the maximum amount for padding, when zooming to fit the canvas - // objects within a subflow, to allow the connection lines (from sub-flow nodes - // to output binding nodes) to be displayed without them doubling back - // on themselves. - getMaxPaddingForConnectionsToOutputBindingNodes() { - let maxPadding = 0; - const outputBindingNodes = this.activePipeline.nodes.filter((n) => n.isSupernodeOutputBinding); - - this.activePipeline.nodes.forEach((n) => { - const nodePadding = CanvasUtils.getNodePaddingToTargetNodes(n, outputBindingNodes, - this.activePipeline.links, this.canvasLayout.linkType); - maxPadding = Math.max(maxPadding, nodePadding); - }); - - return maxPadding; - } - getPortRadius(d) { return CanvasUtils.isSuperBindingNode(d) ? this.getBindingPortRadius() : d.layout.portRadius; } @@ -3404,7 +2791,7 @@ export default class SVGCanvasRenderer { // Returns the radius size of the supernode binding ports scaled up by // the zoom scale amount to give the actual size. getBindingPortRadius() { - return this.canvasLayout.supernodeBindingPortRadius / this.zoomTransform.k; + return this.canvasLayout.supernodeBindingPortRadius / this.zoomUtils.getZoomScale(); } addDynamicNodeIcons(d3Event, d, nodeGrp) { @@ -3437,7 +2824,7 @@ export default class SVGCanvasRenderer { !this.svgCanvasTextArea.isEditingText() && !CanvasUtils.isSuperBindingNode(d)) { this.canvasController.setMouseInObject(true); let pos = this.getContextToolbarPos(objType, d); - pos = this.unTransformPos(pos); + pos = this.zoomUtils.unTransformPos(pos); this.openContextMenu(d3Event, objType, d, null, pos); } } @@ -3650,7 +3037,7 @@ export default class SVGCanvasRenderer { selectedObjectIds: this.canvasController.getSelectedObjectIds(), addBreadcrumbs: (d && d.type === SUPER_NODE) ? this.getSupernodeBreadcrumbs(d3Event.currentTarget) : null, port: port, - zoom: this.zoomTransform.k }); + zoom: this.zoomUtils.getZoomScale() }); } closeContextMenuIfOpen() { @@ -3820,7 +3207,7 @@ export default class SVGCanvasRenderer { .each((d) => { let portRadius = d.layout.portRadius; if (CanvasUtils.isSuperBindingNode(d)) { - portRadius = this.canvasLayout.supernodeBindingPortRadius / this.zoomTransform.k; + portRadius = this.canvasLayout.supernodeBindingPortRadius / this.zoomUtils.getZoomScale(); } if (pos.x >= d.x_pos - portRadius - prox && // Target port sticks out by its radius so need to allow for it. @@ -4078,7 +3465,7 @@ export default class SVGCanvasRenderer { // necessary with binding nodes with mutiple ports. let multiplier = 1; if (CanvasUtils.isSuperBindingNode(data)) { - multiplier = 1 / this.zoomTransform.k; + multiplier = 1 / this.zoomUtils.getZoomScale(); } ports.forEach((p) => { @@ -4132,7 +3519,7 @@ export default class SVGCanvasRenderer { // necessary with binding nodes with mutiple ports. let multiplier = 1; if (CanvasUtils.isSuperBindingNode(data)) { - multiplier = 1 / this.zoomTransform.k; + multiplier = 1 / this.zoomUtils.getZoomScale(); } ports.forEach((p) => { yPosition += (data.layout.portArcRadius * multiplier); @@ -5603,20 +4990,21 @@ export default class SVGCanvasRenderer { !this.regionSelect && !this.isDragging() && !this.isSizing(); } - // Return the x,y coordinates of the svg group relative to the window's viewport - // This is used when a new comment is created from the toolbar to make sure the - // new comment always appears in the view port. + // Return the x,y coordinates for the default position of a new comment + // created from the toolbar. This make sure the new comment always appears + // in the top left corner of the view port. getDefaultCommentOffset() { let xPos = this.canvasLayout.addCommentOffsetX; let yPos = this.canvasLayout.addCommentOffsetY; + const z = this.zoomUtils.getZoomTransform(); - if (this.zoomTransform) { - xPos = this.zoomTransform.x / this.zoomTransform.k; - yPos = this.zoomTransform.y / this.zoomTransform.k; + if (z) { + const xPanByScale = z.x / z.k; + const yPanByScale = z.y / z.k; - // The window's viewport is in the opposite direction of zoomTransform - xPos = -xPos + this.canvasLayout.addCommentOffsetX; - yPos = -yPos + this.canvasLayout.addCommentOffsetY; + // Offset in the negative direction. + xPos = -xPanByScale + this.canvasLayout.addCommentOffsetX; + yPos = -yPanByScale + this.canvasLayout.addCommentOffsetY; } if (this.config.enableSnapToGridType === SNAP_TO_GRID_DURING || diff --git a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-external.js b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-external.js index c0f5156491..5f94080500 100644 --- a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-external.js +++ b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-external.js @@ -53,20 +53,20 @@ export default class SvgCanvasExternal { setPortPositions(info) { const node = this.ren.activePipeline.getNode(info.nodeId); - const zoomTransform = this.ren.getZoomTransform(); + const k = this.ren.zoomUtils.getZoomScale(); if (info.inputPositions) { info.inputPositions.forEach((inputPos) => { const inp = node.inputs.find((input) => input.id === inputPos.id); - inp.cx = inputPos.cx / zoomTransform.k; - inp.cy = inputPos.cy / zoomTransform.k; + inp.cx = inputPos.cx / k; + inp.cy = inputPos.cy / k; }); } if (info.outputPositions) { info.outputPositions.forEach((outputPos) => { const out = node.outputs.find((output) => output.id === outputPos.id); - out.cx = outputPos.cx / zoomTransform.k; - out.cy = outputPos.cy / zoomTransform.k; + out.cx = outputPos.cx / k; + out.cy = outputPos.cy / k; }); } this.ren.displayLinks(); diff --git a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-zoom.js b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-zoom.js new file mode 100644 index 0000000000..a6f24cbb72 --- /dev/null +++ b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-zoom.js @@ -0,0 +1,780 @@ +/* + * Copyright 2017-2023 Elyra Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint no-lonely-if: "off" */ + +import * as d3Selection from "d3-selection"; +import * as d3Zoom from "./d3-zoom-extension/src"; +const d3 = Object.assign({}, d3Selection, d3Zoom); + +import Logger from "../logging/canvas-logger.js"; +import CanvasUtils from "./common-canvas-utils.js"; +import { INTERACTION_CARBON, INTERACTION_MOUSE, INTERACTION_TRACKPAD } + from "./constants/canvas-constants.js"; + +// This utility file provides a d3-zoom handler which manages zoom operations +// on the canvas as well as various utility functions to handle zoom behavior. + +export default class SVGCanvasUtilsZoom { + + constructor(renderer) { + this.ren = renderer; + + this.logger = new Logger("SVGCanvasUtilsZoom"); + + // Dimensions for extent of canvas scaling + this.minScaleExtent = 0.2; + this.maxScaleExtent = 1.8; + + // Keep track of when the context menu has been closed, so we don't remove + // selections when a context menu is closed during a zoom gesture. + this.contextMenuClosedOnZoom = false; + + // Keep track of when text editing has been closed, so we don't remove + // selections when that happens during a zoom gesture. + this.textEditingClosedOnZoom = false; + + // Used to monitor the region selection rectangle. + this.regionSelect = false; + + // Used to track the start of the zoom. + this.zoomStartPoint = { x: 0, y: 0, k: 0, startX: 0, startY: 0 }; + + // Stores the previous events from D3 so we can calculate zoom increment amounts. + this.previousD3Event = {}; + + // Stores the dimensions of the canvas to save recalculating the size on + // each zoom increment. + this.zoomCanvasDimensions = {}; + + // I was not able to figure out how to use the zoom filter method to + // allow mousedown and mousemove messages to go through to the canvas to + // do region selection. Therefore I had to implement region selection in + // the zoom methods. This has the side effect that, when a region is + // selected, d3Event.transform.x and d3Event.transform.y are incremented + // even though the objects in the canvas have not moved. The values below + // are used to store the current transform x and y amounts at the beginning + // of the region selection and then restore those amounts at the end of + // the region selection. + this.regionStartTransformX = 0; + this.regionStartTransformY = 0; + + // Stores the current zoom transform amounts. + this.zoomTransform = d3.zoomIdentity.translate(0, 0).scale(1); + + // Flag to indicate when a zoom handled by zoomHandler is hapening. + this.zooming = false; + + // Flag to indicate when a zoom is invoked programmatically. + this.zoomingAction = false; + + // Flag to indicate when the space key is down (used when dragging). + this.spaceKeyPressed = false; + + // Create a zoom handler for use with the canvas. + this.zoomHandler = + d3.zoom() + .trackpad(this.ren.config.enableInteractionType === INTERACTION_TRACKPAD) + .preventBackGesture(true) + .wheelDelta((d3Event) => -d3Event.deltaY * (this.ren.config.enableInteractionType === INTERACTION_TRACKPAD ? 0.02 : 0.002)) + .scaleExtent([this.minScaleExtent, this.maxScaleExtent]) + .on("start", this.zoomStart.bind(this)) + .on("zoom", this.zoomAction.bind(this)) + .on("end", this.zoomEnd.bind(this)); + + } + + // Saves the state when the user presses and holds the space bar. This + // can be used for gestures that require the space bar to be held down. + setSpaceKeyPressed(state) { + this.spaceKeyPressed = state; + } + + // Returns true if the space bar is pressed and held down. + isSpaceKeyPressed() { + return this.spaceKeyPressed; + } + + // Returns the dragObjectsHandler + getZoomHandler() { + return this.zoomHandler; + } + + // Returns the zoom transform object. + getZoomTransform() { + return this.zoomTransform; + } + + // Returns a copy of the zoom transform object. + getZoom() { + return { ...this.zoomTransform }; + } + + getZoomScale() { + return this.zoomTransform.k; + } + + // Resets the local zoom transform object to the default (identity) zoom. + resetZoomTransform() { + this.zoomTransform = d3.zoomIdentity.translate(0, 0).scale(1); + } + + // Zooms the canvas to the extent specified in the zoom object. + zoomTo(zoomObject) { + const animateTime = 500; + this.zoomCanvasInvokeZoomBehavior(zoomObject, animateTime); + } + + // Pans the canvas by the x and y amount specified in the time specified. + translateBy(x, y, animateTime) { + const z = this.getZoomTransform(); + const zoomObject = d3.zoomIdentity.translate(z.x + x, z.y + y).scale(z.k); + this.zoomCanvasInvokeZoomBehavior(zoomObject, animateTime); + } + + // Zooms in the canvas by an increment amount. + zoomIn() { + if (this.zoomTransform.k < this.maxScaleExtent) { + const newScale = Math.min(this.zoomTransform.k * 1.1, this.maxScaleExtent); + this.ren.canvasSVG.call(this.zoomHandler.scaleTo, newScale); + } + } + + // Zooms out the canvas by an increment amount. + zoomOut() { + if (this.zoomTransform.k > this.minScaleExtent) { + const newScale = Math.max(this.zoomTransform.k / 1.1, this.minScaleExtent); + this.ren.canvasSVG.call(this.zoomHandler.scaleTo, newScale); + } + } + + // Returns true if the canvas is currently zoomed to the maximum amount. + isZoomedToMax() { + return this.zoomTransform ? this.zoomTransform.k === this.maxScaleExtent : false; + } + + // Returns true if the canvas is currently zoomed to the minimum amount. + isZoomedToMin() { + return this.zoomTransform ? this.zoomTransform.k === this.minScaleExtent : false; + } + + // Sets the maximum zoom extent by multiplying the current extent by + // the factor passed in. + setMaxZoomExtent(factor) { + // Don't allow the scale extent to be changed while in the middle of a + // zoom operation. + if (this.zooming) { + return; + } + const newMaxExtent = this.maxScaleExtent * factor; + this.zoomHandler = this.zoomHandler.scaleExtent([this.minScaleExtent, newMaxExtent]); + this.ren.resetCanvasSVGBehaviors(); + } + + // Transforms the x and y fields passed in by the current zoom + // transformation amounts to convert a coordinate position in screen pixels + // to a canvas coordinate position. + transformPos(pos) { + return { + x: (pos.x - this.zoomTransform.x) / this.zoomTransform.k, + y: (pos.y - this.zoomTransform.y) / this.zoomTransform.k + }; + } + + // Transforms the x and y fields passed in by the current zoom + // transformation amounts to convert a canvas coordinate position + // to a coordinate position in screen pixels. + unTransformPos(pos) { + return { + x: (pos.x * this.zoomTransform.k) + this.zoomTransform.x, + y: (pos.y * this.zoomTransform.k) + this.zoomTransform.y + }; + } + + // Transforms the x, y, height and width fields of the object passed in by the + // current zoom transformation amounts to convert coordinate positions and + // dimensions in screen pixels to coordinate positions and dimensions in + // zoomed pixels. + getTransformedRect(svgRect, pad) { + const transPad = (pad / this.zoomTransform.k); + return { + x: (-this.zoomTransform.x / this.zoomTransform.k) + transPad, + y: (-this.zoomTransform.y / this.zoomTransform.k) + transPad, + height: (svgRect.height / this.zoomTransform.k) - (2 * transPad), + width: (svgRect.width / this.zoomTransform.k) - (2 * transPad) + }; + } + + // Handles the beginning of a zoom action + zoomStart(d3Event) { + this.logger.log("zoomStart - " + JSON.stringify(d3Event.transform)); + + // Ensure any open tip is closed before starting a zoom operation. + this.ren.canvasController.closeTip(); + + this.zooming = true; + + // Close the context menu, if it's open, before panning or zooming. + // If the context menu is opened inside the expanded supernode (in-place + // subflow), when the user zooms the canvas, the full page flow is handling + // that zoom, which causes a refresh in the subflow, so the full page flow + // will take care of closing the context menu. This means the in-place + // subflow doesn’t need to do anything on zoom, + // hence: !this.ren.dispUtils.isDisplayingSubFlowInPlace() + if (this.ren.canvasController.isContextMenuDisplayed() && + !this.ren.dispUtils.isDisplayingSubFlowInPlace()) { + this.ren.canvasController.closeContextMenu(); + this.contextMenuClosedOnZoom = true; + } + + // Any text editing in progress will be closed by the textarea's blur event + // if the user clicks on the canvas background. So we set this flag to + // prevent the selection being lost in the zoomEnd (mouseup) event. + if (this.ren.svgCanvasTextArea.isEditingText()) { + this.textEditingClosedOnZoom = true; + } + + this.regionSelect = this.isRegionSelectActivated(d3Event); + + if (this.regionSelect) { + // Add a delay so, if the user just clicks, they don't see the crosshair. + // This will be cleared in zoomEnd if the user's click takes less than 200 ms. + this.addingCursorOverlay = setTimeout(() => this.ren.addTempCursorOverlay("crosshair"), 200); + this.regionStartTransformX = d3Event.transform.x; + this.regionStartTransformY = d3Event.transform.y; + + } else { + if (this.isDragActivated(d3Event)) { + this.addingCursorOverlay = setTimeout(() => this.ren.addTempCursorOverlay("grabbing"), 200); + } else { + this.addingCursorOverlay = setTimeout(() => this.ren.addTempCursorOverlay("default"), 200); + } + } + + const transPos = this.ren.getTransformedMousePos(d3Event); + this.zoomStartPoint = { x: d3Event.transform.x, y: d3Event.transform.y, k: d3Event.transform.k, startX: transPos.x, startY: transPos.y }; + this.previousD3Event = { ...d3Event.transform }; + + // Store the canvas dimensions so we don't have to recalculate + // them for every zoom action event. + this.zoomCanvasDimensions = this.getCanvasDimensions(); + } + + // Handles each increment of a zoom action + zoomAction(d3Event) { + this.logger.log("zoomAction - " + JSON.stringify(d3Event.transform)); + + // If the scale amount is the same we are not zooming, so we must be panning. + if (d3Event.transform.k === this.zoomStartPoint.k) { + if (this.regionSelect) { + this.drawRegionSelector(d3Event); + + } else { + this.zoomCanvasBackground(d3Event); + } + } else { + this.ren.addTempCursorOverlay("default"); + this.zoomCanvasBackground(d3Event); + this.ren.repositionCommentToolbar(); + } + } + + // Handles the end of a zoom action + zoomEnd(d3Event) { + this.logger.log("zoomEnd - " + JSON.stringify(d3Event.transform)); + + // Clears the display of the cursor overlay if the user clicks within 200 ms + clearTimeout(this.addingCursorOverlay); + + const transPos = this.ren.getTransformedMousePos(d3Event); + + // The user just clicked -- with no drag. + if (transPos.x === this.zoomStartPoint.startX && + transPos.y === this.zoomStartPoint.startY && + !this.zoomChanged()) { + this.zoomClick(d3Event); + + } else if (this.regionSelect) { + this.zoomEndRegionSelect(d3Event); + + } else if (this.ren.dispUtils.isDisplayingFullPage() && this.zoomChanged()) { + this.zoomSave(); + } + + // Remove the cursor overlay and reset the SVG background rectangle + // cursor style, which was set in the zoom start method. + this.ren.resetCanvasCursor(d3Event); + this.ren.removeTempCursorOverlay(); + this.contextMenuClosedOnZoom = false; + this.textEditingClosedOnZoom = false; + this.regionSelect = false; + this.zooming = false; + } + + // Returns true if the current zoom transform is different from the + // zoom values at the beginning of the zoom action. + zoomChanged() { + return (this.zoomTransform.k !== this.zoomStartPoint.k || + this.zoomTransform.x !== this.zoomStartPoint.x || + this.zoomTransform.y !== this.zoomStartPoint.y); + } + + // Returns true if the event indicates that a drag (rather than a region + // select) is in action. This means that, with the Carbon interation + // option the space bar is pressed or with legacy interation the + // shift key is NOT pressed. + isDragActivated(d3Event) { + if (this.ren.config.enableInteractionType === INTERACTION_CARBON) { + return this.isSpaceKeyPressed(); + } + return (d3Event && d3Event.sourceEvent && !d3Event.sourceEvent.shiftKey); + } + + // Returns true if the region select gesture is requested by the user. + isRegionSelectActivated(d3Event) { + // The this.zoomingAction flag indicates zooming is being invoked + // programmatically. + if (this.zoomingAction) { + return false; + + } else if (this.ren.config.enableInteractionType === INTERACTION_MOUSE && + (d3Event && d3Event.sourceEvent && d3Event.sourceEvent.shiftKey)) { + return true; + + } else if (this.ren.config.enableInteractionType === INTERACTION_CARBON && + !this.isSpaceKeyPressed()) { + return true; + + } else if (this.ren.config.enableInteractionType === INTERACTION_TRACKPAD && + (d3Event.sourceEvent && d3Event.sourceEvent.buttons === 1) && // Main button is pressed + !this.isSpaceKeyPressed()) { + return true; + } + + return false; + } + + drawRegionSelector(d3Event) { + this.removeRegionSelector(); + const { x, y, width, height } = this.getRegionDimensions(d3Event); + + this.ren.canvasGrp + .append("rect") + .attr("width", width) + .attr("height", height) + .attr("x", x) + .attr("y", y) + .attr("class", "d3-region-selector"); + } + + // Handles the behavior when the user stops doing a region select. + zoomEndRegionSelect(d3Event) { + this.removeRegionSelector(); + + // Reset the transform x and y to what they were before the region + // selection action was started. This directly sets the x and y values + // in the __zoom property of the svgCanvas DOM object. + d3Event.transform.x = this.regionStartTransformX; + d3Event.transform.y = this.regionStartTransformY; + + this.ren.selectObjsInRegion( + this.getRegionDimensions(d3Event)); + } + + // Removes the region selection graphic rectangle. + removeRegionSelector() { + this.ren.canvasGrp.selectAll(".d3-region-selector").remove(); + } + + // Returns the x, y, width and height of the selection region + // where x and y are always the top left corner of the region + // and width and height are therefore always positive. + getRegionDimensions(d3Event) { + const transPos = this.ren.getTransformedMousePos(d3Event); + let x = this.zoomStartPoint.startX; + let y = this.zoomStartPoint.startY; + let width = transPos.x - x; + let height = transPos.y - y; + + if (width < 0) { + width = Math.abs(width); + x -= width; + } + if (height < 0) { + height = Math.abs(height); + y -= height; + } + + return { x, y, width, height }; + } + + // Performs zoom behaviors for each incremental zoom action. + zoomCanvasBackground(d3Event) { + this.regionSelect = false; + + if (this.ren.dispUtils.isDisplayingPrimaryFlowFullPage()) { + const incTransform = this.getTransformIncrement(d3Event); + this.zoomTransform = this.zoomConstrainRegular(incTransform, this.getViewportDimensions(), this.zoomCanvasDimensions); + } else { + this.zoomTransform = d3.zoomIdentity.translate(d3Event.transform.x, d3Event.transform.y).scale(d3Event.transform.k); + } + + this.ren.canvasGrp.attr("transform", this.zoomTransform); + + this.ren.displayCanvasAccoutrements(); + } + + // Handles a zoom operation that is just a click on the canvas background. + zoomClick(d3Event) { + // Only clear selections under these conditions: + // * if the click was on the canvas of the current active pipeline. (This + // is because clicking on the canvas background of an expanded supernode + // should select that node.) + // * if we have not just closed a context menu + // * if we have not just closed text editing + // * if editing actions are enabled OR the target is the canvas background. + // (This condition is necessary because when editing actions are disabled, + // for a read-only canvas, the mouse-up over a node can cause this method + // to be called.) + if (this.ren.dispUtils.isDisplayingCurrentPipeline() && + !this.contextMenuClosedOnZoom && + !this.textEditingClosedOnZoom && + (this.ren.config.enableEditingActions || + (d3Event.sourceEvent?.target?.classList?.contains("d3-svg-background")))) { + this.ren.canvasController.clearSelections(); + } + } + + // Save the zoom amount. The canvas controller/object model will decide + // how this info is saved. + zoomSave() { + // Set the internal zoom value for canvasSVG used by D3. This will be + // used by d3Event next time a zoom action is initiated. + this.ren.canvasSVG.property("__zoom", this.zoomTransform); + + const data = { + editType: "setZoom", + editSource: "canvas", + zoom: this.zoomTransform, + pipelineId: this.ren.activePipeline.id + }; + this.ren.canvasController.editActionHandler(data); + + } + + // Returns a new zoom which is the result of incrementing the current zoom + // by the amount since the previous d3Event event. + // We calculate increments because d3Event.transform is not based on + // the constrained zoom position (which is very annoying) so we keep track + // of the current constraind zoom amount in this.zoomTransform. + getTransformIncrement(d3Event) { + const xInc = d3Event.transform.x - this.previousD3Event.x; + const yInc = d3Event.transform.y - this.previousD3Event.y; + + const newTransform = { x: this.zoomTransform.x + xInc, y: this.zoomTransform.y + yInc, k: d3Event.transform.k }; + this.previousD3Event = { ...d3Event.transform }; + return newTransform; + } + + // Returns a modifed transform object so that the canvas area (the area + // containing nodes and comments) is constrained such that it never totally + // disappears from the view port. + zoomConstrainRegular(transform, viewPort, canvasDimensions) { + if (!canvasDimensions) { + return this.zoomTransform; + } + + const k = transform.k; + let x = transform.x; + let y = transform.y; + + const canv = + this.convertRectAdjustedForScaleWithPadding(canvasDimensions, k, this.getZoomToFitPadding()); + + const rightOffsetLimit = viewPort.width - Math.min((viewPort.width * 0.25), (canv.width * 0.25)); + const leftOffsetLimit = -(Math.max((canv.width - (viewPort.width * 0.25)), (canv.width * 0.75))); + + const bottomOffsetLimit = viewPort.height - Math.min((viewPort.height * 0.25), (canv.height * 0.25)); + const topOffsetLimit = -(Math.max((canv.height - (viewPort.height * 0.25)), (canv.height * 0.75))); + + if (x > -canv.left + rightOffsetLimit) { + x = -canv.left + rightOffsetLimit; + + } else if (x < -canv.left + leftOffsetLimit) { + x = -canv.left + leftOffsetLimit; + } + + if (y > -canv.top + bottomOffsetLimit) { + y = -canv.top + bottomOffsetLimit; + + } else if (y < -canv.top + topOffsetLimit) { + y = -canv.top + topOffsetLimit; + } + + return d3.zoomIdentity.translate(x, y).scale(k); + } + + // Restores the zoom of the canvas, if it has changed, based on the type + // of 'save zoom' specified in the configuration and, if no saved zoom, was + // provided pans the canvas area so it is always visible. + restoreZoom() { + let newZoom = this.ren.canvasController.getSavedZoom(this.ren.activePipeline.id); + + // If there's no saved zoom, and enablePanIntoViewOnOpen is set, pan so + // the canvas area (containing nodes and comments) is visible in the viewport. + if (!newZoom && this.ren.config.enablePanIntoViewOnOpen) { + const canvWithPadding = this.getCanvasDimensionsWithPadding(); + if (canvWithPadding) { + newZoom = { x: -canvWithPadding.left, y: -canvWithPadding.top, k: 1 }; + } + } + + // If there's no saved zoom and we have some initial pan amounts provided use them. + if (!newZoom && this.ren.canvasLayout.initialPanX && this.ren.canvasLayout.initialPanY) { + newZoom = { x: this.ren.canvasLayout.initialPanX, y: this.ren.canvasLayout.initialPanY, k: 1 }; + } + + // If new zoom is different to the current zoom amount, apply it. + if (newZoom && + (newZoom.k !== this.zoomTransform.k || + newZoom.x !== this.zoomTransform.x || + newZoom.y !== this.zoomTransform.y)) { + this.zoomCanvasInvokeZoomBehavior(newZoom); + } + } + + // Zooms the canvas to the amount specified in newZoomTransform. Zooming the + // canvas in this way will invoke the zoom behavior methods: zoomStart, + // zoomAction and zoomEnd. It does not perform a zoom if newZoomTransform + // is the same as the current zoom transform. + zoomCanvasInvokeZoomBehavior(newZoomTransform, animateTime) { + if (isFinite(newZoomTransform.x) && + isFinite(newZoomTransform.y) && + isFinite(newZoomTransform.k) && + this.zoomHasChanged(newZoomTransform)) { + this.zoomingAction = true; + const zoomTransform = d3.zoomIdentity.translate(newZoomTransform.x, newZoomTransform.y).scale(newZoomTransform.k); + if (animateTime) { + this.ren.canvasSVG.call(this.zoomHandler).transition() + .duration(animateTime) + .call(this.zoomHandler.transform, zoomTransform); + } else { + this.ren.canvasSVG.call(this.zoomHandler.transform, zoomTransform); + } + this.zoomingAction = false; + } + } + + // Return true if the new zoom transform passed in is different from the + // current zoom transform. + zoomHasChanged(newZoomTransform) { + return newZoomTransform.k !== this.zoomTransform.k || + newZoomTransform.x !== this.zoomTransform.x || + newZoomTransform.y !== this.zoomTransform.y; + } + + // Zooms the canvas to fit in the current viewport. + zoomToFit() { + const canvasDimensions = this.getCanvasDimensionsWithPadding(); + const viewPortDimensions = this.getViewportDimensions(); + + if (canvasDimensions) { + const xRatio = viewPortDimensions.width / canvasDimensions.width; + const yRatio = viewPortDimensions.height / canvasDimensions.height; + const newScale = Math.min(xRatio, yRatio, 1); // Don't let the canvas be scaled more than 1 in either direction + + let x = (viewPortDimensions.width - (canvasDimensions.width * newScale)) / 2; + let y = (viewPortDimensions.height - (canvasDimensions.height * newScale)) / 2; + + x -= newScale * canvasDimensions.left; + y -= newScale * canvasDimensions.top; + + this.zoomCanvasInvokeZoomBehavior({ x: x, y: y, k: newScale }); + } + } + + // Returns a zoom object that will, if applied to the canvas, zoom the objects + // dentified in the objectIDs array so their center is at the xPos, yPos + // position in the viewport. + getZoomToReveal(objectIDs, xPos, yPos) { + const transformedSVGRect = this.getTransformedViewportDimensions(); + const nodes = this.ren.activePipeline.getNodes(objectIDs); + const comments = this.ren.activePipeline.getComments(objectIDs); + const links = this.ren.activePipeline.getLinks(objectIDs); + + if (nodes.length > 0 || comments.length > 0 || links.length > 0) { + const canvasDimensions = CanvasUtils.getCanvasDimensions(nodes, comments, links, 0, 0, true); + const canv = this.convertRectAdjustedForScaleWithPadding(canvasDimensions, 1, 10); + const xPosInt = parseInt(xPos, 10); + const yPosInt = typeof yPos === "undefined" ? xPosInt : parseInt(yPos, 10); + + if (canv) { + let xOffset; + let yOffset; + + if (!Number.isNaN(xPosInt) && !Number.isNaN(yPosInt)) { + xOffset = transformedSVGRect.x + (transformedSVGRect.width * (xPosInt / 100)) - (canv.left + (canv.width / 2)); + yOffset = transformedSVGRect.y + (transformedSVGRect.height * (yPosInt / 100)) - (canv.top + (canv.height / 2)); + + } else { + if (canv.right > transformedSVGRect.x + transformedSVGRect.width) { + xOffset = transformedSVGRect.x + transformedSVGRect.width - canv.right; + } + if (canv.left < transformedSVGRect.x) { + xOffset = transformedSVGRect.x - canv.left; + } + if (canv.bottom > transformedSVGRect.y + transformedSVGRect.height) { + yOffset = transformedSVGRect.y + transformedSVGRect.height - canv.bottom; + } + if (canv.top < transformedSVGRect.y) { + yOffset = transformedSVGRect.y - canv.top; + } + } + + if (typeof xOffset !== "undefined" || typeof yOffset !== "undefined") { + const x = this.zoomTransform.x + ((xOffset || 0)) * this.zoomTransform.k; + const y = this.zoomTransform.y + ((yOffset || 0)) * this.zoomTransform.k; + return { x: x || 0, y: y || 0, k: this.zoomTransform.k }; + } + } + } + + return null; + } + + // Returns the maximum amount for padding, when zooming to fit the canvas + // objects within a subflow, to allow the connection lines to be displayed + // without them doubling back on themselves. + getMaxZoomToFitPaddingForConnections() { + const paddingForInputBinding = this.getMaxPaddingForConnectionsFromInputBindingNodes(); + const paddingForOutputBinding = this.getMaxPaddingForConnectionsToOutputBindingNodes(); + const padding = Math.max(paddingForInputBinding, paddingForOutputBinding); + return padding; + } + + // Returns the maximum amount for padding, when zooming to fit the canvas + // objects within a subflow, to allow the connection lines (from input binding + // nodes to other sub-flow nodes) to be displayed without them doubling back + // on themselves. + getMaxPaddingForConnectionsFromInputBindingNodes() { + let maxPadding = 0; + const inputBindingNodes = this.ren.activePipeline.nodes.filter((n) => n.isSupernodeInputBinding); + + inputBindingNodes.forEach((n) => { + const nodePadding = CanvasUtils.getNodePaddingToTargetNodes(n, this.ren.activePipeline.nodes, + this.ren.activePipeline.links, this.ren.canvasLayout.linkType); + maxPadding = Math.max(maxPadding, nodePadding); + }); + + return maxPadding; + } + + // Returns the maximum amount for padding, when zooming to fit the canvas + // objects within a subflow, to allow the connection lines (from sub-flow nodes + // to output binding nodes) to be displayed without them doubling back + // on themselves. + getMaxPaddingForConnectionsToOutputBindingNodes() { + let maxPadding = 0; + const outputBindingNodes = this.ren.activePipeline.nodes.filter((n) => n.isSupernodeOutputBinding); + + this.ren.activePipeline.nodes.forEach((n) => { + const nodePadding = CanvasUtils.getNodePaddingToTargetNodes(n, outputBindingNodes, + this.ren.activePipeline.links, this.ren.canvasLayout.linkType); + maxPadding = Math.max(maxPadding, nodePadding); + }); + + return maxPadding; + } + + // Returns an object representing the viewport dimensions which have been + // transformed for the current zoom amount. + getTransformedViewportDimensions() { + const svgRect = this.getViewportDimensions(); + return this.getTransformedRect(svgRect, 0); + } + + // Returns the dimensions of the SVG area. When we are displaying a sub-flow + // we can use the supernode's dimensions. If not we are displaying + // full-page so we can use getBoundingClientRect() to get the dimensions + // (for some reason that method doesn't return correct values with embedded SVG areas). + getViewportDimensions() { + let viewportDimensions = {}; + + if (this.ren.dispUtils.isDisplayingSubFlowInPlace()) { + const dims = this.ren.getParentSupernodeSVGDimensions(); + viewportDimensions.width = dims.width; + viewportDimensions.height = dims.height; + + } else { + if (this.ren.canvasSVG && this.ren.canvasSVG.node()) { + viewportDimensions = this.ren.canvasSVG.node().getBoundingClientRect(); + } else { + viewportDimensions = { x: 0, y: 0, width: 1100, height: 640 }; // Return a sensible default (for Jest tests) + } + } + return viewportDimensions; + } + + // Returns the dimensions in SVG coordinates of the canvas area. This is + // based on the position and width and height of the nodes and comments. It + // does not include the 'super binding nodes' which are the binding nodes in + // a sub-flow that map to a port in the containing supernode. The dimensions + // include an appropriate padding amount. + getCanvasDimensionsWithPadding() { + return this.getCanvasDimensions(this.getZoomToFitPadding()); + } + + // Returns the dimensions in SVG coordinates of the canvas area. This is + // based on the position and width and height of the nodes and comments. It + // does not include the 'super binding nodes' which are the binding nodes in + // a sub-flow that map to a port in the containing supernode. If a pad is + // provided, it is also added in to the dimensions. + getCanvasDimensions(pad) { + const gap = this.ren.canvasLayout.commentHighlightGap; + const canvasDimensions = this.ren.activePipeline.getCanvasDimensions(gap); + return this.convertRectAdjustedForScaleWithPadding(canvasDimensions, 1, pad); + } + + // Returns a rect object describing the rect passed in but + // scaled by k and with padding added. + convertRectAdjustedForScaleWithPadding(rect, k, pad = 0) { + if (rect) { + return { + left: (rect.left * k) - pad, + top: (rect.top * k) - pad, + right: (rect.right * k) + pad, + bottom: (rect.bottom * k) + pad, + width: (rect.width * k) + (2 * pad), + height: (rect.height * k) + (2 * pad) + }; + } + return null; + } + + // Returns the padding space for the canvas objects to be zoomed which takes + // into account any connections that need to be made to/from any sub-flow + // binding nodes plus any space needed for the binding nodes ports. + getZoomToFitPadding() { + let padding = this.ren.canvasLayout.zoomToFitPadding; + + if (this.ren.dispUtils.isDisplayingSubFlow()) { + // Allocate some space for connecting lines and the binding node ports + const newPadding = this.getMaxZoomToFitPaddingForConnections() + (2 * this.ren.canvasLayout.supernodeBindingPortRadius); + padding = Math.max(padding, newPadding); + } + return padding; + } +} From bf6c36221e83b4b14a0d0f3e8ce308554e4284a2 Mon Sep 17 00:00:00 2001 From: CTomlyn Date: Mon, 6 Nov 2023 16:01:57 -0800 Subject: [PATCH 2/3] #1617 Using keyboard to Redo (Command + Shift + Z) beyond end of command stack causes error (#1618) --- .../common-canvas/src/common-canvas/cc-contents.jsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/canvas_modules/common-canvas/src/common-canvas/cc-contents.jsx b/canvas_modules/common-canvas/src/common-canvas/cc-contents.jsx index 24d0d63e63..ff6871383a 100644 --- a/canvas_modules/common-canvas/src/common-canvas/cc-contents.jsx +++ b/canvas_modules/common-canvas/src/common-canvas/cc-contents.jsx @@ -200,12 +200,16 @@ class CanvasContents extends React.Component { } else if (CanvasUtils.isCmndCtrlPressed(evt) && !evt.shiftKey && evt.keyCode === Z_KEY && actions.undo) { CanvasUtils.stopPropagationAndPreventDefault(evt); - this.props.canvasController.keyboardActionHandler("undo"); + if (this.props.canvasController.canUndo()) { + this.props.canvasController.keyboardActionHandler("undo"); + } } else if (CanvasUtils.isCmndCtrlPressed(evt) && ((evt.shiftKey && evt.keyCode === Z_KEY) || evt.keyCode === Y_KEY && actions.redo)) { CanvasUtils.stopPropagationAndPreventDefault(evt); - this.props.canvasController.keyboardActionHandler("redo"); + if (this.props.canvasController.canRedo()) { + this.props.canvasController.keyboardActionHandler("redo"); + } } else if (CanvasUtils.isCmndCtrlPressed(evt) && evt.keyCode === C_KEY && actions.copyToClipboard) { CanvasUtils.stopPropagationAndPreventDefault(evt); From 567fa6116fc5930bf987b1f02aeba78676599bd0 Mon Sep 17 00:00:00 2001 From: CTomlyn Date: Mon, 6 Nov 2023 16:49:45 -0800 Subject: [PATCH 3/3] #1621 In Cypress, open palette directly instead of simulating the action through the right-side flyout. (#1622) --- .../cypress/support/canvas/test-harness-cmds.js | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/canvas_modules/harness/cypress/support/canvas/test-harness-cmds.js b/canvas_modules/harness/cypress/support/canvas/test-harness-cmds.js index 407fdfd91c..f7dd9cfb5b 100644 --- a/canvas_modules/harness/cypress/support/canvas/test-harness-cmds.js +++ b/canvas_modules/harness/cypress/support/canvas/test-harness-cmds.js @@ -60,22 +60,9 @@ Cypress.Commands.add("openCanvasDefinitionForExtraCanvas", (canvasFileName) => { }); }); -Cypress.Commands.add("openCanvasPalette", (paletteName) => { - cy.toggleCommonCanvasSidePanel(); - cy.get("#harness-sidepanel-palette-dropdown").select(paletteName); - // Wait until we can get a palette flyout category from the canvas before proceeding. This - // allows the canvas to load and display before any more test case steps - // are executed. Note: this won't work if the testcase selects a second - // canvas while an existing canvas with nodes is displayed. +Cypress.Commands.add("openCanvasPalette", (paletteFileName) => { cy.document().then((doc) => { - if (doc.canvasController.getCanvasConfig().enablePaletteLayout === "Modal") { - // Palette Layout - Modal - cy.get(".palette-dialog-categories"); - } else { - // Palette Layout - Flyout - cy.get(".palette-flyout-category"); - } - cy.toggleCommonCanvasSidePanel(); + doc.setPaletteDropdownSelect(paletteFileName); }); });