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