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