From 01ecda160494dba8445d42ccb4004dd12d171e68 Mon Sep 17 00:00:00 2001 From: Philipp Fromme Date: Thu, 23 May 2019 12:55:00 +0200 Subject: [PATCH] feat(resize-snapping): add resize snapping Related to bpmn-io/bpmn-js#1290 --- lib/features/snapping/ResizeSnapping.js | 141 +++++++++++++ test/spec/snapping/ResizeSnappingSpec.js | 247 +++++++++++++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 lib/features/snapping/ResizeSnapping.js create mode 100644 test/spec/snapping/ResizeSnappingSpec.js diff --git a/lib/features/snapping/ResizeSnapping.js b/lib/features/snapping/ResizeSnapping.js new file mode 100644 index 000000000..1555dc89e --- /dev/null +++ b/lib/features/snapping/ResizeSnapping.js @@ -0,0 +1,141 @@ +import SnapContext from './SnapContext'; + +import { + bottomLeft, + bottomRight, + getChildren, + isSnapped, + topLeft, + topRight +} from './SnapUtil'; + +import { isCmd } from '../keyboard/KeyboardUtil'; + +import { forEach } from 'min-dash'; + +var HIGHER_PRIORITY = 1250; + + +/** + * Snap during resize. + * + * @param {EventBus} eventBus + * @param {Snapping} snapping + */ +export default function ResizeSnapping(eventBus, snapping) { + var self = this; + + eventBus.on([ 'resize.start' ], function(event) { + self.initSnap(event); + }); + + eventBus.on([ + 'resize.move', + 'resize.end', + ], HIGHER_PRIORITY, function(event) { + var context = event.context, + shape = context.shape, + parent = shape.parent, + direction = context.direction, + snapContext = context.snapContext; + + if (event.originalEvent && isCmd(event.originalEvent)) { + return; + } + + if (isSnapped(event)) { + return; + } + + var snapPoints = snapContext.pointsForTarget(parent); + + if (!snapPoints.initialized) { + snapPoints = self.addSnapTargetPoints(snapPoints, shape, parent, direction); + + snapPoints.initialized = true; + } + + snapping.snap(event, snapPoints); + }); + + eventBus.on([ 'resize.cleanup' ], function() { + snapping.hide(); + }); +} + +ResizeSnapping.prototype.initSnap = function(event) { + var context = event.context, + shape = context.shape, + direction = context.direction, + snapContext = context.snapContext; + + if (!snapContext) { + snapContext = context.snapContext = new SnapContext(); + } + + var snapCorner = getCorner(shape, direction); + + snapContext.setSnapOrigin('corner', { + x: snapCorner.x - event.x, + y: snapCorner.y - event.y + }); + + return snapContext; +}; + +ResizeSnapping.prototype.addSnapTargetPoints = function(snapPoints, shape, target, direction) { + var snapTargets = this.getSnapTargets(shape, target); + + forEach(snapTargets, function(snapTarget) { + snapPoints.add('corner', bottomRight(snapTarget)); + snapPoints.add('corner', topLeft(snapTarget)); + }); + + snapPoints.add('corner', getCorner(shape, direction)); + + return snapPoints; +}; + +ResizeSnapping.$inject = [ + 'eventBus', + 'snapping' +]; + +ResizeSnapping.prototype.getSnapTargets = function(shape, target) { + return getChildren(target).filter(function(child) { + return !isAttached(child, shape) + && !isConnection(child) + && !isHidden(child) + && !isLabel(child); + }); +}; + +// helpers ////////// + +function getCorner(shape, direction) { + if (direction === 'nw') { + return topLeft(shape); + } else if (direction === 'ne') { + return topRight(shape); + } else if (direction === 'sw') { + return bottomLeft(shape); + } else { + return bottomRight(shape); + } +} + +function isAttached(element, host) { + return element.host === host; +} + +function isConnection(element) { + return !!element.waypoints; +} + +function isHidden(element) { + return !!element.hidden; +} + +function isLabel(element) { + return !!element.labelTarget; +} \ No newline at end of file diff --git a/test/spec/snapping/ResizeSnappingSpec.js b/test/spec/snapping/ResizeSnappingSpec.js new file mode 100644 index 000000000..7b7071c39 --- /dev/null +++ b/test/spec/snapping/ResizeSnappingSpec.js @@ -0,0 +1,247 @@ +import { + bootstrapDiagram, + inject +} from 'test/TestHelper'; + +import modelingModule from 'lib/features/modeling'; +import resizeModule from 'lib/features/resize'; +import snappingModule from 'lib/features/snapping'; + +import SnapContext from 'lib/features/snapping/SnapContext'; + +import { + createCanvasEvent as canvasEvent +} from '../../util/MockEvents'; + + +describe('features/snapping - ResizeSnapping', function() { + + beforeEach(bootstrapDiagram({ + modules: [ + modelingModule, + resizeModule, + snappingModule + ] + })); + + beforeEach(inject(function(dragging) { + dragging.setOptions({ manual: true }); + })); + + var rootElement, + shape1; + + beforeEach(inject(function(canvas, elementFactory) { + rootElement = elementFactory.createRoot({ + id: 'root' + }); + + canvas.setRootElement(rootElement); + + shape1 = elementFactory.createShape({ + id: 'shape1', + x: 100, + y: 100, + width: 100, + height: 100, + resizable: 'always' + }); + + canvas.addShape(shape1, rootElement); + + var shape2 = elementFactory.createShape({ + id: 'shape2', + x: 300, + y: 300, + width: 100, + height: 100 + }); + + canvas.addShape(shape2, rootElement); + + var connection = elementFactory.createConnection({ + id: 'connection', + source: shape1, + target: shape2, + waypoints: [ + { x: 500, y: 500 }, + { x: 600, y: 600 }, + { x: 700, y: 700 } + ] + }); + + canvas.addConnection(connection, rootElement); + + var label = elementFactory.createLabel({ + id: 'label', + x: 800, + y: 800, + width: 100, + height: 100, + labelTarget: shape2 + }); + + canvas.addShape(label, rootElement); + })); + + + describe('#initSnap', function() { + + it('should create snap context', inject(function(resizeSnapping, eventBus) { + + // given + var event = eventBus.createEvent({ + x: 100, + y: 100, + shape: shape1, + context: { + shape: shape1 + } + }); + + // when + var snapContext = resizeSnapping.initSnap(event); + + // then + expect(snapContext).to.exist; + expect(event.context.snapContext).to.equal(snapContext); + })); + + + it('should NOT create snap context', inject(function(resizeSnapping) { + + // given + var originalSnapContext = new SnapContext(); + + var event = { + x: 100, + y: 100, + shape: shape1, + context: { + shape: shape1, + snapContext: originalSnapContext + } + }; + + // when + var snapContext = resizeSnapping.initSnap(event); + + // then + expect(snapContext).to.equal(originalSnapContext); + })); + + }); + + + describe('snapping', function() { + + it('should init on resize.start', inject(function(eventBus) { + + // given + var event = eventBus.createEvent({ + x: 100, + y: 100, + shape: shape1, + context: { + shape: shape1 + } + }); + + // when + eventBus.fire('resize.start', event); + + // then + expect(event.context.snapContext).to.exist; + })); + + + it('snap to self', inject(function(dragging, resize) { + + // when + resize.activate(canvasEvent({ x: 200, y: 200 }), shape1, 'se'); + + dragging.move(canvasEvent({ x: 205, y: 205 })); + + dragging.end(); + + // then + expect(shape1).to.have.bounds({ + x: 100, + y: 100, + width: 100, + height: 100 + }); + })); + + + describe('snap to shape', function() { + + it('should snap', inject(function(dragging, resize) { + + // when + resize.activate(canvasEvent({ x: 200, y: 200 }), shape1, 'se'); + + dragging.move(canvasEvent({ x: 305, y: 305 })); + + dragging.end(); + + // then + expect(shape1).to.have.bounds({ + x: 100, + y: 100, + width: 200, // 205 snapped to 200 (left of shape2) + height: 200 // 205 snapped to 200 (top of shape2) + }); + })); + + }); + + + describe('snap to connection', function() { + + it('should NOT snap', inject(function(dragging, resize) { + + // when + resize.activate(canvasEvent({ x: 200, y: 200 }), shape1, 'se'); + + dragging.move(canvasEvent({ x: 605, y: 605 })); + + dragging.end(); + + // then + expect(shape1).to.have.bounds({ + x: 100, + y: 100, + width: 505, // NOT snapped + height: 505 // NOT snapped + }); + })); + + }); + + + describe('snap to label', function() { + + it('should NOT snap', inject(function(dragging, resize) { + + // when + resize.activate(canvasEvent({ x: 200, y: 200 }), shape1, 'se'); + + dragging.move(canvasEvent({ x: 805, y: 805 })); + + dragging.end(); + + // then + expect(shape1).to.have.bounds({ + x: 100, + y: 100, + width: 705, // NOT snapped + height: 705 // NOT snapped + }); + })); + + }); + + }); + +}); \ No newline at end of file