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