6af870e72ffc06cc9a261006627041a123aa5615
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Resources / Public / JavaScript / SvgTree.js
1 /*
2 * This file is part of the TYPO3 CMS project.
3 *
4 * It is free software; you can redistribute it and/or modify it under
5 * the terms of the GNU General Public License, either version 2
6 * of the License, or any later version.
7 *
8 * For the full copyright and license information, please read the
9 * LICENSE.txt file that was distributed with this source code.
10 *
11 * The TYPO3 project - inspiring people to share!
12 */
13
14 /**
15 * Module: TYPO3/CMS/Backend/SvgTree
16 */
17 define(
18 [
19 'jquery',
20 'd3',
21 'TYPO3/CMS/Backend/ContextMenu',
22 'TYPO3/CMS/Backend/Modal',
23 'TYPO3/CMS/Backend/Severity',
24 'TYPO3/CMS/Backend/Notification',
25 'TYPO3/CMS/Backend/Icons',
26 'TYPO3/CMS/Lang/Lang',
27 ],
28 function ($, d3, ContextMenu, Modal, Severity, Notification, Icons) {
29 'use strict';
30
31 /**
32 * @constructor
33 * @exports SvgTree
34 */
35 var SvgTree = function () {
36 this.settings = {
37 showCheckboxes: false,
38 showIcons: false,
39 allowRecursiveDelete: false,
40 nodeHeight: 20,
41 indentWidth: 16,
42 width: 300,
43 duration: 400,
44 dataUrl: '',
45 nodeOver: {},
46 validation: {
47 maxItems: Number.MAX_VALUE,
48 },
49 unselectableElements: [],
50 expandUpToLevel: null,
51 readOnlyMode: false,
52 /**
53 * List node identifiers which can not be selected together with any other node
54 */
55 exclusiveNodesIdentifiers: '',
56 };
57
58 /**
59 * Check if cursor is over svg
60 *
61 * @type {boolean}
62 */
63 this.isOverSvg = false;
64
65 /**
66 * Root <svg> element
67 *
68 * @type {Selection}
69 */
70 this.svg = null;
71
72 /**
73 * Wrapper of svg element
74 *
75 * @type {Selection}
76 */
77 this.d3wrapper = null;
78
79 /**
80 * SVG <g> container wrapping all .nodes, .links, .nodes-bg elements
81 *
82 * @type {Selection}
83 */
84 this.container = null;
85
86 /**
87 * SVG <g> container wrapping all .node elements
88 *
89 * @type {Selection}
90 */
91 this.nodesContainer = null;
92
93 /**
94 * SVG <g> container wrapping all .nodes-bg elements
95 *
96 * @type {Selection}
97 */
98 this.nodesBgContainer = null;
99
100 /**
101 * SVG <defs> container wrapping all icon definitions
102 *
103 * @type {Selection}
104 */
105 this.iconsContainer = null;
106
107 /**
108 * SVG <g> container wrapping all links (lines between parent and child)
109 *
110 * @type {Selection}
111 */
112 this.linksContainer = null;
113
114 /**
115 *
116 * @type {{nodes: Node[], links: Object, icons: Object}}
117 */
118 this.data = {};
119
120 /**
121 * D3 event dispatcher
122 *
123 * @type {Object}
124 */
125 this.dispatch = null;
126
127 /**
128 * jQuery object of wrapper holding the SVG
129 * Height of this wrapper is important (we only render as many nodes as fit in the wrapper
130 *
131 * @type {jQuery}
132 */
133 this.wrapper = null;
134 this.viewportHeight = 0;
135 this.scrollTop = 0;
136 this.scrollBottom = 0;
137 this.position = 0;
138
139 /**
140 * Exclusive node which is currently selected
141 *
142 * @type {Node}
143 */
144 this.exclusiveSelectedNode = null;
145 };
146
147 SvgTree.prototype = {
148 constructor: SvgTree,
149
150 /**
151 * Initializes the tree component - created basic markup, loads and renders data
152 *
153 * @param {String} selector
154 * @param {Object} settings
155 */
156 initialize: function (selector, settings) {
157 var $wrapper = $(selector);
158
159 // Do nothing if already initialized
160 if ($wrapper.data('svgtree-initialized')) {
161 return false;
162 }
163
164 $.extend(this.settings, settings);
165 var _this = this;
166 this.wrapper = $wrapper;
167 this.dispatch = d3.dispatch(
168 'updateNodes',
169 'updateSvg',
170 'loadDataAfter',
171 'prepareLoadedNode',
172 'nodeSelectedAfter',
173 'nodeRightClick',
174 'contextmenu'
175 );
176
177 /**
178 * Create element:
179 *
180 * <svg version="1.1" width="100%">
181 * <g class="nodes-wrapper">
182 * <g class="nodes-bg"><rect class="node-bg"></rect></g>
183 * <g class="links"><path class="link"></path></g>
184 * <g class="nodes"><g class="node"></g></g>
185 * </g>
186 * </svg>
187 */
188 this.d3wrapper = d3
189 .select($wrapper[0]);
190 this.svg = this.d3wrapper.append('svg')
191 .attr('version', '1.1')
192 .attr('width', '100%')
193 .on('mouseover', function () {
194 _this.isOverSvg = true;
195 })
196 .on('mouseout', function () {
197 _this.isOverSvg = false;
198 });
199 this.container = this.svg
200 .append('g')
201 .attr('class', 'nodes-wrapper')
202 .attr('transform', 'translate(' + (this.settings.indentWidth / 2) + ',' + (this.settings.nodeHeight / 2) + ')');
203 this.nodesBgContainer = this.container.append('g')
204 .attr('class', 'nodes-bg');
205 this.linksContainer = this.container.append('g')
206 .attr('class', 'links');
207 this.nodesContainer = this.container.append('g')
208 .attr('class', 'nodes');
209 if (this.settings.showIcons) {
210 this.iconsContainer = this.svg.append('defs');
211 this.data.icons = {};
212 }
213
214 this.updateScrollPosition();
215 this.loadData();
216
217 this.wrapper.on('resize scroll', function () {
218 _this.updateScrollPosition();
219 _this.update();
220 });
221
222 this.wrapper.data('svgtree', this);
223 this.wrapper.data('svgtree-initialized', true);
224 this.wrapper.trigger('svgTree.initialized');
225 this.setWrapperHeight();
226 this.resize();
227 return true;
228 },
229
230 /**
231 * Update svg tree after changed window height
232 */
233 resize: function () {
234 var _this = this;
235
236 $(window).resize(function () {
237 _this.setWrapperHeight();
238 _this.updateScrollPosition();
239 _this.update();
240 });
241 },
242
243 /**
244 * Set svg wrapper height
245 */
246 setWrapperHeight: function () {
247 var treeWrapperHeight = ($('#typo3-pagetree').height() - $('#svg-toolbar').height());
248 $('#typo3-pagetree-tree').height(treeWrapperHeight);
249 },
250
251 /**
252 * Updates variables used for visible nodes calculation
253 */
254 updateScrollPosition: function () {
255 this.viewportHeight = this.wrapper.height();
256 this.scrollTop = this.wrapper.scrollTop();
257 this.scrollBottom = this.scrollTop + this.viewportHeight + (this.viewportHeight / 2);
258 },
259
260 /**
261 * Loads tree data (json) from configured url
262 */
263 loadData: function () {
264 var _this = this;
265 _this.nodesAddPlaceholder();
266
267 d3.json(this.settings.dataUrl, function (error, json) {
268 if (error) {
269 var title = TYPO3.lang.pagetree_networkErrorTitle;
270 var desc = TYPO3.lang.pagetree_networkErrorDesc;
271
272 if (error.target.status || error.target.statusText) {
273 title += ' - ' + (error.target.status || '') + ' ' + (error.target.statusText || '');
274 }
275
276 Notification.error(
277 title,
278 desc);
279
280 _this.nodesRemovePlaceholder();
281 throw error;
282 }
283
284 var nodes = Array.isArray(json) ? json : [];
285 _this.setParametersNode(nodes);
286 _this.dispatch.call('loadDataAfter', _this);
287 _this.prepareDataForVisibleNodes();
288 _this.nodesContainer.selectAll('.node').remove();
289 _this.nodesBgContainer.selectAll('.node-bg').remove();
290 _this.linksContainer.selectAll('.link').remove();
291 _this.update();
292 _this.nodesRemovePlaceholder();
293 });
294 },
295
296 /**
297 * Set parameters like node parents, parentsUid, checked
298 *
299 * @param {Node[]} nodes
300 */
301 setParametersNode: function (nodes) {
302 var _this = this;
303
304 nodes = nodes.map(function (node, index) {
305 node.open = (_this.settings.expandUpToLevel !== null) ? node.depth < _this.settings.expandUpToLevel : Boolean(node.expanded);
306 node.parents = [];
307 node.parentsUid = [];
308 node._isDragged = false;
309 if (node.depth > 0) {
310 var currentDepth = node.depth;
311 for (var i = index; i >= 0; i--) {
312 var currentNode = nodes[i];
313 if (currentNode.depth < currentDepth) {
314 node.parents.push(i);
315 node.parentsUid.push(nodes[i].identifier);
316 currentDepth = currentNode.depth;
317 }
318 }
319 } else if (node.hasChildren) {
320 node.open = true;
321 }
322
323 if (typeof node.checked === 'undefined') {
324 node.checked = false;
325 _this.settings.unselectableElements.push(node.identifier);
326 }
327
328 //dispatch event
329 _this.dispatch.call('prepareLoadedNode', _this, node);
330 return node;
331 });
332
333 _this.nodes = nodes;
334 },
335
336 nodesRemovePlaceholder: function () {
337 $('.svg-tree').find('.node-loader').hide();
338 $('.svg-tree').find('.svg-tree-loader').hide();
339 },
340
341 nodesAddPlaceholder: function (node) {
342 if (node) {
343 $('.svg-tree').find('.node-loader').css({ top: node.y + 15 }).show();
344 } else {
345 $('.svg-tree').find('.svg-tree-loader').show();
346 }
347 },
348
349 /**
350 * Filters out invisible nodes (collapsed) from the full dataset (this.rootNode)
351 * and enriches dataset with additional properties
352 * Visible dataset is stored in this.data
353 */
354 prepareDataForVisibleNodes: function () {
355 var _this = this;
356
357 var blacklist = {};
358 this.nodes.map(function (node, index) {
359 if (!node.open) {
360 blacklist[index] = true;
361 }
362 });
363
364 this.data.nodes = this.nodes.filter(function (node) {
365 return node.hidden !== true && !node.parents.some(function (index) {
366 return Boolean(blacklist[index]);
367 });
368 });
369
370 this.data.links = [];
371 var pathAboveMounts = 0;
372
373 this.data.nodes.forEach(function (n, i) {
374 //delete n.children;
375 n.x = n.depth * _this.settings.indentWidth;
376
377 if (n.readableRootline) {
378 pathAboveMounts += _this.settings.nodeHeight;
379 }
380
381 n.y = (i * _this.settings.nodeHeight) + pathAboveMounts;
382 if (n.parents[0] !== undefined) {
383 _this.data.links.push({
384 source: _this.nodes[n.parents[0]],
385 target: n,
386 });
387 }
388
389 if (_this.settings.showIcons) {
390 _this.fetchIcon(n.icon);
391 _this.fetchIcon(n.overlayIcon);
392 }
393 });
394
395 this.svg.attr('height', ((this.data.nodes.length * this.settings.nodeHeight) + (this.settings.nodeHeight / 2) + pathAboveMounts));
396 },
397
398 /**
399 * Fetch icon from Icon API and store it in data.icons
400 *
401 * @param {String} iconName
402 * @param {Boolean} update
403 */
404 fetchIcon: function (iconName, update) {
405 if (!iconName) {
406 return;
407 }
408
409 if (typeof update === 'undefined') {
410 update = true;
411 }
412
413 var _this = this;
414 if (!(iconName in this.data.icons)) {
415 this.data.icons[iconName] = {
416 identifier: iconName,
417 icon: '',
418 };
419 Icons.getIcon(iconName, Icons.sizes.small, null, null, 'inline').done(function (icon) {
420 _this.data.icons[iconName].icon = icon.match(/<svg.*<\/svg>/im)[0];
421
422 if (update) {
423 _this.update();
424 }
425 });
426 }
427 },
428
429 /**
430 * Renders the subset of the tree nodes fitting the viewport (adding, modifying and removing SVG nodes)
431 */
432 update: function () {
433 var _this = this;
434 var visibleRows = Math.ceil(_this.viewportHeight / _this.settings.nodeHeight + 1);
435 var position = Math.floor(Math.max(_this.scrollTop, 0) / _this.settings.nodeHeight);
436
437 var visibleNodes = this.data.nodes.slice(position, position + visibleRows);
438 var nodes = this.nodesContainer.selectAll('.node').data(visibleNodes, function (d) {
439 return d.identifier;
440 });
441
442 var nodesBg = this.nodesBgContainer.selectAll('.node-bg').data(visibleNodes, function (d) {
443 return d.identifier;
444 });
445
446 // delete nodes without corresponding data
447 nodes
448 .exit()
449 .remove();
450
451 // delete
452 nodesBg
453 .exit()
454 .remove();
455
456 // update nodes background
457 var nodeBgClass = this.updateNodeBgClass(nodesBg);
458
459 nodeBgClass
460 .attr('class', function (node, i) {
461 return _this.getNodeBgClass(node, i, nodeBgClass);
462 })
463 .attr('style', function (node, i) {
464 return node.backgroundColor ? 'fill: ' + node.backgroundColor + ';' : '';
465 });
466
467 this.updateLinks();
468 nodes = this.enterSvgElements(nodes);
469
470 // update nodes
471 nodes
472 .attr('transform', this.getNodeTransform)
473 .select('.node-name')
474 .text(this.getNodeLabel.bind(this));
475
476 nodes
477 .select('.chevron')
478 .attr('transform', this.getChevronTransform)
479 .style('fill', this.getChevronColor)
480 .attr('class', this.getChevronClass);
481
482 if (this.settings.showIcons) {
483 nodes
484 .select('use.node-icon')
485 .attr('xlink:href', this.getIconId);
486 nodes
487 .select('use.node-icon-overlay')
488 .attr('xlink:href', this.getIconOverlayId);
489 }
490
491 //dispatch event
492 this.dispatch.call('updateNodes', this, nodes);
493 },
494
495 /**
496 * @param {Node} nodesBg
497 * @returns {Node} nodesBg
498 */
499 updateNodeBgClass: function (nodesBg) {
500 var _this = this;
501
502 return nodesBg.enter()
503 .append('rect')
504 .merge(nodesBg)
505 .attr('width', '100%')
506 .attr('height', this.settings.nodeHeight)
507 .attr('data-uid', this.getNodeIdentifier)
508 .attr('transform', this.getNodeBgTransform)
509 .on('mouseover', function (node) {
510 _this.nodeBgEvents().mouseOver(node, this);
511 })
512 .on('mouseout', function (node) {
513 _this.nodeBgEvents().mouseOut(node, this);
514 })
515 .on('click', function (node) {
516 _this.nodeBgEvents().click(node, this);
517 _this.selectNode(node);
518 })
519 .on('contextmenu', function (node) {
520 _this.dispatch.call('nodeRightClick', node, this);
521 });
522 },
523
524 /**
525 * node background events
526 *
527 */
528 nodeBgEvents: function () {
529 var _this = this;
530 var self = {};
531
532 self.mouseOver = function (node, element) {
533 var elementNodeBg = _this.svg.select('.nodes-bg .node-bg[data-uid="' + node.identifier + '"]');
534
535 node.isOver = true;
536 _this.settings.nodeOver.node = node;
537
538 if (elementNodeBg.size()) {
539 elementNodeBg
540 .classed('node-over', true)
541 .attr('rx', '3')
542 .attr('ry', '3');
543 }
544 };
545
546 self.mouseOut = function (node, element) {
547 var elementNodeBg = _this.svg.select('.nodes-bg .node-bg[data-uid="' + node.identifier + '"]');
548
549 node.isOver = false;
550 _this.settings.nodeOver.node = false;
551
552 if (elementNodeBg.size()) {
553 elementNodeBg
554 .classed('node-over node-alert', false)
555 .attr('rx', '0')
556 .attr('ry', '0');
557 }
558 };
559
560 self.click = function (node, element) {
561 var $nodeBg = $(element).closest('svg').find('.nodes-bg .node-bg[data-uid=' + node.identifier + ']');
562
563 _this.nodes.forEach(function (node) {
564 if (node.selected === true) {
565 node.selected = false;
566 }
567 });
568
569 node.selected = true;
570 if ($nodeBg.length) {
571 $nodeBg.addClass('node-selected')
572 .parents('svg')
573 .find('.node-selected')
574 .not($nodeBg)
575 .removeClass('node-selected');
576 }
577 };
578
579 return self;
580 },
581
582 /**
583 * Renders links(lines) between parent and child nodes
584 */
585 updateLinks: function () {
586 var _this = this;
587 var visibleLinks = this.data.links.filter(function (linkData) {
588 return linkData.source.y <= _this.scrollBottom && linkData.target.y >= _this.scrollTop;
589 });
590
591 var links = this.linksContainer
592 .selectAll('.link')
593 .data(visibleLinks);
594
595 // delete
596 links
597 .exit()
598 .remove();
599
600 //create
601 links.enter()
602 .append('path')
603 .attr('class', 'link')
604
605 //create + update
606 .merge(links)
607 .attr('d', this.getLinkPath.bind(_this));
608 },
609
610 /**
611 * Adds missing SVG nodes
612 *
613 * @param {Selection} nodes
614 * @returns {Selection}
615 */
616 enterSvgElements: function (nodes) {
617 var _this = this;
618 this.textPosition = 10;
619
620 if (this.settings.showIcons) {
621 var iconsArray = $.map(this.data.icons, function (value) {
622 if (value.icon !== '') return value;
623 });
624
625 var icons = this.iconsContainer
626 .selectAll('.icon-def')
627 .data(iconsArray, function (i) {
628 return i.identifier;
629 });
630
631 icons
632 .enter()
633 .append('g')
634 .attr('class', 'icon-def')
635 .attr('id', function (i) {
636 return 'icon-' + i.identifier;
637 })
638 .append(function (i) {
639 //workaround for IE11 where you can't simply call .html(content) on svg
640 var parser = new DOMParser();
641 var markupText = i.icon.replace('<svg', '<g').replace('/svg>', '/g>');
642 markupText = "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'>" + markupText + '</svg>';
643 var dom = parser.parseFromString(markupText, 'image/svg+xml');
644 return dom.documentElement.firstChild;
645 });
646 }
647
648 // create the node elements
649 var nodeEnter = _this.nodesUpdate(nodes);
650
651 // append the chevron element
652 var chevron = nodeEnter
653 .append('g')
654 .attr('class', 'toggle')
655 .attr('visibility', this.getToggleVisibility)
656 .attr('transform', 'translate(-8, -8)')
657 .on('click', _this.chevronClick.bind(this));
658
659 // improve usability by making the click area a 16px square
660 chevron
661 .append('path')
662 .style('opacity', 0)
663 .attr('d', 'M 0 0 L 16 0 L 16 16 L 0 16 Z');
664 chevron
665 .append('path')
666 .attr('class', 'chevron')
667 .attr('d', 'M 4 3 L 13 8 L 4 13 Z');
668
669 // append the icon element
670 if (this.settings.showIcons) {
671 this.textPosition = 30;
672 nodeEnter
673 .append('use')
674 .attr('x', 8)
675 .attr('y', -8)
676 .attr('class', 'node-icon')
677 .attr('data-uid', this.getNodeIdentifier)
678 .on('click', function (node) {
679 _this.clickOnIcon(node, this);
680 });
681
682 nodeEnter
683 .append('use')
684 .attr('x', 8)
685 .attr('y', -3)
686 .attr('class', 'node-icon-overlay')
687 .on('click', function (node) {
688 _this.clickOnIcon(node, this);
689 });
690 }
691
692 this.dispatch.call('updateSvg', this, nodeEnter);
693
694 _this.appendTextElement(nodeEnter);
695
696 nodeEnter
697 .append('title')
698 .text(this.getNodeTitle.bind(this));
699
700 return nodes.merge(nodeEnter);
701 },
702
703 /**
704 * append the text element
705 *
706 * @param {Node} node
707 * @returns {Node} node
708 */
709 appendTextElement: function (node) {
710 var _this = this;
711
712 return node
713 .append('text')
714 .attr('dx', this.textPosition)
715 .attr('dy', 5)
716 .attr('class', 'node-name')
717 .on('click', function (node) {
718 _this.clickOnLabel(node, this);
719 _this.nodeBgEvents().click(node, this);
720 _this.selectNode(node);
721 });
722 },
723
724 /**
725 * @param {Node} nodes
726 * @returns {Node} nodes
727 */
728 nodesUpdate: function (nodes) {
729 var _this = this;
730
731 nodes = nodes
732 .enter()
733 .append('g')
734 .attr('class', this.getNodeClass)
735 .attr('transform', this.getNodeTransform)
736 .attr('data-table', 'pages')
737 .attr('data-uid', this.getNodeIdentifier)
738 .attr('title', this.getNodeTitle)
739 .on('mouseover', function (node) {
740 _this.nodeBgEvents().mouseOver(node, this);
741 })
742 .on('mouseout', function (node) {
743 _this.nodeBgEvents().mouseOut(node, this);
744 })
745 .on('contextmenu', function (node) {
746 _this.dispatch.call('nodeRightClick', node, this);
747 });
748
749 var nodeStop = nodes
750 .append('text')
751 .text(function (node) {
752 return node.readableRootline;
753 })
754 .attr('class', 'node-rootline')
755 .attr('dx', 0)
756 .attr('dy', -15)
757 .attr('visibility', function (node) {
758 return node.readableRootline ? 'visible' : 'hidden';
759 });
760
761 return nodes;
762 },
763
764 /**
765 * Computes the tree item identifier based on the data
766 *
767 * @param {Node} node
768 * @returns {String}
769 */
770 getNodeIdentifier: function (node) {
771 return node.identifier;
772 },
773
774 /**
775 * Computes the tree item label based on the data
776 *
777 * @param {Node} node
778 * @returns {String}
779 */
780 getNodeLabel: function (node) {
781 return (node.prefix || '') + node.name + (node.suffix || '');
782 },
783
784 /**
785 * Computes the tree node class
786 *
787 * @param {Node} node
788 * @returns {String}
789 */
790 getNodeClass: function (node) {
791 return 'node identifier-' + node.identifier;
792 },
793
794 /**
795 * Computes the tree node-bg class
796 *
797 * @param {Node} node
798 * @param {Integer} i
799 * @param {Object} nodeBgClass
800 * @returns {String}
801 */
802 getNodeBgClass: function (node, i, nodeBgClass) {
803 var bgClass = 'node-bg';
804 var prevNode = false;
805 var nextNode = false;
806
807 if (typeof nodeBgClass === 'object') {
808 prevNode = nodeBgClass.data()[i - 1];
809 nextNode = nodeBgClass.data()[i + 1];
810 }
811
812 if (node.selected) {
813 bgClass += ' node-selected';
814 }
815
816 if ((prevNode && (node.depth > prevNode.depth)) || !prevNode) {
817 node.firstChild = true;
818 bgClass += ' node-firth-child';
819 }
820
821 if ((nextNode && (node.depth > nextNode.depth)) || !nextNode) {
822 node.lastChild = true;
823 bgClass += ' node-last-child';
824 }
825
826 if (node.class) {
827 bgClass += ' ' + node.class;
828 }
829
830 return bgClass;
831 },
832
833 /**
834 * Computes the tree item label based on the data
835 *
836 * @param {Node} node
837 * @returns {String}
838 */
839 getNodeTitle: function (node) {
840 return node.tip ? node.tip : 'uid=' + node.identifier;
841 },
842
843 /**
844 * Returns chevron 'transform' attribute value
845 *
846 * @param {Node} node
847 * @returns {String}
848 */
849 getChevronTransform: function (node) {
850 return node.open ? 'translate(16,0) rotate(90)' : ' rotate(0)';
851 },
852
853 /**
854 * Returns chevron class
855 *
856 * @param {Node} node
857 * @returns {String}
858 */
859 getChevronColor: function (node) {
860 return node.open ? '#000' : '#8e8e8e';
861 },
862
863 /**
864 * Computes toggle 'visibility' attribute value
865 *
866 * @param {Node} node
867 * @returns {String}
868 */
869 getToggleVisibility: function (node) {
870 return node.hasChildren ? 'visible' : 'collapse';
871 },
872
873 /**
874 * Computes chevron 'class' attribute value
875 *
876 * @param {Node} node
877 * @returns {String}
878 */
879 getChevronClass: function (node) {
880 return 'chevron ' + (node.open ? 'expanded' : 'collapsed');
881 },
882
883 /**
884 * Returns icon's href attribute value
885 *
886 * @param {Node} node
887 * @returns {String}
888 */
889 getIconId: function (node) {
890 return '#icon-' + node.icon;
891 },
892
893 /**
894 * Returns icon's href attribute value
895 *
896 * @param {Node} node
897 * @returns {String}
898 */
899 getIconOverlayId: function (node) {
900 return '#icon-' + node.overlayIcon;
901 },
902
903 /**
904 * Returns a SVG path's 'd' attribute value
905 *
906 * @param {Object} link
907 * @returns {String}
908 */
909 getLinkPath: function (link) {
910 var target = {
911 x: link.target._isDragged ? link.target._x : link.target.x,
912 y: link.target._isDragged ? link.target._y : link.target.y,
913 };
914 var path = [];
915 path.push('M' + link.source.x + ' ' + link.source.y);
916 path.push('V' + target.y);
917 if (target.hasChildren) {
918 path.push('H' + (target.x - 2));
919 } else {
920 path.push('H' + ((target.x + this.settings.indentWidth / 4) - 2));
921 }
922
923 return path.join(' ');
924 },
925
926 /**
927 * Returns a 'transform' attribute value for the tree element (absolute positioning)
928 *
929 * @param {Node} node
930 */
931 getNodeTransform: function (node) {
932 return 'translate(' + (node.x || 0) + ',' + (node.y || 0) + ')';
933 },
934
935 /**
936 * Returns a 'transform' attribute value for the node background element (absolute positioning)
937 *
938 * @param {Node} node
939 */
940 getNodeBgTransform: function (node) {
941 return 'translate(-8, ' + ((node.y || 0) - 10) + ')';
942 },
943
944 /**
945 * Node selection logic (triggered by different events)
946 *
947 * @param {Node} node
948 */
949 selectNode: function (node) {
950 if (!this.isNodeSelectable(node)) {
951 return;
952 }
953
954 var checked = node.checked;
955 this.handleExclusiveNodeSelection(node);
956
957 if (this.settings.validation && this.settings.validation.maxItems) {
958 var selectedNodes = this.getSelectedNodes();
959 if (!checked && selectedNodes.length >= this.settings.validation.maxItems) {
960 return;
961 }
962 }
963
964 node.checked = !checked;
965
966 this.dispatch.call('nodeSelectedAfter', this, node);
967 this.update();
968 },
969
970 /**
971 * Handle exclusive nodes functionality
972 * If a node is one of the exclusiveNodesIdentifiers list,
973 * all other nodes has to be unselected before selecting this node.
974 *
975 * @param {Node} node
976 */
977 handleExclusiveNodeSelection: function (node) {
978 var exclusiveKeys = this.settings.exclusiveNodesIdentifiers.split(',');
979 var _this = this;
980 if (this.settings.exclusiveNodesIdentifiers.length && node.checked === false) {
981 if (exclusiveKeys.indexOf('' + node.identifier) > -1) {
982
983 // this key is exclusive, so uncheck all others
984 this.nodes.forEach(function (node) {
985 if (node.checked === true) {
986 node.checked = false;
987 _this.dispatch.call('nodeSelectedAfter', _this, node);
988 }
989 });
990
991 this.exclusiveSelectedNode = node;
992 } else if (exclusiveKeys.indexOf('' + node.identifier) === -1 && this.exclusiveSelectedNode) {
993
994 //current node is not exclusive, but other exclusive node is already selected
995 this.exclusiveSelectedNode.checked = false;
996 this.dispatch.call('nodeSelectedAfter', this, this.exclusiveSelectedNode);
997 this.exclusiveSelectedNode = null;
998 }
999 }
1000 },
1001
1002 /**
1003 * Check whether node can be selected.
1004 * In some cases (e.g. selecting a parent) it should not be possible to select
1005 * element (as it's own parent).
1006 *
1007 * @param {Node} node
1008 * @returns {Boolean}
1009 */
1010 isNodeSelectable: function (node) {
1011 return !this.settings.readOnlyMode && this.settings.unselectableElements.indexOf(node.identifier) === -1;
1012 },
1013
1014 /**
1015 * Returns an array of selected nodes
1016 *
1017 * @returns {Node[]}
1018 */
1019 getSelectedNodes: function () {
1020 return this.nodes.filter(function (node) {
1021 return node.checked;
1022 });
1023 },
1024
1025 /**
1026 * Event handler for clicking on a node's icon
1027 *
1028 * @param {Node} node
1029 * @param {HTMLElement} element
1030 */
1031 clickOnIcon: function (node, element) {
1032 this.dispatch.call('contextmenu', node, element);
1033 },
1034
1035 /**
1036 * Event handler for click on a node's label/text
1037 *
1038 * @param {Node} node
1039 * @param {HTMLElement} element
1040 */
1041 clickOnLabel: function (node, element) {
1042 this.selectNode(node);
1043 this.nodeBgEvents().click(node, element);
1044 },
1045
1046 /**
1047 * Event handler for click on a chevron
1048 *
1049 * @param {Node} node
1050 */
1051 chevronClick: function (node) {
1052 if (node.open) {
1053 this.hideChildren(node);
1054 } else {
1055 this.showChildren(node);
1056 }
1057
1058 this.prepareDataForVisibleNodes();
1059 this.update();
1060 },
1061
1062 /**
1063 * Updates node's data to hide/collapse children
1064 *
1065 * @param {Node} node
1066 */
1067 hideChildren: function (node) {
1068 node.open = false;
1069 },
1070
1071 /**
1072 * Updates node's data to show/expand children
1073 *
1074 * @param {Node} node
1075 */
1076 showChildren: function (node) {
1077 node.open = true;
1078 },
1079
1080 /**
1081 * Refresh view with new data
1082 */
1083 refreshTree: function () {
1084 this.loadData();
1085 },
1086
1087 /**
1088 * Expand all nodes and refresh view
1089 */
1090 expandAll: function () {
1091 this.nodes.forEach(this.showChildren.bind(this));
1092 this.prepareDataForVisibleNodes();
1093 this.update();
1094 },
1095
1096 /**
1097 * Collapse all nodes recursively and refresh view
1098 */
1099 collapseAll: function () {
1100 this.nodes.forEach(this.hideChildren.bind(this));
1101 this.prepareDataForVisibleNodes();
1102 this.update();
1103 },
1104 };
1105
1106 return SvgTree;
1107 });