93aab43337cd39960392454618c809e0359f6709
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Resources / Public / JavaScript / FormEngine / Element / 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/FormEngine/Element/SvgTree
16 */
17 define(['jquery', 'd3'], function ($, d3) {
18 'use strict';
19 /**
20 * Returns descendants of the current node in the pre-order traversal, such that a given node is only visited
21 * after all of its ancestors have already been visited. In other words "children before siblings"
22 *
23 * @returns {Node[]}
24 */
25 d3.hierarchy.prototype.descendantsBefore = function () {
26 var nodes = [];
27 this.eachBefore(function (node) {
28 nodes.push(node);
29 });
30 return nodes;
31 };
32
33 /**
34 * @constructor
35 * @exports SvgTree
36 */
37 var SvgTree = function () {
38 this.settings = {
39 showCheckboxes: false,
40 showIcons: false,
41 nodeHeight: 20,
42 indentWidth: 16,
43 duration: 400,
44 dataUrl: 'tree-configuration.json',
45 validation: {
46 maxItems: Number.MAX_VALUE
47 },
48 unselectableElements: [],
49 expandUpToLevel: null,
50 readOnlyMode: false,
51 /**
52 * List node identifiers which can not be selected together with any other node
53 */
54 exclusiveNodesIdentifiers : ''
55 };
56
57 /**
58 * Root <svg> element
59 *
60 * @type {Selection}
61 */
62 this.svg = null;
63
64 /**
65 * SVG <g> container wrapping all .node elements
66 *
67 * @type {Selection}
68 */
69 this.nodesContainer = null;
70
71 /**
72 * SVG <defs> container wrapping all icon definitions
73 *
74 * @type {Selection}
75 */
76 this.iconsContainer = null;
77
78 /**
79 * SVG <g> container wrapping all links (lines between parent and child)
80 *
81 * @type {Selection}
82 */
83 this.linksContainer = null;
84
85 /**
86 * Tree root node
87 *
88 * @type {Node}
89 */
90 this.rootNode = null;
91
92 /**
93 *
94 * @type {{nodes: Node[], links: Object, icons: Object}}
95 */
96 this.data = {};
97
98 /**
99 * D3 event dispatcher
100 *
101 * @type {Object}
102 */
103 this.dispatch = null;
104
105 /**
106 * jQuery object of wrapper holding the SVG
107 * Height of this wrapper is important (we only render as many nodes as fit in the wrapper
108 *
109 * @type {jQuery}
110 */
111 this.wrapper = null;
112 this.viewportHeight = 0;
113 this.scrollTop = 0;
114 this.scrollBottom = 0;
115 this.position = 0;
116
117 /**
118 * Exclusive node which is currently selected
119 *
120 * @type {Node}
121 */
122 this.exclusiveSelectedNode = null;
123 };
124
125 SvgTree.prototype = {
126 constructor: SvgTree,
127
128 /**
129 * Initializes the tree component - created basic markup, loads and renders data
130 *
131 * @param {String} selector
132 * @param {Object} settings
133 */
134 initialize: function (selector, settings) {
135 var $wrapper = $(selector);
136 // Do nothing if already initialized
137 if ($wrapper.data('svgtree-initialized')) {
138 return false;
139 }
140
141 $.extend(this.settings, settings);
142 var me = this;
143 this.wrapper = $wrapper;
144 this.dispatch = d3.dispatch('updateNodes', 'updateSvg', 'loadDataAfter', 'prepareLoadedNode', 'nodeSelectedAfter');
145 this.svg = d3
146 .select($wrapper[0])
147 .append('svg')
148 .attr('version', '1.1')
149 .attr('width', '100%');
150 var container = this.svg
151 .append('g')
152 .attr('transform', 'translate(' + (this.settings.indentWidth / 2) + ',' + (this.settings.nodeHeight / 2) + ')');
153 this.linksContainer = container.append('g')
154 .attr('class', 'links');
155 this.nodesContainer = container.append('g')
156 .attr('class', 'nodes');
157 if (this.settings.showIcons) {
158 this.iconsContainer = this.svg.append('defs');
159 }
160
161 this.updateScrollPosition();
162 this.loadData();
163
164 this.wrapper.on('resize scroll', function () {
165 me.updateScrollPosition();
166 me.update();
167 });
168 this.wrapper.data('svgtree', this);
169 this.wrapper.data('svgtree-initialized', true);
170 this.wrapper.trigger('svgTree.initialized');
171 return true;
172 },
173
174 /**
175 * Updates variables used for visible nodes calculation
176 */
177 updateScrollPosition: function () {
178 this.viewportHeight = this.wrapper.height();
179 this.scrollTop = this.wrapper.scrollTop();
180 this.scrollBottom = this.scrollTop + this.viewportHeight + (this.viewportHeight / 2);
181 },
182
183 /**
184 * Loads tree data (json) from configured url
185 */
186 loadData: function () {
187 var me = this;
188 d3.json(this.settings.dataUrl, function (error, json) {
189 if (error) throw error;
190 if (json === null) {
191 var $container = $(me.wrapper).closest('.t3js-formengine-field-item');
192 $container.hide();
193 $container.parent().append('<p class="text-danger">' + TYPO3.lang['tcatree.msg_save_first'] + '</p>');
194 return;
195 }
196 if (Array.isArray(json)) {
197 if (json.length > 1) {
198 // If tree comes with multiple root nodes, add them to a new root
199 var tmp = {
200 checked: undefined,
201 children: [],
202 expandable: true,
203 expanded: true,
204 iconTag: null,
205 id: '',
206 identifier: 'root',
207 leaf: false,
208 name: '',
209 overlayIcon: '',
210 text: '',
211 uid: ''
212 };
213 for (var i = 0; i < json.length; i++) {
214 var n = json[i];
215 if (typeof n.identifier === 'undefined') {
216 n.identifier = n.uid;
217 }
218 if (typeof n.name === 'undefined') {
219 n.name = n.text;
220 }
221 if (typeof n.expandable === 'undefined') {
222 n.expandable = true;
223 }
224 if (typeof n.expanded === 'undefined') {
225 n.expanded = true;
226 }
227 if (typeof n.icon !== 'undefined') {
228 n.iconTag = n.icon;
229 }
230 tmp.children.push(n);
231 }
232 json = tmp;
233 } else {
234 json = json[0];
235 }
236 }
237 var rootNode = d3.hierarchy(json);
238 d3.tree(rootNode);
239
240 rootNode.each(function (n) {
241 n.open = (me.settings.expandUpToLevel !== null) ? n.depth < me.settings.expandUpToLevel : Boolean(n.expanded);
242 n.hasChildren = (n.children || n._children) ? 1 : 0;
243 n.parents = [];
244 n._isDragged = false;
245 if (n.parent) {
246 var x = n;
247 while (x && x.parent) {
248 if (x.parent.data.identifier) {
249 n.parents.push(x.parent.data.identifier);
250 }
251 x = x.parent;
252 }
253 }
254 if (typeof n.data.checked == 'undefined') {
255 n.data.checked = false;
256 me.settings.unselectableElements.push(n.data.identifier);
257 }
258 //dispatch event
259 me.dispatch.call('prepareLoadedNode', me, n);
260 });
261 me.rootNode = rootNode;
262 me.dispatch.call('loadDataAfter', me);
263 me.prepareDataForVisibleNodes();
264 me.update();
265 });
266 },
267
268 /**
269 * Filters out invisible nodes (collapsed) from the full dataset (this.rootNode)
270 * and enriches dataset with additional properties
271 * Visible dataset is stored in this.data
272 */
273 prepareDataForVisibleNodes: function () {
274 var me = this;
275
276 var blacklist = {};
277 this.rootNode.eachBefore(function (node) {
278 if (!node.open) {
279 blacklist[node.data.identifier] = true;
280 }
281
282 });
283
284 this.data.nodes = this.rootNode.descendantsBefore().filter(function (node) {
285 return node.hidden != true && !node.parents.some(function (id) {
286 return Boolean(blacklist[id]);
287 });
288 });
289
290 var iconHashes = [];
291 this.data.links = [];
292 this.data.icons = [];
293 this.data.nodes.forEach(function (n, i) {
294 //delete n.children;
295 n.x = n.depth * me.settings.indentWidth;
296 n.y = i * me.settings.nodeHeight;
297 if (n.parent) {
298 me.data.links.push({
299 source: n.parent,
300 target: n
301 });
302 }
303 if (!n.iconHash && me.settings.showIcons && n.data.icon) {
304 n.iconHash = Math.abs(me.hashCode(n.data.icon));
305 if (iconHashes.indexOf(n.iconHash) === -1) {
306 iconHashes.push(n.iconHash);
307 me.data.icons.push({
308 identifier: n.iconHash,
309 icon: n.data.icon
310 });
311 }
312 delete n.data.icon;
313 }
314 if (!n.iconOverlayHash && me.settings.showIcons && n.data.overlayIcon) {
315 n.iconOverlayHash = Math.abs(me.hashCode(n.data.overlayIcon));
316 if (iconHashes.indexOf(n.iconOverlayHash) === -1) {
317 iconHashes.push(n.iconOverlayHash);
318 me.data.icons.push({
319 identifier: n.iconOverlayHash,
320 icon: n.data.overlayIcon
321 });
322 }
323 delete n.data.overlayIcon;
324 }
325 });
326 this.svg.attr('height', this.data.nodes.length * this.settings.nodeHeight);
327 },
328
329 /**
330 * Renders the subset of the tree nodes fitting the viewport (adding, modifying and removing SVG nodes)
331 */
332 update: function () {
333 var me = this;
334 var visibleRows = Math.ceil(this.viewportHeight / this.settings.nodeHeight + 1);
335 var position = Math.floor(Math.max(this.scrollTop, 0) / this.settings.nodeHeight);
336
337 var visibleNodes = this.data.nodes.slice(position, position + visibleRows);
338 var nodes = this.nodesContainer.selectAll('.node').data(visibleNodes, function (d) {
339 return d.data.identifier;
340 });
341
342 // delete nodes without corresponding data
343 nodes
344 .exit()
345 .remove();
346
347 nodes = this.enterSvgElements(nodes);
348
349 this.updateLinks();
350 // update
351 nodes
352 .attr('transform', this.getNodeTransform)
353 .select('text')
354 .text(this.getNodeLabel.bind(me));
355
356 nodes
357 .select('.chevron')
358 .attr('transform', this.getChevronTransform)
359 .attr('visibility', this.getChevronVisibility);
360
361 if (this.settings.showIcons) {
362 nodes
363 .select('use.node-icon')
364 .attr('xlink:href', this.getIconId);
365 nodes
366 .select('use.node-icon-overlay')
367 .attr('xlink:href', this.getIconOverlayId);
368 }
369
370 //dispatch event
371 this.dispatch.call('updateNodes', me, nodes);
372 },
373
374 /**
375 * Renders links(lines) between parent and child nodes
376 */
377 updateLinks: function () {
378 var me = this;
379 var visibleLinks = this.data.links.filter(function (linkData) {
380 return linkData.source.y <= me.scrollBottom && linkData.target.y >= me.scrollTop;
381 });
382
383 var links = this.linksContainer
384 .selectAll('.link')
385 .data(visibleLinks);
386 // delete
387 links
388 .exit()
389 .remove();
390
391 //create
392 links.enter().append('path')
393 .attr('class', 'link')
394 //create + update
395 .merge(links)
396 .attr('d', this.getLinkPath.bind(me));
397 },
398
399 /**
400 * Adds missing SVG nodes
401 *
402 * @param {Selection} nodes
403 * @returns {Selection}
404 */
405 enterSvgElements: function (nodes) {
406 var me = this;
407 me.textPosition = 10;
408
409 if (me.settings.showIcons) {
410 var icons = this.iconsContainer
411 .selectAll('.icon-def')
412 .data(this.data.icons, function (i) {
413 return i.identifier;
414 });
415 icons
416 .enter()
417 .append('g')
418 .attr('class', 'icon-def')
419 .attr('id', function (i) {
420 return 'icon-' + i.identifier;
421 })
422 .append(function (i) {
423 //workaround for IE11 where you can't simply call .html(content) on svg
424 var parser = new DOMParser();
425 var markupText = i.icon.replace('<svg', '<g').replace('/svg>', '/g>');
426 markupText = "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'>" + markupText + "</svg>";
427 var dom = parser.parseFromString(markupText, "image/svg+xml");
428 return dom.documentElement.firstChild;
429 });
430 }
431
432 // create the node elements
433 var nodeEnter = nodes
434 .enter()
435 .append('g')
436 .attr('class', this.getNodeClass)
437 .attr('transform', this.getNodeTransform);
438
439 // append the chevron element
440 var chevron = nodeEnter
441 .append('g')
442 .attr('class', 'toggle')
443 .on('click', this.chevronClick.bind(me));
444
445 // improve usability by making the click area a 16px square
446 chevron
447 .append('path')
448 .style('opacity', 0)
449 .attr('d', 'M 0 0 L 16 0 L 16 16 L 0 16 Z');
450 chevron
451 .append('path')
452 .attr('class', 'chevron')
453 .attr('d', 'M 4 3 L 13 8 L 4 13 Z');
454
455 // append the icon element
456 if (this.settings.showIcons) {
457 me.textPosition = 30;
458 nodeEnter
459 .append('use')
460 .attr('x', 8)
461 .attr('y', -8)
462 .attr('class', 'node-icon')
463 .on('click', this.clickOnIcon.bind(me));
464 nodeEnter
465 .append('use')
466 .attr('x', 8)
467 .attr('y', -3)
468 .attr('class', 'node-icon-overlay')
469 .on('click', this.clickOnIcon.bind(me));
470 }
471
472 this.dispatch.call('updateSvg', me, nodeEnter);
473
474 // append the text element
475 nodeEnter
476 .append('text')
477 .attr('dx', me.textPosition)
478 .attr('dy', 5)
479 .on('click', this.clickOnLabel.bind(me))
480 .on('dblclick', this.dblClickOnLabel.bind(me));
481
482 nodeEnter
483 .append('title')
484 .text(this.getNodeTitle.bind(me));
485
486 return nodes.merge(nodeEnter);
487 },
488
489 /**
490 * Computes the tree item label based on the data
491 *
492 * @param {Node} node
493 * @returns {String}
494 */
495 getNodeLabel: function (node) {
496 return node.data.name;
497 },
498
499 /**
500 * Computes the tree node class
501 *
502 * @param {Node} node
503 * @returns {String}
504 */
505 getNodeClass: function (node) {
506 return 'node identifier-' + node.data.identifier;
507 },
508
509 /**
510 * Computes the tree item label based on the data
511 *
512 * @param {Node} node
513 * @returns {String}
514 */
515 getNodeTitle: function (node) {
516 return 'uid=' + node.data.identifier;
517 },
518
519 /**
520 * Returns chevron 'transform' attribute value
521 *
522 * @param {Node} node
523 * @returns {String}
524 */
525 getChevronTransform: function (node) {
526 return node.open ? 'translate(8 -8) rotate(90)' : 'translate(-8 -8) rotate(0)';
527 },
528
529 /**
530 * Computes chevron 'visibility' attribute value
531 *
532 * @param {Node} node
533 * @returns {String}
534 */
535 getChevronVisibility: function (node) {
536 return node.hasChildren ? 'visible' : 'hidden';
537 },
538
539 /**
540 * Returns icon's href attribute value
541 *
542 * @param {Node} node
543 * @returns {String}
544 */
545 getIconId: function (node) {
546 return '#icon-' + node.iconHash;
547 },
548 /**
549 * Returns icon's href attribute value
550 *
551 * @param {Node} node
552 * @returns {String}
553 */
554 getIconOverlayId: function (node) {
555 return '#icon-' + node.iconOverlayHash;
556 },
557
558 /**
559 * Returns a SVG path's 'd' attribute value
560 *
561 * @param {Object} link
562 * @returns {String}
563 */
564 getLinkPath: function (link) {
565 var me = this;
566
567 var target = {
568 x: link.target._isDragged ? link.target._x : link.target.x,
569 y: link.target._isDragged ? link.target._y : link.target.y
570 };
571 var path = [];
572 path.push('M' + link.source.x + ' ' + link.source.y);
573 path.push('V' + target.y);
574 if (target.hasChildren) {
575 path.push('H' + target.x);
576 } else {
577 path.push('H' + (target.x + me.settings.indentWidth / 4));
578 }
579 return path.join(' ');
580 },
581
582 /**
583 * Returns a 'transform' attribute value for the tree element (absolute positioning)
584 *
585 * @param {Node} node
586 */
587 getNodeTransform: function (node) {
588 return 'translate(' + node.x + ',' + node.y + ')';
589 },
590
591 /**
592 * Simple hash function used to create icon href's
593 *
594 * @param {String} s
595 * @returns {String}
596 */
597 hashCode: function (s) {
598 return s.split('')
599 .reduce(function (a, b) {
600 a = ((a << 5) - a) + b.charCodeAt(0);
601 return a & a
602 }, 0);
603 },
604
605 /**
606 * Node selection logic (triggered by different events)
607 *
608 * @param {Node} node
609 */
610 selectNode: function (node) {
611 if (!this.isNodeSelectable(node)) {
612 return;
613 }
614 var checked = node.data.checked;
615 this.handleExclusiveNodeSelection(node);
616
617 if (this.settings.validation && this.settings.validation.maxItems) {
618 var selectedNodes = this.getSelectedNodes();
619 if (!checked && selectedNodes.length >= this.settings.validation.maxItems) {
620 return;
621 }
622 }
623 node.data.checked = !checked;
624
625 this.dispatch.call('nodeSelectedAfter', this, node);
626 this.update();
627 },
628
629 /**
630 * Handle exclusive nodes functionality
631 * If a node is one of the exclusiveNodesIdentifiers list, all other nodes has to be unselected before selecting this node.
632 *
633 * @param {Node} node
634 */
635 handleExclusiveNodeSelection: function (node) {
636 var exclusiveKeys = this.settings.exclusiveNodesIdentifiers.split(','),
637 me = this;
638 if (this.settings.exclusiveNodesIdentifiers.length && node.data.checked === false) {
639 if (exclusiveKeys.indexOf('' + node.data.identifier) > -1) {
640 // this key is exclusive, so uncheck all others
641 this.rootNode.each(function (node) {
642 if (node.data.checked === true) {
643 node.data.checked = false;
644 me.dispatch.call('nodeSelectedAfter', me, node);
645 }
646 });
647 this.exclusiveSelectedNode = node;
648 } else if (exclusiveKeys.indexOf('' + node.data.identifier) === -1 && this.exclusiveSelectedNode) {
649 //current node is not exclusive, but other exclusive node is already selected
650 this.exclusiveSelectedNode.data.checked = false;
651 this.dispatch.call('nodeSelectedAfter', this, this.exclusiveSelectedNode);
652 this.exclusiveSelectedNode = null;
653 }
654 }
655 },
656
657 /**
658 * Check whether node can be selected, in some cases like parent selector it should not be possible to select
659 * element as it's own parent
660 *
661 * @param {Node} node
662 * @returns {Boolean}
663 */
664 isNodeSelectable: function (node) {
665 return !this.settings.readOnlyMode && this.settings.unselectableElements.indexOf(node.data.identifier) == -1;
666 },
667
668 /**
669 * Returns an array of selected nodes
670 *
671 * @returns {Node[]}
672 */
673 getSelectedNodes: function () {
674 var selectedNodes = [];
675
676 this.rootNode.each(function (node) {
677 if (node.data.checked) {
678 selectedNodes.push(node)
679 }
680 });
681 return selectedNodes;
682 },
683
684 /**
685 * Event handler for clicking on a node's icon
686 *
687 * @param {Node} node
688 */
689 clickOnIcon: function (node) {
690 },
691
692 /**
693 * Event handler for click on a node's label/text
694 *
695 * @param {Node} node
696 */
697 clickOnLabel: function (node) {
698 this.selectNode(node);
699 },
700
701 /**
702 * Event handler for double click on a node's label
703 *
704 * @param {Node} node
705 */
706 dblClickOnLabel: function (node) {
707 },
708
709 /**
710 * Event handler for click on a chevron
711 *
712 * @param {Node} node
713 */
714 chevronClick: function (node) {
715 if (node.open) {
716 this.hideChildren(node);
717 } else {
718 this.showChildren(node);
719 }
720 this.prepareDataForVisibleNodes();
721 this.update();
722 },
723
724 /**
725 * Updates node's data to hide/collapse children
726 *
727 * @param {Node} node
728 */
729 hideChildren: function (node) {
730 node.open = false;
731 },
732
733 /**
734 * Updates node's data to show/expand children
735 *
736 * @param {Node} node
737 */
738 showChildren: function (node) {
739 node.open = true;
740 },
741
742 /**
743 * Expand all nodes and refresh view
744 */
745 expandAll: function () {
746 this.rootNode.each(this.showChildren.bind(this));
747 this.prepareDataForVisibleNodes();
748 this.update();
749 },
750
751 /**
752 * Collapse all nodes recursively and refresh view
753 */
754 collapseAll: function () {
755 this.rootNode.each(this.hideChildren.bind(this));
756 this.prepareDataForVisibleNodes();
757 this.update();
758 }
759 };
760
761 return SvgTree;
762 });