1 /*
  2  * TextureChoiceComponent.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 CoreTools.js
 22 // Requires toolkit.js
 23 
 24 /**
 25  * Button displaying a texture as an icon. When the user clicks
 26  * on this button a dialog appears to let him choose an other texture.
 27  * @param {UserPreferences} preferences user preferences
 28  * @param {TextureChoiceController} controller texture choice controller
 29  * @constructor
 30  * @author Louis Grignon
 31  * @author Emmanuel Puybaret
 32  */
 33 function TextureChoiceComponent(preferences, controller) {
 34   JSComponent.call(this, preferences, document.createElement("span"), true);
 35 
 36   this.controller = controller;
 37   this.getHTMLElement().innerHTML = '<button class="texture-button"><div class="texture-preview" /></button>';
 38   this.button = this.findElement(".texture-button");
 39 
 40   var component = this;
 41   this.registerEventListener(this.button, "click", function(ev) { 
 42       component.openTextureDialog(); 
 43     });
 44   
 45   this.preview = this.findElement(".texture-preview");
 46   var textureChangeListener = function() {
 47       component.updateTexture(controller.getTexture());
 48     };
 49   this.registerPropertyChangeListener(controller, "TEXTURE", textureChangeListener);
 50   this.updateTexture(controller.getTexture());
 51 }
 52 TextureChoiceComponent.prototype = Object.create(JSComponent.prototype);
 53 TextureChoiceComponent.prototype.constructor = TextureChoiceComponent;
 54 
 55 /**
 56  * Updates the texture image displayed by this button.
 57  * @param {Texture} texture
 58  * @private 
 59  */
 60 TextureChoiceComponent.prototype.updateTexture = function(texture) {
 61   if (texture == null) {
 62     this.preview.style.backgroundImage = "none";
 63   } else {
 64     var component = this;
 65     TextureManager.getInstance().loadTexture(texture.getImage(), 
 66         {
 67           textureUpdated: function(image) {
 68             component.preview.style.backgroundImage = "url('" + image.src + "')";
 69           },
 70           textureError: function(error) {
 71             console.log("Image cannot be loaded", error);
 72           }
 73         });
 74   }
 75 }
 76 
 77 /**
 78  * Enables or disables this component.
 79  * @param {boolean} enabled  
 80  */
 81 TextureChoiceComponent.prototype.setEnabled = function(enabled) {
 82   this.button.disabled = !enabled;
 83 }
 84 
 85 /**
 86  * @private
 87  */
 88 TextureChoiceComponent.prototype.openTextureDialog = function() {
 89   var dialog = new JSTextureDialog(this.getUserPreferences(), this.controller);
 90   if (this.controller.getTexture() != null) {
 91     dialog.setSelectedTexture(this.controller.getTexture());
 92   }
 93   dialog.displayView();
 94 }
 95 
 96 /**
 97  * @return {boolean}
 98  */
 99 TextureChoiceComponent.prototype.confirmDeleteSelectedCatalogTexture = function() {
100   // Remove html tags from message because confirm does not support it
101   var messageText = this.getLocalizedLabelText("TextureChoiceComponent", "confirmDeleteSelectedCatalogTexture.message").
102       replace(/\<[^\>]*\>/g, " ").replace(/[ ]+/g, " ").replace(/^\s*/, "");
103   return confirm(messageText);
104 }
105 
106 
107 /**
108  * The texture selector dialog class.
109  * @param {UserPreferences} preferences the current user preferences
110  * @param {TextureChoiceController} controller texture choice controller
111  * @extends JSDialog
112  * @constructor
113  * @private
114  */
115 function JSTextureDialog(preferences, controller) {
116   this.controller = controller;
117   this.selectedTextureModel = {
118       texture: null,
119       xOffset: 0,
120       yOffset: 0, 
121       angleInRadians: 0, 
122       scale: 1
123     };
124 
125   /**
126    * @param {CatalogTexture} catalogTexture 
127    * @return {HTMLElement}
128    */
129   var createTextureListItem = function(catalogTexture) {
130       var textureCategory = catalogTexture.getCategory();
131       var catalogTextureItem = document.createElement("div");
132       catalogTextureItem.classList.add("item");
133       catalogTexture.getImage().getStreamURL({
134           urlReady: function(streamUrl) {
135             catalogTextureItem.innerHTML = '<img src="' + streamUrl + '" />' 
136               + textureCategory.getName() + " - " + catalogTexture.getName();
137           },
138           urlError: function(url) {
139             catalogTextureItem.innerHTML = '<img/>' 
140               + textureCategory.getName() + " - " + catalogTexture.getName();
141           }
142         });
143       catalogTextureItem._catalogTexture = catalogTexture;
144       return catalogTextureItem;
145     };
146 
147   var html = 
148     '<div class="columns-2">' + 
149     '  <div class="column1">' + 
150     '    <div class="texture-search"><input type="text" /></div>' + 
151     '    <div class="texture-catalog-list"></div>' + 
152     '    <div class="recent-textures"></div>' +
153     '  </div>' +
154     '  <div class="column2">' + 
155     '    <div class="selected-texture-preview">' + 
156     '      <div></div>' + 
157     '    </div>' + 
158     '    <div class="selected-texture-config label-input-grid">' +
159     '      <div>@{TextureChoiceComponent.xOffsetLabel.text}</div>' + 
160     '      <div><span data-name="selected-texture-offset-x" /></div>' +
161     '      <div>@{TextureChoiceComponent.yOffsetLabel.text}</div>' + 
162     '      <div><span data-name="selected-texture-offset-y" /></div>' +
163     '      <div>@{TextureChoiceComponent.angleLabel.text}</div>' + 
164     '      <div><span data-name="selected-texture-angle" /></div>' +
165     '      <div>@{TextureChoiceComponent.scaleLabel.text}</div>' + 
166     '      <div><span data-name="selected-texture-scale" /></div>' +
167     '    </div>' + 
168     '    <hr />' +
169     '    <div class="imported-textures-panel">' +
170     '      <div><button import>@{TextureChoiceComponent.importTextureButton.text}</button></div>' +  
171     '      <div><button disabled modify>@{TextureChoiceComponent.modifyTextureButton.text}</button></div>' +
172     '      <div><button disabled delete>@{TextureChoiceComponent.deleteTextureButton.text}</button></div>' +  
173     '    </div>' +  
174     '  </div>' + 
175     '</div>';
176 
177   JSDialog.call(this, preferences, controller.getDialogTitle(), html, 
178       {
179         applier: function(dialog) {
180           // Force refresh model from inputs, even if "change" event was not raised 
181           this.updateTextureTransform();
182           var selectedTexture = dialog.getSelectedTexture();
183           controller.setTexture(selectedTexture);
184           if (selectedTexture != null) {
185             controller.addRecentTexture(selectedTexture);
186           }
187         },
188         disposer: function(dialog) {
189           preferences.getTexturesCatalog().removeTexturesListener(dialog.texturesCatalogListener);
190         }
191       });
192 
193   this.getHTMLElement().classList.add("texture-chooser-dialog");
194   
195   this.recentTexturesPanel = this.findElement(".recent-textures");
196   
197   this.catalogList = this.findElement(".texture-catalog-list");
198   this.selectedTexturePreview = this.findElement(".selected-texture-preview > div");
199 
200   this.xOffsetInput = new JSSpinner(preferences, this.getElement("selected-texture-offset-x"), 
201       {
202         value: 0,
203         minimum: 0,
204         maximum: 100,
205         stepSize: 5
206       });
207   this.yOffsetInput = new JSSpinner(preferences, this.getElement("selected-texture-offset-y"), 
208       {
209         value: 0,
210         minimum: 0,
211         maximum: 100,
212         stepSize: 5
213       });
214   this.angleInput = new JSSpinner(preferences, this.getElement("selected-texture-angle"), 
215       {
216         format: new IntegerFormat(),
217         value: 0,
218         minimum: 0,
219         maximum: 360,
220         stepSize: 15
221       });
222   this.scaleInput = new JSSpinner(preferences, this.getElement("selected-texture-scale"), 
223       { 
224         value: 100,
225         minimum: 1,
226         maximum: 10000,
227         stepSize: 5
228       });
229   var dialog = this;
230   this.registerEventListener([this.xOffsetInput, this.yOffsetInput, this.angleInput, this.scaleInput], "input", 
231       function(ev) {
232         dialog.updateTextureTransform();
233       });
234 
235   var textureCategories = preferences.getTexturesCatalog().getCategories();
236   for (var i = 0; i < textureCategories.length; i++) {
237     var textureCategory = textureCategories[i];
238     for (var j = 0; j < textureCategory.getTextures().length; j++) {
239       var catalogTexture = textureCategory.getTextures()[j];
240       dialog.catalogList.appendChild(createTextureListItem(catalogTexture));
241     }
242   }
243   this.texturesCatalogItems = this.catalogList.childNodes;
244 
245   var mouseClicked = function(ev) {
246       dialog.selectTexture(dialog.getCatalogTextureFromItem(this));
247     };
248   this.registerEventListener(this.texturesCatalogItems, "click", mouseClicked);
249   this.initCatalogTextureSearch(preferences);
250   this.initRecentTextures();
251   this.registerPropertyChangeListener(preferences, "RECENT_TEXTURES", function(ev) {
252       dialog.initRecentTextures();
253     });
254   this.registerEventListener(this.findElements(".item"), "dblclick", function(ev) { 
255       dialog.validate(); 
256     });
257   if (!OperatingSystem.isInternetExplorerOrLegacyEdge()
258       || !window.PointerEvent) {
259     // Simulate double touch on the same element
260     var lastTouchTime = -1;
261     var textureElement = null;
262     this.registerEventListener(this.findElements(".item"), "touchstart", function(ev) {
263         var time = Date.now();
264         if (time - lastTouchTime < 500
265             && textureElement === ev.target) {
266           ev.preventDefault();
267           dialog.validate();
268         } else {
269           lastTouchTime = time; 
270           textureElement = ev.target;
271         }
272       });
273   }
274 
275   this.initImportTexturesPanel();
276 
277   this.texturesCatalogListener = function(ev) {
278       var catalogTexture = ev.getItem && ev.getItem();
279       switch (ev.getType()) {
280         case CollectionEvent.Type.ADD:
281           dialog.searchInput.value = "";
282           var listItem = createTextureListItem(catalogTexture);
283           dialog.catalogList.appendChild(listItem);
284           dialog.registerEventListener(listItem, "click", mouseClicked);
285           dialog.selectTexture(catalogTexture);
286           break;
287         case CollectionEvent.Type.DELETE:
288           var catalogTextureItem = dialog.getCatalogTextureItem(catalogTexture);
289           dialog.catalogList.removeChild(catalogTextureItem);
290           var firstItem = dialog.catalogList.querySelector(".item");
291           if (firstItem) {
292             dialog.selectTexture(dialog.getCatalogTextureFromItem(firstItem));
293           }
294           break;
295       }
296     };
297   preferences.getTexturesCatalog().addTexturesListener(this.texturesCatalogListener);
298 }
299 JSTextureDialog.prototype = Object.create(JSDialog.prototype);
300 JSTextureDialog.prototype.constructor = JSTextureDialog;
301 
302 /**
303  * Returns the currently selected texture.
304  * @return {HomeTexture} currently selected texture
305  */
306 JSTextureDialog.prototype.getSelectedTexture = function() {
307   if (this.selectedTextureModel.texture != null) {
308     return new HomeTexture(
309         this.selectedTextureModel.texture,
310         this.selectedTextureModel.xOffset,
311         this.selectedTextureModel.yOffset,
312         this.selectedTextureModel.angleInRadians,
313         this.selectedTextureModel.scale,
314         this.controller.getTexture() instanceof HomeTexture 
315             ? this.controller.getTexture().isFittingArea()
316             : false,
317         true);
318   } else {
319     return null;
320   }
321 }
322 
323 /**
324  * Applies given texture values to this dialog.
325  * @param {HomeTexture} texture 
326  */
327 JSTextureDialog.prototype.setSelectedTexture = function(texture) {
328   if (texture != null) {
329     this.selectedTextureModel.texture = texture;
330     this.selectedTextureModel.xOffset = texture.getXOffset();
331     this.selectedTextureModel.yOffset = texture.getYOffset();
332     this.selectedTextureModel.angleInRadians = texture.getAngle();
333     this.selectedTextureModel.scale = texture.getScale();
334     
335     this.xOffsetInput.setValue(this.selectedTextureModel.xOffset * 100);
336     this.yOffsetInput.setValue(this.selectedTextureModel.yOffset * 100);
337     this.angleInput.setValue(Math.toDegrees(this.selectedTextureModel.angleInRadians));
338     this.scaleInput.setValue(this.selectedTextureModel.scale * 100);
339   
340     // Search texture in catalog
341     var textureContent = texture.getImage();
342     var textureCategories = this.getUserPreferences().getTexturesCatalog().getCategories();
343     var catalogTexture = null;
344     for (var i = 0; i < textureCategories.length && catalogTexture === null; i++) {
345       var categoryTextures = textureCategories[i].getTextures();
346       for (var j = 0; j < categoryTextures.length; j++) {
347         if (textureContent.equals(categoryTextures[j].getImage())) {
348           catalogTexture = categoryTextures[j];
349           break;
350         }
351       }
352     }
353     if (catalogTexture !== null) {
354       this.selectTexture(catalogTexture);
355     } else {
356       this.selectTexture(texture);
357     }
358   } else {
359     this.selectedTexturePreview.style.backgroundImage = "none";
360   }
361   
362   this.updateTextureTransform();
363 }
364 
365 /**
366  * @param {Texture} texture  the texture to be selected
367  * @private
368  */
369 JSTextureDialog.prototype.selectTexture = function(texture) {
370   this.selectedTextureModel.texture = texture;
371   var modifyTextureEnabled = false;
372   if (texture != null) {
373     for (var i = 0; i < this.texturesCatalogItems.length; i++) {
374       this.texturesCatalogItems[i].classList.remove("selected");
375     }
376     if (texture instanceof CatalogTexture) {
377       var catalogTextureItem = this.getCatalogTextureItem(texture);
378       catalogTextureItem.classList.add("selected");
379       var catalogList = this.catalogList;
380       setTimeout(function() {
381           var textureItemTop = catalogTextureItem.offsetTop - catalogList.offsetTop;
382           var textureItemBottom = textureItemTop + catalogTextureItem.clientHeight;
383           if (textureItemTop < catalogList.scrollTop || textureItemBottom > (catalogList.scrollTop + catalogList.clientHeight)) {
384             catalogList.scrollTop = textureItemTop - catalogList.offsetTop;
385           }
386         }, 10);
387       this.selectedTexturePreview.style.backgroundImage = "url('" + catalogTextureItem.querySelector("img").src + "')";
388       modifyTextureEnabled = texture != null && texture.isModifiable();
389     } else {
390       var dialog = this;
391       TextureManager.getInstance().loadTexture(texture.getImage(), 
392           {
393             textureUpdated: function(image) {
394               dialog.selectedTexturePreview.style.backgroundImage = "url('" + image.src + "')";
395             },
396             textureError: function(error) {
397               dialog.selectedTexturePreview.style.backgroundImage = "none";
398             }
399           });
400     }
401   } else {
402     dialog.selectedTexturePreview.style.backgroundImage = "none";
403   }
404 
405   this.modifyTextureButton.disabled = !modifyTextureEnabled;
406   this.deleteTextureButton.disabled = !modifyTextureEnabled;
407 }
408 
409 /**
410  * @private
411  */
412 JSTextureDialog.prototype.updateTextureTransform = function() {
413   this.selectedTextureModel.xOffset = this.xOffsetInput.getValue() / 100;
414   this.selectedTextureModel.yOffset = this.yOffsetInput.getValue() / 100;
415   this.selectedTextureModel.angleInRadians = Math.toRadians(this.angleInput.getValue());
416   this.selectedTextureModel.scale = this.scaleInput.getValue() / 100;
417   this.selectedTexturePreview.style.transform = 
418       /* "translate(" + this.xOffsetInput.getValue() + "%, " + this.yOffsetInput.getValue() + "%)" */
419         " rotate(" + this.angleInput.getValue() + "deg)"
420       /* + " scale(" + this.selectedTextureModel.scale + ", " + this.selectedTextureModel.scale + ")" */;
421 }
422 
423 /**
424  * @param {CatalogTexture} catalogTexture 
425  * @return {HTMLElement | null} null if no item found for given texture
426  * @private
427  */
428 JSTextureDialog.prototype.getCatalogTextureItem = function(catalogTexture) {
429   if (catalogTexture != null) {
430     var catalogContent = catalogTexture.getImage();
431     for (var i = 0; i < this.texturesCatalogItems.length; i++) {
432       var item = this.texturesCatalogItems[i];
433       var itemContent = this.getCatalogTextureFromItem(item).getImage();
434       if (catalogContent.equals(itemContent)) {
435         return item;
436       }
437     }
438   }
439   return null;
440 }
441 
442 /**
443  * @param {HTMLElement} item 
444  * @return {CatalogTexture} matching texture
445  * @private
446  */
447 JSTextureDialog.prototype.getCatalogTextureFromItem = function(item) {
448   return item._catalogTexture;
449 }
450 
451 /**
452  * @param {UserPreferences} preferences 
453  * @private
454  */
455 JSTextureDialog.prototype.initCatalogTextureSearch = function(preferences) {
456   var dialog = this;
457   this.searchInput = this.findElement(".texture-search input");
458   this.searchInput.placeholder = ResourceAction.getLocalizedLabelText(preferences, "TextureChoiceComponent", "searchLabel.text").replace(":", "");
459   this.registerEventListener(dialog.searchInput, "input", function() {
460       var valueToSearch = CoreTools.removeAccents(dialog.searchInput.value.trim());
461       for (var i = 0; i < dialog.texturesCatalogItems.length; i++) {
462         var item = dialog.texturesCatalogItems[i];
463         var textureDescriptor = item._catalogTexture.getName() + "|" + item._catalogTexture.getCategory().getName();
464         if (item._catalogTexture.getCreator() !== null) {
465           textureDescriptor += "|" + item._catalogTexture.getCreator();
466         }
467         if (RegExp(valueToSearch, "i").test(CoreTools.removeAccents(textureDescriptor))) {
468           item.classList.remove("hidden");
469         } else {
470           item.classList.add("hidden");
471         }
472       }
473     });
474   this.searchInput.addEventListener("focusin", function(ev) {
475       setTimeout(function() { 
476           if (dialog.searchInput.value != "") {
477             dialog.searchInput.select(); 
478           }
479         }, 100);
480     });
481 }
482 
483 /**
484  * @private
485  */
486 JSTextureDialog.prototype.initRecentTextures = function() {
487   var dialog = this;
488   this.recentTexturesPanel.innerHTML = "";
489   var recentTextures = this.getUserPreferences().getRecentTextures();
490   for (var i = 0; i < recentTextures.length; i++) {
491     var recentTexture = recentTextures[i];
492     var recentTextureElement = document.createElement("div");
493     recentTextureElement.classList.add("item");
494     recentTextureElement._catalogTexture = recentTexture;
495     this.recentTexturesPanel.appendChild(recentTextureElement);
496     TextureManager.getInstance().loadTexture(recentTexture.getImage(), 
497         {
498           textureUpdated: function(image) {
499             recentTextureElement.style.backgroundImage = "url('" + image.src + "')";
500           },
501           textureError: function(error) {
502             dialog.recentTexturesPanel.removeChild(recentTextureElement);
503           }
504         });
505   }
506 
507   var dialog = this;
508   this.registerEventListener(this.recentTexturesPanel.childNodes, "click", function(ev) {
509       dialog.selectTexture(dialog.getCatalogTextureFromItem(this));
510     });
511 }
512 
513 /**
514  * @private
515  */
516 JSTextureDialog.prototype.initImportTexturesPanel = function() {
517   this.importTextureButton = this.findElement(".imported-textures-panel [import]");
518   this.modifyTextureButton = this.findElement(".imported-textures-panel [modify]");
519   this.deleteTextureButton = this.findElement(".imported-textures-panel [delete]");
520   
521   var dialog = this;
522   var controller = this.controller;
523   this.registerEventListener(this.importTextureButton, "click", function(ev) { 
524       controller.importTexture();
525     });
526   this.registerEventListener(this.modifyTextureButton, "click", function(ev) { 
527       controller.modifyTexture(dialog.selectedTextureModel.texture); 
528     });
529   this.registerEventListener(this.deleteTextureButton, "click", function(ev) { 
530       controller.deleteTexture(dialog.selectedTextureModel.texture);
531     });
532 }
533