525 lines
10 KiB
JavaScript
525 lines
10 KiB
JavaScript
|
|
import {
|
||
|
|
assign,
|
||
|
|
every,
|
||
|
|
forEach,
|
||
|
|
isArray,
|
||
|
|
isDefined,
|
||
|
|
isFunction,
|
||
|
|
some
|
||
|
|
} from 'min-dash';
|
||
|
|
|
||
|
|
import {
|
||
|
|
delegate as domDelegate,
|
||
|
|
event as domEvent,
|
||
|
|
attr as domAttr,
|
||
|
|
query as domQuery,
|
||
|
|
classes as domClasses,
|
||
|
|
domify as domify
|
||
|
|
} from 'min-dom';
|
||
|
|
|
||
|
|
import { getBBox } from '../../util/Elements';
|
||
|
|
|
||
|
|
import {
|
||
|
|
escapeCSS
|
||
|
|
} from '../../util/EscapeUtil';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @typedef {import('../../model').Base} Base
|
||
|
|
*
|
||
|
|
* @typedef {import('../../util/Types').Rect} Rect
|
||
|
|
*/
|
||
|
|
|
||
|
|
var entrySelector = '.entry';
|
||
|
|
|
||
|
|
var DEFAULT_PRIORITY = 1000;
|
||
|
|
var CONTEXT_PAD_PADDING = 12;
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @typedef {Base|Base[]} ContextPadTarget
|
||
|
|
*
|
||
|
|
* @typedef {import('../../core/Canvas').default} Canvas
|
||
|
|
* @typedef {import('../../core/EventBus').default} EventBus
|
||
|
|
* @typedef {import('../overlays/Overlays').default} Overlays
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A context pad that displays element specific, contextual actions next
|
||
|
|
* to a diagram element.
|
||
|
|
*
|
||
|
|
* @param {Canvas} canvas
|
||
|
|
* @param {Object} config
|
||
|
|
* @param {boolean|Object} [config.scale={ min: 1.0, max: 1.5 }]
|
||
|
|
* @param {number} [config.scale.min]
|
||
|
|
* @param {number} [config.scale.max]
|
||
|
|
* @param {EventBus} eventBus
|
||
|
|
* @param {Overlays} overlays
|
||
|
|
*/
|
||
|
|
export default function ContextPad(canvas, config, eventBus, overlays) {
|
||
|
|
|
||
|
|
this._canvas = canvas;
|
||
|
|
this._eventBus = eventBus;
|
||
|
|
this._overlays = overlays;
|
||
|
|
|
||
|
|
var scale = isDefined(config && config.scale) ? config.scale : {
|
||
|
|
min: 1,
|
||
|
|
max: 1.5
|
||
|
|
};
|
||
|
|
|
||
|
|
this._overlaysConfig = {
|
||
|
|
scale: scale
|
||
|
|
};
|
||
|
|
|
||
|
|
this._current = null;
|
||
|
|
|
||
|
|
this._init();
|
||
|
|
}
|
||
|
|
|
||
|
|
ContextPad.$inject = [
|
||
|
|
'canvas',
|
||
|
|
'config.contextPad',
|
||
|
|
'eventBus',
|
||
|
|
'overlays'
|
||
|
|
];
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Registers events needed for interaction with other components.
|
||
|
|
*/
|
||
|
|
ContextPad.prototype._init = function() {
|
||
|
|
var self = this;
|
||
|
|
|
||
|
|
this._eventBus.on('selection.changed', function(event) {
|
||
|
|
|
||
|
|
var selection = event.newSelection;
|
||
|
|
|
||
|
|
var target = selection.length
|
||
|
|
? selection.length === 1
|
||
|
|
? selection[0]
|
||
|
|
: selection
|
||
|
|
: null;
|
||
|
|
|
||
|
|
if (target) {
|
||
|
|
self.open(target, true);
|
||
|
|
} else {
|
||
|
|
self.close();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
this._eventBus.on('elements.changed', function(event) {
|
||
|
|
var elements = event.elements,
|
||
|
|
current = self._current;
|
||
|
|
|
||
|
|
if (!current) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var currentTarget = current.target;
|
||
|
|
|
||
|
|
var currentChanged = some(
|
||
|
|
isArray(currentTarget) ? currentTarget : [ currentTarget ],
|
||
|
|
function(element) {
|
||
|
|
return includes(elements, element);
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
// re-open if elements in current selection changed
|
||
|
|
if (currentChanged) {
|
||
|
|
self.open(currentTarget, true);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Register context pad provider.
|
||
|
|
*
|
||
|
|
* @param {number} [priority=1000]
|
||
|
|
* @param {ContextPadProvider} provider
|
||
|
|
*
|
||
|
|
* @example
|
||
|
|
* const contextPadProvider = {
|
||
|
|
* getContextPadEntries: function(element) {
|
||
|
|
* return function(entries) {
|
||
|
|
* return {
|
||
|
|
* ...entries,
|
||
|
|
* 'entry-1': {
|
||
|
|
* label: 'My Entry',
|
||
|
|
* action: function() { alert("I have been clicked!"); }
|
||
|
|
* }
|
||
|
|
* };
|
||
|
|
* }
|
||
|
|
* },
|
||
|
|
*
|
||
|
|
* getMultiElementContextPadEntries: function(elements) {
|
||
|
|
* // ...
|
||
|
|
* }
|
||
|
|
* };
|
||
|
|
*
|
||
|
|
* contextPad.registerProvider(800, contextPadProvider);
|
||
|
|
*/
|
||
|
|
ContextPad.prototype.registerProvider = function(priority, provider) {
|
||
|
|
if (!provider) {
|
||
|
|
provider = priority;
|
||
|
|
priority = DEFAULT_PRIORITY;
|
||
|
|
}
|
||
|
|
|
||
|
|
this._eventBus.on('contextPad.getProviders', priority, function(event) {
|
||
|
|
event.providers.push(provider);
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get context pad entries for given elements.
|
||
|
|
*
|
||
|
|
* @param {ContextPadTarget} target
|
||
|
|
*
|
||
|
|
* @return {ContextPadEntryDescriptor[]} list of entries
|
||
|
|
*/
|
||
|
|
ContextPad.prototype.getEntries = function(target) {
|
||
|
|
var providers = this._getProviders();
|
||
|
|
|
||
|
|
var provideFn = isArray(target)
|
||
|
|
? 'getMultiElementContextPadEntries'
|
||
|
|
: 'getContextPadEntries';
|
||
|
|
|
||
|
|
var entries = {};
|
||
|
|
|
||
|
|
// loop through all providers and their entries.
|
||
|
|
// group entries by id so that overriding an entry is possible
|
||
|
|
forEach(providers, function(provider) {
|
||
|
|
|
||
|
|
if (!isFunction(provider[provideFn])) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var entriesOrUpdater = provider[provideFn](target);
|
||
|
|
|
||
|
|
if (isFunction(entriesOrUpdater)) {
|
||
|
|
entries = entriesOrUpdater(entries);
|
||
|
|
} else {
|
||
|
|
forEach(entriesOrUpdater, function(entry, id) {
|
||
|
|
entries[id] = entry;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return entries;
|
||
|
|
};
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Trigger context pad via DOM event.
|
||
|
|
*
|
||
|
|
* The entry to trigger is determined by the target element.
|
||
|
|
*
|
||
|
|
* @param {string} action
|
||
|
|
* @param {Event} event
|
||
|
|
* @param {boolean} [autoActivate=false]
|
||
|
|
*/
|
||
|
|
ContextPad.prototype.trigger = function(action, event, autoActivate) {
|
||
|
|
|
||
|
|
var entry,
|
||
|
|
originalEvent,
|
||
|
|
button = event.delegateTarget || event.target;
|
||
|
|
|
||
|
|
if (!button) {
|
||
|
|
return event.preventDefault();
|
||
|
|
}
|
||
|
|
|
||
|
|
entry = domAttr(button, 'data-action');
|
||
|
|
originalEvent = event.originalEvent || event;
|
||
|
|
|
||
|
|
return this.triggerEntry(entry, action, originalEvent, autoActivate);
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Trigger context pad entry entry.
|
||
|
|
*
|
||
|
|
* @param {string} entryId
|
||
|
|
* @param {string} action
|
||
|
|
* @param {Event} event
|
||
|
|
* @param {boolean} [autoActivate=false]
|
||
|
|
*/
|
||
|
|
ContextPad.prototype.triggerEntry = function(entryId, action, event, autoActivate) {
|
||
|
|
|
||
|
|
if (!this.isShown()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var target = this._current.target,
|
||
|
|
entries = this._current.entries;
|
||
|
|
|
||
|
|
var entry = entries[entryId];
|
||
|
|
|
||
|
|
if (!entry) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var handler = entry.action;
|
||
|
|
|
||
|
|
if (this._eventBus.fire('contextPad.trigger', { entry, event }) === false) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// simple action (via callback function)
|
||
|
|
if (isFunction(handler)) {
|
||
|
|
if (action === 'click') {
|
||
|
|
return handler(event, target, autoActivate);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
if (handler[action]) {
|
||
|
|
return handler[action](event, target, autoActivate);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// silence other actions
|
||
|
|
event.preventDefault();
|
||
|
|
};
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Open the context pad for given elements.
|
||
|
|
*
|
||
|
|
* @param {ContextPadTarget} target
|
||
|
|
* @param {boolean} [force=false] - Force re-opening context pad.
|
||
|
|
*/
|
||
|
|
ContextPad.prototype.open = function(target, force) {
|
||
|
|
if (!force && this.isOpen(target)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.close();
|
||
|
|
|
||
|
|
this._updateAndOpen(target);
|
||
|
|
};
|
||
|
|
|
||
|
|
ContextPad.prototype._getProviders = function() {
|
||
|
|
|
||
|
|
var event = this._eventBus.createEvent({
|
||
|
|
type: 'contextPad.getProviders',
|
||
|
|
providers: []
|
||
|
|
});
|
||
|
|
|
||
|
|
this._eventBus.fire(event);
|
||
|
|
|
||
|
|
return event.providers;
|
||
|
|
};
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {ContextPadTarget} target
|
||
|
|
*/
|
||
|
|
ContextPad.prototype._updateAndOpen = function(target) {
|
||
|
|
var entries = this.getEntries(target),
|
||
|
|
pad = this.getPad(target),
|
||
|
|
html = pad.html,
|
||
|
|
image;
|
||
|
|
|
||
|
|
forEach(entries, function(entry, id) {
|
||
|
|
var grouping = entry.group || 'default',
|
||
|
|
control = domify(entry.html || '<div class="entry" draggable="true"></div>'),
|
||
|
|
container;
|
||
|
|
|
||
|
|
domAttr(control, 'data-action', id);
|
||
|
|
|
||
|
|
container = domQuery('[data-group=' + escapeCSS(grouping) + ']', html);
|
||
|
|
if (!container) {
|
||
|
|
container = domify('<div class="group"></div>');
|
||
|
|
domAttr(container, 'data-group', grouping);
|
||
|
|
|
||
|
|
html.appendChild(container);
|
||
|
|
}
|
||
|
|
|
||
|
|
container.appendChild(control);
|
||
|
|
|
||
|
|
if (entry.className) {
|
||
|
|
addClasses(control, entry.className);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (entry.title) {
|
||
|
|
domAttr(control, 'title', entry.title);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (entry.imageUrl) {
|
||
|
|
image = domify('<img>');
|
||
|
|
domAttr(image, 'src', entry.imageUrl);
|
||
|
|
image.style.width = '100%';
|
||
|
|
image.style.height = '100%';
|
||
|
|
|
||
|
|
control.appendChild(image);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
domClasses(html).add('open');
|
||
|
|
|
||
|
|
this._current = {
|
||
|
|
target: target,
|
||
|
|
entries: entries,
|
||
|
|
pad: pad
|
||
|
|
};
|
||
|
|
|
||
|
|
this._eventBus.fire('contextPad.open', { current: this._current });
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {ContextPadTarget} target
|
||
|
|
*
|
||
|
|
* @return {Overlay}
|
||
|
|
*/
|
||
|
|
ContextPad.prototype.getPad = function(target) {
|
||
|
|
if (this.isOpen()) {
|
||
|
|
return this._current.pad;
|
||
|
|
}
|
||
|
|
|
||
|
|
var self = this;
|
||
|
|
|
||
|
|
var overlays = this._overlays;
|
||
|
|
|
||
|
|
var html = domify('<div class="djs-context-pad"></div>');
|
||
|
|
|
||
|
|
var position = this._getPosition(target);
|
||
|
|
|
||
|
|
var overlaysConfig = assign({
|
||
|
|
html: html
|
||
|
|
}, this._overlaysConfig, position);
|
||
|
|
|
||
|
|
domDelegate.bind(html, entrySelector, 'click', function(event) {
|
||
|
|
self.trigger('click', event);
|
||
|
|
});
|
||
|
|
|
||
|
|
domDelegate.bind(html, entrySelector, 'dragstart', function(event) {
|
||
|
|
self.trigger('dragstart', event);
|
||
|
|
});
|
||
|
|
|
||
|
|
// stop propagation of mouse events
|
||
|
|
domEvent.bind(html, 'mousedown', function(event) {
|
||
|
|
event.stopPropagation();
|
||
|
|
});
|
||
|
|
|
||
|
|
var activeRootElement = this._canvas.getRootElement();
|
||
|
|
|
||
|
|
this._overlayId = overlays.add(activeRootElement, 'context-pad', overlaysConfig);
|
||
|
|
|
||
|
|
var pad = overlays.get(this._overlayId);
|
||
|
|
|
||
|
|
this._eventBus.fire('contextPad.create', {
|
||
|
|
target: target,
|
||
|
|
pad: pad
|
||
|
|
});
|
||
|
|
|
||
|
|
return pad;
|
||
|
|
};
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Close the context pad
|
||
|
|
*/
|
||
|
|
ContextPad.prototype.close = function() {
|
||
|
|
if (!this.isOpen()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this._overlays.remove(this._overlayId);
|
||
|
|
|
||
|
|
this._overlayId = null;
|
||
|
|
|
||
|
|
this._eventBus.fire('contextPad.close', { current: this._current });
|
||
|
|
|
||
|
|
this._current = null;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if pad is open.
|
||
|
|
*
|
||
|
|
* If target is provided, check if it is opened
|
||
|
|
* for the given target (single or multiple elements).
|
||
|
|
*
|
||
|
|
* @param {ContextPadTarget} [target]
|
||
|
|
* @return {boolean}
|
||
|
|
*/
|
||
|
|
ContextPad.prototype.isOpen = function(target) {
|
||
|
|
var current = this._current;
|
||
|
|
|
||
|
|
if (!current) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// basic no-args is open check
|
||
|
|
if (!target) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
var currentTarget = current.target;
|
||
|
|
|
||
|
|
// strict handling of single vs. multi-selection
|
||
|
|
if (isArray(target) !== isArray(currentTarget)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isArray(target)) {
|
||
|
|
return (
|
||
|
|
target.length === currentTarget.length &&
|
||
|
|
every(target, function(element) {
|
||
|
|
return includes(currentTarget, element);
|
||
|
|
})
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
return currentTarget === target;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if pad is open and not hidden.
|
||
|
|
*
|
||
|
|
* @return {boolean}
|
||
|
|
*/
|
||
|
|
ContextPad.prototype.isShown = function() {
|
||
|
|
return this.isOpen() && this._overlays.isShown();
|
||
|
|
};
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get contex pad position.
|
||
|
|
*
|
||
|
|
* @param {ContextPadTarget} target
|
||
|
|
*
|
||
|
|
* @return {Rect}
|
||
|
|
*/
|
||
|
|
ContextPad.prototype._getPosition = function(target) {
|
||
|
|
|
||
|
|
var elements = isArray(target) ? target : [ target ];
|
||
|
|
var bBox = getBBox(elements);
|
||
|
|
|
||
|
|
return {
|
||
|
|
position: {
|
||
|
|
left: bBox.x + bBox.width + CONTEXT_PAD_PADDING,
|
||
|
|
top: bBox.y - CONTEXT_PAD_PADDING / 2
|
||
|
|
}
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
|
||
|
|
// helpers //////////
|
||
|
|
|
||
|
|
function addClasses(element, classNames) {
|
||
|
|
var classes = domClasses(element);
|
||
|
|
|
||
|
|
classNames = isArray(classNames) ? classNames : classNames.split(/\s+/g);
|
||
|
|
|
||
|
|
classNames.forEach(function(cls) {
|
||
|
|
classes.add(cls);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {*[]} array
|
||
|
|
* @param {*} item
|
||
|
|
*
|
||
|
|
* @return {boolean}
|
||
|
|
*/
|
||
|
|
function includes(array, item) {
|
||
|
|
return array.indexOf(item) !== -1;
|
||
|
|
}
|