index.js (28655B)
1 import L from 'leaflet'; 2 import './style.css'; 3 import enableTopRow from '~/lib/leaflet.control.layers.top-row'; 4 import ko from 'knockout'; 5 import {notify} from '~/lib/notifications'; 6 import * as logging from '~/lib/logging'; 7 import safeLocalStorage from '~/lib/safe-localstorage'; 8 import './customLayer'; 9 10 function getLayerDefaultHotkey(layer) { 11 const layerOptions = layer?.layer.options; 12 if (!layerOptions) { 13 return null; 14 } 15 if (layerOptions.hotkey) { 16 return layerOptions.hotkey; 17 } 18 if (layerOptions.code?.length === 1) { 19 return layerOptions.code; 20 } 21 return null; 22 } 23 24 class LayersConfigDialog { 25 constructor(builtInLayers, customLayers, withHotkeys, cbOk) { 26 this.builtInLayers = builtInLayers; 27 this.customLayers = customLayers; 28 this.withHotkeys = withHotkeys; 29 this.cbOk = cbOk; 30 31 this.visible = ko.observable(false); 32 this.layerGroups = ko.observableArray([]); 33 34 this.initWindow(); 35 } 36 37 allLayers() { 38 return [ 39 ...([].concat(...this.builtInLayers.map((group) => group.layers))), 40 ...this.customLayers 41 ]; 42 } 43 44 allLayerModels() { 45 return [].concat(...this.layerGroups().map((group) => group.layers())); 46 } 47 48 initWindow() { 49 const container = this.window = 50 L.DomUtil.create('div', 'leaflet-layers-dialog-wrapper'); 51 L.DomEvent 52 .disableClickPropagation(container) 53 .disableScrollPropagation(container); 54 container.setAttribute('data-bind', "visible: visible"); 55 container.innerHTML = ` 56 <div class="leaflet-layers-config-window"> 57 <form> 58 <!-- ko foreach: layerGroups --> 59 <div class="section-header" data-bind="html: group"></div> 60 <!-- ko foreach: layers --> 61 <label class="layer-label"> 62 <input type="checkbox" class="layer-enabled-checkbox" data-bind="checked: enabled"/> 63 <span data-bind="text: title"></span> 64 <!-- ko if: $root.withHotkeys --> 65 <div class="hotkey-input" 66 tabindex="0" 67 data-bind=" 68 text: hotkey, 69 attr: {title: $root.getHotkeyTooltip($data)}, 70 event: { 71 keyup: $root.onHotkeyInput.bind($root), 72 click: function(_, e) {e.target.focus()}, 73 blur: function() {error(null)}, 74 }, 75 clickBubble: false, 76 keyupBubble: false"> 77 ></div> 78 <div class="error" data-bind="text: error, visible: error"></div> 79 <!-- /ko --> 80 </label> 81 <!-- /ko --> 82 <!-- /ko --> 83 </form> 84 <div class="buttons-row"> 85 <div href="#" class="button" data-bind="click: onOkClicked">Ok</div> 86 <div href="#" class="button" data-bind="click: onCancelClicked">Cancel</div> 87 <div href="#" class="button" data-bind="click: onResetClicked">Reset</div> 88 </div> 89 </div> 90 `; 91 ko.applyBindings(this, container); 92 } 93 94 getWindow() { 95 return this.window; 96 } 97 98 showDialog() { 99 this.updateModelFromLayers(); 100 this.visible(true); 101 } 102 103 updateModelFromLayers() { 104 this.layerGroups.removeAll(); 105 for (const group of this.builtInLayers) { 106 this.layerGroups.push({ 107 group: group.group, 108 layers: ko.observableArray( 109 group.layers.map((l) => ({ 110 title: l.title, 111 enabled: ko.observable(l.enabled), 112 hotkey: ko.observable(l.layer.hotkey), 113 origLayer: l, 114 error: ko.observable(null), 115 })) 116 ), 117 }); 118 } 119 if (this.customLayers.length) { 120 this.layerGroups.push({ 121 group: 'Custom layers', 122 layers: ko.observableArray( 123 this.customLayers.map((l) => ({ 124 title: l.title, 125 enabled: ko.observable(l.enabled), 126 hotkey: ko.observable(l.layer.hotkey), 127 origLayer: l, 128 error: ko.observable(null), 129 })) 130 ), 131 }); 132 } 133 } 134 135 updateLayersFromModel() { 136 for (const layer of this.allLayerModels()) { 137 layer.origLayer.enabled = layer.enabled(); 138 layer.origLayer.layer.hotkey = layer.hotkey(); 139 } 140 } 141 142 getLayersEnabledOnlyInModel() { 143 const newLayers = []; 144 for (const layer of this.allLayerModels()) { 145 if (layer.enabled() && !layer.origLayer.enabled) { 146 newLayers.push(layer.origLayer); 147 } 148 } 149 return newLayers; 150 } 151 152 onOkClicked() { 153 const newEnabledLayers = this.getLayersEnabledOnlyInModel(); 154 this.updateLayersFromModel(); 155 this.visible(false); 156 this.cbOk(newEnabledLayers); 157 } 158 159 onCancelClicked() { 160 this.visible(false); 161 } 162 163 onResetClicked() { 164 for (const layer of this.allLayerModels()) { 165 layer.enabled(layer.origLayer.isDefault); 166 layer.hotkey(getLayerDefaultHotkey(layer.origLayer)); 167 } 168 } 169 170 displayError(message, layerModel, event) { 171 layerModel.error(message); 172 173 setTimeout(() => { 174 event.target.parentNode.querySelector('.error') 175 .scrollIntoView({block: 'nearest', behavior: 'smooth'}); 176 }, 0); 177 } 178 179 onHotkeyInput(layerModel, event) { 180 layerModel.error(null); 181 if (['Delete', 'Backspace', 'Space'].includes(event.code)) { 182 layerModel.hotkey(null); 183 return; 184 } 185 186 if (/Enter|Escape/u.test(event.code)) { 187 event.target.blur(); 188 return; 189 } 190 191 if (/Alt|Shift|Control|Tab|Lock|Meta|ContextMenu|Lang|Arrow/u.test(event.code)) { 192 return; 193 } 194 const match = /^(Key|Digit)(.)/u.exec(event.code); 195 if (!match) { 196 this.displayError('Only keys A-Z and 0-9 can be used for hotkeys.', layerModel, event); 197 return; 198 } 199 const newHotkey = match[2]; 200 for (const layer of this.allLayerModels()) { 201 if (layer !== layerModel && layer.hotkey() === newHotkey) { 202 this.displayError(`Hotkey "${newHotkey}" is already used by layer "${layer.title}"`, layerModel, event); 203 return; 204 } 205 } 206 layerModel.hotkey(newHotkey); 207 } 208 209 getHotkeyTooltip(layer) { 210 return layer.hotkey() ? 'Change or remove hotkey' : 'Set hotkey'; 211 } 212 } 213 214 function enableConfig(control, {layers, customLayersOrder}, options = {withHotkeys: true}) { 215 if (control._configEnabled) { 216 return; 217 } 218 219 enableTopRow(control); 220 221 const originalOnAdd = control.onAdd; 222 const originalUnserializeState = control.unserializeState; 223 const originalAddItem = control._addItem; 224 225 L.Util.extend(control, { 226 _configEnabled: true, 227 _builtinLayersByGroup: layers, 228 _builtinLayers: [].concat(...layers.map((group) => group.layers)), 229 _customLayers: [], 230 _withHotkeys: options.withHotkeys, 231 232 onAdd: function(map) { 233 const container = originalOnAdd.call(this, map); 234 this.__injectConfigButton(); 235 this.initLayersConfigWindow(); 236 this.loadSettings(); 237 return container; 238 }, 239 240 allLayers: function() { 241 return [...this._builtinLayers, ...this._customLayers]; 242 }, 243 244 __injectConfigButton: function() { 245 const configButton = L.DomUtil.create('div', 'button icon-settings'); 246 configButton.title = 'Configure layers'; 247 this._topRow.appendChild(configButton); 248 L.DomEvent.on(configButton, 'click', this._onConfigButtonClick, this); 249 250 const newCustomLayerButton = L.DomUtil.create('div', 'button icon-edit'); 251 newCustomLayerButton.title = 'Add custom layer'; 252 this._topRow.appendChild(newCustomLayerButton); 253 L.DomEvent.on(newCustomLayerButton, 'click', this.onCustomLayerCreateClicked, this); 254 }, 255 256 migrateSetting: function() { 257 let oldSettings; 258 let newSettings; 259 try { 260 oldSettings = JSON.parse(safeLocalStorage.getItem('layersEnabled')); 261 } catch { 262 // consider empty 263 } 264 try { 265 newSettings = JSON.parse(safeLocalStorage.getItem('leafletLayersSettings')); 266 } catch { 267 // consider empty 268 } 269 270 if (!oldSettings || newSettings) { 271 return; 272 } 273 274 const layersSettings = []; 275 for (let [code, isEnabled] of Object.entries(oldSettings)) { 276 layersSettings.push({ 277 code, 278 isCustom: Boolean(code.match(/^-cs(.+)$/u)), 279 enabled: isEnabled, 280 }); 281 } 282 const settings = {layers: layersSettings}; 283 safeLocalStorage.setItem('leafletLayersSettings', JSON.stringify(settings)); 284 }, 285 286 loadSettings: function() { 287 this.migrateSetting(); 288 // load settings from storage 289 const serialized = safeLocalStorage.getItem('leafletLayersSettings'); 290 let settings = {}; 291 if (serialized) { 292 try { 293 settings = JSON.parse(serialized); 294 } catch (e) { 295 logging.captureMessage('Failed to load layers settings from localstorage - invalid json', { 296 "localstorage.leafletLayersSettings": serialized.slice(0, 1000) 297 }); 298 } 299 } 300 const layersSettings = settings.layers ?? []; 301 302 // load custom layers; 303 for (const layerSettings of layersSettings) { 304 if (layerSettings.isCustom) { 305 // custom layers can be upgraded in loadCustomLayerFromString and their code will change 306 const newCode = this.loadCustomLayerFromString(String(layerSettings.code)); 307 if (newCode) { 308 layerSettings.code = newCode; 309 } else { 310 logging.captureMessage( 311 `Failed to load custom layer from local storage record: "${layerSettings.code}"` 312 ); 313 } 314 } 315 } 316 317 // apply settings to layers 318 const layersSettingsByCode = {}; 319 layersSettings.forEach((it) => { 320 layersSettingsByCode[it.code] = it; 321 }); 322 323 for (let layer of this.allLayers()) { 324 const layerSettings = layersSettingsByCode[layer.layer.options.code] ?? {}; 325 // if storage is empty enable only default layers 326 // if new default layer appears it will be enabled 327 layer.enabled = layerSettings.enabled ?? layer.isDefault; 328 layer.layer.hotkey = layerSettings.hotkey || getLayerDefaultHotkey(layer); 329 } 330 this.updateLayers(); 331 }, 332 333 _onConfigButtonClick: function() { 334 if (this._layersConfigDialog.visible() || this._customLayerWindow) { 335 return; 336 } 337 this._layersConfigDialog.showDialog(); 338 }, 339 340 initLayersConfigWindow: function() { 341 this._layersConfigDialog = new LayersConfigDialog( 342 this._builtinLayersByGroup, 343 this._customLayers, 344 this._withHotkeys, 345 this.onConfigDialogOkClicked.bind(this), 346 ); 347 this._map._controlContainer.appendChild(this._layersConfigDialog.getWindow()); 348 }, 349 350 onConfigDialogOkClicked: function(addedLayers) { 351 this.updateLayers(addedLayers); 352 }, 353 354 onCustomLayerCreateClicked: function() { 355 this.showCustomLayerForm( 356 [ 357 { 358 caption: 'Add layer', 359 callback: (fieldValues) => this.onCustomLayerAddClicked(fieldValues) 360 }, 361 { 362 caption: 'Cancel', 363 callback: () => this.onCustomLayerCancelClicked() 364 } 365 ], 366 { 367 name: 'Custom layer', 368 url: '', 369 tms: false, 370 maxZoom: 18, 371 isOverlay: false, 372 scaleDependent: false, 373 isTop: true 374 } 375 ); 376 }, 377 378 updateLayersListControl: function(addedLayers) { 379 const disabledLayers = this.allLayers().filter((l) => !l.enabled); 380 disabledLayers.forEach((l) => this._map.removeLayer(l.layer)); 381 [...this._layers].forEach((l) => this.removeLayer(l.layer)); 382 383 let hasBaselayerOnMap = false; 384 const enabledLayers = this.allLayers().filter((l) => l.enabled); 385 enabledLayers.sort((l1, l2) => l1.order - l2.order); 386 enabledLayers.forEach((l) => { 387 l.layer._justAdded = addedLayers && addedLayers.includes(l); 388 const {layer: {options: {isOverlay}}} = l; 389 if (isOverlay) { 390 this.addOverlay(l.layer, l.title); 391 } else { 392 this.addBaseLayer(l.layer, l.title); 393 } 394 if (!isOverlay && this._map.hasLayer(l.layer)) { 395 hasBaselayerOnMap = true; 396 } 397 } 398 ); 399 // если нет активного базового слоя, включить первый, если он есть 400 if (!hasBaselayerOnMap) { 401 for (let layer of enabledLayers) { 402 if (!layer.layer.options.isOverlay) { 403 this._map.addLayer(layer.layer); 404 break; 405 } 406 } 407 } 408 }, 409 410 updateLayers: function(addedLayers) { 411 this.updateLayersListControl(addedLayers); 412 this.saveSettings(); 413 }, 414 415 saveSettings: function() { 416 const layersSettings = []; 417 418 for (let layer of this.allLayers()) { 419 layersSettings.push({ 420 code: layer.layer.options.code, 421 isCustom: layer.isCustom, 422 enabled: layer.enabled, 423 hotkey: layer.layer.hotkey, 424 }); 425 } 426 const settings = {layers: layersSettings}; 427 safeLocalStorage.setItem('leafletLayersSettings', JSON.stringify(settings)); 428 }, 429 430 unserializeState: function(values) { 431 if (values) { 432 values = values.map((code) => { 433 let newCode = this.loadCustomLayerFromString(code); 434 return newCode || code; 435 }); 436 for (let layer of this.allLayers()) { 437 if (layer.layer.options && values.includes(layer.layer.options.code)) { 438 layer.enabled = true; 439 } 440 } 441 this.updateLayers(); 442 } 443 return originalUnserializeState.call(this, values); 444 }, 445 446 showCustomLayerForm: function(buttons, fieldValues) { 447 if (this._customLayerWindow || this._configWindowVisible) { 448 return; 449 } 450 this._customLayerWindow = 451 L.DomUtil.create('div', 'leaflet-layers-dialog-wrapper', this._map._controlContainer); 452 453 L.DomEvent 454 .disableClickPropagation(this._customLayerWindow) 455 .disableScrollPropagation(this._customLayerWindow); 456 457 let customLayerWindow = L.DomUtil.create('div', 'custom-layers-window', this._customLayerWindow); 458 let form = L.DomUtil.create('form', '', customLayerWindow); 459 L.DomEvent.on(form, 'submit', L.DomEvent.preventDefault); 460 461 const dialogModel = { 462 name: ko.observable(fieldValues.name), 463 url: ko.observable(fieldValues.url), 464 tms: ko.observable(fieldValues.tms), 465 scaleDependent: ko.observable(fieldValues.scaleDependent), 466 maxZoom: ko.observable(fieldValues.maxZoom), 467 isOverlay: ko.observable(fieldValues.isOverlay), 468 isTop: ko.observable(fieldValues.isTop), 469 buttons: buttons, 470 buttonClicked: function buttonClicked(callbackN) { 471 const fieldValues = { 472 name: dialogModel.name().trim(), 473 url: dialogModel.url().trim(), 474 tms: dialogModel.tms(), 475 scaleDependent: dialogModel.scaleDependent(), 476 maxZoom: dialogModel.maxZoom(), 477 isOverlay: dialogModel.isOverlay(), 478 isTop: dialogModel.isTop() 479 }; 480 buttons[callbackN].callback(fieldValues); 481 } 482 }; 483 484 /* eslint-disable max-len */ 485 const formHtml = ` 486 <p><a class="doc-link" href="https://leafletjs.com/reference-1.0.3.html#tilelayer" target="_blank">See Leaflet TileLayer documentation for url format</a></p> 487 <label>Layer name<br/> 488 <span class="hint">Maximum 40 characters</span><br/> 489 <input maxlength="40" class="layer-name" data-bind="value: name"/></label><br/> 490 <label>Tile url template<br/><textarea data-bind="value: url" class="layer-url"></textarea></label><br/> 491 <label><input type="radio" name="overlay" data-bind="checked: isOverlay, checkedValue: false">Base layer</label><br/> 492 <label><input type="radio" name="overlay" data-bind="checked: isOverlay, checkedValue: true">Overlay</label><br/> 493 <hr/> 494 <label><input type="radio" name="top-or-bottom" 495 data-bind="checked: isTop, checkedValue: false, enable: isOverlay">Place below other layers</label><br/> 496 <label><input type="radio" name="top-or-bottom" 497 data-bind="checked: isTop, checkedValue: true, enable: isOverlay">Place above other layers</label><br/> 498 <hr/> 499 <label><input type="checkbox" data-bind="checked: scaleDependent"/>Content depends on scale(like OSM or Google maps)</label><br/> 500 <label><input type="checkbox" data-bind="checked: tms" />TMS rows order</label><br /> 501 502 <label>Max zoom<br> 503 <select data-bind="options: [9,10,11,12,13,14,15,16,17,18], value: maxZoom"></select></label> 504 <div data-bind="foreach: buttons"> 505 <a class="button" data-bind="click: $root.buttonClicked.bind(null, $index()), text: caption"></a> 506 </div>`; 507 /* eslint-enable max-len */ 508 form.innerHTML = formHtml; 509 ko.applyBindings(dialogModel, form); 510 }, 511 512 _addItem: function(obj) { 513 var label = originalAddItem.call(this, obj); 514 if (obj.layer.__customLayer) { 515 const editButton = L.DomUtil.create('div', 'custom-layer-edit-button icon-edit', label.children[0]); 516 editButton.title = 'Edit layer'; 517 L.DomEvent.on(editButton, 'click', (e) => 518 this.onCustomLayerEditClicked(obj.layer.__customLayer, e) 519 ); 520 } 521 if (obj.layer._justAdded) { 522 L.DomUtil.addClass(label, 'leaflet-layers-configure-just-added-1'); 523 setTimeout(() => { 524 L.DomUtil.addClass(label, 'leaflet-layers-configure-just-added-2'); 525 }, 0); 526 } 527 return label; 528 }, 529 530 serializeCustomLayer: function(fieldValues) { 531 let s = JSON.stringify(fieldValues); 532 s = s.replace(/[\u007f-\uffff]/ug, 533 function(c) { 534 return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4); 535 } 536 ); 537 538 function encodeUrlSafeBase64(s) { 539 return btoa(s) 540 .replace(/\+/ug, '-') 541 .replace(/\//ug, '_'); 542 } 543 544 return '-cs' + encodeUrlSafeBase64(s); 545 }, 546 547 customLayerExists: function(fieldValues, ignoreLayer) { 548 const serialized = this.serializeCustomLayer(fieldValues); 549 for (let layer of this._customLayers) { 550 if (layer !== ignoreLayer && layer.serialized === serialized) { 551 return layer; 552 } 553 } 554 return false; 555 }, 556 557 checkCustomLayerValues: function(fieldValues) { 558 if (!fieldValues.url) { 559 return {error: 'Url is empty'}; 560 } 561 if (!fieldValues.name) { 562 return {error: 'Name is empty'}; 563 } 564 return {}; 565 }, 566 567 onCustomLayerAddClicked: function(fieldValues) { 568 const error = this.checkCustomLayerValues(fieldValues).error; 569 if (error) { 570 notify(error); 571 return; 572 } 573 574 const duplicateLayer = this.customLayerExists(fieldValues); 575 if (duplicateLayer) { 576 let msg = 'Same layer already exists'; 577 if (!duplicateLayer.enabled) { 578 msg += ' but it is hidden. You can enable it in layers setting.'; 579 } 580 notify(msg); 581 return; 582 } 583 584 const layer = this.createCustomLayer(fieldValues); 585 layer.enabled = true; 586 this._customLayers.push(layer); 587 this.hideCustomLayerForm(); 588 this.updateLayers(); 589 }, 590 591 createCustomLayer: function(fieldValues) { 592 const serialized = this.serializeCustomLayer(fieldValues); 593 const tileLayer = new L.Layer.CustomLayer(fieldValues.url, { 594 isOverlay: fieldValues.isOverlay, 595 tms: fieldValues.tms, 596 maxNativeZoom: fieldValues.maxZoom, 597 scaleDependent: fieldValues.scaleDependent, 598 print: true, 599 jnx: true, 600 code: serialized, 601 noCors: true, 602 isTop: fieldValues.isTop 603 } 604 ); 605 606 const customLayer = { 607 title: fieldValues.name, 608 isDefault: false, 609 isCustom: true, 610 serialized: serialized, 611 layer: tileLayer, 612 order: 613 (fieldValues.isOverlay && fieldValues.isTop) ? customLayersOrder.top : customLayersOrder.bottom, 614 fieldValues: fieldValues, 615 enabled: true, 616 checked: ko.observable(true) 617 }; 618 tileLayer.__customLayer = customLayer; 619 return customLayer; 620 }, 621 622 onCustomLayerCancelClicked: function() { 623 this.hideCustomLayerForm(); 624 }, 625 626 hideCustomLayerForm: function() { 627 if (!this._customLayerWindow) { 628 return; 629 } 630 this._customLayerWindow.parentNode.removeChild(this._customLayerWindow); 631 this._customLayerWindow = null; 632 }, 633 634 onCustomLayerEditClicked: function(layer, e) { 635 L.DomEvent.stop(e); 636 this.showCustomLayerForm([ 637 { 638 caption: 'Save', 639 callback: (fieldValues) => this.onCustomLayerChangeClicked(layer, fieldValues), 640 }, 641 {caption: 'Delete', callback: () => this.onCustomLayerDeleteClicked(layer)}, 642 {caption: 'Cancel', callback: () => this.onCustomLayerCancelClicked()} 643 ], layer.fieldValues 644 ); 645 }, 646 647 onCustomLayerChangeClicked: function(layer, newFieldValues) { 648 const error = this.checkCustomLayerValues(newFieldValues).error; 649 if (error) { 650 notify(error); 651 return; 652 } 653 const duplicateLayer = this.customLayerExists(newFieldValues, layer); 654 if (duplicateLayer) { 655 let msg = 'Same layer already exists'; 656 if (!duplicateLayer.enabled) { 657 msg += ' but it is hidden. You can enable it in layers setting.'; 658 } 659 notify(msg); 660 return; 661 } 662 663 const newLayer = this.createCustomLayer(newFieldValues); 664 newLayer.layer.hotkey = layer.layer.hotkey; 665 this._customLayers.splice(this._customLayers.indexOf(layer), 1, newLayer); 666 const newLayerVisible = ( 667 this._map.hasLayer(layer.layer) && 668 // turn off layer if changing from overlay to baselayer 669 (!layer.layer.options.isOverlay || newLayer.layer.options.isOverlay) 670 ); 671 if (newLayerVisible) { 672 this._map.addLayer(newLayer.layer); 673 } 674 this._map.removeLayer(layer.layer); 675 this.updateLayers(); 676 if (newLayerVisible) { 677 newLayer.layer.fire('add'); 678 } 679 this.hideCustomLayerForm(); 680 }, 681 682 onCustomLayerDeleteClicked: function(layer) { 683 this._map.removeLayer(layer.layer); 684 this._customLayers.splice(this._customLayers.indexOf(layer), 1); 685 this.updateLayers(); 686 this.hideCustomLayerForm(); 687 }, 688 689 loadCustomLayerFromString: function(s) { 690 let fieldValues; 691 const m = s.match(/^-cs(.+)$/u); 692 if (m) { 693 s = m[1].replace(/-/ug, '+').replace(/_/ug, '/'); 694 try { 695 s = atob(s); 696 fieldValues = JSON.parse(s); 697 } catch (e) { 698 // ignore malformed data 699 } 700 701 if (fieldValues) { 702 // upgrade 703 if (fieldValues.isTop === undefined) { 704 fieldValues.isTop = true; 705 } 706 if (!this.customLayerExists(fieldValues)) { 707 this._customLayers.push(this.createCustomLayer(fieldValues)); 708 } 709 return this.serializeCustomLayer(fieldValues); 710 } 711 } 712 return null; 713 } 714 } 715 ); 716 } 717 718 export default enableConfig;
