1 /*
  2  * toolkit.js
  3  *
  4  * Sweet Home 3D, Copyright (c) 2024 Space Mushrooms <info@sweethome3d.com>
  5  *
  6  * This program is free software; you can redistribute it and/or modify
  7  * it under the terms of the GNU General Public License as published by
  8  * the Free Software Foundation; either version 2 of the License, or
  9  * (at your option) any later version.
 10  *
 11  * This program is distributed in the hope that it will be useful,
 12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
 13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 14  * GNU General Public License for more details.
 15  *
 16  * You should have received a copy of the GNU General Public License
 17  * along with this program; if not, write to the Free Software
 18  * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 19  */
 20 
 21 // Requires UserPreferences.js
 22 // Requires ResourceAction.js
 23 
 24 /**
 25  * The root class for additional UI components.
 26  * @param {UserPreferences} preferences the current user preferences
 27  * @param {string|HTMLElement} template template element (view HTML will be this element's innerHTML) 
 28  *     or HTML string (if null or undefined, then the component creates an empty div for the root node)
 29  * @param {boolean} [useElementAsRootHTMLElement]
 30  * @constructor
 31  * @author Renaud Pawlak
 32  * @author Emmanuel Puybaret
 33  */
 34 function JSComponent(preferences, template, useElementAsRootHTMLElement) {
 35   this.preferences = preferences;
 36 
 37   if (template instanceof HTMLElement && useElementAsRootHTMLElement === true) {
 38     this.container = template;
 39   } else {
 40     var html = "";
 41     if (template != null) {
 42       html = typeof template == "string" ? template : template.innerHTML;
 43     }
 44     this.container = document.createElement("div");
 45     this.container.innerHTML = this.buildHtmlFromTemplate(html);
 46   }
 47 }
 48 
 49 /**
 50  * Returns the HTML element used to view this component.
 51  * @return {HTMLElement}
 52  */
 53 JSComponent.prototype.getHTMLElement = function() {
 54   return this.container;
 55 }
 56 
 57 /**
 58  * Returns the user preferences used to localize this component.
 59  * @return {UserPreferences}
 60  */
 61 JSComponent.prototype.getUserPreferences = function() {
 62   return this.preferences;
 63 }
 64 
 65 /**
 66  * Returns true if element is or is child of candidateParent, false otherwise.
 67  * @param {HTMLElement} element
 68  * @param {HTMLElement} candidateParent
 69  * @return {boolean}
 70  */
 71 JSComponent.isElementContained = function(element, candidateParent) {
 72   if (element == null || candidateParent == null) {
 73     return false;
 74   }
 75 
 76   var currentParent = element;
 77   do {
 78     if (currentParent == candidateParent) {
 79       return true;
 80     }
 81   } while (currentParent = currentParent.parentElement);
 82 
 83   return false;
 84 }
 85 
 86 /**
 87  * Substitutes all the place holders in the html with localized labels.
 88  * @param {UserPreferences} preferences the current user preferences
 89  * @param {string} html 
 90  */
 91 JSComponent.substituteWithLocale = function(preferences, html) {
 92   return html.replace(/\@\{([a-zA-Z0-9_.]+)\}/g, function(fullMatch, str) {
 93       return ResourceAction.getLocalizedLabelText(preferences, 
 94           str.substring(0, str.indexOf('.')), str.substring(str.indexOf('.') + 1));
 95     });
 96 }
 97 
 98 /**
 99  * Substitutes all the place holders in the given html with localized labels.
100  * @param {string} templateHtml 
101  */
102 JSComponent.prototype.buildHtmlFromTemplate = function(templateHtml) {
103   return JSComponent.substituteWithLocale(this.preferences, templateHtml);
104 }
105 
106 /**
107  * Returns the localized text defined for the given <code>>resourceClass</code> + <code>propertyKey</code>.
108  * @param {Object} resourceClass
109  * @param {string} propertyKey
110  * @param {Array} resourceParameters
111  * @return {string}
112  * @protected
113  */
114 JSComponent.prototype.getLocalizedLabelText = function(resourceClass, propertyKey, resourceParameters) {
115   return ResourceAction.getLocalizedLabelText(this.preferences, resourceClass, propertyKey, resourceParameters);
116 }
117 
118 /**
119  * Attaches the given component to a child DOM element, becoming a child component.
120  * @param {string} name the component's name, which matches child DOM element name (as defined in {@link JSComponent#getElement})
121  * @param {JSComponent} component child component instance
122  */
123 JSComponent.prototype.attachChildComponent = function(name, component) {
124   this.getElement(name).appendChild(component.getHTMLElement());
125 }
126 
127 /**
128  * Registers the given listener on given elements(s) and removes them when this component is disposed.
129  * @param {(HTMLElement[]|HTMLElement)} elements
130  * @param {string} eventName
131  * @param {function} listener
132  */
133 JSComponent.prototype.registerEventListener = function(elements, eventName, listener) {
134   if (elements == null) {
135     return;
136   }
137   if (elements instanceof NodeList || elements instanceof HTMLCollection) {
138     var array = new Array(elements.length);
139     for (var i = 0; i < elements.length; i++) {
140       array[i] = elements[i];
141     }
142     elements = array;
143   }
144   if (!Array.isArray(elements)) {
145     elements = [elements];
146   }
147   if (this.listeners == null) {
148     this.listeners = [];
149   }
150   for (var i = 0; i < elements.length; i++) {
151     var element = elements[i];
152     element.addEventListener(eventName, listener, false);
153   }
154   this.listeners.push(
155       {
156         listener: listener,
157         eventName: eventName,
158         elements: elements
159       });
160 }
161 
162 /**
163  * Registers the given property change listener on object and removes it when this component is disposed.
164  * @param {Object} object
165  * @param {string} propertyName
166  * @param {function} listener
167  */
168 JSComponent.prototype.registerPropertyChangeListener = function(object, propertyName, listener) {
169   object.addPropertyChangeListener(propertyName, listener);
170   this.listeners.push(
171       {
172         listener: listener,
173         propertyName: propertyName,
174         object: object
175       });
176 }
177 
178 /**
179  * Releases all listeners registered with {@link JSComponent#registerEventListener}
180  * @private
181  */
182 JSComponent.prototype.unregisterEventListeners = function() {
183   if (Array.isArray(this.listeners)) {
184     for (var i = 0; i < this.listeners.length; i++) {
185       var registeredEntry = this.listeners[i];
186       if (registeredEntry.eventName !== undefined) {
187         for (var j = 0; j < registeredEntry.elements.length; j++) {
188           var element = registeredEntry.elements[j];
189           element.removeEventListener(registeredEntry.eventName, registeredEntry.listener);
190         }
191       } else {
192         registeredEntry.object.removePropertyChangeListener(registeredEntry.propertyName, registeredEntry.listener);
193       }
194     }
195   }
196 }
197 
198 /**
199  * Returns the named element that corresponds to the given name within this component.
200  * A named element shall define the "name" attribute (for instance an input), or
201  * a "data-name" attribute if the name attribute is not supported.
202  */
203 JSComponent.prototype.getElement = function(name) {
204   var element = this.container.querySelector("[name='" + name + "']");
205   if (element == null) {
206     element = this.container.querySelector("[data-name='" + name + "']");
207   }
208   return element;
209 }
210 
211 /**
212  * Returns the element that matches the given query selector within this component.
213  * @param {string} query css selector to be applied on children elements
214  */
215 JSComponent.prototype.findElement = function(query) {
216   return this.container.querySelector(query);
217 }
218 
219 /**
220  * Returns the elements that match the given query selector within this component.
221  * @param {string} query css selector to be applied on children elements
222  */
223 JSComponent.prototype.findElements = function(query) {
224   return this.container.querySelectorAll(query);
225 }
226 
227 /**
228  * Releases any resource or listener associated with this component, when it's disposed. 
229  * Override to perform custom clean.
230  * Don't forget to call super method: JSComponent.prototype.dispose()
231  */
232 JSComponent.prototype.dispose = function() {
233   this.unregisterEventListeners();
234 }
235 
236 /**
237  * @param {string} value option's value
238  * @param {string} text option's display text
239  * @param {boolean} [selected] true if selected, default false
240  * @return {HTMLOptionElement}
241  * @ignore
242  */
243 JSComponent.createOptionElement = function(value, text, selected) {
244   var option = document.createElement("option");
245   option.value = value;
246   option.textContent = text;
247   if (selected !== undefined) {
248     option.selected = selected;
249   }
250   return option;
251 }
252 
253 
254 /**
255  * A class to create dialogs.
256  * @param preferences      the current user preferences
257  * @param {string} title the dialog's title (may contain HTML)
258  * @param {string|HTMLElement} template template element (view HTML will be this element's innerHTML) or HTML string (if null or undefined, then the component creates an empty div
259  * for the root node)
260  * @param {{applier: function(JSDialog), disposer: function(JSDialog), size?: "small"|"medium"|"default"}} [behavior]
261  * - applier: an optional dialog application function
262  * - disposer: an optional dialog function to release associated resources, listeners, ...
263  * - size: override style with "small" or "medium"
264  * @constructor
265  * @author Renaud Pawlak
266  */
267 function JSDialog(preferences, title, template, behavior) {
268   JSComponent.call(this, preferences, template, behavior);
269 
270   var dialog = this;
271   if (behavior != null) {
272     this.applier = behavior.applier;
273     this.disposer = behavior.disposer;
274   }
275 
276   this.getHTMLElement().classList.add("dialog-container");
277   if (behavior.size) {
278     this.getHTMLElement().classList.add(behavior.size);
279   }
280   this.getHTMLElement()._dialogBoxInstance = this;
281 
282   document.body.appendChild(this.getHTMLElement());
283 
284   if (title != null) {
285     this.setTitle(title);
286   }
287 
288   this.registerEventListener(this.getCloseButton(), "click", function() {
289       dialog.cancel();
290     });
291   this.registerEventListener(this.getHTMLElement(), "mousedown", function(ev) {
292       ev.stopPropagation();
293     });
294 
295   this.buttonsPanel = this.findElement(".dialog-buttons");
296   if (OperatingSystem.isMacOSX()) {
297     this.buttonsPanel.classList.add("mac");
298   }
299   this.appendButtons(this.buttonsPanel);
300   this.getHTMLElement().classList.add('buttons-' + this.buttonsPanel.querySelectorAll('button').length);
301 }
302 JSDialog.prototype = Object.create(JSComponent.prototype);
303 JSDialog.prototype.constructor = JSDialog;
304 
305 /**
306  * Appends dialog buttons to given panel.
307  * Caution : this method is called from constructor.
308  * @param {HTMLElement} buttonsPanel Dialog buttons panel
309  * @protected
310  */
311 JSDialog.prototype.appendButtons = function(buttonsPanel) {
312   var html;
313   if (this.applier) {
314     html = "<button class='dialog-ok-button default-capable'>@{OptionPane.okButton.textAndMnemonic}</button>"
315          + "<button class='dialog-cancel-button'>@{OptionPane.cancelButton.textAndMnemonic}</button>";
316   } else {
317     html = "<button class='dialog-cancel-button default-capable'>@{InternalFrameTitlePane.closeButtonAccessibleName}</button>";
318   }
319   buttonsPanel.innerHTML = JSComponent.substituteWithLocale(this.getUserPreferences(), html);
320 
321   var dialog = this;
322   var cancelButton = this.findElement(".dialog-cancel-button");
323   if (cancelButton) {
324     this.registerEventListener(cancelButton, "click", function(ev) {
325         dialog.cancel();
326       });
327   }
328   var okButton = this.findElement(".dialog-ok-button");
329   if (okButton) {
330     this.registerEventListener(okButton, "click", function(ev) {
331         dialog.validate();
332       });
333   }
334 }
335 
336 /**
337  * Closes currently displayed topmost dialog if any.
338  * @private
339  */
340 JSDialog.closeTopMostDialog = function() {
341   var topMostDialog = JSDialog.getTopMostDialog();
342   if (topMostDialog != null) {
343     topMostDialog.close();
344   }
345 }
346 
347 /**
348  * Returns the currently displayed topmost dialog if any.
349  * @return {JSDialog} currently displayed topmost dialog if any, otherwise null
350  * @ignore
351  */
352 JSDialog.getTopMostDialog = function() {
353   var visibleDialogElements = document.querySelectorAll(".dialog-container.visible");
354   var topMostDialog = null;
355   if (visibleDialogElements.length > 0) {
356     for (var i = 0; i < visibleDialogElements.length; i++) {
357       var visibleDialog = visibleDialogElements[i]._dialogBoxInstance;
358       if (topMostDialog == null || topMostDialog.displayIndex <= visibleDialog.displayIndex) {
359         topMostDialog = visibleDialog;
360       }
361     }
362   }
363   return topMostDialog;
364 }
365 
366 /**
367  * @param {string} templateHtml
368  */
369 JSDialog.prototype.buildHtmlFromTemplate = function(templateHtml) {
370   return JSComponent.substituteWithLocale(this.getUserPreferences(),
371       '<div class="dialog-content">' +
372       '  <div class="dialog-top">' +
373       '    <span class="title"></span>' +
374       '    <span class="dialog-close-button">×</span>' +
375       '  </div>' +
376       '  <div class="dialog-body">' +
377       JSComponent.prototype.buildHtmlFromTemplate.call(this, templateHtml) +
378       '  </div>' +
379       '  <div class="dialog-buttons">' +
380       '  </div>' +
381       '</div>');
382 }
383 
384 /**
385  * Returns the input that corresponds to the given name within this dialog.
386  */
387 JSDialog.prototype.getInput = function(name) {
388   return this.getHTMLElement().querySelector("[name='" + name + "']");
389 }
390 
391 /**
392  * Returns the close button of this dialog.
393  */
394 JSDialog.prototype.getCloseButton = function() {
395   return this.getHTMLElement().querySelector(".dialog-close-button");
396 }
397 
398 /**
399  * Called when the user presses the OK button.
400  * Override to implement custom behavior when the dialog is validated by the user.
401  */
402 JSDialog.prototype.validate = function() {
403   if (this.applier != null) {
404     this.applier(this);
405   }
406   this.close();
407 }
408 
409 /**
410  * Called when the user closes the dialog with no validation.
411  */
412 JSDialog.prototype.cancel = function() {
413   this.close();
414 }
415 
416 /**
417  * Closes the dialog and discard the associated DOM.
418  */
419 JSDialog.prototype.close = function() {
420   this.getHTMLElement().classList.add("closing");
421   var dialog = this;
422   // Let 500ms before releasing the dialog so that the closing animation can apply
423   setTimeout(function() {
424       dialog.getHTMLElement().classList.remove("visible");
425       dialog.dispose();
426       if (dialog.getHTMLElement() && document.body.contains(dialog.getHTMLElement())) {
427         document.body.removeChild(dialog.getHTMLElement());
428       }
429     }, 500);
430 }
431 
432 /**
433  * Releases any resource or listener associated with this component, when it's disposed. 
434  * Override to perform custom clean - Don't forget to call super.dispose().
435  */
436 JSDialog.prototype.dispose = function() {
437   JSComponent.prototype.dispose.call(this);
438   if (typeof this.disposer == "function") {
439     this.disposer(this);
440   }
441 }
442 
443 /**
444  * Sets dialog title
445  * @param {string} title
446  */
447 JSDialog.prototype.setTitle = function(title) {
448   var titleElement = this.findElement(".dialog-top .title");
449   titleElement.textContent = JSComponent.substituteWithLocale(this.getUserPreferences(), title || "");
450 }
451 
452 /**
453  * @return {boolean} true if this dialog is currently shown, false otherwise
454  */
455 JSDialog.prototype.isDisplayed = function() {
456   return this.getHTMLElement().classList.contains("visible");
457 }
458 
459 /**
460  * Default implementation of the DialogView.displayView function.
461  */
462 JSDialog.prototype.displayView = function(parentView) {
463   var dialog = this;
464 
465   this.getHTMLElement().style.display = "block";
466 
467   // Force browser to refresh before adding visible class to allow transition on width and height
468   setTimeout(function() {
469       dialog.getHTMLElement().classList.add("visible");
470       dialog.displayIndex = JSDialog.shownDialogsCounter++;
471       var inputs = dialog.findElements('input');
472       for (var i = 0; i < inputs.length; i++) {
473         var focusedInput = inputs[i];
474         if (!focusedInput.classList.contains("not-focusable-at-opening")) {
475           focusedInput.focus();
476           break;
477         }
478       }
479     }, 100);
480 }
481 
482 JSDialog.shownDialogsCounter = 0;
483 
484 
485 /**
486  * A class to create wizard dialogs.
487  * @param {UserPreferences} preferences the current user preferences
488  * @param {WizardController} controller wizard's controller
489  * @param {string} title the dialog's title (may contain HTML)
490  * @param {string|HTMLElement} template template element (view HTML will be this element's innerHTML) or HTML string (if null or undefined, then the component creates an empty div
491  * for the root node)
492  * @param {{applier: function(JSDialog), disposer: function(JSDialog)}} [behavior]
493  * - applier: an optional dialog application function
494  * - disposer: an optional dialog function to release associated resources, listeners, ...
495  * @constructor
496  * @author Louis Grignon
497  */
498 function JSWizardDialog(preferences, controller, title, behavior) {
499   JSDialog.call(this, preferences, title,
500       '<div class="wizard">' +
501       '  <div stepIcon><div></div></div>' +
502       '  <div stepView></div>' +
503       '</div>',
504       behavior);
505 
506   this.getHTMLElement().classList.add("wizard-dialog");
507 
508   this.controller = controller;
509   this.stepIconPanel = this.findElement("[stepIcon]");
510   this.stepViewPanel = this.findElement("[stepView]");
511 
512   var dialog = this;
513   this.cancelButton = this.findElement(".wizard-cancel-button");
514   this.backButton = this.findElement(".wizard-back-button");
515   this.nextButton = this.findElement(".wizard-next-button");
516 
517   this.registerEventListener(this.cancelButton, "click", function(ev) {
518       dialog.cancel();
519     });
520 
521   this.backButton.disabled = !controller.isBackStepEnabled();
522   this.registerPropertyChangeListener(controller, "BACK_STEP_ENABLED", function(ev) {
523       dialog.backButton.disabled = !controller.isBackStepEnabled();
524     });
525 
526   this.nextButton.disabled = !controller.isNextStepEnabled();
527   this.registerPropertyChangeListener(controller, "NEXT_STEP_ENABLED", function(ev) {
528       dialog.nextButton.disabled = !controller.isNextStepEnabled();
529     });
530 
531   this.updateNextButtonText();
532   this.registerPropertyChangeListener(controller, "LAST_STEP", function(ev) {
533       dialog.updateNextButtonText();
534     });
535 
536   this.registerEventListener(this.backButton, "click", function(ev) {
537       controller.goBackToPreviousStep();
538     });
539   this.registerEventListener(this.nextButton, "click", function(ev) {
540       if (controller.isLastStep()) {
541         controller.finish();
542         if (dialog != null) {
543           dialog.validate();
544         }
545       } else {
546         controller.goToNextStep();
547       }
548     });
549 
550   this.updateStepView();
551   this.registerPropertyChangeListener(controller, "STEP_VIEW", function(ev) {
552       dialog.updateStepView();
553     });
554 
555   this.updateStepIcon();
556   this.registerPropertyChangeListener(controller, "STEP_ICON", function(ev) {
557       dialog.updateStepIcon();
558     });
559   
560   this.registerPropertyChangeListener(controller, "TITLE", function(ev) {
561       dialog.setTitle(controller.getTitle());
562     });
563 }
564 JSWizardDialog.prototype = Object.create(JSDialog.prototype);
565 JSWizardDialog.prototype.constructor = JSWizardDialog;
566 
567 /**
568  * Append dialog buttons to given panel
569  * @param {HTMLElement} buttonsPanel Dialog buttons panel
570  * @protected
571  */
572 JSWizardDialog.prototype.appendButtons = function(buttonsPanel) {
573   var cancelButton = "<button class='wizard-cancel-button'>@{InternalFrameTitlePane.closeButtonAccessibleName}</button>";
574   var backButton = "<button class='wizard-back-button'>@{WizardPane.backOptionButton.text}</button>";
575   var nextButton = "<button class='wizard-next-button default-capable'></button>";
576   var buttons = "<div class='dialog-buttons'>" 
577       + (OperatingSystem.isMacOSX() ? nextButton + backButton : backButton + nextButton) 
578       + cancelButton + "</div>";
579   buttonsPanel.innerHTML = JSComponent.substituteWithLocale(this.getUserPreferences(), buttons);
580 }
581 
582 /**
583  * Change text of the next button depending on if state is last step or not
584  * @private
585  */
586 JSWizardDialog.prototype.updateNextButtonText = function() {
587   this.nextButton.innerText = this.getLocalizedLabelText("WizardPane",
588       this.controller.isLastStep()
589           ? "finishOptionButton.text"
590           : "nextOptionButton.text");
591 }
592 
593 /**
594  * Updates UI for current step.
595  * @private
596  */
597 JSWizardDialog.prototype.updateStepView = function() {
598   var stepView = this.controller.getStepView();
599   this.stepViewPanel.innerHTML = "";
600   this.stepViewPanel.appendChild(stepView.getHTMLElement());
601 }
602 
603 /**
604  * Updates image for current step.
605  * @private
606  */
607 JSWizardDialog.prototype.updateStepIcon = function() {
608   var iconPanel = this.stepIconPanel;
609   var imageContainer = this.stepIconPanel.querySelector('div');
610   imageContainer.innerHTML = "";
611   // Add new icon
612   var stepIcon = this.controller.getStepIcon();
613   if (stepIcon != null) {
614     var backgroundColor1 = "rgb(163, 168, 226)";
615     var backgroundColor2 = "rgb(80, 86, 158)";
616     try {
617       // Read gradient colors used to paint icon background
618       var stepIconBackgroundColors = this.getLocalizedLabelText(
619           "WizardPane", "stepIconBackgroundColors").trim().split(" ");
620       backgroundColor1 = stepIconBackgroundColors[0];
621       if (stepIconBackgroundColors.length == 1) {
622         backgroundColor2 = backgroundColor1;
623       } else if (stepIconBackgroundColors.length == 2) {
624         backgroundColor2 = stepIconBackgroundColors[1];
625       }
626     } catch (ex) {
627       // Do not change if exception
628     }
629 
630     var gradientColor1 = backgroundColor1;
631     var gradientColor2 = backgroundColor2;
632     iconPanel.style.background = "linear-gradient(180deg, " + gradientColor1 + " 0%, " + gradientColor2 + " 100%)";
633     iconPanel.style.border = "solid 1px #333333";
634     var icon = new Image();
635     icon.crossOrigin = "anonymous";
636     imageContainer.appendChild(icon);
637     icon.src = stepIcon.indexOf("://") === -1 
638         ?  ZIPTools.getScriptFolder() + stepIcon 
639         : stepIcon;
640   }
641 }
642 
643 
644 /**
645  * A dialog prompting user to choose whether an image should be resized or not.
646  * @param {UserPreferences} preferences
647  * @param {string} title title of the dialog
648  * @param {string} message message to be displayed
649  * @param {string} cancelButtonMessage
650  * @param {string} keepUnchangedButtonMessage
651  * @param {string} okButtonMessage
652  * @param {function()} imageResizeRequested called when user selected "resize image" option
653  * @param {function()} originalImageRequested called when user selected "keep image unchanged" option
654  * @constructor
655  * @package
656  * @ignore
657  */
658 function JSImageResizingDialog(preferences,
659                 title, message, cancelButtonMessage, keepUnchangedButtonMessage, okButtonMessage, 
660                 imageResizeRequested, originalImageRequested) {
661   this.cancelButtonMessage = JSComponent.substituteWithLocale(preferences, cancelButtonMessage);
662   this.keepUnchangedButtonMessage = JSComponent.substituteWithLocale(preferences, keepUnchangedButtonMessage);
663   this.okButtonMessage = JSComponent.substituteWithLocale(preferences, okButtonMessage);
664   
665   JSDialog.call(this, preferences,
666       JSComponent.substituteWithLocale(preferences, title),
667       "<div>" +
668       JSComponent.substituteWithLocale(preferences, message).replace("<br>", " ") +
669       "</div>",
670       {
671         applier: function(dialog) {
672           if (dialog.resizeRequested) {
673             imageResizeRequested();
674           } else {
675             originalImageRequested();
676           }
677         }
678       });
679   
680   var dialog = this;
681   var cancelButton = this.findElement(".dialog-cancel-button");
682   this.registerEventListener(cancelButton, "click", function(ev) {
683       dialog.cancel();
684     });
685   var okButtons = this.findElements(".dialog-ok-button");
686   this.registerEventListener(okButtons, "click", function(ev) {
687       dialog.resizeRequested = !ev.target.classList.contains("keep-image-unchanged-button");
688       dialog.validate();
689     });
690 }
691 JSImageResizingDialog.prototype = Object.create(JSDialog.prototype);
692 JSImageResizingDialog.prototype.constructor = JSImageResizingDialog;
693 
694 /**
695  * Appends dialog buttons to given panel.
696  * @param {HTMLElement} buttonsPanel Dialog buttons panel
697  * @protected
698  */
699 JSImageResizingDialog.prototype.appendButtons = function(buttonsPanel) {
700   buttonsPanel.innerHTML = JSComponent.substituteWithLocale(this.getUserPreferences(),
701       "<button class='dialog-ok-button default-capable'>" + this.okButtonMessage + "</button>"
702       + "<button class='keep-image-unchanged-button dialog-ok-button'>" + this.keepUnchangedButtonMessage + "</button>"
703       + "<button class='dialog-cancel-button'>" + this.cancelButtonMessage + "</button>");
704 }
705 
706 
707 /**
708  * Class handling context menus.
709  * @param {UserPreferences} preferences the current user preferences
710  * @param {HTMLElement|HTMLElement[]} sourceElements context menu will show when right click on this element. 
711  *        Cannot be null for the root node
712  * @param {function(JSPopupMenu.Builder, HTMLElement)}  build 
713  *    Function called with a builder, and optionally with source element (which was right clicked, to show this menu)
714  * @constructor
715  * @ignore
716  * @author Louis Grignon
717  * @author Renaud Pawlak
718  */
719 function JSPopupMenu(preferences, sourceElements, build) {
720   if (sourceElements == null || sourceElements.length === 0) {
721     throw new Error("Cannot register a context menu on an empty list of elements");
722   }
723   JSComponent.call(this, preferences, "");
724 
725   this.sourceElements = sourceElements;
726   if (!Array.isArray(sourceElements)) {
727     this.sourceElements = [sourceElements];
728   }
729   this.build = build;
730   this.getHTMLElement().classList.add("popup-menu");
731 
732   document.body.appendChild(this.getHTMLElement());
733 
734   var popupMenu = this;
735   this.registerEventListener(sourceElements, "contextmenu", function(ev) {
736       ev.preventDefault();
737       if (JSPopupMenu.current != null) {
738         JSPopupMenu.current.close();
739       }
740       popupMenu.showSourceElement(this, ev);
741     });
742 }
743 JSPopupMenu.prototype = Object.create(JSComponent.prototype);
744 JSPopupMenu.prototype.constructor = JSPopupMenu;
745 
746 /**
747  * Closes currently displayed context menu if any.
748  * @static
749  * @private
750  */
751 JSPopupMenu.closeOpenedMenu = function() {
752   if (JSPopupMenu.current != null) {
753     JSPopupMenu.current.close();
754     return true;
755   }
756   return false;
757 }
758 
759 /**
760  * @param {HTMLElement} sourceElement
761  * @param {Event} ev
762  * @private
763  */
764 JSPopupMenu.prototype.showSourceElement = function(sourceElement, ev) {
765   this.menuItemListeners = [];
766 
767   var builder = new JSPopupMenu.Builder();
768   this.build(builder, sourceElement);
769 
770   var items = builder.items;
771   // Remove last element if it is a separator
772   if (items.length > 0 && items[items.length - 1] == JSPopupMenu.CONTEXT_MENU_SEPARATOR_ITEM) {
773     items.pop();
774   }
775   var menuElement = this.createMenuElement(items);
776 
777   this.getHTMLElement().appendChild(menuElement);
778   
779   // Accept focus
780   this.getHTMLElement().setAttribute("tabindex", 1000);
781   this.getHTMLElement().style.outline = "none";
782   this.getHTMLElement().style.outlineWidth = "0";
783 
784   // Temporarily use hidden visibility to get element's height
785   this.getHTMLElement().style.visibility = "hidden";
786   this.getHTMLElement().classList.add("visible");
787 
788   // Adjust top/left and display
789   var anchorX = ev.clientX;
790   if (menuElement.clientWidth > window.innerWidth) {
791     anchorX = 0;
792   } else if (anchorX + menuElement.clientWidth + 20 > window.innerWidth) {
793     anchorX = Math.max(0, window.innerWidth - menuElement.clientWidth - 20);
794   }
795   var anchorY = ev.clientY;
796   if (menuElement.clientHeight > window.innerHeight) {
797     anchorY = 0;
798   } else if (anchorY + menuElement.clientHeight + 10 > window.innerHeight) {
799     anchorY = window.innerHeight - menuElement.clientHeight - 10;
800   }
801 
802   this.getHTMLElement().style.visibility = "visible";
803   this.getHTMLElement().style.left = anchorX + "px";
804   this.getHTMLElement().style.top = anchorY + "px";
805   // Request focus to receive esc key press
806   this.getHTMLElement().focus();
807 
808   JSPopupMenu.current = this;
809 }
810 
811 /**
812  * @param {{}[]} items same type as JSPopupMenu.Builder.items
813  * @param {number} [zIndex] default to initial value: 1000
814  * @return {HTMLElement} menu root html element (`<ul>`)
815  * @private
816  */
817 JSPopupMenu.prototype.createMenuElement = function(items, zIndex) {
818   if (zIndex === undefined) {
819     zIndex = 1000;
820   }
821 
822   var menuElement = document.createElement("ul");
823   menuElement.classList.add("items");
824   menuElement.style.zIndex = zIndex;
825   menuElement.addEventListener("contextmenu", function(ev) {
826       ev.preventDefault();
827     });
828 
829   var backElement = document.createElement("li");
830   backElement.classList.add("item");
831   backElement.classList.add("back");
832   backElement.textContent = "×";
833   this.registerEventListener(backElement, "click", function(ev) {
834       var isRootMenu = menuElement.parentElement.tagName.toLowerCase() != "li";
835       if (isRootMenu) {
836         JSPopupMenu.closeOpenedMenu();
837       } else {
838         menuElement.classList.remove("visible");
839         ev.stopPropagation();
840       }
841     });
842   menuElement.appendChild(backElement);
843 
844   for (var i = 0; i < items.length; i++) {
845     var item = items[i];
846 
847     var itemElement = document.createElement("li");
848     if (item == JSPopupMenu.CONTEXT_MENU_SEPARATOR_ITEM) {
849       itemElement.classList.add("separator");
850     } else {
851       this.initMenuItemElement(itemElement, item, zIndex);
852     }
853 
854     menuElement.appendChild(itemElement)
855   }
856 
857   return menuElement;
858 }
859 
860 /**
861  * Initializes a menu item element for the given item descriptor (model).
862  * @param {HTMLElement} menuItemElement
863  * @param {{}[]} item an item from JSPopupMenu.Builder.items
864  * @param {number} zIndex current menu z-index
865  * @private
866  */
867 JSPopupMenu.prototype.initMenuItemElement = function(itemElement, item, zIndex) {
868   var popupMenu = this;
869 
870   var itemIconElement = document.createElement("img");
871   if (item.iconPath != null) {
872     itemIconElement.src = item.iconPath;
873     itemIconElement.classList.add("visible");
874   }
875 
876   if (item.mode !== undefined) {
877     itemElement.classList.add("checkable");
878     if (item.selected === true) {
879       itemElement.classList.add("selected");
880     }
881     if (item.iconPath == null) {
882       itemIconElement = document.createElement("span");
883       itemIconElement.innerHTML = item.selected === true ? "✓" : " ";
884       itemIconElement.classList.add("visible");
885     }
886   }
887 
888   var itemLabelElement = document.createElement("span");
889   itemLabelElement.textContent = JSComponent.substituteWithLocale(this.getUserPreferences(), item.label);
890   itemElement.classList.add("item");
891   itemIconElement.classList.add("icon");
892   itemElement.appendChild(itemIconElement);
893   itemElement.appendChild(itemLabelElement);
894   if (Array.isArray(item.subItems)) {
895     itemElement.classList.add("sub-menu");
896 
897     var subMenuElement = this.createMenuElement(item.subItems, zIndex + 1);
898     this.registerEventListener(itemElement, "click", function(ev) {
899         subMenuElement.classList.add("visible");
900       });
901     this.registerEventListener(itemElement, "mouseover", function(ev) {
902         var itemRect = itemElement.getBoundingClientRect();
903         subMenuElement.style.position = "fixed";
904         var anchorX = itemRect.left + itemElement.clientWidth;
905         if (subMenuElement.clientWidth > window.innerWidth) {
906           anchorX = 0;
907         } else if (anchorX + subMenuElement.clientWidth > window.innerWidth) {
908           anchorX = window.innerWidth - subMenuElement.clientWidth;
909         }
910         var anchorY = itemRect.top;
911         if (subMenuElement.clientHeight > window.innerHeight) {
912           anchorY = 0;
913         } else if (anchorY + subMenuElement.clientHeight > window.innerHeight) {
914           anchorY = window.innerHeight - subMenuElement.clientHeight;
915         }
916         subMenuElement.style.left = anchorX;
917         subMenuElement.style.top = anchorY;
918       });
919 
920     itemElement.appendChild(subMenuElement);
921   }
922 
923   if (typeof item.itemSelectedListener == "function") {
924     var listener = function() {
925         popupMenu.close();
926         setTimeout(function() {
927             item.itemSelectedListener();
928           }, 50);
929       };
930     itemElement.addEventListener("click", listener);
931     itemElement.addEventListener("mouseup", listener);
932     this.menuItemListeners.push(function() {
933         itemElement.removeEventListener("click", listener);
934         itemElement.removeEventListener("mouseup", listener);
935       });
936   }
937 }
938 
939 /**
940  * Closes the context menu.
941  */
942 JSPopupMenu.prototype.close = function() {
943   this.getHTMLElement().removeAttribute("tabindex");
944   this.getHTMLElement().classList.remove("visible");
945   JSPopupMenu.current = null;
946 
947   if (this.menuItemListeners) {
948     for (var i = 0; i < this.menuItemListeners.length; i++) {
949       this.menuItemListeners[i]();
950     }
951   }
952 
953   this.menuItemListeners = null;
954   this.getHTMLElement().innerHTML = "";
955 }
956 
957 /**
958  * Builds items of a context menu which is about to be shown.
959  * @ignore
960  */
961 JSPopupMenu.Builder = function() {
962   /** @type {{ label?: string, iconPath?: string, itemSelectedListener?: function(), subItems?: {}[] }[] } } */
963   this.items = [];
964 }
965 JSPopupMenu.Builder.prototype = Object.create(JSPopupMenu.Builder.prototype);
966 JSPopupMenu.Builder.prototype.constructor = JSPopupMenu.Builder;
967 
968 /**
969  * Add a checkable item
970  * @param {string} label
971  * @param {function()} [itemSelectedListener]
972  * @param {boolean} [checked]
973  */
974 JSPopupMenu.Builder.prototype.addCheckBoxItem = function(label, itemSelectedListener, checked) {
975   this.addNewMenuItem(label, undefined, itemSelectedListener, checked === true, "checkbox");
976 }
977 
978 /**
979  * Add a radio button item
980  * @param {string} label
981  * @param {function()} [itemSelectedListener]
982  * @param {boolean} [checked]
983  */
984 JSPopupMenu.Builder.prototype.addRadioButtonItem = function(label, itemSelectedListener, checked) {
985   this.addNewMenuItem(label, undefined, itemSelectedListener, checked === true, "radiobutton");
986 }
987 
988 /**
989  * Adds an item to this menu using either a ResourceAction, or icon (optional), label & callback.
990  * 1) builder.addMenuItem(pane.getAction(MyPane.ActionType.MY_ACTION))
991  * 2) builder.addMenuItem('resources/icons/tango/media-skip-forward.png', "myitem", function() { console.log('my item clicked') })
992  * 3) builder.addMenuItem("myitem", function() { console.log('my item clicked') })
993  * @param {ResourceAction|string} actionOrIconPathOrLabel
994  * @param {string|function()} [itemSelectedListenerOrLabel]
995  * @param {function()} [itemSelectedListener]
996  * @return {JSPopupMenu.Builder}
997  */
998 JSPopupMenu.Builder.prototype.addMenuItem = function(actionOrIconPathOrLabel, itemSelectedListenerOrLabel, itemSelectedListener) {
999   var label = null;
1000   var iconPath = null;
1001   var itemSelectedListener = null;
1002   // Defined only for a check action
1003   var checked = undefined;
1004   // Defined only for a toggle action
1005   var selected = undefined;
1006 
1007   if (actionOrIconPathOrLabel instanceof ResourceAction) {
1008     var action = actionOrIconPathOrLabel;
1009     // Do no show item if action is disabled
1010     if (!action.isEnabled() || action.getValue(ResourceAction.VISIBLE) === false) {
1011       return this;
1012     }
1013 
1014     iconPath = action.getURL(AbstractAction.SMALL_ICON);
1015     label = action.getValue(ResourceAction.POPUP) || action.getValue(AbstractAction.NAME);
1016 
1017     if (action.getValue(ResourceAction.TOGGLE_BUTTON_GROUP)) {
1018       selected = action.getValue(AbstractAction.SELECTED_KEY);
1019     }
1020     itemSelectedListener = function() {
1021         action.actionPerformed();
1022       };
1023   } else if (typeof itemSelectedListener == "function") {
1024     iconPath = actionOrIconPathOrLabel;
1025     label = itemSelectedListenerOrLabel;
1026     itemSelectedListener = itemSelectedListener;
1027   } else {
1028     label = actionOrIconPathOrLabel;
1029     itemSelectedListener = itemSelectedListenerOrLabel;
1030   }
1031 
1032   this.addNewMenuItem(label, iconPath, itemSelectedListener, selected, selected !== undefined ? "radiobutton" : undefined);
1033   return this;
1034 }
1035 
1036 /**
1037  * @param {string} label
1038  * @param {string | undefined} [iconPath]
1039  * @param {function() | undefined} [itemSelectedListener]
1040  * @param {boolean | undefined} [selected]
1041  * @param {"radiobutton" | "checkbox" | undefined} [mode]
1042  * @private
1043  */
1044 JSPopupMenu.Builder.prototype.addNewMenuItem = function(label, iconPath, itemSelectedListener, selected, mode) {
1045   this.items.push({
1046       label: label,
1047       iconPath: iconPath,
1048       itemSelectedListener: itemSelectedListener,
1049       selected: selected,
1050       mode: mode
1051     });
1052 }
1053 
1054 /**
1055  * Adds a sub menu to this menu.
1056  * @param {ResourceAction|string} action
1057  * @param {function(JSPopupMenu.Builder)} buildSubMenu
1058  * @return {JSPopupMenu.Builder}
1059  */
1060 JSPopupMenu.Builder.prototype.addSubMenu = function(action, buildSubMenu) {
1061   // Do no show item if action is disabled
1062   if (action.isEnabled()) {
1063     var label = action.getValue(ResourceAction.POPUP) || action.getValue(AbstractAction.NAME);
1064     var iconPath = action.getURL(AbstractAction.SMALL_ICON);
1065     var subMenuBuilder = new JSPopupMenu.Builder();
1066     buildSubMenu(subMenuBuilder);
1067     var subItems = subMenuBuilder.items;
1068     if (subItems.length > 0) {
1069       this.items.push({
1070           label: label,
1071           iconPath: iconPath,
1072           subItems: subItems
1073         });
1074     }
1075   }
1076 
1077   return this;
1078 }
1079 
1080 JSPopupMenu.CONTEXT_MENU_SEPARATOR_ITEM = {};
1081 
1082 /**
1083  * Adds a separator after previous items.
1084  * Does nothing if there are no items yet or if the latest added item is already a separator.
1085  * @return {JSPopupMenu.Builder}
1086  */
1087 JSPopupMenu.Builder.prototype.addSeparator = function() {
1088   if (this.items.length > 0 && this.items[this.items.length - 1] != JSPopupMenu.CONTEXT_MENU_SEPARATOR_ITEM) {
1089     this.items.push(JSPopupMenu.CONTEXT_MENU_SEPARATOR_ITEM);
1090   }
1091   return this;
1092 }
1093 
1094 
1095 // Global initializations of the toolkit
1096 if (!JSPopupMenu.globalCloserRegistered) {
1097   var listener = function(ev) {
1098       if (JSPopupMenu.current != null
1099         && !JSComponent.isElementContained(ev.target, JSPopupMenu.current.getHTMLElement())) {
1100         // Clicked outside menu
1101         if (JSPopupMenu.closeOpenedMenu()) {
1102           ev.stopPropagation();
1103           ev.preventDefault();
1104         }
1105       }
1106     };
1107   window.addEventListener("click", listener);
1108   window.addEventListener("touchstart", listener);
1109   
1110   document.addEventListener("keydown", function(ev) {
1111       if (ev.key == "Escape" || ev.keyCode == 27) {
1112         if (!JSComboBox.closeOpenedSelectionPanel()) {
1113           JSDialog.closeTopMostDialog();
1114           JSPopupMenu.closeOpenedMenu();
1115         }
1116       } else if (ev.keyCode == 13 && JSDialog.getTopMostDialog() != null) {
1117         var defaultCapableButton = JSDialog.getTopMostDialog().findElement(".default-capable");
1118         if (defaultCapableButton != null) {
1119           defaultCapableButton.click();
1120         }
1121       }
1122     });
1123 
1124   JSPopupMenu.globalCloserRegistered = true;
1125 }
1126 
1127 
1128 /**
1129  * A spinner component with -+ buttons able to decrease / increase edtied value.
1130  * @param {UserPreferences} preferences the current user preferences
1131  * @param {HTMLElement} spanElement span element on which the spinner is installed
1132  * @param {{format?: Format, nullable?: boolean, value?: number, minimum?: number, maximum?: number, stepSize?: number}} [options]
1133  * - format: number format to be used for this input - default to DecimalFormat for current content
1134  * - nullable: false if null/undefined is not allowed - default false
1135  * - value: initial value,
1136  * - minimum: minimum number value,
1137  * - maximum: maximum number value,
1138  * - stepSize: step between values when increment / decrement using UI - default 1
1139  * @constructor
1140  * @extends JSComponent
1141  * @author Louis Grignon
1142  * @author Emmanuel Puybaret
1143  */
1144 function JSSpinner(preferences, spanElement, options) {
1145   if (spanElement.tagName.toUpperCase() != "SPAN") {
1146     throw new Error("JSSpinner: please provide a span for the spinner to work - " + spanElement + " is not a span");
1147   }
1148 
1149   if (!options) { 
1150     options = {}; 
1151   }
1152   this.checkMinimumMaximum(options.minimum, options.maximum);
1153 
1154   if (!isNaN(parseFloat(options.minimum))) { 
1155     this.minimum = options.minimum;
1156   }
1157   if (!isNaN(parseFloat(options.maximum))) { 
1158     this.maximum = options.maximum;
1159   }
1160   if (isNaN(parseFloat(options.stepSize))) { 
1161     this.stepSize = 1; 
1162   } else {
1163     this.stepSize = options.stepSize;
1164   }
1165   if (typeof options.nullable == "boolean") { 
1166     this.nullable = options.nullable;
1167   } else {
1168     this.nullable = false; 
1169   }
1170   if (options.format instanceof Format) { 
1171     this.format = options.format;
1172   } else {
1173     this.format = new DecimalFormat(); 
1174   }
1175 
1176   var component = this;
1177   JSComponent.call(this, preferences, spanElement, true);
1178 
1179   spanElement.classList.add("spinner");
1180   
1181   this.textInput = document.createElement("input");
1182   this.textInput.type = "text";
1183   spanElement.appendChild(this.textInput);
1184 
1185   this.registerEventListener(this.textInput, "focus", function(ev) {
1186       component.updateUI();
1187     });
1188   this.registerEventListener(this.textInput, "focusout", function(ev) {
1189       component.updateUI();
1190     });
1191 
1192   this.registerEventListener(this.textInput, "input", function(ev) {
1193       if (component.isFocused()) {
1194         var pos = new ParsePosition(0);
1195         var inputValue = component.parseValueFromInput(pos);
1196         if (pos.getIndex() != component.textInput.value.length
1197             || inputValue == null && !component.nullable
1198             || (component.minimum != null && inputValue < component.minimum) 
1199             || (component.maximum != null && inputValue > component.maximum)) {
1200           component.textInput.style.color = "red";
1201         } else {
1202           component.textInput.style.color = null;
1203           component.value = inputValue;
1204         }
1205       }
1206     });
1207 
1208   this.registerEventListener(this.textInput, "blur", function(ev) {
1209       var inputValue = component.parseValueFromInput();
1210       if (inputValue == null && !component.nullable) {
1211         var restoredValue = component.value;
1212         if (restoredValue == null) {
1213           restoredValue = component.getDefaultValue();
1214         }
1215         inputValue = restoredValue;
1216       }
1217       component.textInput.style.color = null;
1218       component.setValue(inputValue);
1219     });
1220 
1221   this.initIncrementDecrementButtons(spanElement);
1222 
1223   Object.defineProperty(this, "width", {
1224       get: function() { return spanElement.style.width; },
1225       set: function(value) { spanElement.style.width = value; }
1226     });
1227   Object.defineProperty(this, "parentElement", {
1228       get: function() { return spanElement.parentElement; }
1229     });
1230   Object.defineProperty(this, "previousElementSibling", {
1231       get: function() { return spanElement.previousElementSibling; }
1232     });
1233   Object.defineProperty(this, "style", {
1234       get: function() { return spanElement.style; }
1235     });
1236 
1237   this.setValue(options.value);
1238 }
1239 JSSpinner.prototype = Object.create(JSComponent.prototype);
1240 JSSpinner.prototype.constructor = JSSpinner;
1241 
1242 /**
1243  * @return {Object} the value of this spinner
1244  */
1245 JSSpinner.prototype.getValue = function() {
1246   return this.value;
1247 }
1248 
1249 /**
1250  * @param {Object} value the value of this spinner
1251  */
1252 JSSpinner.prototype.setValue = function(value) {
1253   if (value instanceof Big) {
1254     value = parseFloat(value);
1255   }
1256   if (value != null && typeof value != "number") {
1257     throw new Error("JSSpinner: Expected values of type number");
1258   }
1259   if (value == null && !this.nullable) {
1260     value = this.getDefaultValue();
1261   }
1262   if (value != null && this.minimum != null && value < this.minimum) {
1263     value = this.minimum;
1264   }
1265   if (value != null && this.maximum != null && value > this.maximum) {
1266     value = this.maximum;
1267   }
1268 
1269   if (value != this.value) {
1270     this.value = value;
1271     this.updateUI();
1272   }
1273 }
1274 
1275 /**
1276  * @return {number} minimum of this spinner
1277  * @private
1278  */
1279 JSSpinner.prototype.checkMinimumMaximum = function(minimum, maximum) {
1280   if (minimum != null && maximum != null && minimum > maximum) {
1281     throw new Error("JSSpinner: minimum is not below maximum - minimum = " + minimum + " maximum = " + maximum);
1282   }
1283 }
1284 
1285 /**
1286  * @return {boolean} <code>true</code> if this spinner may contain no value
1287  */
1288 JSSpinner.prototype.isNullable = function() {
1289   return this.nullable;
1290 }
1291 
1292 /**
1293  * @param {boolean} nullable <code>true</code> if this spinner may contain no value
1294  */
1295 JSSpinner.prototype.setNullable = function(nullable) {
1296   var containsNullValue = this.nullable && this.value === null;
1297   this.nullable = nullable;
1298   if (!nullable && containsNullValue) {
1299     this.value = this.getDefaultValue();
1300   }
1301 }
1302 
1303 /**
1304  * @return {Format} format used to format the value of this spinner
1305  */
1306 JSSpinner.prototype.getFormat = function() {
1307   return this.format;
1308 }
1309 
1310 /**
1311  * @param {Format} format  format used to format the value of this spinner
1312  */
1313 JSSpinner.prototype.setFormat = function(format) {
1314   this.format = format;
1315   this.updateUI();
1316 }
1317 
1318 /**
1319  * @return {number} minimum of this spinner
1320  */
1321 JSSpinner.prototype.getMinimum = function() {
1322   return this.minimum;
1323 }
1324 
1325 /**
1326  * @param {number} minimum minimum value of this spinner
1327  */
1328 JSSpinner.prototype.setMinimum = function(minimum) {
1329   this.checkMinimumMaximum(minimum, this.maximum);
1330   this.minimum = minimum; 
1331 }
1332 
1333 /**
1334  * @return {number} minimum of this spinner
1335  */
1336 JSSpinner.prototype.getMinimum = function() {
1337   return this.minimum;
1338 }
1339 
1340 /**
1341  * @param {number} minimum minimum value of this spinner
1342  */
1343 JSSpinner.prototype.setMinimum = function(minimum) {
1344   this.checkMinimumMaximum(minimum, this.maximum);
1345   this.minimum = minimum; 
1346 }
1347 
1348 /**
1349  * @return {number} maximum of this spinner
1350  */
1351 JSSpinner.prototype.getMaximum = function() {
1352   return this.maximum;
1353 }
1354 
1355 /**
1356  * @param {number} maximum maximum value of this spinner
1357  */
1358 JSSpinner.prototype.setMaximum = function(maximum) {
1359   this.checkMinimumMaximum(this.minimum, maximum);
1360   this.maximum = maximum; 
1361 }
1362 
1363 /**
1364  * @return {number} step size of this spinner
1365  */
1366 JSSpinner.prototype.getStepSize = function() {
1367   return this.stepSize;
1368 }
1369 
1370 /**
1371  * @param {number} stepSize step size of this spinner
1372  */
1373 JSSpinner.prototype.setStepSize = function(stepSize) {
1374   this.stepSize = stepSize; 
1375 }
1376 
1377 /**
1378  * @return {HTMLInputElement} underlying input element
1379  */
1380 JSSpinner.prototype.getInputElement = function() {
1381   return this.textInput;
1382 }
1383 
1384 JSSpinner.prototype.addEventListener = function() {
1385   return this.textInput.addEventListener.apply(this.textInput, arguments);
1386 }
1387 
1388 JSSpinner.prototype.removeEventListener = function() {
1389   return this.textInput.removeEventListener.apply(this.textInput, arguments);
1390 }
1391 
1392 /**
1393  * Refreshes UI for current state / options. For instance, if format has changed, displayed text is updated.
1394  * @private
1395  */
1396 JSSpinner.prototype.updateUI = function() {
1397   this.textInput.value = this.formatValueForUI(this.value);
1398 }
1399 
1400 /**
1401  * @param {ParsePosition} [parsePosition]
1402  * @return {number}
1403  * @private
1404  */
1405 JSSpinner.prototype.parseValueFromInput = function(parsePosition) {
1406   if (!this.textInput.value || this.textInput.value.trim() == "") {
1407     if (this.nullable) {
1408       return null;
1409     } else {
1410       return this.value;
1411     }
1412   }
1413   return this.format.parse(this.textInput.value, 
1414       parsePosition != undefined ? parsePosition : new ParsePosition(0));
1415 }
1416 
1417 /**
1418  * @return {number}
1419  * @private
1420  */
1421 JSSpinner.prototype.getDefaultValue = function() {
1422   var defaultValue = 0;
1423   if (this.minimum != null && this.minimum > defaultValue) {
1424     defaultValue = this.minimum;
1425   }
1426   if (this.maximum != null && this.maximum < defaultValue) {
1427     defaultValue = this.maximum;
1428   }
1429   return defaultValue;
1430 }
1431 
1432 /**
1433  * @param {number} value
1434  * @return {string}
1435  * @private
1436  */
1437 JSSpinner.prototype.formatValueForUI = function(value) {
1438   if (value == null) {
1439     return "";
1440   }
1441 
1442   if (!this.isFocused()) {
1443     return this.format.format(value);
1444   }
1445   if (this.noGroupingFormat == null || this.lastFormat !== this.format) {
1446     // Format changed, compute focused format
1447     this.lastFormat = this.format;
1448     this.noGroupingFormat = this.lastFormat.clone();
1449     this.noGroupingFormat.setGroupingUsed(false);
1450   }
1451   return this.noGroupingFormat.format(value);
1452 }
1453 
1454 /**
1455  * @return {boolean} true if this spinner has focus
1456  * @private
1457  */
1458 JSSpinner.prototype.isFocused = function() {
1459   return this.textInput === document.activeElement;
1460 }
1461 
1462 /**
1463  * Creates and initialize increment & decrement buttons + related keystrokes.
1464  * @private
1465  */
1466 JSSpinner.prototype.initIncrementDecrementButtons = function(spanElement) {
1467   var component = this;
1468   this.incrementButton = document.createElement("button");
1469   this.incrementButton.setAttribute("increment", "");
1470   this.incrementButton.textContent = "+";
1471   this.incrementButton.tabIndex = -1;
1472   spanElement.appendChild(this.incrementButton);
1473 
1474   this.decrementButton = document.createElement("button");
1475   this.decrementButton.setAttribute("decrement", "");
1476   this.decrementButton.textContent = "-";
1477   this.decrementButton.tabIndex = -1;
1478   spanElement.appendChild(this.decrementButton);
1479 
1480   var incrementValue = function(ev) {
1481       var previousValue = component.value;
1482       if (previousValue == null || isNaN(previousValue)) {
1483         previousValue = component.getDefaultValue();
1484       }
1485       component.setValue(previousValue + component.stepSize);
1486       component.fireInputEvent();
1487     };
1488   var decrementValue = function(ev) {
1489       var previousValue = component.value;
1490       if (previousValue == null || isNaN(previousValue)) {
1491         previousValue = component.getDefaultValue();
1492       }
1493       component.setValue(previousValue - component.stepSize);
1494       component.fireInputEvent();
1495     };
1496 
1497   // Repeat incrementValue / decrementValue every 80 ms with an initial delay of 400 ms
1498   // while mouse button kept pressed, and ensure at least one change is triggered for a short click
1499   var repeatAction = function(ev, button, action) {
1500       if (component.isFocused()) {
1501         ev.preventDefault(); // Prevent input from losing focus 
1502       }
1503       var stopRepeatedTask = function(ev) {
1504           clearTimeout(taskId);
1505           button.removeEventListener("mouseleave", stopRepeatedTask);
1506           button.removeEventListener("mouseup", stopRepeatedTask);
1507         };
1508       var clickAction = function(ev) {
1509           clearTimeout(taskId);
1510           button.removeEventListener("click", clickAction);
1511           action();
1512         };
1513       button.addEventListener("click", clickAction);
1514       var repeatedTask = function() {
1515           action();
1516           taskId = setTimeout(repeatedTask, 80); 
1517         };
1518       var taskId = setTimeout(function() {
1519           button.removeEventListener("click", clickAction);
1520           button.addEventListener("mouseleave", stopRepeatedTask);
1521           button.addEventListener("mouseup", stopRepeatedTask);
1522           repeatedTask();
1523         }, 400);
1524     };
1525   var repeatIncrementValue = function(ev) {
1526       repeatAction(ev, component.incrementButton, incrementValue);
1527     };
1528   this.registerEventListener(component.incrementButton, "mousedown", repeatIncrementValue);
1529   
1530   var repeatDecrementValue = function(ev) {
1531       repeatAction(ev, component.decrementButton, decrementValue);
1532     };
1533   this.registerEventListener(component.decrementButton, "mousedown", repeatDecrementValue);
1534 
1535   this.registerEventListener(component.textInput, "keydown", function(ev) {
1536       var keyStroke = KeyStroke.getKeyStrokeForEvent(ev, "keydown");
1537       if (keyStroke.lastIndexOf(" UP") > 0) {
1538         ev.stopImmediatePropagation();
1539         incrementValue();
1540       } else if (keyStroke.lastIndexOf(" DOWN") > 0) {
1541         ev.stopImmediatePropagation();
1542         decrementValue();
1543       }
1544     });
1545   this.registerEventListener(this.textInput, "focus", function(ev) {
1546       component.updateUI();
1547     });
1548 }
1549 
1550 /**
1551  * Fires an "input" event on behalf of underlying text input.
1552  * @private
1553  */
1554 JSSpinner.prototype.fireInputEvent = function() {
1555   var ev = document.createEvent("Event");
1556   ev.initEvent("input", true, true);
1557   this.textInput.dispatchEvent(ev);
1558 }
1559 
1560 /**
1561  * Enables or disables this component.
1562  * @param {boolean} enabled
1563  */
1564 JSSpinner.prototype.setEnabled = function(enabled) {
1565   this.textInput.disabled = !enabled;
1566   this.incrementButton.disabled = !enabled;
1567   this.decrementButton.disabled = !enabled;
1568 }
1569 
1570 
1571 /**
1572  * A combo box component which allows any type of content (e.g. images).
1573  * @param {UserPreferences} preferences the current user preferences
1574  * @param {HTMLElement} selectElement HTML element on which install this component
1575  * @param {{nullable?: boolean, value?: any, availableValues: (any)[], renderCell?: function(value: any, element: HTMLElement), selectionChanged: function(newValue: any)}} [options]
1576  * - nullable: false if null/undefined is not allowed - default false
1577  * - value: initial value - default undefined if nullable or first available value,
1578  * - availableValues: available values in this combo,
1579  * - renderCell: a function which builds displayed element for a given value - defaults to setting textContent to value.toString()
1580  * - selectionChanged: called with new value when selected by user
1581  * @constructor
1582  * @extends JSComponent
1583  * @author Louis Grignon
1584  */
1585 function JSComboBox(preferences, selectElement, options) {
1586   JSComponent.call(this, preferences, selectElement, true);
1587   
1588   if (!options) { 
1589     options = {}; 
1590   }
1591   if (typeof options.nullable != "boolean") { 
1592     options.nullable = false; 
1593   }
1594   if (!Array.isArray(options.availableValues) || options.availableValues.length <= 0) {
1595     throw new Error("JSComboBox: No available values provided");
1596   }
1597   if (typeof options.renderCell != "function") {
1598     options.renderCell = function(value, element) {
1599         element.textContent = value == null ? "" : value.toString();
1600       };
1601   }
1602   if (options.value == null && !options.nullable) {
1603     options.value = options.availableValues[0];
1604   }
1605 
1606   this.options = options;
1607 
1608   selectElement.classList.add("combo-box");
1609 
1610   this.button = document.createElement("button");
1611   selectElement.appendChild(this.button);
1612 
1613   this.preview = document.createElement("div");
1614   this.preview.classList.add("preview");
1615   this.button.appendChild(this.preview);
1616 
1617   this.initSelectionPanel();
1618   var component = this;
1619   this.registerEventListener(this.button, "click", function(ev) {
1620       ev.stopImmediatePropagation();
1621       component.openSelectionPanel(ev.pageX, ev.pageY);
1622     });
1623 
1624   this.setSelectedItem(options.value);
1625 }
1626 JSComboBox.prototype = Object.create(JSComponent.prototype);
1627 JSComboBox.prototype.constructor = JSComboBox;
1628 
1629 /**
1630  * @private
1631  */
1632 JSComboBox.prototype.initSelectionPanel = function() {
1633   var selectionPanel = document.createElement("div");
1634   selectionPanel.classList.add("selection-panel");
1635 
1636   for (var i = 0; i < this.options.availableValues.length; i++) {
1637     var currentItemElement = document.createElement("div");
1638     currentItemElement.value = this.options.availableValues[i];
1639     this.options.renderCell(currentItemElement.value, currentItemElement);
1640     selectionPanel.appendChild(currentItemElement);
1641   }
1642 
1643   this.getHTMLElement().appendChild(selectionPanel);
1644   this.selectionPanel = selectionPanel;
1645 
1646   var component = this;
1647   this.registerEventListener(selectionPanel.children, "click", function(ev) {
1648       component.selectedItem = this.value;
1649       component.updateUI();
1650       if (typeof component.options.selectionChanged == "function") {
1651         component.options.selectionChanged(component.selectedItem);
1652       }
1653     });
1654   this.registerEventListener(this.selectionPanel, "focusout", function(ev) {
1655       comboBox.closeSelectionPanel();
1656     });
1657 }
1658 
1659 /**
1660  * @return {number} the value selected in this combo box
1661  */
1662 JSComboBox.prototype.getSelectedItem = function() {
1663   return this.selectedItem;
1664 }
1665 
1666 /**
1667  * @param {number} selectedItem  the value to select in this combo box
1668  */
1669 JSComboBox.prototype.setSelectedItem = function(selectedItem) {
1670   var isValueAvailable = false;
1671   for (var i = 0; i < this.options.availableValues.length; i++) {
1672     if (this.areValuesEqual(selectedItem, this.options.availableValues[i])) {
1673       isValueAvailable = true;
1674       break;
1675     }
1676   }
1677   if (!isValueAvailable) {
1678     selectedItem = null;
1679   }
1680 
1681   if (selectedItem == null && !this.options.nullable) {
1682     selectedItem = this.options.availableValues[0];
1683   }
1684 
1685   if (!this.areValuesEqual(selectedItem, this.selectedItem)) {
1686     this.selectedItem = selectedItem;
1687     this.updateUI();
1688   }
1689 }
1690 
1691 /**
1692  * Enables or disables this combo box.
1693  * @param {boolean} enabled 
1694  */
1695 JSComboBox.prototype.setEnabled = function(enabled) {
1696   this.button.disabled = !enabled;
1697 }
1698 
1699 /**
1700  * Opens the combo box's selection panel.
1701  * @param {number} pageX
1702  * @param {number} pageY
1703  * @private
1704  */
1705 JSComboBox.prototype.openSelectionPanel = function(pageX, pageY) {
1706   if (JSComboBox.current != null) {
1707     JSComboBox.current.closeSelectionPanel();
1708   }
1709   
1710   var comboBox = this;
1711   this.closeSelectionPanelListener = function() {
1712       comboBox.closeSelectionPanel();
1713     }
1714 
1715   this.selectionPanel.style.display = "block";
1716   this.selectionPanel.style.opacity = 1;
1717   this.selectionPanel.style.left = (pageX + this.selectionPanel.clientWidth > window.width ? window.width - this.selectionPanel.clientWidth : pageX) + "px";
1718   this.selectionPanel.style.top = (pageY + this.selectionPanel.clientHeight > window.innerHeight ? window.innerHeight - this.selectionPanel.clientHeight : pageY) + "px";
1719   window.addEventListener("click", this.closeSelectionPanelListener);
1720   JSComboBox.current = this;
1721 }
1722 
1723 /**
1724  * Closes the combo box's selection panel.
1725  * @private
1726  */
1727 JSComboBox.prototype.closeSelectionPanel = function() {
1728   window.removeEventListener("click", this.closeSelectionPanelListener);
1729   this.selectionPanel.style.opacity = 0;
1730   this.selectionPanel.style.display = "none";
1731   this.closeSelectionPanelListener = null;
1732   JSComboBox.current = null;
1733 }
1734 
1735 /**
1736  * Closes currently displayed selection panel if any.
1737  * @static
1738  * @ignore
1739  */
1740 JSComboBox.closeOpenedSelectionPanel= function() {
1741   if (JSComboBox.current != null) {
1742     JSComboBox.current.closeSelectionPanel();
1743     return true;
1744   }
1745   return false;
1746 }
1747 
1748 /**
1749  * Refreshes UI, i.e. preview of selected value.
1750  */
1751 JSComboBox.prototype.updateUI = function() {
1752   this.preview.innerHTML = "";
1753   this.options.renderCell(this.getSelectedItem(), this.preview);
1754 }
1755 
1756 /**
1757  * Checks if value1 and value2 are equal. Returns true if so.
1758  * NOTE: this internally uses JSON.stringify to compare values
1759  * @return {boolean}
1760  * @private
1761  */
1762 JSComboBox.prototype.areValuesEqual = function(value1, value2) {
1763   return JSON.stringify(value1) == JSON.stringify(value2);
1764 }
1765 
1766 
1767 /*
1768  * @typedef {{
1769  *   visibleColumnNames?: string[],
1770  *   expandedRowsIndices?: number[],
1771  *   expandedRowsValues?: any[],
1772  *   sort?: { columnName: string, direction: "asc" | "desc" }
1773  * }} TreeTableState
1774  * @property TreeTableState.expandedRowsIndices index in filtered and sorted rows, expandedRowsValues can also be used but not both (expandedRowsValues will be preferred)
1775  * @property TreeTableState.expandedRowsValues expanded rows listed by their values. It takes precedence over expandedRowsIndices but achieves the same goal
1776  */
1777 /*
1778  * @typedef {{
1779  *   columns: {
1780  *       name: string,
1781  *       orderIndex: number,
1782  *       label: string,
1783  *       defaultWidth?: string
1784  *   }[],
1785  *   renderCell: function(value: any, columnName: string, cell: HTMLElement): void,
1786  *   getValueComparator: function(sortConfig?: { columnName: string, direction: "asc" | "desc" }): function(value1: any, value2: any),
1787  *   selectionChanged: function(values: any[]): void,
1788  *   rowDoubleClicked: function(value: any): void,
1789  *   expandedRowsChanged: function(expandedRowsValues: any[], expandedRowsIndices: number[]): void,
1790  *   sortChanged: function(sort: { columnName: string, direction: "asc" | "desc" }): void,
1791  *   initialState?: TreeTableState
1792  * }} TreeTableModel
1793  * @property TreeTableModel.renderCell render cell to given html element for given value, column name
1794  * @property TreeTableModel.selectionChanged called when a row selection changes, passing updated selected values
1795  * @property TreeTableModel.rowDoubleClicked called when a row is double clicked, passing row's value
1796  */
1797 
1798 /**
1799  * A flexible tree table which allows grouping (tree aspect), sorting, some inline edition, single/multi selection, contextual menu, copy/paste.
1800  * @param {HTMLElement} container html element on which this component is installed
1801  * @param {UserPreferences} preferences the current user preferences
1802  * @param {TreeTableModel} model table's configuration
1803  * @param {{value: any, children: {value, children}[] }[]} [data] data source for this tree table - defaults to empty data
1804  * @constructor
1805  * @extends JSComponent
1806  * @author Louis Grignon
1807  */
1808 function JSTreeTable(container, preferences, model, data) {
1809   JSComponent.call(this, preferences, container, true);
1810   
1811   /**
1812    * @type {TreeTableState}
1813    */
1814   this.state = {};
1815   this.selectedRowsValues = [];
1816   
1817   this.tableElement = document.createElement("div");
1818   this.tableElement.classList.add("tree-table");
1819   container.appendChild(this.tableElement);
1820   this.setModel(model);
1821   this.setData(data ? data : []);
1822 }
1823 JSTreeTable.prototype = Object.create(JSComponent.prototype);
1824 JSTreeTable.prototype.constructor = JSTreeTable;
1825 
1826 /**
1827  * Sets data and updates rows in UI.
1828  * @param {{value: any, children: {value, children}[] }[]} data
1829  */
1830 JSTreeTable.prototype.setData = function(data) {
1831   this.data = data;
1832 
1833   var expandedRowsValues = this.getExpandedRowsValues();
1834   if (expandedRowsValues != null) {
1835     this.updateState({
1836         expandedRowsValues: expandedRowsValues
1837       });
1838   }
1839 
1840   if (this.isDisplayed()) {
1841     this.generateTableRows();
1842     this.fireExpandedRowsChanged();
1843   }
1844 }
1845 
1846 /**
1847  * Updates in UI the data of the row matching the given value.
1848  * @param {any} value
1849  * @param {string} [columnName] name of the column which may have changed 
1850  */
1851 JSTreeTable.prototype.updateRowData = function(value, columnName) {
1852   if (this.isDisplayed()) {
1853     if (!this.state.sort 
1854         || this.state.sort.columnName == null
1855         || (columnName !== undefined && this.state.sort.columnName != columnName)) {
1856       var columnNames = this.getColumnNames();
1857       var columnIndex = columnName !== undefined
1858           ? columnNames.indexOf(columnName)
1859           : 0;
1860       if (columnIndex >= 0) {
1861         var rows = this.bodyElement.children;
1862         for (i = 0; i < rows.length; i++) {
1863           var row = rows[i];
1864           if (row._model.value === value) {
1865             if (columnName !== undefined) {
1866               this.model.renderCell(value, columnName, row.children[columnIndex]);
1867             } else {
1868               for (var j = 0; j < columnNames.length; j++) {
1869                 this.model.renderCell(value, columnNames[j], row.children[j]);
1870               }
1871             }
1872             break;
1873           }
1874         }
1875       }
1876     } else {
1877       this.generateTableRows();
1878     }
1879   }
1880 }
1881 
1882 /**
1883  * Gets current table data
1884  * @return {{value: any, children: {value, children}[] }[]}
1885  */
1886 JSTreeTable.prototype.getData = function() {
1887   return this.data;
1888 }
1889 
1890 /**
1891  * @param {TreeTableModel} model
1892  */
1893 JSTreeTable.prototype.setModel = function(model) {
1894   this.model = model;
1895 
1896   this.updateState(model.initialState);
1897   this.columnsWidths = this.getColumnsWidthByName();
1898 
1899   if (this.isDisplayed()) {
1900     this.generateTableHeaders();
1901     this.generateTableRows();
1902   }
1903 }
1904 
1905 /**
1906  * @private
1907  */
1908 JSTreeTable.prototype.isDisplayed = function() {
1909   return window.getComputedStyle(this.getHTMLElement()).display != "none";
1910 }
1911 
1912 /**
1913  * @param {any[]} values
1914  */
1915 JSTreeTable.prototype.setSelectedRowsByValue = function(values) {
1916   this.selectedRowsValues = values.slice(0);
1917   if (this.isDisplayed()) {
1918     this.expandGroupOfSelectedRows(values);
1919     var rows = this.bodyElement.children;
1920     // Unselect all
1921     for (var j = 0; j < rows.length; j++) {
1922       var row = rows[j];
1923       row._model.selected = false;
1924       row.classList.remove("selected");
1925     }
1926     // Select values
1927     for (var i = 0; i < values.length; i++) {
1928       for (var j = 0; j < rows.length; j++) {
1929         var row = rows[j];
1930         if (row._model.value === values [i]) {
1931           this.selectRowAt(j);
1932           break;
1933         }
1934       }
1935     }
1936     this.scrollToSelectedRowsIfNotVisible();
1937   }
1938 }
1939 
1940 /**
1941  * Selects the row at the given <code>index</code> and its children.
1942  * @param {number} index
1943  * @private
1944  */
1945 JSTreeTable.prototype.selectRowAt = function(index) {
1946   var rows = this.bodyElement.children;
1947   var row = rows[index];
1948   row._model.selected = true;
1949   row.classList.add("selected");
1950   if (row._model.group
1951       && row._model.collapsed === false) {
1952     // Change children selection of expanded group
1953     for (var i = index + 1; i < rows.length; i++) {
1954       var childrenRow = rows[i];
1955       if (childrenRow._model.parentGroup
1956           && childrenRow._model.parentGroup.value === row._model.value) {
1957         this.selectRowAt(i);
1958       }
1959     }
1960   }
1961 }
1962   
1963 /**
1964  * Expands the parents of the given values when they are collapsed.
1965  * @private
1966  */
1967 JSTreeTable.prototype.expandGroupOfSelectedRows = function(values) {
1968   if (this.isDisplayed()) {
1969     var rows = this.bodyElement.children;
1970     for (var i = 0; i < values.length; i++) {
1971       for (var j = 0; j < rows.length; j++) {
1972         var row = rows[j];
1973         if (row._model.value === values [i]) {
1974           if (row._model.hidden) {
1975             this.expandOrCollapseRow(row._model.parentGroup, true);
1976             // Find parent group
1977             for (var k = j - 1; k >= 0; k--) {
1978               if (row._model.parentGroup.value === rows[k]._model.value) {
1979                 rows[k]._model.collapsed = false;
1980                 rows[k].classList.remove("collapsed");
1981                 // Make sibling rows visible
1982                 for (k++; k < rows.length; k++) {
1983                   var childrenRow = rows[k];
1984                   if (childrenRow._model.parentGroup
1985                       && childrenRow._model.parentGroup.value === row._model.parentGroup.value) {
1986                     childrenRow._model.hidden = false;
1987                     childrenRow.style.display = "flex";
1988                   }
1989                 }
1990                 if (row._model.parentGroup.parentGroup) {
1991                   this.expandGroupOfSelectedRows([row._model.parentGroup.value]);
1992                 }
1993                 break;
1994               } 
1995             }
1996           }
1997           break;
1998         }
1999       }
2000     }
2001   }
2002 }
2003 
2004 /**
2005  * @private
2006  */
2007 JSTreeTable.prototype.scrollToSelectedRowsIfNotVisible = function() {
2008   var selectedRows = this.bodyElement.querySelectorAll(".selected");
2009   if (selectedRows.length > 0) {
2010     // If one selected row is visible, do not change scroll
2011     for (var i = 0; i < selectedRows.length; i++) {
2012       var selectedRow = selectedRows[i];
2013       var rowTop = selectedRow.offsetTop - this.bodyElement.offsetTop;
2014       var rowBottom = rowTop + selectedRow.clientHeight;
2015       if (rowTop >= this.bodyElement.scrollTop && rowBottom <= (this.bodyElement.scrollTop + this.bodyElement.clientHeight)) {
2016         return;
2017       }
2018     }
2019 
2020     this.bodyElement.scrollTop = selectedRows[0].offsetTop - this.bodyElement.offsetTop;
2021   }
2022 }
2023 
2024 /**
2025  * @return {any[]} expanded rows by their values
2026  * @private
2027  */
2028 JSTreeTable.prototype.getExpandedRowsValues = function() {
2029   if (this.state && this.state.expandedRowsValues) {
2030     return this.state.expandedRowsValues;
2031   }
2032   return undefined;
2033 }
2034 
2035 /**
2036  * @private
2037  */
2038 JSTreeTable.prototype.fireExpandedRowsChanged = function() {
2039   if (this.state.expandedRowsValues != null) {
2040     this.updateExpandedRowsIndices();
2041     this.model.expandedRowsChanged(this.state.expandedRowsValues, this.state.expandedRowsIndices);
2042   }
2043 }
2044 
2045 /**
2046  * Refreshes expandedRowsIndices from expandedRowsValues
2047  * @private
2048  */
2049 JSTreeTable.prototype.updateExpandedRowsIndices = function() {
2050   if (this.state.expandedRowsValues != null 
2051       && this.data != null 
2052       && this.data.sortedList != null) {
2053     this.state.expandedRowsIndices = [];
2054     for (var i = 0; i < this.data.sortedList.length; i++) {
2055       var value = this.data.sortedList[i].value;
2056       if (this.state.expandedRowsValues.indexOf(value) > -1) {
2057         this.state.expandedRowsIndices.push(i);
2058       }
2059     }
2060   }
2061 }
2062 
2063 /**
2064  * @private
2065  */
2066 JSTreeTable.prototype.fireSortChanged = function() {
2067   if (this.state.sort != null) {
2068     this.model.sortChanged(this.state.sort);
2069   }
2070 }
2071 
2072 /**
2073  * @param {Partial<TreeTableState>} [stateProperties]
2074  * @private
2075  */
2076 JSTreeTable.prototype.updateState = function(stateProperties) {
2077   if (stateProperties) {
2078     CoreTools.merge(this.state, stateProperties);
2079   }
2080 }
2081 
2082 /**
2083  * @return {function(value1: any, value2: any)}
2084  * @private
2085  */
2086 JSTreeTable.prototype.getValueComparator = function() {
2087   return this.model.getValueComparator(this.state.sort);
2088 }
2089 
2090 /**
2091  * @private
2092  */
2093 JSTreeTable.prototype.generateTableHeaders = function() {
2094   var treeTable = this;
2095 
2096   var head = this.tableElement.querySelector("[header]");
2097   if (!head) {
2098     head = document.createElement("div");
2099     head.setAttribute("header", "true");
2100     this.tableElement.appendChild(head);
2101     this.tableElement.appendChild(document.createElement("br"));
2102   }
2103   head.innerHTML = "";
2104 
2105   var columns = this.getColumns();
2106   for (var i = 0; i < columns.length; i++) {
2107     var column = columns[i];
2108     var headCell = document.createElement("div");
2109     head.appendChild(headCell);
2110     headCell.setAttribute("cell", "true");
2111     headCell.textContent = column.label;
2112     headCell.dataset["name"] = column.name;
2113     if (this.state.sort && this.state.sort.columnName == column.name) {
2114       headCell.classList.add("sort");
2115       if (this.state.sort.direction == "desc") {
2116         headCell.classList.add("descending");
2117       }
2118     }
2119 
2120     headCell.style.width = treeTable.getColumnWidth(column.name);
2121   }
2122   this.registerEventListener(head.children, "click", function(ev) {
2123       var columnName = this.dataset["name"];
2124       var descending = this.classList.contains("sort") && !this.classList.contains("descending");
2125       treeTable.sortTable(columnName, descending);
2126     });
2127 }
2128 
2129 /**
2130  * @private
2131  */
2132 JSTreeTable.prototype.generateTableRows = function() {
2133   var treeTable = this;
2134   var tableRowsGenerator = function() {
2135       var scrollTop = 0;
2136       if (treeTable.bodyElement) {
2137         scrollTop = treeTable.bodyElement.scrollTop;
2138         treeTable.bodyElement.parentElement.removeChild(treeTable.bodyElement);
2139       }
2140       treeTable.bodyElement = document.createElement("div");
2141       treeTable.bodyElement.setAttribute("body", "true");
2142     
2143       // Generate simplified table model: a sorted list of items
2144       var sortedList = treeTable.data.sortedList = [];
2145       var comparator = treeTable.getValueComparator();
2146     
2147       /**
2148        * @param {{value: any, children: any[]}[]} currentNodes
2149        * @param {number} currentIndentation
2150        * @param {any} [parentGroup]
2151        * @return {Object[]} generated children items
2152        */
2153       var sortDataTree = function(currentNodes, currentIndentation, parentGroup) {
2154           // Children nodes are hidden by default, and will be flagged as visible with setCollapsed, see below
2155           var hideChildren = currentIndentation > 0;
2156           var sortedCurrentNodes = comparator != null
2157               ? currentNodes.sort(function(leftNode, rightNode) {
2158                     return comparator(leftNode.value, rightNode.value);
2159                   })
2160               : currentNodes;
2161           var currentNodesItems = [];
2162           for (var i = 0; i < sortedCurrentNodes.length; i++) {
2163             var currentNode = sortedCurrentNodes[i];
2164             var currentNodeSelected = treeTable.selectedRowsValues.indexOf(currentNode.value) > -1;
2165             var selected = (parentGroup && parentGroup.selected) || currentNodeSelected;
2166             var sortedListItem = {
2167               value: currentNode.value,
2168               indentation: currentIndentation,
2169               group: false,
2170               parentGroup: parentGroup,
2171               selected: selected,
2172               hidden: hideChildren,
2173               collapsed: undefined,
2174               childrenItems: undefined,
2175               setCollapsed: function() {},
2176               isInCollapsedGroup: function() {
2177                 var parent = this;
2178                 while ((parent = parent.parentGroup)) {
2179                   if (parent.collapsed === true) {
2180                     return true;
2181                   }
2182                 }
2183                 return false;
2184               }
2185             };
2186             currentNodesItems.push(sortedListItem);
2187             sortedList.push(sortedListItem);
2188       
2189             // Create node's children items
2190             if (Array.isArray(currentNode.children) && currentNode.children.length > 0) {
2191               sortedListItem.group = true;
2192               sortedListItem.collapsed = true;
2193               sortedListItem.childrenItems = sortDataTree(currentNode.children, currentIndentation + 1, sortedListItem);
2194               sortedListItem.setCollapsed = (function(item) {
2195                   return function(collapsed) {
2196                     item.collapsed = collapsed;
2197                     for (var i = 0; i < item.childrenItems.length; i++) {
2198                       item.childrenItems[i].hidden = collapsed;
2199                     }
2200                   }
2201                 })(sortedListItem);
2202             }
2203           }
2204       
2205           return currentNodesItems;
2206         };
2207       sortDataTree(treeTable.data.slice(0), 0);
2208     
2209       // Synchronize expandedRowsIndices/expandedRowsValues & flag groups as expanded, and children as visible
2210       treeTable.updateExpandedRowsIndices();
2211       if (treeTable.state.expandedRowsIndices && treeTable.state.expandedRowsIndices.length > 0) {
2212         var expandedRowsValues = [];
2213         for (var i = 0; i < treeTable.state.expandedRowsIndices.length; i++) {
2214           var item = sortedList[treeTable.state.expandedRowsIndices[i]];
2215           if (item) {
2216             expandedRowsValues.push(item.value);
2217             if (!item.isInCollapsedGroup()) {
2218               item.setCollapsed(false);
2219             }
2220           }
2221         }
2222         if (expandedRowsValues.length > 0) {
2223           treeTable.state.expandedRowsValues = expandedRowsValues;
2224         }
2225       }
2226     
2227       // Generate DOM for items
2228       var columnNames = treeTable.getColumnNames();
2229       for (var i = 0; i < sortedList.length; i++) {
2230         var row = treeTable.generateRowElement(columnNames, i, sortedList[i]);
2231         treeTable.bodyElement.appendChild(row);
2232       }
2233     
2234       treeTable.tableElement.appendChild(treeTable.bodyElement);
2235   
2236       treeTable.bodyElement.scrollTop = scrollTop;
2237       delete treeTable.generatingTableRows;
2238     };
2239     
2240   if (this.data) {
2241     if (treeTable.bodyElement) {
2242       if (!this.generatingTableRows) {
2243         // Invoke later table update
2244         this.generatingTableRows = true;
2245         setTimeout(tableRowsGenerator, 0);
2246       }
2247     } else {
2248       // Ensure body element exists
2249       tableRowsGenerator();
2250     }
2251   }
2252 }
2253 
2254 /**
2255  * @param {string[]} columnNames
2256  * @param {number} rowIndex
2257  * @param {{
2258         value: any,
2259         indentation: number,
2260         group: boolean,
2261         selected: boolean,
2262         hidden: boolean,
2263         collapsed?: boolean,
2264         childrenItems?: boolean,
2265         setCollapsed: function(),
2266     }} rowModel
2267  * @private
2268  */
2269 JSTreeTable.prototype.generateRowElement = function(columnNames, rowIndex, rowModel) {
2270   var treeTable = this;
2271   var row = document.createElement("div");
2272   row.setAttribute("row", "true");
2273 
2274   var mainCell = null;
2275   for (var j = 0; j < columnNames.length; j++) {
2276     var columnName = columnNames[j];
2277     var cell = document.createElement("div");
2278     cell.setAttribute("cell", "true");
2279     this.model.renderCell(rowModel.value, columnName, cell);
2280     cell.style.width = this.getColumnWidth(columnName);
2281 
2282     if (mainCell == null || cell.classList.contains("main")) {
2283       mainCell = cell;
2284     }
2285 
2286     row.appendChild(cell);
2287   }
2288 
2289   if (mainCell != null) {
2290     mainCell.classList.add("main");
2291     mainCell.style.paddingLeft = (15 + rowModel.indentation * 10) + "px";
2292     if (rowModel.group) {
2293       this.registerEventListener(mainCell, "click", function(ev) {
2294           if (ev.clientX < 16) {
2295             ev.stopImmediatePropagation();
2296             var expanded = mainCell.parentElement.classList.contains("collapsed");
2297             treeTable.expandOrCollapseRow(rowModel, expanded);
2298             mainCell.parentElement._model.collapsed = !expanded;
2299             if (expanded) {
2300               mainCell.parentElement.classList.remove("collapsed");
2301             } else {
2302               mainCell.parentElement.classList.add("collapsed");
2303             }
2304             var rows = treeTable.bodyElement.children;
2305             for (var i = 0; i < rows.length; i++) {
2306               var row = rows[i];
2307               var rowCollapsed = rows[i]._model.isInCollapsedGroup();
2308               if (expanded && rows[i]._model.hidden !== rowCollapsed) {
2309                 rows[i].classList.add("selected");
2310               }
2311               rows[i]._model.hidden = rowCollapsed;
2312               rows[i].style.display = rowCollapsed ? "none" : "flex";
2313             }
2314           }
2315           return false;
2316         });
2317 
2318       row.classList.add("group");
2319       if (rowModel.collapsed) {
2320         row.classList.add("collapsed");
2321       }
2322     }
2323   }
2324   if (rowModel.hidden) {
2325     row.style.display = "none";
2326   }
2327   if (rowModel.selected) {
2328     row.classList.add("selected");
2329   }
2330 
2331   this.registerEventListener(row, "click", function(ev) {
2332       var row = this;
2333       var rowValue = row._model.value;
2334   
2335       if (OperatingSystem.isMacOSX() ? ev.metaKey : ev.ctrlKey) {
2336         var index = treeTable.selectedRowsValues.indexOf(rowValue);
2337         if (index < 0) {
2338           row.classList.add("selected");
2339           treeTable.selectedRowsValues.push(rowValue);
2340         } else {
2341           row.classList.remove("selected");
2342           treeTable.selectedRowsValues.splice(index, 1);
2343         }
2344       } else {
2345         row.classList.add("selected");
2346         treeTable.selectedRowsValues = [rowValue];
2347       }
2348       if (typeof treeTable.model.selectionChanged == "function") {
2349         treeTable.model.selectionChanged(treeTable.selectedRowsValues);
2350       }
2351     });
2352   this.registerEventListener(row, "dblclick", function(ev) {
2353       if (typeof treeTable.model.rowDoubleClicked == "function") {
2354         var row = this;
2355         var rowValue = row._model.value;
2356         treeTable.model.rowDoubleClicked(rowValue);
2357       }
2358     });
2359 
2360   row._model = rowModel;
2361   return row;
2362 }
2363 
2364 /**
2365  * @param {Object} rowModel
2366  * @param {boolean} expand true if expanded, false if collapsed
2367  * @private
2368  */
2369 JSTreeTable.prototype.expandOrCollapseRow = function(rowModel, expand) {
2370   var treeTable = this;
2371 
2372   // TODO Test on touch device
2373   if (treeTable.state.expandedRowsValues == null) {
2374     treeTable.state.expandedRowsValues = [];
2375   }
2376   var index = treeTable.state.expandedRowsValues.indexOf(rowModel.value);
2377   if (expand) {
2378     if (index < 0) {
2379       treeTable.state.expandedRowsValues.push(rowModel.value);
2380       this.fireExpandedRowsChanged();
2381     }
2382   } else {
2383     if (index >= 0) {
2384       treeTable.state.expandedRowsValues.splice(index, 1);
2385       this.fireExpandedRowsChanged();
2386     }
2387   }
2388 }
2389 
2390 /**
2391  * @param {string} columnName
2392  * @param {boolean} descending
2393  * @private
2394  */
2395 JSTreeTable.prototype.sortTable = function(columnName, descending) {
2396   if (!this.state.sort) {
2397     this.state.sort = {};
2398   }
2399   this.state.sort.columnName = columnName;
2400   this.state.sort.direction = descending ? "desc" : "asc";
2401 
2402   this.fireSortChanged(this.state.sort);
2403 }
2404 
2405 /**
2406  * @param {string} columnName
2407  * @return {string} css width value, e.g. "2em"
2408  * @private
2409  */
2410 JSTreeTable.prototype.getColumnWidth = function(columnName) {
2411   return this.columnsWidths[columnName];
2412 }
2413 
2414 /**
2415  * @private
2416  */
2417 JSTreeTable.prototype.getColumns = function() {
2418   return this.model.columns.slice(0);
2419 }
2420 
2421 /**
2422  * Returns the names of the columns displayed in this table.
2423  * @return {string[]} 
2424  * @private
2425  */
2426 JSTreeTable.prototype.getColumnNames = function() {
2427   var columnNames = new Array(this.model.columns.length);
2428   for (var i = 0; i < columnNames.length; i++) {
2429     columnNames[i] = this.model.columns[i].name;
2430   }
2431   return columnNames;
2432 }
2433 
2434 /**
2435  * @return {{[name: string]: string}}
2436  * @see getColumnWidth(name)
2437  * @private
2438  */
2439 JSTreeTable.prototype.getColumnsWidthByName = function() {
2440   var columns = this.model.columns;
2441   var widths = {};
2442   for (var i = 0; i < columns.length; i++) {
2443     var column = columns[i];
2444     var width = column.defaultWidth ? column.defaultWidth : "6rem";
2445     widths[column.name] = width;
2446   }
2447   return widths;
2448 }
2449 
2450 /** 
2451  * Removes components added to this panel and their listeners.
2452  */
2453 JSTreeTable.prototype.dispose = function() {
2454   this.unregisterEventListeners();
2455   this.container.removeChild(this.tableElement);
2456 }