UNPKG

@joint/core

Version:

JavaScript diagramming library

221 lines (199 loc) 7.2 kB
import V from '../V/index.mjs'; import { HighlighterView } from '../dia/HighlighterView.mjs'; const MASK_CLIP = 20; function forEachDescendant(vel, fn) { const descendants = vel.children(); while (descendants.length > 0) { const descendant = descendants.shift(); if (fn(descendant)) { descendants.push(...descendant.children()); } } } export const mask = HighlighterView.extend({ tagName: 'rect', className: 'highlight-mask', attributes: { 'pointer-events': 'none' }, options: { padding: 3, maskClip: MASK_CLIP, deep: false, attrs: { 'stroke': '#FEB663', 'stroke-width': 3, 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter', } }, VISIBLE: 'white', INVISIBLE: 'black', MASK_ROOT_ATTRIBUTE_BLACKLIST: [ 'marker-start', 'marker-end', 'marker-mid', 'transform', 'stroke-dasharray', 'class', ], MASK_CHILD_ATTRIBUTE_BLACKLIST: [ 'stroke', 'fill', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'fill-opacity', 'marker-start', 'marker-end', 'marker-mid', 'class', ], // TODO: change the list to a function callback MASK_REPLACE_TAGS: [ 'FOREIGNOBJECT', 'IMAGE', 'USE', 'TEXT', 'TSPAN', 'TEXTPATH' ], // TODO: change the list to a function callback MASK_REMOVE_TAGS: [ 'TEXT', 'TSPAN', 'TEXTPATH' ], transformMaskChild(cellView, childEl) { const { MASK_CHILD_ATTRIBUTE_BLACKLIST, MASK_REPLACE_TAGS, MASK_REMOVE_TAGS } = this; const childTagName = childEl.tagName(); // Do not include the element in the mask's image if (!V.isSVGGraphicsElement(childEl) || MASK_REMOVE_TAGS.includes(childTagName)) { childEl.remove(); return false; } // Replace the element with a rectangle if (MASK_REPLACE_TAGS.includes(childTagName)) { // Note: clone() method does not change the children ids const originalChild = cellView.vel.findOne(`#${childEl.id}`); if (originalChild) { const { node: originalNode } = originalChild; let childBBox = cellView.getNodeBoundingRect(originalNode); if (cellView.model.isElement()) { childBBox = V.transformRect(childBBox, cellView.getNodeMatrix(originalNode)); } const replacement = V('rect', childBBox.toJSON()); const { x: ox, y: oy } = childBBox.center(); const { angle, cx = ox, cy = oy } = originalChild.rotate(); if (angle) replacement.rotate(angle, cx, cy); // Note: it's not important to keep the same sibling index since all subnodes are filled childEl.parent().append(replacement); } childEl.remove(); return false; } // Keep the element, but clean it from certain attributes MASK_CHILD_ATTRIBUTE_BLACKLIST.forEach(attrName => { if (attrName === 'fill' && childEl.attr('fill') === 'none') return; childEl.removeAttr(attrName); }); return true; }, transformMaskRoot(_cellView, rootEl) { const { MASK_ROOT_ATTRIBUTE_BLACKLIST } = this; MASK_ROOT_ATTRIBUTE_BLACKLIST.forEach(attrName => { rootEl.removeAttr(attrName); }); }, getMaskShape(cellView, vel) { const { options, MASK_REPLACE_TAGS } = this; const { deep } = options; const tagName = vel.tagName(); let maskRoot; if (tagName === 'G') { if (!deep) return null; maskRoot = vel.clone(); forEachDescendant(maskRoot, maskChild => this.transformMaskChild(cellView, maskChild)); } else { if (MASK_REPLACE_TAGS.includes(tagName)) return null; maskRoot = vel.clone(); } this.transformMaskRoot(cellView, maskRoot); return maskRoot; }, getMaskId() { return `highlight-mask-${this.cid}`; }, getMask(cellView, vNode) { const { VISIBLE, INVISIBLE, options } = this; const { padding, attrs } = options; // support both `strokeWidth` and `stroke-width` attribute names const strokeWidth = parseFloat(V('g').attr(attrs).attr('stroke-width')); const hasNodeFill = vNode.attr('fill') !== 'none'; let magnetStrokeWidth = parseFloat(vNode.attr('stroke-width')); if (isNaN(magnetStrokeWidth)) magnetStrokeWidth = 1; // stroke of the invisible shape const minStrokeWidth = magnetStrokeWidth + padding * 2; // stroke of the visible shape const maxStrokeWidth = minStrokeWidth + strokeWidth * 2; let maskEl = this.getMaskShape(cellView, vNode); if (!maskEl) { const nodeBBox = cellView.getNodeBoundingRect(vNode.node); // Make sure the rect is visible nodeBBox.inflate(nodeBBox.width ? 0 : 0.5, nodeBBox.height ? 0 : 0.5); maskEl = V('rect', nodeBBox.toJSON()); } maskEl.attr(attrs); return V('mask', { 'id': this.getMaskId() }).append([ maskEl.clone().attr({ 'fill': hasNodeFill ? VISIBLE : 'none', 'stroke': VISIBLE, 'stroke-width': maxStrokeWidth }), maskEl.clone().attr({ 'fill': hasNodeFill ? INVISIBLE : 'none', 'stroke': INVISIBLE, 'stroke-width': minStrokeWidth }) ]); }, removeMask(paper) { const maskNode = paper.svg.getElementById(this.getMaskId()); if (maskNode) { paper.defs.removeChild(maskNode); } }, addMask(paper, maskEl) { paper.defs.appendChild(maskEl.node); }, highlight(cellView, node) { const { options, vel } = this; const { padding, attrs, maskClip = MASK_CLIP, layer } = options; const color = ('stroke' in attrs) ? attrs['stroke'] : '#000000'; if (!layer && node === cellView.el) { // If the highlighter is appended to the cellView // and we measure the size of the cellView wrapping group // it's necessary to remove the highlighter first vel.remove(); } const highlighterBBox = cellView.getNodeBoundingRect(node).inflate(padding + maskClip); const highlightMatrix = this.getNodeMatrix(cellView, node); const maskEl = this.getMask(cellView, V(node)); this.addMask(cellView.paper, maskEl); vel.attr(highlighterBBox.toJSON()); vel.attr({ 'transform': V.matrixToTransformString(highlightMatrix), 'mask': `url(#${maskEl.id})`, 'fill': color }); }, unhighlight(cellView) { this.removeMask(cellView.paper); } });