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 }