diagram-js
Version:
A toolbox for displaying and modifying diagrams on the web
578 lines (445 loc) • 14.1 kB
JavaScript
var round = Math.round;
import { assign } from 'min-dash';
import {
event as domEvent
} from 'min-dom';
import {
getOriginal,
toPoint,
stopPropagation
} from '../../util/Event';
import {
set as cursorSet,
unset as cursorUnset
} from '../../util/Cursor';
import {
install as installClickTrap
} from '../../util/ClickTrap';
import {
delta as deltaPos
} from '../../util/PositionUtil';
import { isKey } from '../keyboard/KeyboardUtil';
/**
* @typedef {import('../../util/Types').Point} Point
*
* @typedef {import('../../core/Canvas').default} Canvas
* @typedef {import('../../core/ElementRegistry').default} ElementRegistry
* @typedef {import('../../core/EventBus').default} EventBus
* @typedef {import('../selection/Selection').default} Selection
*/
var DRAG_ACTIVE_CLS = 'djs-drag-active';
function preventDefault(event) {
event.preventDefault();
}
function isTouchEvent(event) {
// check for TouchEvent being available first
// (i.e. not available on desktop Firefox)
return typeof TouchEvent !== 'undefined' && event instanceof TouchEvent;
}
function getLength(point) {
return Math.sqrt(Math.pow(point.x, 2) + Math.pow(point.y, 2));
}
/**
* A helper that fires canvas localized drag events and realizes
* the general "drag-and-drop" look and feel.
*
* Calling {@link Dragging#activate} activates dragging on a canvas.
*
* It provides the following:
*
* * emits life cycle events, namespaced with a prefix assigned
* during dragging activation
* * sets and restores the cursor
* * sets and restores the selection if elements still exist
* * ensures there can be only one drag operation active at a time
*
* Dragging may be canceled manually by calling {@link Dragging#cancel}
* or by pressing ESC.
*
*
* ## Life-cycle events
*
* Dragging can be in three different states, off, initialized
* and active.
*
* (1) off: no dragging operation is in progress
* (2) initialized: a new drag operation got initialized but not yet
* started (i.e. because of no initial move)
* (3) started: dragging is in progress
*
* Eventually dragging will be off again after a drag operation has
* been ended or canceled via user click or ESC key press.
*
* To indicate transitions between these states dragging emits generic
* life-cycle events with the `drag.` prefix _and_ events namespaced
* to a prefix choosen by a user during drag initialization.
*
* The following events are emitted (appropriately prefixed) via
* the {@link EventBus}.
*
* * `init`
* * `start`
* * `move`
* * `end`
* * `ended` (dragging already in off state)
* * `cancel` (only if previously started)
* * `canceled` (dragging already in off state, only if previously started)
* * `cleanup`
*
*
* @example
*
* ```javascript
* function MyDragComponent(eventBus, dragging) {
*
* eventBus.on('mydrag.start', function(event) {
* console.log('yes, we start dragging');
* });
*
* eventBus.on('mydrag.move', function(event) {
* console.log('canvas local coordinates', event.x, event.y, event.dx, event.dy);
*
* // local drag data is passed with the event
* event.context.foo; // "BAR"
*
* // the original mouse event, too
* event.originalEvent; // MouseEvent(...)
* });
*
* eventBus.on('element.click', function(event) {
* dragging.init(event, 'mydrag', {
* cursor: 'grabbing',
* data: {
* context: {
* foo: "BAR"
* }
* }
* });
* });
* }
* ```
*
* @param {EventBus} eventBus
* @param {Canvas} canvas
* @param {Selection} selection
* @param {ElementRegistry} elementRegistry
*/
export default function Dragging(eventBus, canvas, selection, elementRegistry) {
var defaultOptions = {
threshold: 5,
trapClick: true
};
// the currently active drag operation
// dragging is active as soon as this context exists.
//
// it is visually _active_ only when a context.active flag is set to true.
var context;
/* convert a global event into local coordinates */
function toLocalPoint(globalPosition) {
var viewbox = canvas.viewbox();
var clientRect = canvas._container.getBoundingClientRect();
return {
x: viewbox.x + (globalPosition.x - clientRect.left) / viewbox.scale,
y: viewbox.y + (globalPosition.y - clientRect.top) / viewbox.scale
};
}
// helpers
function fire(type, dragContext) {
dragContext = dragContext || context;
var event = eventBus.createEvent(
assign(
{},
dragContext.payload,
dragContext.data,
{ isTouch: dragContext.isTouch }
)
);
// default integration
if (eventBus.fire('drag.' + type, event) === false) {
return false;
}
return eventBus.fire(dragContext.prefix + '.' + type, event);
}
function restoreSelection(previousSelection) {
var existingSelection = previousSelection.filter(function(element) {
return elementRegistry.get(element.id);
});
existingSelection.length && selection.select(existingSelection);
}
// event listeners
function move(event, activate) {
var payload = context.payload,
displacement = context.displacement;
var globalStart = context.globalStart,
globalCurrent = toPoint(event),
globalDelta = deltaPos(globalCurrent, globalStart);
var localStart = context.localStart,
localCurrent = toLocalPoint(globalCurrent),
localDelta = deltaPos(localCurrent, localStart);
// activate context explicitly or once threshold is reached
if (!context.active && (activate || getLength(globalDelta) > context.threshold)) {
// fire start event with original
// starting coordinates
assign(payload, {
x: round(localStart.x + displacement.x),
y: round(localStart.y + displacement.y),
dx: 0,
dy: 0
}, { originalEvent: event });
if (false === fire('start')) {
return cancel();
}
context.active = true;
// unset selection and remember old selection
// the previous (old) selection will always passed
// with the event via the event.previousSelection property
if (!context.keepSelection) {
payload.previousSelection = selection.get();
selection.select(null);
}
// allow custom cursor
if (context.cursor) {
cursorSet(context.cursor);
}
// indicate dragging via marker on root element
canvas.addMarker(canvas.getRootElement(), DRAG_ACTIVE_CLS);
}
stopPropagation(event);
if (context.active) {
// update payload with actual coordinates
assign(payload, {
x: round(localCurrent.x + displacement.x),
y: round(localCurrent.y + displacement.y),
dx: round(localDelta.x),
dy: round(localDelta.y)
}, { originalEvent: event });
// emit move event
fire('move');
}
}
function end(event) {
var previousContext,
returnValue = true;
if (context.active) {
if (event) {
context.payload.originalEvent = event;
// suppress original event (click, ...)
// because we just ended a drag operation
stopPropagation(event);
}
// implementations may stop restoring the
// original state (selections, ...) by preventing the
// end events default action
returnValue = fire('end');
}
if (returnValue === false) {
fire('rejected');
}
previousContext = cleanup(returnValue !== true);
// last event to be fired when all drag operations are done
// at this point in time no drag operation is in progress anymore
fire('ended', previousContext);
}
// cancel active drag operation if the user presses
// the ESC key on the keyboard
function checkCancel(event) {
if (isKey('Escape', event)) {
preventDefault(event);
cancel();
}
}
// prevent ghost click that might occur after a finished
// drag and drop session
function trapClickAndEnd(event) {
var untrap;
// trap the click in case we are part of an active
// drag operation. This will effectively prevent
// the ghost click that cannot be canceled otherwise.
if (context.active) {
untrap = installClickTrap(eventBus);
// remove trap after minimal delay
setTimeout(untrap, 400);
// prevent default action (click)
preventDefault(event);
}
end(event);
}
function trapTouch(event) {
move(event);
}
// update the drag events model element (`hover`) and graphical element (`hoverGfx`)
// properties during hover and out and fire {prefix}.hover and {prefix}.out properties
// respectively
function hover(event) {
var payload = context.payload;
payload.hoverGfx = event.gfx;
payload.hover = event.element;
fire('hover');
}
function out(event) {
fire('out');
var payload = context.payload;
payload.hoverGfx = null;
payload.hover = null;
}
// life-cycle methods
function cancel(restore) {
var previousContext;
if (!context) {
return;
}
var wasActive = context.active;
if (wasActive) {
fire('cancel');
}
previousContext = cleanup(restore);
if (wasActive) {
// last event to be fired when all drag operations are done
// at this point in time no drag operation is in progress anymore
fire('canceled', previousContext);
}
}
function cleanup(restore) {
var previousContext,
endDrag;
fire('cleanup');
// reset cursor
cursorUnset();
if (context.trapClick) {
endDrag = trapClickAndEnd;
} else {
endDrag = end;
}
// reset dom listeners
domEvent.unbind(document, 'mousemove', move);
domEvent.unbind(document, 'dragstart', preventDefault);
domEvent.unbind(document, 'selectstart', preventDefault);
domEvent.unbind(document, 'mousedown', endDrag, true);
domEvent.unbind(document, 'mouseup', endDrag, true);
domEvent.unbind(document, 'keyup', checkCancel);
domEvent.unbind(document, 'touchstart', trapTouch, true);
domEvent.unbind(document, 'touchcancel', cancel, true);
domEvent.unbind(document, 'touchmove', move, true);
domEvent.unbind(document, 'touchend', end, true);
eventBus.off('element.hover', hover);
eventBus.off('element.out', out);
// remove drag marker on root element
canvas.removeMarker(canvas.getRootElement(), DRAG_ACTIVE_CLS);
// restore selection, unless it has changed
var previousSelection = context.payload.previousSelection;
if (restore !== false && previousSelection && !selection.get().length) {
restoreSelection(previousSelection);
}
previousContext = context;
context = null;
return previousContext;
}
/**
* Initialize a drag operation.
*
* If `localPosition` is given, drag events will be emitted
* relative to it.
*
* @param {MouseEvent|TouchEvent} [event]
* @param {Point} [relativeTo] actual diagram local position this drag operation should start at
* @param {string} prefix
* @param {Object} [options]
*/
function init(event, relativeTo, prefix, options) {
// only one drag operation may be active, at a time
if (context) {
cancel(false);
}
if (typeof relativeTo === 'string') {
options = prefix;
prefix = relativeTo;
relativeTo = null;
}
options = assign({}, defaultOptions, options || {});
var data = options.data || {},
originalEvent,
globalStart,
localStart,
endDrag,
isTouch;
if (options.trapClick) {
endDrag = trapClickAndEnd;
} else {
endDrag = end;
}
if (event) {
originalEvent = getOriginal(event) || event;
globalStart = toPoint(event);
stopPropagation(event);
// prevent default browser dragging behavior
if (originalEvent.type === 'dragstart') {
preventDefault(originalEvent);
}
} else {
originalEvent = null;
globalStart = { x: 0, y: 0 };
}
localStart = toLocalPoint(globalStart);
if (!relativeTo) {
relativeTo = localStart;
}
isTouch = isTouchEvent(originalEvent);
context = assign({
prefix: prefix,
data: data,
payload: {},
globalStart: globalStart,
displacement: deltaPos(relativeTo, localStart),
localStart: localStart,
isTouch: isTouch
}, options);
// skip dom registration if trigger
// is set to manual (during testing)
if (!options.manual) {
// add dom listeners
if (isTouch) {
domEvent.bind(document, 'touchstart', trapTouch, true);
domEvent.bind(document, 'touchcancel', cancel, true);
domEvent.bind(document, 'touchmove', move, true);
domEvent.bind(document, 'touchend', end, true);
} else {
// assume we use the mouse to interact per default
domEvent.bind(document, 'mousemove', move);
// prevent default browser drag and text selection behavior
domEvent.bind(document, 'dragstart', preventDefault);
domEvent.bind(document, 'selectstart', preventDefault);
domEvent.bind(document, 'mousedown', endDrag, true);
domEvent.bind(document, 'mouseup', endDrag, true);
}
domEvent.bind(document, 'keyup', checkCancel);
eventBus.on('element.hover', hover);
eventBus.on('element.out', out);
}
fire('init');
if (options.autoActivate) {
move(event, true);
}
}
// cancel on diagram destruction
eventBus.on('diagram.destroy', cancel);
// API
this.init = init;
this.move = move;
this.hover = hover;
this.out = out;
this.end = end;
this.cancel = cancel;
// for introspection
this.context = function() {
return context;
};
this.setOptions = function(options) {
assign(defaultOptions, options);
};
}
Dragging.$inject = [
'eventBus',
'canvas',
'selection',
'elementRegistry'
];