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