@maxgraph/core
Version:
maxGraph is a fully client side JavaScript diagramming library that uses SVG and HTML for rendering.
1,215 lines (1,211 loc) • 66.9 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 Rectangle from '../geometry/Rectangle.js';
import { NONE } from '../../util/Constants.js';
import InternalEvent from '../event/InternalEvent.js';
import RectangleShape from '../shape/node/RectangleShape.js';
import ImageShape from '../shape/node/ImageShape.js';
import EllipseShape from '../shape/node/EllipseShape.js';
import Point from '../geometry/Point.js';
import { getRotatedPoint, intersects, mod, toRadians } from '../../util/mathUtils.js';
import Client from '../../Client.js';
import { isMouseEvent, isShiftDown } from '../../util/EventUtils.js';
import { HandleConfig, VertexHandlerConfig } from './config.js';
/**
* Event handler for resizing cells.
*
* This handler is automatically created in {@link AbstractGraph.createHandler}.
*
* Some elements of this handler and its subclasses can be configured using {@link EdgeHandlerConfig}.
*/
class VertexHandler {
/**
* Specifies if a rotation handle should be visible.
*
* This implementation returns {@link VertexHandlerConfig.rotationEnabled}.
* @since 0.12.0
*/
isRotationEnabled() {
return VertexHandlerConfig.rotationEnabled;
}
/**
* Constructs an event handler that allows to resize vertices and groups.
*
* @param state {@link CellState} of the cell to be resized.
*/
constructor(state) {
this.sizers = [];
/**
* Specifies if only one sizer handle at the bottom, right corner should be used.
* @default false
*/
this.singleSizer = false;
/**
* Holds the index of the current handle.
*/
this.index = null;
/**
* Specifies if the bounds of handles should be used for hit-detection in IE or if {@link tolerance} > 0.
* @default true
*/
this.allowHandleBoundsCheck = true;
/**
* Optional {@link Image} to be used as handles.
* @default null
*/
this.handleImage = null;
/**
* If handles are currently visible.
* @default true
*/
this.handlesVisible = true;
/**
* Optional tolerance for hit-detection in {@link getHandleForEvent}.
* @default 0
*/
this.tolerance = 0;
/**
* Specifies if the parent should be highlighted if a child cell is selected.
* @default false
*/
this.parentHighlightEnabled = false;
/**
* Specifies if rotation steps should be "rasterized" depending on the distance to the handle.
* @default true
*/
this.rotationRaster = true;
/**
* Specifies the cursor for the rotation handle.
* @default 'crosshair'.
*/
this.rotationCursor = 'crosshair';
/**
* Specifies if resize should change the cell in-place. This is an experimental
* feature for non-touch devices.
* @default false
*/
this.livePreview = false;
/**
* Specifies if the live preview should be moved to the front.
* @default false
*/
this.movePreviewToFront = false;
/**
* Specifies if sizers should be hidden and spaced if the vertex is small.
* @default false
*/
this.manageSizers = false;
/**
* Specifies if the size of groups should be constrained by the children.
* @default false
*/
this.constrainGroupByChildren = false;
/**
* Vertical spacing for rotation icon.
* @default -16
*/
this.rotationHandleVSpacing = -16;
/**
* The horizontal offset for the handles. This is updated in {@link redrawHandles}
* if {@link manageSizers} is `true` and the sizers are offset horizontally.
*/
this.horizontalOffset = 0;
/**
* The horizontal offset for the handles. This is updated in <redrawHandles>
* if {@link manageSizers} is true and the sizers are offset vertically.
*/
this.verticalOffset = 0;
this.minBounds = null;
this.x0 = 0;
this.y0 = 0;
this.customHandles = [];
this.inTolerance = false;
this.startX = 0;
this.startY = 0;
this.rotationShape = null;
this.currentAlpha = null;
this.startAngle = 0;
this.startDist = 0;
this.ghostPreview = null;
this.livePreviewActive = false;
this.childOffsetX = 0;
this.childOffsetY = 0;
this.parentState = null;
this.parentHighlight = null;
this.unscaledBounds = null;
this.preview = null;
this.labelShape = null;
this.edgeHandlers = [];
this.EMPTY_POINT = new Point();
this.state = state;
this.graph = this.state.view.graph;
this.selectionBounds = this.getSelectionBounds(this.state);
this.bounds = new Rectangle(this.selectionBounds.x, this.selectionBounds.y, this.selectionBounds.width, this.selectionBounds.height);
this.selectionBorder = this.createSelectionShape(this.bounds);
// VML dialect required here for event transparency in IE
this.selectionBorder.dialect = 'svg';
this.selectionBorder.pointerEvents = false;
this.selectionBorder.rotation = this.state.style.rotation ?? 0;
this.selectionBorder.init(this.graph.getView().getOverlayPane());
InternalEvent.redirectMouseEvents(this.selectionBorder.node, this.graph, this.state);
if (this.graph.isCellMovable(this.state.cell)) {
this.selectionBorder.setCursor(VertexHandlerConfig.cursorMovable);
}
const selectionHandler = this.getSelectionHandler();
// Adds the sizer handles
if (selectionHandler &&
(selectionHandler.maxCells <= 0 ||
this.graph.getSelectionCount() < selectionHandler.maxCells)) {
const resizable = this.graph.isCellResizable(this.state.cell);
this.sizers = [];
if (resizable ||
(this.graph.isLabelMovable(this.state.cell) &&
this.state.width >= 2 &&
this.state.height >= 2)) {
let i = 0;
if (resizable) {
if (!this.singleSizer) {
this.sizers.push(this.createSizer('nw-resize', i++));
this.sizers.push(this.createSizer('n-resize', i++));
this.sizers.push(this.createSizer('ne-resize', i++));
this.sizers.push(this.createSizer('w-resize', i++));
this.sizers.push(this.createSizer('e-resize', i++));
this.sizers.push(this.createSizer('sw-resize', i++));
this.sizers.push(this.createSizer('s-resize', i++));
}
this.sizers.push(this.createSizer('se-resize', i++));
}
const geo = this.state.cell.getGeometry();
if (geo != null &&
!geo.relative &&
!this.graph.isSwimlane(this.state.cell) &&
this.graph.isLabelMovable(this.state.cell)) {
// Marks this as the label handle for getHandleForEvent
this.labelShape = this.createSizer(HandleConfig.labelCursor, InternalEvent.LABEL_HANDLE, HandleConfig.labelSize, HandleConfig.labelFillColor);
this.sizers.push(this.labelShape);
}
}
else if (this.graph.isCellMovable(this.state.cell) &&
!this.graph.isCellResizable(this.state.cell) &&
this.state.width < 2 &&
this.state.height < 2) {
this.labelShape = this.createSizer(VertexHandlerConfig.cursorMovable, InternalEvent.LABEL_HANDLE, undefined, HandleConfig.labelFillColor);
this.sizers.push(this.labelShape);
}
}
// Adds the rotation handler
if (this.isRotationHandleVisible()) {
this.rotationShape = this.createSizer(this.rotationCursor, InternalEvent.ROTATION_HANDLE, HandleConfig.size + 3, HandleConfig.fillColor);
this.sizers.push(this.rotationShape);
}
this.customHandles = this.createCustomHandles();
this.redraw();
if (this.constrainGroupByChildren) {
this.updateMinBounds();
}
// Handles escape keystrokes
this.escapeHandler = (_sender, _evt) => {
if (this.livePreview && this.index != null) {
// Redraws the live preview
this.state.view.graph.cellRenderer.redraw(this.state, true);
// Redraws connected edges
this.state.view.invalidate(this.state.cell);
this.state.invalid = false;
this.state.view.validate();
}
this.reset();
};
this.state.view.graph.addListener(InternalEvent.ESCAPE, this.escapeHandler);
}
getSelectionHandler() {
return this.graph.getPlugin('SelectionHandler');
}
/**
* Returns `true` if the rotation handle should be showing.
*/
isRotationHandleVisible() {
const selectionHandler = this.getSelectionHandler();
const selectionHandlerCheck = selectionHandler
? selectionHandler.maxCells <= 0 ||
this.graph.getSelectionCount() < selectionHandler.maxCells
: true;
return (this.graph.isEnabled() &&
this.isRotationEnabled() &&
this.graph.isCellRotatable(this.state.cell) &&
selectionHandlerCheck);
}
/**
* Returns `true` if the aspect ratio if the cell should be maintained.
*/
isConstrainedEvent(me) {
return isShiftDown(me.getEvent()) || this.state.style.aspect === 'fixed';
}
/**
* Returns `true` if the center of the vertex should be maintained during the resize.
*/
isCenteredEvent(state, me) {
return false;
}
/**
* Returns an array of custom handles.
*
* This implementation returns an empty array.
*/
createCustomHandles() {
return [];
}
/**
* Initializes the shapes required for this vertex handler.
*/
updateMinBounds() {
const children = this.graph.getChildCells(this.state.cell);
if (children.length > 0) {
this.minBounds = this.graph.view.getBounds(children);
if (this.minBounds) {
const s = this.state.view.scale;
const t = this.state.view.translate;
this.minBounds.x -= this.state.x;
this.minBounds.y -= this.state.y;
this.minBounds.x /= s;
this.minBounds.y /= s;
this.minBounds.width /= s;
this.minBounds.height /= s;
this.x0 = this.state.x / s - t.x;
this.y0 = this.state.y / s - t.y;
}
}
}
/**
* Returns the Rectangle that defines the bounds of the selection border.
*/
getSelectionBounds(state) {
return new Rectangle(Math.round(state.x), Math.round(state.y), Math.round(state.width), Math.round(state.height));
}
/**
* Creates the shape used to draw the selection border.
*/
createParentHighlightShape(bounds) {
return this.createSelectionShape(bounds);
}
/**
* Creates the shape used to draw the selection border.
*/
createSelectionShape(bounds) {
const shape = new RectangleShape(Rectangle.fromRectangle(bounds), NONE, this.getSelectionColor());
shape.strokeWidth = this.getSelectionStrokeWidth();
shape.isDashed = this.isSelectionDashed();
return shape;
}
/**
* Returns {@link VertexHandlerConfig.selectionColor}.
*/
getSelectionColor() {
return VertexHandlerConfig.selectionColor;
}
/**
* Returns {@link VertexHandlerConfig.selectionStrokeWidth}.
*/
getSelectionStrokeWidth() {
return VertexHandlerConfig.selectionStrokeWidth;
}
/**
* Returns {@link VertexHandlerConfig.selectionDashed}.
*/
isSelectionDashed() {
return VertexHandlerConfig.selectionDashed;
}
/**
* Creates a sizer handle for the specified cursor and index and returns
* the new {@link RectangleShape} that represents the handle.
*/
createSizer(cursor, index, size = HandleConfig.size, fillColor = HandleConfig.fillColor) {
const bounds = new Rectangle(0, 0, size, size);
const sizer = this.createSizerShape(bounds, index, fillColor);
if (sizer.bounds &&
sizer.isHtmlAllowed() &&
this.state.text &&
this.state.text.node.parentNode === this.graph.container) {
sizer.bounds.height -= 1;
sizer.bounds.width -= 1;
sizer.dialect = 'strictHtml';
sizer.init(this.graph.container);
}
else {
sizer.dialect = this.graph.dialect !== 'svg' ? 'mixedHtml' : 'svg';
sizer.init(this.graph.getView().getOverlayPane());
}
InternalEvent.redirectMouseEvents(sizer.node, this.graph, this.state);
if (this.graph.isEnabled()) {
sizer.setCursor(cursor);
}
if (!this.isSizerVisible(index)) {
sizer.visible = false;
}
return sizer;
}
/**
* Returns `true` if the sizer for the given index is visible.
*
* This implementation returns `true` for all given indices.
*/
isSizerVisible(_index) {
return true;
}
/**
* Creates the shape used for the sizer handle for the specified bounds an
* index. Only images and rectangles should be returned if support for HTML
* labels with not foreign objects is required.
*/
createSizerShape(bounds, index, fillColor = HandleConfig.fillColor) {
if (this.handleImage) {
bounds = new Rectangle(bounds.x, bounds.y, this.handleImage.width, this.handleImage.height);
const shape = new ImageShape(bounds, this.handleImage.src);
// Allows HTML rendering of the images
shape.preserveImageAspect = false;
return shape;
}
const strokeColor = HandleConfig.strokeColor;
if (index === InternalEvent.ROTATION_HANDLE) {
return new EllipseShape(bounds, fillColor, strokeColor);
}
return new RectangleShape(bounds, fillColor, strokeColor);
}
/**
* Helper method to create an {@link Rectangle} around the given center point
* with a width and height of 2*s or 6, if no s is given.
*/
moveSizerTo(shape, x, y) {
if (shape && shape.bounds) {
shape.bounds.x = Math.floor(x - shape.bounds.width / 2);
shape.bounds.y = Math.floor(y - shape.bounds.height / 2);
// Fixes visible inactive handles in VML
if (shape.node && shape.node.style.display !== 'none') {
shape.redraw();
}
}
}
/**
* Returns the index of the handle for the given event. This returns the index
* of the sizer from where the event originated or {@link InternalEvent.LABEL_HANDLE}.
*/
getHandleForEvent(me) {
// 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;
const checkShape = (shape) => {
const st = shape && shape.constructor !== ImageShape && this.allowHandleBoundsCheck
? shape.strokeWidth + shape.svgStrokeTolerance
: null;
const real = st
? new Rectangle(me.getGraphX() - Math.floor(st / 2), me.getGraphY() - Math.floor(st / 2), st, st)
: hit;
return (shape &&
shape.bounds &&
(me.isSource(shape) ||
(real &&
intersects(shape.bounds, real) &&
shape.node.style.display !== 'none' &&
shape.node.style.visibility !== 'hidden')));
};
if (checkShape(this.rotationShape)) {
return InternalEvent.ROTATION_HANDLE;
}
if (checkShape(this.labelShape)) {
return InternalEvent.LABEL_HANDLE;
}
for (let i = 0; i < this.sizers.length; i += 1) {
if (checkShape(this.sizers[i])) {
return i;
}
}
if (this.customHandles != null && this.isCustomHandleEvent(me)) {
// 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;
}
}
}
return null;
}
/**
* Returns `true` if the given event allows custom handles to be changed.
*
* This implementation returns `true`.
*/
isCustomHandleEvent(me) {
return true;
}
/**
* Handles the event if a handle has been clicked. By consuming the
* event all subsequent events of the gesture are redirected to this
* handler.
*/
mouseDown(_sender, me) {
if (!me.isConsumed() && this.graph.isEnabled()) {
const handle = this.getHandleForEvent(me);
if (handle) {
this.start(me.getGraphX(), me.getGraphY(), handle);
me.consume();
}
}
}
/**
* Called if {@link livePreview} is enabled to check if a border should be painted.
*
* This implementation returns `true` if the shape is transparent.
*/
isLivePreviewBorder() {
return (this.state.shape &&
this.state.shape.fill === NONE &&
this.state.shape.stroke === NONE);
}
/**
* Starts the handling of the mouse gesture.
*/
start(x, y, index) {
this.livePreviewActive = this.livePreview && this.state.cell.getChildCount() === 0;
this.inTolerance = true;
this.childOffsetX = 0;
this.childOffsetY = 0;
this.index = index;
this.startX = x;
this.startY = y;
if (this.index <= InternalEvent.CUSTOM_HANDLE && this.isGhostPreview()) {
this.ghostPreview = this.createGhostPreview();
}
else {
// Saves reference to parent state
const parent = this.state.cell.getParent();
if (this.state.view.currentRoot !== parent &&
parent &&
(parent.isVertex() || parent.isEdge())) {
this.parentState = this.state.view.graph.view.getState(parent);
}
// Creates a preview that can be on top of any HTML label
this.selectionBorder.node.style.display =
index === InternalEvent.ROTATION_HANDLE ? 'inline' : 'none';
// Creates the border that represents the new bounds
if (!this.livePreviewActive || this.isLivePreviewBorder()) {
this.preview = this.createSelectionShape(this.bounds);
if (!(Client.IS_SVG && (this.state.style.rotation ?? 0) != 0) &&
this.state.text != null &&
this.state.text.node.parentNode === this.graph.container) {
this.preview.dialect = 'strictHtml';
this.preview.init(this.graph.container);
}
else {
this.preview.dialect = 'svg';
this.preview.init(this.graph.view.getOverlayPane());
}
}
if (index === InternalEvent.ROTATION_HANDLE) {
// With the rotation handle in a corner, need the angle and distance
const pos = this.getRotationHandlePosition();
const dx = pos.x - this.state.getCenterX();
const dy = pos.y - this.state.getCenterY();
this.startAngle = dx !== 0 ? (Math.atan(dy / dx) * 180) / Math.PI + 90 : 0;
this.startDist = Math.sqrt(dx * dx + dy * dy);
}
// Prepares the handles for live preview
if (this.livePreviewActive) {
this.hideSizers();
if (index === InternalEvent.ROTATION_HANDLE && this.rotationShape) {
this.rotationShape.node.style.display = '';
}
else if (index === InternalEvent.LABEL_HANDLE && this.labelShape) {
this.labelShape.node.style.display = '';
}
else if (this.sizers[index]) {
this.sizers[index].node.style.display = '';
}
else if (index <= InternalEvent.CUSTOM_HANDLE) {
this.customHandles[InternalEvent.CUSTOM_HANDLE - index].setVisible(true);
}
// Gets the array of connected edge handlers for redrawing
const edges = this.state.cell.getEdges();
this.edgeHandlers = [];
const selectionCellsHandler = this.graph.getPlugin('SelectionCellsHandler');
for (let i = 0; i < edges.length; i += 1) {
const handler = selectionCellsHandler?.getHandler(edges[i]);
if (handler) {
this.edgeHandlers.push(handler);
}
}
}
}
}
/**
* Starts the handling of the mouse gesture.
*/
createGhostPreview() {
const shape = this.graph.cellRenderer.createShape(this.state);
shape.init(this.graph.view.getOverlayPane());
shape.scale = this.state.view.scale;
shape.bounds = this.bounds;
shape.outline = true;
return shape;
}
/**
* Shortcut to {@link hideSizers}.
*/
setHandlesVisible(visible) {
this.handlesVisible = visible;
for (let i = 0; i < this.sizers.length; i += 1) {
this.sizers[i].node.style.display = visible ? '' : 'none';
}
for (let i = 0; i < this.customHandles.length; i += 1) {
this.customHandles[i].setVisible(visible);
}
}
/**
* Hides all sizers except.
*
* Starts the handling of the mouse gesture.
*/
hideSizers() {
this.setHandlesVisible(false);
}
/**
* Checks if the coordinates for the given event are within the
* {@link AbstractGraph.tolerance}. If the event is a mouse event then the tolerance is
* ignored.
*/
checkTolerance(me) {
if (this.inTolerance && this.startX !== null && this.startY !== null) {
if (isMouseEvent(me.getEvent()) ||
Math.abs(me.getGraphX() - this.startX) > this.graph.getEventTolerance() ||
Math.abs(me.getGraphY() - this.startY) > this.graph.getEventTolerance()) {
this.inTolerance = false;
}
}
}
/**
* Hook for subclasses do show details while the handler is active.
*/
updateHint(me) {
return;
}
/**
* Hooks for subclasses to hide details when the handler gets inactive.
*/
removeHint() {
return;
}
/**
* Hook for rounding the angle. This uses {@link Math.round}.
*/
roundAngle(angle) {
return Math.round(angle * 10) / 10;
}
/**
* Hook for rounding the unscaled width or height. This uses {@link Math.round}.
*/
roundLength(length) {
return Math.round(length * 100) / 100;
}
/**
* Handles the event by updating the preview.
*/
mouseMove(_sender, me) {
if (!me.isConsumed() && this.index != null) {
// Checks tolerance for ignoring single clicks
this.checkTolerance(me);
if (!this.inTolerance) {
if (this.index <= InternalEvent.CUSTOM_HANDLE) {
if (this.customHandles != null) {
this.customHandles[InternalEvent.CUSTOM_HANDLE - this.index].processEvent(me);
this.customHandles[InternalEvent.CUSTOM_HANDLE - this.index].active = true;
if (this.ghostPreview != null) {
this.ghostPreview.apply(this.state);
this.ghostPreview.strokeWidth =
this.getSelectionStrokeWidth() /
this.ghostPreview.scale /
this.ghostPreview.scale;
this.ghostPreview.isDashed = this.isSelectionDashed();
this.ghostPreview.stroke = this.getSelectionColor();
this.ghostPreview.redraw();
if (this.selectionBounds != null) {
this.selectionBorder.node.style.display = 'none';
}
}
else {
if (this.movePreviewToFront) {
this.moveToFront();
}
this.customHandles[InternalEvent.CUSTOM_HANDLE - this.index].positionChanged();
}
}
}
else if (this.index === InternalEvent.LABEL_HANDLE) {
this.moveLabel(me);
}
else {
if (this.index === InternalEvent.ROTATION_HANDLE) {
this.rotateVertex(me);
}
else {
this.resizeVertex(me);
}
this.updateHint(me);
}
}
me.consume();
}
// Workaround for disabling the connect highlight when over handle
else if (!this.graph.isMouseDown && this.getHandleForEvent(me)) {
me.consume(false);
}
}
/**
* Returns `true` if a ghost preview should be used for custom handles.
*/
isGhostPreview() {
return this.state.cell.getChildCount() > 0;
}
/**
* Moves the vertex.
*/
moveLabel(me) {
const point = new Point(me.getGraphX(), me.getGraphY());
const tr = this.graph.view.translate;
const { scale } = this.graph.view;
if (this.graph.isGridEnabledEvent(me.getEvent())) {
point.x = (this.graph.snap(point.x / scale - tr.x) + tr.x) * scale;
point.y = (this.graph.snap(point.y / scale - tr.y) + tr.y) * scale;
}
const index = this.rotationShape ? this.sizers.length - 2 : this.sizers.length - 1;
this.moveSizerTo(this.sizers[index], point.x, point.y);
}
/**
* Rotates the vertex.
*/
rotateVertex(me) {
const point = new Point(me.getGraphX(), me.getGraphY());
let dx = this.state.x + this.state.width / 2 - point.x;
let dy = this.state.y + this.state.height / 2 - point.y;
this.currentAlpha =
dx !== 0 ? (Math.atan(dy / dx) * 180) / Math.PI + 90 : dy < 0 ? 180 : 0;
if (dx > 0) {
this.currentAlpha -= 180;
}
this.currentAlpha -= this.startAngle;
// Rotation raster
if (this.rotationRaster && this.graph.isGridEnabledEvent(me.getEvent())) {
let raster;
dx = point.x - this.state.getCenterX();
dy = point.y - this.state.getCenterY();
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist - this.startDist < 2) {
raster = 15;
}
else if (dist - this.startDist < 25) {
raster = 5;
}
else {
raster = 1;
}
this.currentAlpha = Math.round(this.currentAlpha / raster) * raster;
}
else {
this.currentAlpha = this.roundAngle(this.currentAlpha);
}
this.selectionBorder.rotation = this.currentAlpha;
this.selectionBorder.redraw();
if (this.livePreviewActive) {
this.redrawHandles();
}
}
/**
* Resizes the vertex.
*/
resizeVertex(me) {
const ct = new Point(this.state.getCenterX(), this.state.getCenterY());
const alpha = toRadians(this.state.style.rotation ?? 0);
const point = new Point(me.getGraphX(), me.getGraphY());
const tr = this.graph.view.translate;
const { scale } = this.graph.view;
let cos = Math.cos(-alpha);
let sin = Math.sin(-alpha);
let dx = point.x - this.startX;
let dy = point.y - this.startY;
// Rotates vector for mouse gesture
const tx = cos * dx - sin * dy;
const ty = sin * dx + cos * dy;
dx = tx;
dy = ty;
const geo = this.state.cell.getGeometry();
if (geo && this.index !== null) {
this.unscaledBounds = this.union(geo, dx / scale, dy / scale, this.index, this.graph.isGridEnabledEvent(me.getEvent()), 1, new Point(0, 0), this.isConstrainedEvent(me), this.isCenteredEvent(this.state, me));
}
// Keeps vertex within maximum graph or parent bounds
if (geo && !geo.relative) {
let max = this.graph.getMaximumGraphBounds();
// Handles child cells
if (max != null && this.parentState != null) {
max = Rectangle.fromRectangle(max);
max.x -= (this.parentState.x - tr.x * scale) / scale;
max.y -= (this.parentState.y - tr.y * scale) / scale;
}
if (this.graph.isConstrainChild(this.state.cell)) {
let tmp = this.graph.getCellContainmentArea(this.state.cell);
if (tmp != null) {
const overlap = this.graph.getOverlap(this.state.cell);
if (overlap > 0) {
tmp = Rectangle.fromRectangle(tmp);
tmp.x -= tmp.width * overlap;
tmp.y -= tmp.height * overlap;
tmp.width += 2 * tmp.width * overlap;
tmp.height += 2 * tmp.height * overlap;
}
if (!max) {
max = tmp;
}
else {
max = Rectangle.fromRectangle(max);
max.intersect(tmp);
}
}
}
if (max && this.unscaledBounds) {
if (this.unscaledBounds.x < max.x) {
this.unscaledBounds.width -= max.x - this.unscaledBounds.x;
this.unscaledBounds.x = max.x;
}
if (this.unscaledBounds.y < max.y) {
this.unscaledBounds.height -= max.y - this.unscaledBounds.y;
this.unscaledBounds.y = max.y;
}
if (this.unscaledBounds.x + this.unscaledBounds.width > max.x + max.width) {
this.unscaledBounds.width -=
this.unscaledBounds.x + this.unscaledBounds.width - max.x - max.width;
}
if (this.unscaledBounds.y + this.unscaledBounds.height > max.y + max.height) {
this.unscaledBounds.height -=
this.unscaledBounds.y + this.unscaledBounds.height - max.y - max.height;
}
}
}
if (this.unscaledBounds) {
const old = this.bounds;
this.bounds = new Rectangle((this.parentState ? this.parentState.x : tr.x * scale) +
this.unscaledBounds.x * scale, (this.parentState ? this.parentState.y : tr.y * scale) +
this.unscaledBounds.y * scale, this.unscaledBounds.width * scale, this.unscaledBounds.height * scale);
if (geo && geo.relative && this.parentState) {
this.bounds.x += this.state.x - this.parentState.x;
this.bounds.y += this.state.y - this.parentState.y;
}
cos = Math.cos(alpha);
sin = Math.sin(alpha);
const c2 = new Point(this.bounds.getCenterX(), this.bounds.getCenterY());
dx = c2.x - ct.x;
dy = c2.y - ct.y;
const dx2 = cos * dx - sin * dy;
const dy2 = sin * dx + cos * dy;
const dx3 = dx2 - dx;
const dy3 = dy2 - dy;
const dx4 = this.bounds.x - this.state.x;
const dy4 = this.bounds.y - this.state.y;
const dx5 = cos * dx4 - sin * dy4;
const dy5 = sin * dx4 + cos * dy4;
this.bounds.x += dx3;
this.bounds.y += dy3;
// Rounds unscaled bounds to int
this.unscaledBounds.x = this.roundLength(this.unscaledBounds.x + dx3 / scale);
this.unscaledBounds.y = this.roundLength(this.unscaledBounds.y + dy3 / scale);
this.unscaledBounds.width = this.roundLength(this.unscaledBounds.width);
this.unscaledBounds.height = this.roundLength(this.unscaledBounds.height);
// Shifts the children according to parent offset
if (!this.state.cell.isCollapsed() && (dx3 !== 0 || dy3 !== 0)) {
this.childOffsetX = this.state.x - this.bounds.x + dx5;
this.childOffsetY = this.state.y - this.bounds.y + dy5;
}
else {
this.childOffsetX = 0;
this.childOffsetY = 0;
}
if (!old.equals(this.bounds)) {
if (this.livePreviewActive) {
this.updateLivePreview(me);
}
if (this.preview != null) {
this.drawPreview();
}
else {
this.updateParentHighlight();
}
}
}
}
/**
* Repaints the live preview.
*/
updateLivePreview(me) {
// TODO: Apply child offset to children in live preview
const { scale } = this.graph.view;
const tr = this.graph.view.translate;
// Saves current state
const tempState = this.state.clone();
// Temporarily changes size and origin
this.state.x = this.bounds.x;
this.state.y = this.bounds.y;
this.state.origin = new Point(this.state.x / scale - tr.x, this.state.y / scale - tr.y);
this.state.width = this.bounds.width;
this.state.height = this.bounds.height;
// Redraws cell and handles
let off = this.state.absoluteOffset;
off = new Point(off.x, off.y);
// Required to store and reset absolute offset for updating label position
this.state.absoluteOffset.x = 0;
this.state.absoluteOffset.y = 0;
const geo = this.state.cell.getGeometry();
if (geo != null) {
const offset = geo.offset || this.EMPTY_POINT;
if (offset != null && !geo.relative) {
this.state.absoluteOffset.x = this.state.view.scale * offset.x;
this.state.absoluteOffset.y = this.state.view.scale * offset.y;
}
this.state.view.updateVertexLabelOffset(this.state);
}
// Draws the live preview
this.state.view.graph.cellRenderer.redraw(this.state, true);
// Redraws connected edges TODO: Include child edges
this.state.view.invalidate(this.state.cell);
this.state.invalid = false;
this.state.view.validate();
this.redrawHandles();
// Moves live preview to front
if (this.movePreviewToFront) {
this.moveToFront();
}
// Hides folding icon
if (this.state.control != null && this.state.control.node != null) {
this.state.control.node.style.visibility = 'hidden';
}
// Restores current state
this.state.setState(tempState);
}
/**
* Handles the event by applying the changes to the geometry.
*/
moveToFront() {
if ((this.state.text && this.state.text.node && this.state.text.node.nextSibling) ||
(this.state.shape &&
this.state.shape.node &&
this.state.shape.node.nextSibling &&
(!this.state.text || this.state.shape.node.nextSibling !== this.state.text.node))) {
if (this.state.shape && this.state.shape.node && this.state.shape.node.parentNode) {
this.state.shape.node.parentNode.appendChild(this.state.shape.node);
}
if (this.state.text && this.state.text.node && this.state.text.node.parentNode) {
this.state.text.node.parentNode.appendChild(this.state.text.node);
}
}
}
/**
* Handles the event by applying the changes to the geometry.
*/
mouseUp(_sender, me) {
if (this.index != null && this.state != null) {
const point = new Point(me.getGraphX(), me.getGraphY());
const { index } = this;
this.index = null;
if (this.ghostPreview == null) {
// Required to restore order in case of no change
this.state.view.invalidate(this.state.cell, false, false);
this.state.view.validate();
}
this.graph.batchUpdate(() => {
if (index <= InternalEvent.CUSTOM_HANDLE) {
if (this.customHandles != null) {
// Creates style before changing cell state
const style = this.state.view.graph.getCellStyle(this.state.cell);
this.customHandles[InternalEvent.CUSTOM_HANDLE - index].active = false;
this.customHandles[InternalEvent.CUSTOM_HANDLE - index].execute(me);
// Sets style and apply on shape to force repaint and
// check if execute has removed custom handles
if (this.customHandles != null &&
this.customHandles[InternalEvent.CUSTOM_HANDLE - index] != null) {
this.state.style = style;
this.customHandles[InternalEvent.CUSTOM_HANDLE - index].positionChanged();
}
}
}
else if (index === InternalEvent.ROTATION_HANDLE) {
if (this.currentAlpha != null) {
const delta = this.currentAlpha - (this.state.style.rotation ?? 0);
if (delta !== 0) {
this.rotateCell(this.state.cell, delta);
}
}
else {
this.rotateClick();
}
}
else {
const gridEnabled = this.graph.isGridEnabledEvent(me.getEvent());
const alpha = toRadians(this.state.style.rotation ?? 0);
const cos = Math.cos(-alpha);
const sin = Math.sin(-alpha);
let dx = point.x - this.startX;
let dy = point.y - this.startY;
// Rotates vector for mouse gesture
const tx = cos * dx - sin * dy;
const ty = sin * dx + cos * dy;
dx = tx;
dy = ty;
const s = this.graph.view.scale;
const recurse = this.isRecursiveResize(this.state, me);
this.resizeCell(this.state.cell, this.roundLength(dx / s), this.roundLength(dy / s), index, gridEnabled, this.isConstrainedEvent(me), recurse);
}
});
me.consume();
this.reset();
this.redrawHandles();
}
}
/**
* Returns the `recursiveResize` status of the given state.
* @param state the given {@link CellState}. This implementation takes the value of this state.
* @param me the mouse event.
*/
isRecursiveResize(state, me) {
return this.graph.isRecursiveResize(this.state);
}
/**
* Hook for subclasses to implement a single click on the rotation handle.
* This code is executed as part of the model transaction.
*
* This implementation is empty.
*/
rotateClick() {
return;
}
/**
* Rotates the given cell and its children by the given angle in degrees.
*
* @param cell {@link Cell} to be rotated.
* @param angle Angle in degrees.
* @param parent if set, consider the parent in the rotation computation.
*/
rotateCell(cell, angle, parent) {
if (angle !== 0) {
const model = this.graph.getDataModel();
if (cell.isVertex() || cell.isEdge()) {
if (!cell.isEdge()) {
const style = this.graph.getCurrentCellStyle(cell);
const total = (style.rotation ?? 0) + angle;
this.graph.setCellStyles('rotation', total, [cell]);
}
let geo = cell.getGeometry();
if (geo && parent) {
const pgeo = parent.getGeometry();
if (pgeo != null && !parent.isEdge()) {
geo = geo.clone();
geo.rotate(angle, new Point(pgeo.width / 2, pgeo.height / 2));
model.setGeometry(cell, geo);
}
if ((cell.isVertex() && !geo.relative) || cell.isEdge()) {
// Recursive rotation
const childCount = cell.getChildCount();
for (let i = 0; i < childCount; i += 1) {
this.rotateCell(cell.getChildAt(i), angle, cell);
}
}
}
}
}
}
/**
* Resets the state of this handler.
*/
reset() {
if (this.index !== null && this.sizers[this.index].node.style.display === 'none') {
this.sizers[this.index].node.style.display = '';
}
this.index = null;
this.currentAlpha = null;
// TODO: Reset and redraw cell states for live preview
if (this.preview) {
this.preview.destroy();
this.preview = null;
}
if (this.ghostPreview) {
this.ghostPreview.destroy();
this.ghostPreview = null;
}
if (this.livePreviewActive) {
for (let i = 0; i < this.sizers.length; i += 1) {
this.sizers[i].node.style.display = '';
}
// Shows folding icon
if (this.state.control && this.state.control.node) {
this.state.control.node.style.visibility = '';
}
}
for (let i = 0; i < this.customHandles.length; i += 1) {
if (this.customHandles[i].active) {
this.customHandles[i].active = false;
this.customHandles[i].reset();
}
else {
this.customHandles[i].setVisible(true);
}
}
// Checks if handler has been destroyed
this.selectionBorder.node.style.display = 'inline';
this.selectionBounds = this.getSelectionBounds(this.state);
this.bounds = new Rectangle(this.selectionBounds.x, this.selectionBounds.y, this.selectionBounds.width, this.selectionBounds.height);
this.drawPreview();
this.removeHint();
this.redrawHandles();
this.edgeHandlers = [];
this.handlesVisible = true;
this.unscaledBounds = null;
}
/**
* Uses the given vector to change the bounds of the given cell
* in the graph using {@link AbstractGraph.resizeCell}.
*/
resizeCell(cell, dx, dy, index, gridEnabled, constrained, recurse) {
let geo = cell.getGeometry();
if (geo) {
if (index === InternalEvent.LABEL_HANDLE &&
this.labelShape &&
this.labelShape.bounds) {
const alpha = -toRadians(this.state.style.rotation ?? 0);
const cos = Math.cos(alpha);
const sin = Math.sin(alpha);
const { scale } = this.graph.view;
const pt = getRotatedPoint(new Point(Math.round((this.labelShape.bounds.getCenterX() - this.startX) / scale), Math.round((this.labelShape.bounds.getCenterY() - this.startY) / scale)), cos, sin);
geo = geo.clone();
if (geo.offset == null) {
geo.offset = pt;
}
else {
geo.offset.x += pt.x;
geo.offset.y += pt.y;
}
this.graph.model.setGeometry(cell, geo);
}
else if (this.unscaledBounds) {
const { scale } = this.graph.view;
if (this.childOffsetX !== 0 || this.childOffsetY !== 0) {
this.moveChildren(cell, Math.round(this.childOffsetX / scale), Math.round(this.childOffsetY / scale));
}
this.graph.resizeCell(cell, this.unscaledBounds, recurse);
}
}
}
/**
* Moves the children of the given cell by the given vector.
*/
moveChildren(cell, dx, dy) {
const model = this.graph.getDataModel();
const childCount = cell.getChildCount();
for (let i = 0; i < childCount; i += 1) {
const child = cell.getChildAt(i);
let geo = child.getGeometry();
if (geo != null) {
geo = geo.clone();
geo.translate(dx, dy);
model.setGeometry(child, geo);
}
}
}
/**
* Returns the union of the given bounds and location for the specified
* handle index.
*
* To override this to limit the size of vertex via a minWidth/-Height style,
* the following code can be used.
*
* ```javascript
* const vertexHandlerUnion = union;
* vertexHandler.union = (bounds, dx, dy, index, gridEnabled, scale, tr, constrained) => {
* const result = vertexHandlerUnion.apply(this, arguments);
*
* result.width = Math.max(result.width, this.state.style.minWidth ?? 0));
* result.height = Math.max(result.height, this.state.style.minHeight ?? 0));
*
* return result;
* };
* ```
*
* The minWidth/-Height style can then be used as follows:
*
* ```javascript
* graph.insertVertex({
* parent,
* value: 'Hello,',
* position: [20, 20],
* size: [80, 30],
* style: {
* minWidth: 100,
* minHeight: 100,
* },
* });
* ```
*
* To override this to update the height for a wrapped text if the width of a vertex is
* changed, the following can be used.
*
* ```javascript
* const vertexHandlerUnion = union;
* vertexHandler.union = (bounds, dx, dy, index, gridEnabled, scale, tr, constrained) => {
* const result = vertexHandlerUnion.apply(this, arguments);
* const s = this.state;
*
* if (this.graph.isHtmlLabel(s.cell)
* && (index == 3 || index == 4)
* && s.text != null && s.style.whiteSpace == 'wrap') {
* const label = this.graph.getLabel(s.cell);
* const fontSize = s.style.fontSize ?? constants.DEFAULT_FONTSIZE;
* const ww = result.width / s.view.scale - s.text.spacingRight - s.text.spacingLeft
*
* result.height = styleUtils.getSizeForString(label, fontSize, s.style.fontFamily, ww).height;
* }
*
* return result;
* };
* ```
*/
union(bounds, dx, dy, index, gridEnabled, scale, tr, constrained, centered) {
gridEnabled = gridEnabled && this.graph.isGridEnabled();
if (this.singleSizer) {
let x = bounds.x + bounds.width + dx;
let y = bounds.y + bounds.height + dy;
if (gridEnabled) {
x = this.graph.snap(x / scale) * scale;
y = this.graph.snap(y / scale) * scale;
}
const rect = new Rectangle(bounds.x, bounds.y, 0, 0);
rect.add(new Rectangle(x, y, 0, 0));
return rect;
}
const w0 = bounds.width;
const h0 = bounds.height;
let left = bounds.x - tr.x * scale;
let right = left + w0;
let top = bounds.y - tr.y * scale;
let bottom = top + h0;
const cx = left + w0 / 2;
const cy = top + h0 / 2;
if (index > 4 /* Bottom Row */) {
bottom += dy;
if (gridEnabled) {