@joint/core
Version:
JavaScript diagramming library
871 lines (694 loc) • 29.4 kB
JavaScript
import { assign, isFunction, toArray } from '../util/index.mjs';
import { CellView } from './CellView.mjs';
import { Cell } from './Cell.mjs';
import V from '../V/index.mjs';
import { elementViewPortPrototype } from './ports.mjs';
import { Rect, snapToGrid } from '../g/index.mjs';
const Flags = {
TOOLS: CellView.Flags.TOOLS,
UPDATE: 'UPDATE',
TRANSLATE: 'TRANSLATE',
RESIZE: 'RESIZE',
PORTS: 'PORTS',
ROTATE: 'ROTATE',
RENDER: 'RENDER'
};
const DragActions = {
MOVE: 'move',
MAGNET: 'magnet',
};
// Element base view and controller.
// -------------------------------------------
export const ElementView = CellView.extend({
/**
* @abstract
*/
_removePorts: function() {
// implemented in ports.js
},
/**
*
* @abstract
*/
_renderPorts: function() {
// implemented in ports.js
},
className: function() {
var classNames = CellView.prototype.className.apply(this).split(' ');
classNames.push('element');
return classNames.join(' ');
},
initialize: function() {
CellView.prototype.initialize.apply(this, arguments);
this._initializePorts();
},
presentationAttributes: {
'attrs': [Flags.UPDATE],
'position': [Flags.TRANSLATE, Flags.TOOLS],
'size': [Flags.RESIZE, Flags.PORTS, Flags.TOOLS],
'angle': [Flags.ROTATE, Flags.TOOLS],
'markup': [Flags.RENDER],
'ports': [Flags.PORTS],
},
initFlag: [Flags.RENDER],
UPDATE_PRIORITY: 0,
confirmUpdate: function(flag, opt) {
const { useCSSSelectors } = this;
if (this.hasFlag(flag, Flags.PORTS)) {
this._removePorts();
this._cleanPortsCache();
}
let transformHighlighters = false;
if (this.hasFlag(flag, Flags.RENDER)) {
this.render();
this.updateTools(opt);
this.updateHighlighters(true);
transformHighlighters = true;
flag = this.removeFlag(flag, [Flags.RENDER, Flags.UPDATE, Flags.RESIZE, Flags.TRANSLATE, Flags.ROTATE, Flags.PORTS, Flags.TOOLS]);
} else {
let updateHighlighters = false;
// Skip this branch if render is required
if (this.hasFlag(flag, Flags.RESIZE)) {
this.resize(opt);
updateHighlighters = true;
// Resize method is calling `update()` internally
flag = this.removeFlag(flag, [Flags.RESIZE, Flags.UPDATE]);
if (useCSSSelectors) {
// `resize()` rendered the ports when useCSSSelectors are enabled
flag = this.removeFlag(flag, Flags.PORTS);
}
}
if (this.hasFlag(flag, Flags.UPDATE)) {
this.update(this.model, null, opt);
flag = this.removeFlag(flag, Flags.UPDATE);
updateHighlighters = true;
if (useCSSSelectors) {
// `update()` will render ports when useCSSSelectors are enabled
flag = this.removeFlag(flag, Flags.PORTS);
}
}
if (this.hasFlag(flag, Flags.TRANSLATE)) {
this.translate();
flag = this.removeFlag(flag, Flags.TRANSLATE);
transformHighlighters = true;
}
if (this.hasFlag(flag, Flags.ROTATE)) {
this.rotate();
flag = this.removeFlag(flag, Flags.ROTATE);
transformHighlighters = true;
}
if (this.hasFlag(flag, Flags.PORTS)) {
this._renderPorts();
updateHighlighters = true;
flag = this.removeFlag(flag, Flags.PORTS);
}
if (updateHighlighters) {
this.updateHighlighters(false);
}
}
if (transformHighlighters) {
this.transformHighlighters();
}
if (this.hasFlag(flag, Flags.TOOLS)) {
this.updateTools(opt);
flag = this.removeFlag(flag, Flags.TOOLS);
}
return flag;
},
/**
* @abstract
*/
_initializePorts: function() {
// implemented in ports.js
},
update: function(_, renderingOnlyAttrs) {
this.cleanNodesCache();
// When CSS selector strings are used, make sure no rule matches port nodes.
const { useCSSSelectors } = this;
if (useCSSSelectors) this._removePorts();
var model = this.model;
var modelAttrs = model.attr();
this.updateDOMSubtreeAttributes(this.el, modelAttrs, {
rootBBox: new Rect(model.size()),
selectors: this.selectors,
scalableNode: this.scalableNode,
rotatableNode: this.rotatableNode,
// Use rendering only attributes if they differs from the model attributes
roAttributes: (renderingOnlyAttrs === modelAttrs) ? null : renderingOnlyAttrs
});
if (useCSSSelectors) {
this._renderPorts();
}
},
rotatableSelector: 'rotatable',
scalableSelector: 'scalable',
scalableNode: null,
rotatableNode: null,
// `prototype.markup` is rendered by default. Set the `markup` attribute on the model if the
// default markup is not desirable.
renderMarkup: function() {
var element = this.model;
var markup = element.get('markup') || element.markup;
if (!markup) throw new Error('dia.ElementView: markup required');
if (Array.isArray(markup)) return this.renderJSONMarkup(markup);
if (typeof markup === 'string') return this.renderStringMarkup(markup);
throw new Error('dia.ElementView: invalid markup');
},
renderJSONMarkup: function(markup) {
var doc = this.parseDOMJSON(markup, this.el);
var selectors = this.selectors = doc.selectors;
this.rotatableNode = V(selectors[this.rotatableSelector]) || null;
this.scalableNode = V(selectors[this.scalableSelector]) || null;
// Fragment
this.vel.append(doc.fragment);
},
renderStringMarkup: function(markup) {
var vel = this.vel;
vel.append(V(markup));
// Cache transformation groups
this.rotatableNode = vel.findOne('.rotatable');
this.scalableNode = vel.findOne('.scalable');
var selectors = this.selectors = {};
selectors[this.selector] = this.el;
},
render: function() {
this.vel.empty();
this.renderMarkup();
if (this.scalableNode) {
// Double update is necessary for elements with the scalable group only
// Note the resize() triggers the other `update`.
this.update();
}
this.resize();
if (this.rotatableNode) {
// Translate transformation is applied on `this.el` while the rotation transformation
// on `this.rotatableNode`
this.rotate();
this.translate();
} else {
this.updateTransformation();
}
if (!this.useCSSSelectors) this._renderPorts();
return this;
},
resize: function(opt) {
if (this.scalableNode) return this.sgResize(opt);
if (this.model.attributes.angle) this.rotate();
this.update();
},
translate: function() {
if (this.rotatableNode) return this.rgTranslate();
this.updateTransformation();
},
rotate: function() {
if (this.rotatableNode) {
this.rgRotate();
// It's necessary to call the update for the nodes outside
// the rotatable group referencing nodes inside the group
this.update();
return;
}
this.updateTransformation();
},
updateTransformation: function() {
var transformation = this.getTranslateString();
var rotateString = this.getRotateString();
if (rotateString) transformation += ' ' + rotateString;
this.vel.attr('transform', transformation);
},
getTranslateString: function() {
const { x, y } = this.model.position();
return `translate(${x},${y})`;
},
getRotateString: function() {
const angle = this.model.angle();
if (!angle) return null;
const { width, height } = this.model.size();
return `rotate(${angle},${width / 2},${height / 2})`;
},
// Rotatable & Scalable Group
// always slower, kept mainly for backwards compatibility
rgRotate: function() {
this.rotatableNode.attr('transform', this.getRotateString());
},
rgTranslate: function() {
this.vel.attr('transform', this.getTranslateString());
},
sgResize: function(opt) {
var model = this.model;
var angle = model.angle();
var size = model.size();
var scalable = this.scalableNode;
// Getting scalable group's bbox.
// Due to a bug in webkit's native SVG .getBBox implementation, the bbox of groups with path children includes the paths' control points.
// To work around the issue, we need to check whether there are any path elements inside the scalable group.
var recursive = false;
if (scalable.node.getElementsByTagName('path').length > 0) {
// If scalable has at least one descendant that is a path, we need to switch to recursive bbox calculation.
// If there are no path descendants, group bbox calculation works and so we can use the (faster) native function directly.
recursive = true;
}
var scalableBBox = scalable.getBBox({ recursive: recursive });
// Make sure `scalableBbox.width` and `scalableBbox.height` are not zero which can happen if the element does not have any content. By making
// the width/height 1, we prevent HTML errors of the type `scale(Infinity, Infinity)`.
var sx = (size.width / (scalableBBox.width || 1));
var sy = (size.height / (scalableBBox.height || 1));
scalable.attr('transform', 'scale(' + sx + ',' + sy + ')');
// Now the interesting part. The goal is to be able to store the object geometry via just `x`, `y`, `angle`, `width` and `height`
// Order of transformations is significant but we want to reconstruct the object always in the order:
// resize(), rotate(), translate() no matter of how the object was transformed. For that to work,
// we must adjust the `x` and `y` coordinates of the object whenever we resize it (because the origin of the
// rotation changes). The new `x` and `y` coordinates are computed by canceling the previous rotation
// around the center of the resized object (which is a different origin then the origin of the previous rotation)
// and getting the top-left corner of the resulting object. Then we clean up the rotation back to what it originally was.
// Cancel the rotation but now around a different origin, which is the center of the scaled object.
var rotatable = this.rotatableNode;
var rotation = rotatable && rotatable.attr('transform');
if (rotation) {
rotatable.attr('transform', rotation + ' rotate(' + (-angle) + ',' + (size.width / 2) + ',' + (size.height / 2) + ')');
var rotatableBBox = scalable.getBBox({ target: this.paper.cells });
// Store new x, y and perform rotate() again against the new rotation origin.
model.set('position', { x: rotatableBBox.x, y: rotatableBBox.y }, assign({ updateHandled: true }, opt));
this.translate();
this.rotate();
}
// Update must always be called on non-rotated element. Otherwise, relative positioning
// would work with wrong (rotated) bounding boxes.
this.update();
},
// Embedding mode methods.
// -----------------------
prepareEmbedding: function(data = {}) {
const element = data.model || this.model;
const paper = data.paper || this.paper;
const graph = paper.model;
const initialZIndices = data.initialZIndices = {};
const embeddedCells = element.getEmbeddedCells({ deep: true });
const connectedLinks = graph.getConnectedLinks(element, { deep: true, includeEnclosed: true });
// Note: an embedded cell can be a connect link, but it's fine
// to iterate over the cell twice.
[
element,
...embeddedCells,
...connectedLinks
].forEach(cell => initialZIndices[cell.id] = cell.attributes.z);
element.startBatch('to-front');
// Bring the model to the front with all his embeds.
element.toFront({ deep: true, ui: true });
// Note that at this point cells in the collection are not sorted by z index (it's running in the batch, see
// the dia.Graph._sortOnChangeZ), so we can't assume that the last cell in the collection has the highest z.
const maxZ = graph.getElements().reduce((max, cell) => Math.max(max, cell.attributes.z || 0), 0);
// Move to front also all the inbound and outbound links that are connected
// to any of the element descendant. If we bring to front only embedded elements,
// links connected to them would stay in the background.
connectedLinks.forEach((link) => {
if (link.attributes.z <= maxZ) {
link.set('z', maxZ + 1, { ui: true });
}
});
element.stopBatch('to-front');
// Before we start looking for suitable parent we remove the current one.
const parentId = element.parent();
if (parentId) {
const parent = graph.getCell(parentId);
parent.unembed(element, { ui: true });
data.initialParentId = parentId;
} else {
data.initialParentId = null;
}
},
processEmbedding: function(data = {}, evt, x, y) {
const model = data.model || this.model;
const paper = data.paper || this.paper;
const graph = paper.model;
const { findParentBy, frontParentOnly, validateEmbedding } = paper.options;
let candidates;
if (isFunction(findParentBy)) {
candidates = toArray(findParentBy.call(graph, this, evt, x, y));
} else if (findParentBy === 'pointer') {
candidates = graph.findElementsAtPoint({ x, y });
} else {
candidates = graph.findElementsUnderElement(model, { searchBy: findParentBy });
}
candidates = candidates.filter((el) => {
return (el instanceof Cell) && (model.id !== el.id) && !el.isEmbeddedIn(model);
});
if (frontParentOnly) {
// pick the element with the highest `z` index
candidates = candidates.slice(-1);
}
let newCandidateView = null;
const prevCandidateView = data.candidateEmbedView;
// iterate over all candidates starting from the last one (has the highest z-index).
for (let i = candidates.length - 1; i >= 0; i--) {
const candidate = candidates[i];
if (prevCandidateView && prevCandidateView.model.id == candidate.id) {
// candidate remains the same
newCandidateView = prevCandidateView;
break;
} else {
const view = candidate.findView(paper);
if (!isFunction(validateEmbedding) || validateEmbedding.call(paper, this, view)) {
// flip to the new candidate
newCandidateView = view;
break;
}
}
}
if (newCandidateView && newCandidateView != prevCandidateView) {
// A new candidate view found. Highlight the new one.
this.clearEmbedding(data);
data.candidateEmbedView = newCandidateView.highlight(
newCandidateView.findProxyNode(null, 'container'),
{ embedding: true }
);
}
if (!newCandidateView && prevCandidateView) {
// No candidate view found. Unhighlight the previous candidate.
this.clearEmbedding(data);
}
},
clearEmbedding: function(data) {
data || (data = {});
var candidateView = data.candidateEmbedView;
if (candidateView) {
// No candidate view found. Unhighlight the previous candidate.
candidateView.unhighlight(
candidateView.findProxyNode(null, 'container'),
{ embedding: true }
);
data.candidateEmbedView = null;
}
},
finalizeEmbedding: function(data = {}) {
const candidateView = data.candidateEmbedView;
const element = data.model || this.model;
const paper = data.paper || this.paper;
if (candidateView) {
// We finished embedding. Candidate view is chosen to become the parent of the model.
candidateView.model.embed(element, { ui: true });
candidateView.unhighlight(candidateView.findProxyNode(null, 'container'), { embedding: true });
data.candidateEmbedView = null;
} else {
const { validateUnembedding } = paper.options;
const { initialParentId } = data;
// The element was originally embedded into another element.
// The interaction would unembed the element. Let's validate
// if the element can be unembedded.
if (
initialParentId &&
typeof validateUnembedding === 'function' &&
!validateUnembedding.call(paper, this)
) {
this._disallowUnembed(data);
return;
}
}
paper.model.getConnectedLinks(element, { deep: true }).forEach(link => {
link.reparent({ ui: true });
});
},
_disallowUnembed: function(data) {
const { model, whenNotAllowed = 'revert' } = data;
const element = model || this.model;
const paper = data.paper || this.paper;
const graph = paper.model;
switch (whenNotAllowed) {
case 'remove': {
element.remove({ ui: true });
break;
}
case 'revert': {
const { initialParentId, initialPosition, initialZIndices } = data;
// Revert the element's position (and the position of its embedded cells if any)
if (initialPosition) {
const { x, y } = initialPosition;
element.position(x, y, { deep: true, ui: true });
}
// Revert all the z-indices changed during the embedding
if (initialZIndices) {
Object.keys(initialZIndices).forEach(id => {
const cell = graph.getCell(id);
if (cell) {
cell.set('z', initialZIndices[id], { ui: true });
}
});
}
// Revert the original parent
const parent = graph.getCell(initialParentId);
if (parent) {
parent.embed(element, { ui: true });
}
break;
}
}
},
getTargetParentView: function(evt) {
const { candidateEmbedView = null } = this.eventData(evt);
return candidateEmbedView;
},
getDelegatedView: function() {
var view = this;
var model = view.model;
var paper = view.paper;
while (view) {
if (model.isLink()) break;
if (!model.isEmbedded() || view.can('stopDelegation')) return view;
model = model.getParentCell();
view = paper.findViewByModel(model);
}
return null;
},
findProxyNode: function(el, type) {
el || (el = this.el);
const nodeSelector = el.getAttribute(`${type}-selector`);
if (nodeSelector) {
const port = this.findAttribute('port', el);
if (port) {
const proxyPortNode = this.findPortNode(port, nodeSelector);
if (proxyPortNode) return proxyPortNode;
} else {
const proxyNode = this.findNode(nodeSelector);
if (proxyNode) return proxyNode;
}
}
return el;
},
// Interaction. The controller part.
// ---------------------------------
notifyPointerdown(evt, x, y) {
CellView.prototype.pointerdown.call(this, evt, x, y);
this.notify('element:pointerdown', evt, x, y);
},
notifyPointermove(evt, x, y) {
CellView.prototype.pointermove.call(this, evt, x, y);
this.notify('element:pointermove', evt, x, y);
},
notifyPointerup(evt, x, y) {
this.notify('element:pointerup', evt, x, y);
CellView.prototype.pointerup.call(this, evt, x, y);
},
pointerdblclick: function(evt, x, y) {
CellView.prototype.pointerdblclick.apply(this, arguments);
this.notify('element:pointerdblclick', evt, x, y);
},
pointerclick: function(evt, x, y) {
CellView.prototype.pointerclick.apply(this, arguments);
this.notify('element:pointerclick', evt, x, y);
},
contextmenu: function(evt, x, y) {
CellView.prototype.contextmenu.apply(this, arguments);
this.notify('element:contextmenu', evt, x, y);
},
pointerdown: function(evt, x, y) {
this.notifyPointerdown(evt, x, y);
this.dragStart(evt, x, y);
},
pointermove: function(evt, x, y) {
const data = this.eventData(evt);
const { targetMagnet, action, delegatedView } = data;
if (targetMagnet) {
this.magnetpointermove(evt, targetMagnet, x, y);
}
switch (action) {
case DragActions.MAGNET:
this.dragMagnet(evt, x, y);
break;
case DragActions.MOVE:
(delegatedView || this).drag(evt, x, y);
// eslint: no-fallthrough=false
default:
if (data.preventPointerEvents) break;
this.notifyPointermove(evt, x, y);
break;
}
// Make sure the element view data is passed along.
// It could have been wiped out in the handlers above.
this.eventData(evt, data);
},
pointerup: function(evt, x, y) {
const data = this.eventData(evt);
const { targetMagnet, action, delegatedView } = data;
if (targetMagnet) {
this.magnetpointerup(evt, targetMagnet, x, y);
}
switch (action) {
case DragActions.MAGNET:
this.dragMagnetEnd(evt, x, y);
break;
case DragActions.MOVE:
(delegatedView || this).dragEnd(evt, x, y);
// eslint: no-fallthrough=false
default:
if (data.preventPointerEvents) break;
this.notifyPointerup(evt, x, y);
}
if (targetMagnet) {
this.magnetpointerclick(evt, targetMagnet, x, y);
}
this.checkMouseleave(evt);
},
mouseover: function(evt) {
CellView.prototype.mouseover.apply(this, arguments);
this.notify('element:mouseover', evt);
},
mouseout: function(evt) {
CellView.prototype.mouseout.apply(this, arguments);
this.notify('element:mouseout', evt);
},
mouseenter: function(evt) {
CellView.prototype.mouseenter.apply(this, arguments);
this.notify('element:mouseenter', evt);
},
mouseleave: function(evt) {
CellView.prototype.mouseleave.apply(this, arguments);
this.notify('element:mouseleave', evt);
},
mousewheel: function(evt, x, y, delta) {
CellView.prototype.mousewheel.apply(this, arguments);
this.notify('element:mousewheel', evt, x, y, delta);
},
onmagnet: function(evt, x, y) {
const { currentTarget: targetMagnet } = evt;
this.magnetpointerdown(evt, targetMagnet, x, y);
this.eventData(evt, { targetMagnet });
this.dragMagnetStart(evt, x, y);
},
magnetpointerdown: function(evt, magnet, x, y) {
this.notify('element:magnet:pointerdown', evt, magnet, x, y);
},
magnetpointermove: function(evt, magnet, x, y) {
this.notify('element:magnet:pointermove', evt, magnet, x, y);
},
magnetpointerup: function(evt, magnet, x, y) {
this.notify('element:magnet:pointerup', evt, magnet, x, y);
},
magnetpointerdblclick: function(evt, magnet, x, y) {
this.notify('element:magnet:pointerdblclick', evt, magnet, x, y);
},
magnetcontextmenu: function(evt, magnet, x, y) {
this.notify('element:magnet:contextmenu', evt, magnet, x, y);
},
// Drag Start Handlers
dragStart: function(evt, x, y) {
if (this.isDefaultInteractionPrevented(evt)) return;
var view = this.getDelegatedView();
if (!view || !view.can('elementMove')) return;
this.eventData(evt, {
action: DragActions.MOVE,
delegatedView: view
});
const position = view.model.position();
view.eventData(evt, {
initialPosition: position,
pointerOffset: position.difference(x, y),
restrictedArea: this.paper.getRestrictedArea(view, x, y)
});
},
dragMagnetStart: function(evt, x, y) {
const { paper } = this;
const isPropagationAlreadyStopped = evt.isPropagationStopped();
if (isPropagationAlreadyStopped) {
// Special case when the propagation was already stopped
// on the `element:magnet:pointerdown` event.
// Do not trigger any `element:pointer*` events
// but still start the magnet dragging.
this.eventData(evt, { preventPointerEvents: true });
}
if (this.isDefaultInteractionPrevented(evt) || !this.can('addLinkFromMagnet')) {
// Stop the default action, which is to start dragging a link.
return;
}
const { targetMagnet = evt.currentTarget } = this.eventData(evt);
evt.stopPropagation();
// Invalid (Passive) magnet. Start dragging the element.
if (!paper.options.validateMagnet.call(paper, this, targetMagnet, evt)) {
if (isPropagationAlreadyStopped) {
// Do not trigger `element:pointerdown` and start element dragging
// if the propagation was stopped.
this.dragStart(evt, x, y);
// The `element:pointerdown` event is not triggered because
// of `preventPointerEvents` flag.
} else {
// We need to reset the action
// to `MOVE` so that the element is dragged.
this.pointerdown(evt, x, y);
}
return;
}
// Valid magnet. Start dragging a link.
if (paper.options.magnetThreshold <= 0) {
this.dragLinkStart(evt, targetMagnet, x, y);
}
this.eventData(evt, { action: DragActions.MAGNET });
},
// Drag Handlers
snapToGrid: function(evt, x, y) {
const grid = this.paper.options.gridSize;
return {
x: snapToGrid(x, grid),
y: snapToGrid(y, grid)
};
},
drag: function(evt, x, y) {
var paper = this.paper;
var element = this.model;
var data = this.eventData(evt);
var { pointerOffset, restrictedArea, embedding } = data;
// Make sure the new element's position always snaps to the current grid
const { x: elX, y: elY } = this.snapToGrid(evt, x + pointerOffset.x, y + pointerOffset.y);
element.position(elX, elY, { restrictedArea, deep: true, ui: true });
if (paper.options.embeddingMode) {
if (!embedding) {
// Prepare the element for embedding only if the pointer moves.
// We don't want to do unnecessary action with the element
// if an user only clicks/dblclicks on it.
this.prepareEmbedding(data);
embedding = true;
}
this.processEmbedding(data, evt, x, y);
}
this.eventData(evt, {
embedding
});
},
dragMagnet: function(evt, x, y) {
this.dragLink(evt, x, y);
},
// Drag End Handlers
dragEnd: function(evt, x, y) {
var data = this.eventData(evt);
if (data.embedding) this.finalizeEmbedding(data);
},
dragMagnetEnd: function(evt, x, y) {
this.dragLinkEnd(evt, x, y);
},
magnetpointerclick: function(evt, magnet, x, y) {
var paper = this.paper;
if (paper.eventData(evt).mousemoved > paper.options.clickThreshold) return;
this.notify('element:magnet:pointerclick', evt, magnet, x, y);
}
}, {
Flags: Flags,
});
assign(ElementView.prototype, elementViewPortPrototype);