@maxgraph/core
Version:
maxGraph is a fully client side JavaScript diagramming library that uses SVG and HTML for rendering.
1,132 lines (1,129 loc) • 74.7 kB
JavaScript
/*
Copyright 2021-present The maxGraph project Contributors
Copyright (c) 2006-2015, JGraph Ltd
Copyright (c) 2006-2015, Gaudenz Alder
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.
*/
import CellMarker from '../cell/CellMarker.js';
import Point from '../geometry/Point.js';
import { DEFAULT_HOTSPOT, DEFAULT_INVALID_COLOR, DEFAULT_VALID_COLOR, HIGHLIGHT_STROKEWIDTH, LOCKED_HANDLE_FILLCOLOR, NONE, OUTLINE_HIGHLIGHT_COLOR, OUTLINE_HIGHLIGHT_STROKEWIDTH, } from '../../util/Constants.js';
import { contains, findNearestSegment, intersects, ptSegDistSq, } from '../../util/mathUtils.js';
import { convertPoint, getOffset, setOpacity } from '../../util/styleUtils.js';
import EllipseShape from '../shape/node/EllipseShape.js';
import ImageShape from '../shape/node/ImageShape.js';
import RectangleShape from '../shape/node/RectangleShape.js';
import ConnectionConstraint from '../other/ConnectionConstraint.js';
import InternalEvent from '../event/InternalEvent.js';
import ConstraintHandler from './ConstraintHandler.js';
import Rectangle from '../geometry/Rectangle.js';
import Client from '../../Client.js';
import { EdgeStyle } from '../style/builtin-style-elements.js';
import { getClientX, getClientY, isAltDown, isMouseEvent, isShiftDown, } from '../../util/EventUtils.js';
import { equalPoints } from '../../util/arrayUtils.js';
import { EdgeHandlerConfig, HandleConfig } from './config.js';
/**
* Graph event handler that reconnects edges, modifies control points and the edge label location.
*
* Uses {@link CellMarker} for finding and highlighting new source and target vertices.
*
* This handler is automatically created in {@link AbstractGraph.createHandler} for each selected edge.
*
* Some elements of this handler and its subclasses can be configured using {@link EdgeHandlerConfig}.
*/
class EdgeHandler {
constructor(state) {
/**
* Holds the current validation error while a connection is being changed.
*/
this.error = null;
/**
* Holds the {@link Shape}s that represent the points.
*/
this.bends = [];
/**
* Specifies if cloning by control-drag is enabled.
* @default true
*/
this.cloneEnabled = true;
/**
* Specifies if removing bends by double click is enabled.
* @default false
*/
this.dblClickRemoveEnabled = false;
/**
* Specifies if removing bends by dropping them on other bends is enabled.
* @default false
*/
this.mergeRemoveEnabled = false;
/**
* Specifies if removing bends by creating straight segments should be enabled.
* If enabled, this can be overridden by holding down the alt key while moving.
* @default false
*/
this.straightRemoveEnabled = false;
/**
* Specifies if the parent should be highlighted if a child cell is selected.
* @default false
*/
this.parentHighlightEnabled = false;
/**
* Specifies if bends should be added to the graph container.
* This is updated in {@link constructor} based on whether the edge or one of its terminals has an HTML label in the container.
*/
this.preferHtml = false;
/**
* Specifies if the bounds of handles should be used for hit-detection in IE.
* @default true
*/
this.allowHandleBoundsCheck = true;
/**
* Specifies if waypoints should snap to the routing centers of terminals.
* @default false
*/
this.snapToTerminals = false;
/**
* Optional {@link Image} to be used as handles.
* @default null
*/
this.handleImage = null;
this.labelHandleImage = null;
/**
* Optional tolerance for hit-detection in {@link getHandleForEvent}.
* @default 0
*/
this.tolerance = 0;
/**
* Specifies if connections to the outline of a highlighted target should be
* enabled. This will allow to place the connection point along the outline of
* the highlighted target.
* @default false
*/
this.outlineConnect = false;
/**
* Specifies if the label handle should be moved if it intersects with another
* handle. Uses {@link checkLabelHandle} for checking and moving.
* @default false
*/
this.manageLabelHandle = false;
this.currentPoint = null;
this.parentHighlight = null;
this.index = null;
this.isSource = false;
this.isTarget = false;
this.isLabel = false;
this.points = [];
this.snapPoint = null;
this.abspoints = [];
this.startX = 0;
this.startY = 0;
this.outline = true;
this.active = true;
// `state.shape` must exists.
this.state = state;
this.graph = this.state.view.graph;
this.marker = this.createMarker();
this.constraintHandler = this.createConstraintHandler();
// Clones the original points from the cell
// and makes sure at least one point exists
this.points = [];
// Uses the absolute points of the state
// for the initial configuration and preview
this.abspoints = this.getSelectionPoints(this.state);
this.shape = this.createSelectionShape(this.abspoints);
this.shape.dialect = this.graph.dialect !== 'svg' ? 'mixedHtml' : 'svg';
this.shape.init(this.graph.getView().getOverlayPane());
this.shape.pointerEvents = false;
this.shape.setCursor(EdgeHandlerConfig.cursorMovable);
InternalEvent.redirectMouseEvents(this.shape.node, this.graph, this.state);
// Updates preferHtml
this.preferHtml =
this.state.text != null && this.state.text.node.parentNode === this.graph.container;
if (!this.preferHtml) {
// Checks source terminal
const sourceState = this.state.getVisibleTerminalState(true);
if (sourceState != null) {
this.preferHtml =
sourceState.text != null &&
sourceState.text.node.parentNode === this.graph.container;
}
if (!this.preferHtml) {
// Checks target terminal
const targetState = this.state.getVisibleTerminalState(false);
if (targetState != null) {
this.preferHtml =
targetState.text != null &&
targetState.text.node.parentNode === this.graph.container;
}
}
}
const selectionHandler = this.graph.getPlugin('SelectionHandler');
// Creates bends for the non-routed absolute points
// or bends that don't correspond to points
if (selectionHandler &&
(this.graph.getSelectionCount() < selectionHandler.maxCells ||
selectionHandler.maxCells <= 0)) {
this.bends = this.createBends();
if (this.isVirtualBendsEnabled()) {
this.virtualBends = this.createVirtualBends();
}
}
// Adds a rectangular handle for the label position
this.label = new Point(this.state.absoluteOffset.x, this.state.absoluteOffset.y);
this.labelShape = this.createLabelHandleShape();
this.initBend(this.labelShape);
this.labelShape.setCursor(HandleConfig.labelCursor);
this.customHandles = this.createCustomHandles();
this.updateParentHighlight();
this.redraw();
// Handles escape keystrokes
this.escapeHandler = (_sender, _evt) => {
const dirty = this.index != null;
this.reset();
if (dirty) {
this.graph.cellRenderer.redraw(this.state, false, state.view.isRendering());
}
};
this.state.view.graph.addListener(InternalEvent.ESCAPE, this.escapeHandler);
}
/**
* Hook for subclasses to change the implementation of {@link ConstraintHandler} used here.
* @since 0.21.0
*/
createConstraintHandler() {
return new ConstraintHandler(this.graph);
}
/**
* Returns true if the parent highlight should be visible. This implementation
* always returns true.
*/
isParentHighlightVisible() {
const parent = this.state.cell.getParent();
return parent ? !this.graph.isCellSelected(parent) : null;
}
/**
* Updates the highlight of the parent if {@link parentHighlightEnabled} is true.
*/
updateParentHighlight() {
if (!this.isDestroyed()) {
const visible = this.isParentHighlightVisible();
const parent = this.state.cell.getParent();
const pstate = parent ? this.graph.view.getState(parent) : null;
if (this.parentHighlight) {
if (parent && parent.isVertex() && visible) {
const b = this.parentHighlight.bounds;
if (pstate &&
b &&
(b.x !== pstate.x ||
b.y !== pstate.y ||
b.width !== pstate.width ||
b.height !== pstate.height)) {
this.parentHighlight.bounds = Rectangle.fromRectangle(pstate);
this.parentHighlight.redraw();
}
}
else {
if (pstate && pstate.parentHighlight === this.parentHighlight) {
pstate.parentHighlight = null;
}
this.parentHighlight.destroy();
this.parentHighlight = null;
}
}
else if (this.parentHighlightEnabled && visible) {
if (parent && parent.isVertex() && pstate && !pstate.parentHighlight) {
this.parentHighlight = this.createParentHighlightShape(pstate);
// VML dialect required here for event transparency in IE
this.parentHighlight.dialect = 'svg';
this.parentHighlight.pointerEvents = false;
if (pstate.style.rotation) {
this.parentHighlight.rotation = pstate.style.rotation;
}
this.parentHighlight.init(this.graph.getView().getOverlayPane());
this.parentHighlight.redraw();
// Shows highlight once per parent
pstate.parentHighlight = this.parentHighlight;
}
}
}
}
/**
* Returns an array of custom handles. This implementation returns an empty array.
*/
createCustomHandles() {
return [];
}
/**
* Returns true if virtual bends should be added. This returns true if
* {@link virtualBendsEnabled} is true and the current style allows and
* renders custom waypoints.
*/
isVirtualBendsEnabled(evt) {
return (EdgeHandlerConfig.virtualBendsEnabled &&
(this.state.style.edgeStyle == null ||
this.state.style.edgeStyle === NONE ||
this.state.style.noEdgeStyle) &&
this.state.style.shape !== 'arrow');
}
/**
* Returns true if the given cell allows new connections to be created. This implementation
* always returns true.
*/
isCellEnabled(cell) {
return true;
}
/**
* Returns true if the given event is a trigger to add a new Point. This
* implementation returns true if shift is pressed.
*/
isAddPointEvent(evt) {
return isShiftDown(evt);
}
/**
* Returns true if the given event is a trigger to remove a point. This
* implementation returns true if shift is pressed.
*/
isRemovePointEvent(evt) {
return isShiftDown(evt);
}
/**
* Returns the list of points that defines the selection stroke.
*/
getSelectionPoints(state) {
return state.absolutePoints;
}
/**
* Creates the shape used to draw the selection border.
*/
createParentHighlightShape(bounds) {
const shape = new RectangleShape(Rectangle.fromRectangle(bounds), NONE, this.getSelectionColor());
shape.strokeWidth = this.getSelectionStrokeWidth();
shape.isDashed = this.isSelectionDashed();
return shape;
}
/**
* Creates the shape used to draw the selection border.
*/
createSelectionShape(points) {
const c = this.state.shape.constructor;
const shape = new c();
shape.outline = true;
shape.apply(this.state);
shape.isDashed = this.isSelectionDashed();
shape.stroke = this.getSelectionColor();
shape.isShadow = false;
return shape;
}
/**
* Returns {@link EdgeHandlerConfig.selectionColor}.
*/
getSelectionColor() {
return EdgeHandlerConfig.selectionColor;
}
/**
* Returns {@link EdgeHandlerConfig.selectionStrokeWidth}.
*/
getSelectionStrokeWidth() {
return EdgeHandlerConfig.selectionStrokeWidth;
}
/**
* Returns {@link EdgeHandlerConfig.selectionDashed}.
*/
isSelectionDashed() {
return EdgeHandlerConfig.selectionDashed;
}
/**
* Returns true if the given cell is connectable. This is a hook to
* disable floating connections. This implementation returns true.
*/
isConnectableCell(cell) {
return true;
}
/**
* Creates and returns the {@link CellMarker} used in {@link marker}.
*/
getCellAt(x, y) {
return !this.outlineConnect ? this.graph.getCellAt(x, y) : null;
}
/**
* Creates and returns the {@link CellMarker} used in {@link marker}.
*/
createMarker() {
return new EdgeHandlerCellMarker(this.graph, this);
}
/**
* Returns the error message or an empty string if the connection for the
* given source, target pair is not valid. Otherwise, it returns null. This
* implementation uses {@link AbstractGraph.getEdgeValidationError}.
*
* @param source {@link Cell} that represents the source terminal.
* @param target {@link Cell} that represents the target terminal.
*/
validateConnection(source, target) {
return this.graph.getEdgeValidationError(this.state.cell, source, target);
}
/**
* Creates and returns the bends used for modifying the edge. This is
* typically an array of {@link RectangleShape}.
*/
createBends() {
const { cell } = this.state;
const bends = [];
for (let i = 0; i < this.abspoints.length; i += 1) {
if (this.isHandleVisible(i)) {
const source = i === 0;
const target = i === this.abspoints.length - 1;
const terminal = source || target;
if (terminal || this.graph.isCellBendable(cell)) {
((index) => {
const bend = this.createHandleShape(index);
this.initBend(bend, () => {
if (this.dblClickRemoveEnabled) {
this.removePoint(this.state, index);
}
});
if (this.isHandleEnabled(i)) {
bend.setCursor(terminal ? EdgeHandlerConfig.cursorTerminal : EdgeHandlerConfig.cursorBend);
}
bends.push(bend);
if (!terminal) {
this.points.push(new Point(0, 0));
bend.node.style.visibility = 'hidden';
}
})(i);
}
}
}
return bends;
}
/**
* Creates and returns the bends used for modifying the edge. This is
* typically an array of {@link RectangleShape}.
*/
createVirtualBends() {
const { cell } = this.state;
const last = this.abspoints[0];
const bends = [];
if (this.graph.isCellBendable(cell)) {
for (let i = 1; i < this.abspoints.length; i += 1) {
((bend) => {
this.initBend(bend);
bend.setCursor(EdgeHandlerConfig.cursorVirtualBend);
bends.push(bend);
})(this.createHandleShape());
}
}
return bends;
}
/**
* Creates the shape used to display the given bend.
*/
isHandleEnabled(index) {
return true;
}
/**
* Returns true if the handle at the given index is visible.
*/
isHandleVisible(index) {
const source = this.state.getVisibleTerminalState(true);
const target = this.state.getVisibleTerminalState(false);
const geo = this.state.cell.getGeometry();
const edgeStyle = geo
? this.graph.view.getEdgeStyle(this.state, geo.points || undefined, source, target)
: null;
return (edgeStyle !== EdgeStyle.EntityRelation ||
index === 0 ||
index === this.abspoints.length - 1);
}
/**
* Creates the shape used to display the given bend.
* Note that the index
* - may be `null` for special cases, such as when called from {@link ElbowEdgeHandler.createVirtualBend}.
* - is `null` for virtual handles.
*
* Only images and rectangles should be returned if support for HTML labels with not foreign objects is required.
*/
createHandleShape(_index) {
if (this.handleImage) {
const shape = new ImageShape(new Rectangle(0, 0, this.handleImage.width, this.handleImage.height), this.handleImage.src);
// Allows HTML rendering of the images
shape.preserveImageAspect = false;
return shape;
}
let s = HandleConfig.size;
if (this.preferHtml) {
s -= 1;
}
const shapeConstructor = EdgeHandlerConfig.handleShape === 'circle' ? EllipseShape : RectangleShape;
return new shapeConstructor(new Rectangle(0, 0, s, s), HandleConfig.fillColor, HandleConfig.strokeColor);
}
/**
* Creates the shape used to display the label handle.
*/
createLabelHandleShape() {
if (this.labelHandleImage) {
const shape = new ImageShape(new Rectangle(0, 0, this.labelHandleImage.width, this.labelHandleImage.height), this.labelHandleImage.src);
// Allows HTML rendering of the images
shape.preserveImageAspect = false;
return shape;
}
const s = HandleConfig.labelSize;
return new RectangleShape(new Rectangle(0, 0, s, s), HandleConfig.labelFillColor, HandleConfig.strokeColor);
}
/**
* Helper method to initialize the given bend.
*
* @param bend {@link Shape} that represents the bend to be initialized.
* @param dblClick Optional function to be called on double click.
*/
initBend(bend, dblClick) {
if (this.preferHtml) {
bend.dialect = 'strictHtml';
bend.init(this.graph.container);
}
else {
bend.dialect = this.graph.dialect !== 'svg' ? 'mixedHtml' : 'svg';
bend.init(this.graph.getView().getOverlayPane());
}
InternalEvent.redirectMouseEvents(bend.node, this.graph, this.state, null, null, null, dblClick);
if (Client.IS_TOUCH) {
bend.node.setAttribute('pointer-events', 'none');
}
}
/**
* Returns the index of the handle for the given event.
*/
getHandleForEvent(me) {
let result = null;
// Connection highlight may consume events before they reach sizer handle
const tol = !isMouseEvent(me.getEvent()) ? this.tolerance : 1;
const hit = this.allowHandleBoundsCheck && tol > 0
? new Rectangle(me.getGraphX() - tol, me.getGraphY() - tol, 2 * tol, 2 * tol)
: null;
let minDistSq = Number.POSITIVE_INFINITY;
function checkShape(shape) {
if (shape &&
shape.bounds &&
shape.node &&
shape.node.style.display !== 'none' &&
shape.node.style.visibility !== 'hidden' &&
(me.isSource(shape) || (hit && intersects(shape.bounds, hit)))) {
const dx = me.getGraphX() - shape.bounds.getCenterX();
const dy = me.getGraphY() - shape.bounds.getCenterY();
const tmp = dx * dx + dy * dy;
if (tmp <= minDistSq) {
minDistSq = tmp;
return true;
}
}
return false;
}
if (this.isCustomHandleEvent(me) && this.customHandles) {
// Inverse loop order to match display order
for (let i = this.customHandles.length - 1; i >= 0; i--) {
if (checkShape(this.customHandles[i].shape)) {
// LATER: Return reference to active shape
return InternalEvent.CUSTOM_HANDLE - i;
}
}
}
if (me.isSource(this.state.text) || checkShape(this.labelShape)) {
result = InternalEvent.LABEL_HANDLE;
}
for (let i = 0; i < this.bends.length; i += 1) {
if (checkShape(this.bends[i])) {
result = i;
}
}
if (this.virtualBends && this.isAddVirtualBendEvent(me)) {
for (let i = 0; i < this.virtualBends.length; i += 1) {
if (checkShape(this.virtualBends[i])) {
result = InternalEvent.VIRTUAL_HANDLE - i;
}
}
}
return result;
}
/**
* Returns true if the given event allows virtual bends to be added. This
* implementation returns true.
*/
isAddVirtualBendEvent(me) {
return true;
}
/**
* Returns true if the given event allows custom handles to be changed. This
* implementation returns true.
*/
isCustomHandleEvent(me) {
return true;
}
/**
* Handles the event by checking if a special element of the handler
* was clicked, in which case the index parameter is non-null. The
* indices may be one of {@link InternalEvent.LABEL_HANDLE} or the number of the respective
* control point. The source and target points are used for reconnecting
* the edge.
*/
mouseDown(_sender, me) {
const handle = this.getHandleForEvent(me);
if (handle !== null && this.bends[handle]) {
const b = this.bends[handle].bounds;
if (b)
this.snapPoint = new Point(b.getCenterX(), b.getCenterY());
}
if (EdgeHandlerConfig.addBendOnShiftClickEnabled &&
handle === null &&
this.isAddPointEvent(me.getEvent())) {
this.addPoint(this.state, me.getEvent());
me.consume();
}
else if (handle !== null && !me.isConsumed() && this.graph.isEnabled()) {
const cell = me.getCell();
if (EdgeHandlerConfig.removeBendOnShiftClickEnabled &&
this.isRemovePointEvent(me.getEvent())) {
this.removePoint(this.state, handle);
}
else if (handle !== InternalEvent.LABEL_HANDLE ||
(cell && this.graph.isLabelMovable(cell))) {
if (this.virtualBends && handle <= InternalEvent.VIRTUAL_HANDLE) {
setOpacity(this.virtualBends[InternalEvent.VIRTUAL_HANDLE - handle].node, 100);
}
this.start(me.getX(), me.getY(), handle);
}
me.consume();
}
}
/**
* Starts the handling of the mouse gesture.
*/
start(x, y, index) {
this.startX = x;
this.startY = y;
this.isSource = this.bends.length === 0 ? false : index === 0;
this.isTarget = this.bends.length === 0 ? false : index === this.bends.length - 1;
this.isLabel = index === InternalEvent.LABEL_HANDLE;
if (this.isSource || this.isTarget) {
const { cell } = this.state;
const terminal = cell.getTerminal(this.isSource);
if ((terminal == null && this.graph.isTerminalPointMovable(cell, this.isSource)) ||
(terminal != null &&
this.graph.isCellDisconnectable(cell, terminal, this.isSource))) {
this.index = index;
}
}
else {
this.index = index;
}
// Hides other custom handles
if (this.index !== null &&
this.index <= InternalEvent.CUSTOM_HANDLE &&
this.index > InternalEvent.VIRTUAL_HANDLE) {
if (this.customHandles != null) {
for (let i = 0; i < this.customHandles.length; i += 1) {
if (i !== InternalEvent.CUSTOM_HANDLE - this.index) {
this.customHandles[i].setVisible(false);
}
}
}
}
}
/**
* Returns a clone of the current preview state for the given point and terminal.
*/
clonePreviewState(point, terminal) {
return this.state.clone();
}
/**
* Returns the tolerance for the guides. Default value is
* gridSize * scale / 2.
*/
getSnapToTerminalTolerance() {
return (this.graph.getGridSize() * this.graph.getView().scale) / 2;
}
/**
* Hook for subclassers do show details while the handler is active.
*/
updateHint(me, point) {
return;
}
/**
* Hooks for subclassers to hide details when the handler gets inactive.
*/
removeHint() {
return;
}
/**
* Hook for rounding the unscaled width or height. This uses Math.round.
*/
roundLength(length) {
return Math.round(length);
}
/**
* Returns true if {@link snapToTerminals} is true and if alt is not pressed.
*/
isSnapToTerminalsEvent(me) {
return this.snapToTerminals && !isAltDown(me.getEvent());
}
/**
* Returns the point for the given event.
*/
getPointForEvent(me) {
const view = this.graph.getView();
const { scale } = view;
const point = new Point(this.roundLength(me.getGraphX() / scale) * scale, this.roundLength(me.getGraphY() / scale) * scale);
const tt = this.getSnapToTerminalTolerance();
let overrideX = false;
let overrideY = false;
if (tt > 0 && this.isSnapToTerminalsEvent(me)) {
const snapToPoint = (pt) => {
if (pt) {
const { x } = pt;
if (Math.abs(point.x - x) < tt) {
point.x = x;
overrideX = true;
}
const { y } = pt;
if (Math.abs(point.y - y) < tt) {
point.y = y;
overrideY = true;
}
}
};
// Temporary function
const snapToTerminal = (terminal) => {
if (terminal) {
snapToPoint(new Point(view.getRoutingCenterX(terminal), view.getRoutingCenterY(terminal)));
}
};
snapToTerminal(this.state.getVisibleTerminalState(true));
snapToTerminal(this.state.getVisibleTerminalState(false));
for (let i = 0; i < this.state.absolutePoints.length; i += 1) {
snapToPoint(this.state.absolutePoints[i]);
}
}
if (this.graph.isGridEnabledEvent(me.getEvent())) {
const tr = view.translate;
if (!overrideX) {
point.x = (this.graph.snap(point.x / scale - tr.x) + tr.x) * scale;
}
if (!overrideY) {
point.y = (this.graph.snap(point.y / scale - tr.y) + tr.y) * scale;
}
}
return point;
}
/**
* Updates the given preview state taking into account the state of the constraint handler.
*/
getPreviewTerminalState(me) {
this.constraintHandler.update(me, this.isSource, true, me.isSource(this.marker.highlight.shape) ? null : this.currentPoint);
if (this.constraintHandler.currentFocus && this.constraintHandler.currentConstraint) {
// Handles special case where grid is large and connection point is at actual point in which
// case the outline is not followed as long as we're < gridSize / 2 away from that point
if (this.marker.highlight &&
this.marker.highlight.shape &&
this.marker.highlight.state &&
this.marker.highlight.state.cell === this.constraintHandler.currentFocus.cell) {
// Direct repaint needed if cell already highlighted
if (this.marker.highlight.shape.stroke !== 'transparent') {
this.marker.highlight.shape.stroke = 'transparent';
this.marker.highlight.repaint();
}
}
else {
this.marker.markCell(this.constraintHandler.currentFocus.cell, 'transparent');
}
const other = this.graph.view.getTerminalPort(this.state, this.graph.view.getState(this.state.cell.getTerminal(!this.isSource)), !this.isSource);
const otherCell = other ? other.cell : null;
const source = this.isSource ? this.constraintHandler.currentFocus.cell : otherCell;
const target = this.isSource ? otherCell : this.constraintHandler.currentFocus.cell;
// Updates the error message of the handler
this.error = this.validateConnection(source, target);
let result = null;
if (this.error === null) {
result = this.constraintHandler.currentFocus;
}
if (this.error !== null || (result && !this.isCellEnabled(result.cell))) {
this.constraintHandler.reset();
}
return result;
}
if (!this.graph.isIgnoreTerminalEvent(me.getEvent())) {
this.marker.process(me);
const state = this.marker.getValidState();
if (state && !this.isCellEnabled(state.cell)) {
this.constraintHandler.reset();
this.marker.reset();
}
return this.marker.getValidState();
}
this.marker.reset();
return null;
}
/**
* Updates the given preview state taking into account the state of the constraint handler.
*
* @param pt {@link Point} that contains the current pointer position.
* @param me Optional {@link MouseEvent} that contains the current event.
*/
getPreviewPoints(pt, me) {
const geometry = this.state.cell.getGeometry();
if (!geometry)
return null;
let points = (geometry.points || []).slice();
const point = new Point(pt.x, pt.y);
let result = null;
if (!this.isSource && !this.isTarget && this.index !== null) {
this.convertPoint(point, false);
// Adds point from virtual bend
if (this.index <= InternalEvent.VIRTUAL_HANDLE) {
points.splice(InternalEvent.VIRTUAL_HANDLE - this.index, 0, point);
}
// Removes point if dragged on terminal point
if (!this.isSource && !this.isTarget) {
for (let i = 0; i < this.bends.length; i += 1) {
if (i !== this.index) {
const bend = this.bends[i];
if (bend && contains(bend.bounds, pt.x, pt.y)) {
if (this.index <= InternalEvent.VIRTUAL_HANDLE) {
points.splice(InternalEvent.VIRTUAL_HANDLE - this.index, 1);
}
else {
points.splice(this.index - 1, 1);
}
result = points;
}
}
}
// Removes point if user tries to straighten a segment
if (!result && this.straightRemoveEnabled && (!me || !isAltDown(me.getEvent()))) {
const tol = this.graph.getEventTolerance() * this.graph.getEventTolerance();
const abs = this.state.absolutePoints.slice();
abs[this.index] = pt;
// Handes special case where removing waypoint affects tolerance (flickering)
const src = this.state.getVisibleTerminalState(true);
if (src != null) {
const c = this.graph.getConnectionConstraint(this.state, src, true);
// Checks if point is not fixed
if (c == null || this.graph.getConnectionPoint(src, c) == null) {
abs[0] = new Point(src.view.getRoutingCenterX(src), src.view.getRoutingCenterY(src));
}
}
const trg = this.state.getVisibleTerminalState(false);
if (trg != null) {
const c = this.graph.getConnectionConstraint(this.state, trg, false);
// Checks if point is not fixed
if (c == null || this.graph.getConnectionPoint(trg, c) == null) {
abs[abs.length - 1] = new Point(trg.view.getRoutingCenterX(trg), trg.view.getRoutingCenterY(trg));
}
}
const checkRemove = (idx, tmp) => {
if (idx > 0 &&
idx < abs.length - 1 &&
ptSegDistSq(abs[idx - 1].x, abs[idx - 1].y, abs[idx + 1].x, abs[idx + 1].y, tmp.x, tmp.y) < tol) {
points.splice(idx - 1, 1);
result = points;
}
};
// LATER: Check if other points can be removed if a segment is made straight
checkRemove(this.index, pt);
}
}
// Updates existing point
if (result == null && this.index > InternalEvent.VIRTUAL_HANDLE) {
points[this.index - 1] = point;
}
}
else if (this.graph.isResetEdgesOnConnect()) {
points = [];
}
return result != null ? result : points;
}
/**
* Returns true if {@link outlineConnect} is true and the source of the event is the outline shape
* or shift is pressed.
*/
isOutlineConnectEvent(me) {
if (!this.currentPoint)
return false;
const offset = getOffset(this.graph.container);
const evt = me.getEvent();
const clientX = getClientX(evt);
const clientY = getClientY(evt);
const doc = document.documentElement;
const left = (window.pageXOffset || doc.scrollLeft) - (doc.clientLeft || 0);
const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);
const gridX = this.currentPoint.x - this.graph.container.scrollLeft + offset.x - left;
const gridY = this.currentPoint.y - this.graph.container.scrollTop + offset.y - top;
return (this.outlineConnect &&
!isShiftDown(me.getEvent()) &&
(me.isSource(this.marker.highlight.shape) ||
(isAltDown(me.getEvent()) && me.getState() != null) ||
this.marker.highlight.isHighlightAt(clientX, clientY) ||
((gridX !== clientX || gridY !== clientY) &&
me.getState() == null &&
this.marker.highlight.isHighlightAt(gridX, gridY))));
}
/**
* Updates the given preview state taking into account the state of the constraint handler.
*/
updatePreviewState(edgeState, point, terminalState, me, outline = false) {
// Computes the points for the edge style and terminals
const sourceState = this.isSource
? terminalState
: this.state.getVisibleTerminalState(true);
const targetState = this.isTarget
? terminalState
: this.state.getVisibleTerminalState(false);
let sourceConstraint = this.graph.getConnectionConstraint(edgeState, sourceState, true);
let targetConstraint = this.graph.getConnectionConstraint(edgeState, targetState, false);
let constraint = this.constraintHandler.currentConstraint;
if (constraint == null && outline) {
if (terminalState != null) {
// Handles special case where mouse is on outline away from actual end point
// in which case the grid is ignored and mouse point is used instead
if (me.isSource(this.marker.highlight.shape)) {
point = new Point(me.getGraphX(), me.getGraphY());
}
constraint = this.graph.getOutlineConstraint(point, terminalState, me);
this.constraintHandler.setFocus(me, terminalState, this.isSource);
this.constraintHandler.currentConstraint = constraint;
this.constraintHandler.currentPoint = point;
}
else {
constraint = new ConnectionConstraint(null);
}
}
if (this.outlineConnect &&
this.marker.highlight != null &&
this.marker.highlight.shape != null) {
const s = this.graph.view.scale;
if (this.constraintHandler.currentConstraint != null &&
this.constraintHandler.currentFocus != null) {
this.marker.highlight.shape.stroke = outline
? OUTLINE_HIGHLIGHT_COLOR
: 'transparent';
this.marker.highlight.shape.strokeWidth = OUTLINE_HIGHLIGHT_STROKEWIDTH / s / s;
this.marker.highlight.repaint();
}
else if (this.marker.hasValidState()) {
const cell = me.getCell();
this.marker.highlight.shape.stroke =
cell && cell.isConnectable() && this.marker.getValidState() !== me.getState()
? 'transparent'
: DEFAULT_VALID_COLOR;
this.marker.highlight.shape.strokeWidth = HIGHLIGHT_STROKEWIDTH / s / s;
this.marker.highlight.repaint();
}
}
if (this.isSource) {
sourceConstraint = constraint;
}
else if (this.isTarget) {
targetConstraint = constraint;
}
if (this.isSource || this.isTarget) {
if (constraint != null && constraint.point != null) {
edgeState.style[this.isSource ? 'exitX' : 'entryX'] = constraint.point.x;
edgeState.style[this.isSource ? 'exitY' : 'entryY'] = constraint.point.y;
}
else {
delete edgeState.style[this.isSource ? 'exitX' : 'entryX'];
delete edgeState.style[this.isSource ? 'exitY' : 'entryY'];
}
}
edgeState.setVisibleTerminalState(sourceState, true);
edgeState.setVisibleTerminalState(targetState, false);
if (!this.isSource || sourceState != null) {
edgeState.view.updateFixedTerminalPoint(edgeState, sourceState, true, sourceConstraint);
}
if (!this.isTarget || targetState != null) {
edgeState.view.updateFixedTerminalPoint(edgeState, targetState, false, targetConstraint);
}
if ((this.isSource || this.isTarget) && terminalState == null) {
edgeState.setAbsoluteTerminalPoint(point, this.isSource);
if (this.marker.getMarkedState() == null) {
this.error = this.graph.isAllowDanglingEdges() ? null : '';
}
}
edgeState.view.updatePoints(edgeState, this.points, sourceState, targetState);
edgeState.view.updateFloatingTerminalPoints(edgeState, sourceState, targetState);
}
/**
* Handles the event by updating the preview.
*/
mouseMove(_sender, me) {
if (this.index != null && this.marker != null) {
this.currentPoint = this.getPointForEvent(me);
this.error = null;
// Uses the current point from the constraint handler if available
if (!this.graph.isIgnoreTerminalEvent(me.getEvent()) &&
isShiftDown(me.getEvent()) &&
this.snapPoint != null) {
if (Math.abs(this.snapPoint.x - this.currentPoint.x) <
Math.abs(this.snapPoint.y - this.currentPoint.y)) {
this.currentPoint.x = this.snapPoint.x;
}
else {
this.currentPoint.y = this.snapPoint.y;
}
}
if (this.index <= InternalEvent.CUSTOM_HANDLE &&
this.index > InternalEvent.VIRTUAL_HANDLE) {
if (this.customHandles != null) {
this.customHandles[InternalEvent.CUSTOM_HANDLE - this.index].processEvent(me);
this.customHandles[InternalEvent.CUSTOM_HANDLE - this.index].positionChanged();
if (this.shape != null && this.shape.node != null) {
this.shape.node.style.display = 'none';
}
}
}
else if (this.isLabel && this.label) {
this.label.x = this.currentPoint.x;
this.label.y = this.currentPoint.y;
}
else {
this.points = this.getPreviewPoints(this.currentPoint, me);
let terminalState = this.isSource || this.isTarget ? this.getPreviewTerminalState(me) : null;
if (this.constraintHandler.currentConstraint != null &&
this.constraintHandler.currentFocus != null &&
this.constraintHandler.currentPoint != null) {
this.currentPoint = this.constraintHandler.currentPoint.clone();
}
else if (this.outlineConnect) {
// Need to check outline before cloning terminal state
const outline = this.isSource || this.isTarget ? this.isOutlineConnectEvent(me) : false;
if (outline) {
terminalState = this.marker.highlight.state;
}
else if (terminalState != null &&
terminalState !== me.getState() &&
me.getCell()?.isConnectable() &&
this.marker.highlight.shape != null) {
this.marker.highlight.shape.stroke = 'transparent';
this.marker.highlight.repaint();
terminalState = null;
}
}
if (terminalState != null && !this.isCellEnabled(terminalState.cell)) {
terminalState = null;
this.marker.reset();
}
if (this.currentPoint) {
const clone = this.clonePreviewState(this.currentPoint, terminalState != null ? terminalState.cell : null);
this.updatePreviewState(clone, this.currentPoint, terminalState, me, this.outline);
// Sets the color of the preview to valid or invalid, updates the
// points of the preview and redraws
const color = this.error == null ? this.marker.validColor : this.marker.invalidColor;
this.setPreviewColor(color);
this.abspoints = clone.absolutePoints;
this.active = true;
this.updateHint(me, this.currentPoint);
}
}
// This should go before calling isOutlineConnectEvent above. As a workaround
// we add an offset of gridSize to the hint to avoid problem with hit detection
// in highlight.isHighlightAt (which uses comonentFromPoint)
this.drawPreview();
InternalEvent.consume(me.getEvent());
me.consume();
}
}
/**
* Handles the event to applying the previewed changes on the edge by
* using {@link moveLabel}, {@link connect} or {@link changePoints}.
*/
mouseUp(_sender, me) {
// Workaround for wrong event source in Webkit
if (this.index != null && this.marker != null) {
if (this.shape != null && this.shape.node != null) {
this.shape.node.style.display = '';
}
let edge = this.state.cell;
const { index } = this;
this.index = null;
// Ignores event if mouse has not been moved
if (me.getX() !== this.startX || me.getY() !== this.startY) {
const clone = !this.graph.isIgnoreTerminalEvent(me.getEvent()) &&
this.graph.isCloneEvent(me.getEvent()) &&
this.cloneEnabled &&
this.graph.isCellsCloneable();
// Displays the reason for not carriying out the change
// if there is an error message with non-zero length
if (this.error != null) {
if (this.error.length > 0) {
this.graph.validationAlert(this.error);
}
}
else if (index <= InternalEvent.CUSTOM_HANDLE &&
index > InternalEvent.VIRTUAL_HANDLE) {
if (this.customHandles != null) {
const model = this.graph.getDataModel();
model.beginUpdate();
try {
this.customHandles[InternalEvent.CUSTOM_HANDLE - index].execute(me);
if (this.shape != null && this.shape.node != null) {
this.shape.apply(this.state);
this.shape.redraw();
}
}
finally {
model.endUpdate();
}
}
}
else if (this.isLabel && this.label) {
this.moveLabel(this.state, this.label.x, this.label.y);
}
else if (this.isSource || this.isTarget) {
let terminal = null;
if (this.constraintHandler.currentConstraint != null &&
this.constraintHandler.currentFocus != null) {
terminal = this.constraintHandler.currentFocus.cell;
}
if (!terminal &&
this.marker.hasValidState() &&
this.marker.highlight != null &&
this.marker.highlight.shape != null &&
this.marker.highlight.shape.stroke !== 'transparent' &&
this.marker.highlight.shape.stroke !== 'white') {
terminal = this.marker.validState.cell;
}
if (terminal) {
const model = this.graph.getDataModel();
const parent = edge.getParent();
model.beginUpdate();
try {
// Clones and adds the cell
if (clone) {
let geo = edge.getGeometry();
const cloned = this.graph.cloneCell(edge);
model.add(parent, cloned, parent.getChildCount());
if (geo != null) {
geo = geo.clone();
model.setGeometry(cloned, geo);
}
const other = edge.getTerminal(!this.isSource);
this.graph.connectCell(cloned, other, !this.isSource);
edge = cloned;
}
edge = this.connect(edge, terminal, this.isSource, clone, me);
}
finally {
model.endUpdate();
}
}
else if (this.graph.isAllowDanglingEdges()) {
const pt = this.abspoints[this.isSource ? 0 : this.abspoints.length - 1];
pt.x = this.roundLength(pt.x / this.graph.view.scale - this.graph.view.translate.x);
pt.y = this.roundLength(pt.y / this.graph.view.scale - this.graph.view.translate.y);
const parent = edge.getParent();
const pstate = parent ? this.graph.getView().getState(parent