index.js (29047B)
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 hasBaselayerOnMap = true; 405 break; 406 } 407 } 408 } 409 // Not quite correct - the event will be fired even if there was no base layer before the update. 410 // Still it is better than not firing event at all when there is no base layer after update. 411 if (!hasBaselayerOnMap) { 412 this._map.fire('baselayerchange'); 413 } 414 }, 415 416 updateLayers: function(addedLayers) { 417 this.updateLayersListControl(addedLayers); 418 this.saveSettings(); 419 }, 420 421 saveSettings: function() { 422 const layersSettings = []; 423 424 for (let layer of this.allLayers()) { 425 layersSettings.push({ 426 code: layer.layer.options.code, 427 isCustom: layer.isCustom, 428 enabled: layer.enabled, 429 hotkey: layer.layer.hotkey, 430 }); 431 } 432 const settings = {layers: layersSettings}; 433 safeLocalStorage.setItem('leafletLayersSettings', JSON.stringify(settings)); 434 }, 435 436 unserializeState: function(values) { 437 if (values) { 438 values = values.map((code) => { 439 let newCode = this.loadCustomLayerFromString(code); 440 return newCode || code; 441 }); 442 for (let layer of this.allLayers()) { 443 if (layer.layer.options && values.includes(layer.layer.options.code)) { 444 layer.enabled = true; 445 } 446 } 447 this.updateLayers(); 448 } 449 return originalUnserializeState.call(this, values); 450 }, 451 452 showCustomLayerForm: function(buttons, fieldValues) { 453 if (this._customLayerWindow || this._configWindowVisible) { 454 return; 455 } 456 this._customLayerWindow = 457 L.DomUtil.create('div', 'leaflet-layers-dialog-wrapper', this._map._controlContainer); 458 459 L.DomEvent 460 .disableClickPropagation(this._customLayerWindow) 461 .disableScrollPropagation(this._customLayerWindow); 462 463 let customLayerWindow = L.DomUtil.create('div', 'custom-layers-window', this._customLayerWindow); 464 let form = L.DomUtil.create('form', '', customLayerWindow); 465 L.DomEvent.on(form, 'submit', L.DomEvent.preventDefault); 466 467 const dialogModel = { 468 name: ko.observable(fieldValues.name), 469 url: ko.observable(fieldValues.url), 470 tms: ko.observable(fieldValues.tms), 471 scaleDependent: ko.observable(fieldValues.scaleDependent), 472 maxZoom: ko.observable(fieldValues.maxZoom), 473 isOverlay: ko.observable(fieldValues.isOverlay), 474 isTop: ko.observable(fieldValues.isTop), 475 buttons: buttons, 476 buttonClicked: function buttonClicked(callbackN) { 477 const fieldValues = { 478 name: dialogModel.name().trim(), 479 url: dialogModel.url().trim(), 480 tms: dialogModel.tms(), 481 scaleDependent: dialogModel.scaleDependent(), 482 maxZoom: dialogModel.maxZoom(), 483 isOverlay: dialogModel.isOverlay(), 484 isTop: dialogModel.isTop() 485 }; 486 buttons[callbackN].callback(fieldValues); 487 } 488 }; 489 490 /* eslint-disable max-len */ 491 const formHtml = ` 492 <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> 493 <label>Layer name<br/> 494 <span class="hint">Maximum 40 characters</span><br/> 495 <input maxlength="40" class="layer-name" data-bind="value: name"/></label><br/> 496 <label>Tile url template<br/><textarea data-bind="value: url" class="layer-url"></textarea></label><br/> 497 <label><input type="radio" name="overlay" data-bind="checked: isOverlay, checkedValue: false">Base layer</label><br/> 498 <label><input type="radio" name="overlay" data-bind="checked: isOverlay, checkedValue: true">Overlay</label><br/> 499 <hr/> 500 <label><input type="radio" name="top-or-bottom" 501 data-bind="checked: isTop, checkedValue: false, enable: isOverlay">Place below other layers</label><br/> 502 <label><input type="radio" name="top-or-bottom" 503 data-bind="checked: isTop, checkedValue: true, enable: isOverlay">Place above other layers</label><br/> 504 <hr/> 505 <label><input type="checkbox" data-bind="checked: scaleDependent"/>Content depends on scale(like OSM or Google maps)</label><br/> 506 <label><input type="checkbox" data-bind="checked: tms" />TMS rows order</label><br /> 507 508 <label>Max zoom<br> 509 <select data-bind="options: [9,10,11,12,13,14,15,16,17,18], value: maxZoom"></select></label> 510 <div data-bind="foreach: buttons"> 511 <a class="button" data-bind="click: $root.buttonClicked.bind(null, $index()), text: caption"></a> 512 </div>`; 513 /* eslint-enable max-len */ 514 form.innerHTML = formHtml; 515 ko.applyBindings(dialogModel, form); 516 }, 517 518 _addItem: function(obj) { 519 var label = originalAddItem.call(this, obj); 520 if (obj.layer.__customLayer) { 521 const editButton = L.DomUtil.create('div', 'custom-layer-edit-button icon-edit', label.children[0]); 522 editButton.title = 'Edit layer'; 523 L.DomEvent.on(editButton, 'click', (e) => 524 this.onCustomLayerEditClicked(obj.layer.__customLayer, e) 525 ); 526 } 527 if (obj.layer._justAdded) { 528 L.DomUtil.addClass(label, 'leaflet-layers-configure-just-added-1'); 529 setTimeout(() => { 530 L.DomUtil.addClass(label, 'leaflet-layers-configure-just-added-2'); 531 }, 0); 532 } 533 return label; 534 }, 535 536 serializeCustomLayer: function(fieldValues) { 537 let s = JSON.stringify(fieldValues); 538 s = s.replace(/[\u007f-\uffff]/ug, 539 function(c) { 540 return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4); 541 } 542 ); 543 544 function encodeUrlSafeBase64(s) { 545 return btoa(s) 546 .replace(/\+/ug, '-') 547 .replace(/\//ug, '_'); 548 } 549 550 return '-cs' + encodeUrlSafeBase64(s); 551 }, 552 553 customLayerExists: function(fieldValues, ignoreLayer) { 554 const serialized = this.serializeCustomLayer(fieldValues); 555 for (let layer of this._customLayers) { 556 if (layer !== ignoreLayer && layer.serialized === serialized) { 557 return layer; 558 } 559 } 560 return false; 561 }, 562 563 checkCustomLayerValues: function(fieldValues) { 564 if (!fieldValues.url) { 565 return {error: 'Url is empty'}; 566 } 567 if (!fieldValues.name) { 568 return {error: 'Name is empty'}; 569 } 570 return {}; 571 }, 572 573 onCustomLayerAddClicked: function(fieldValues) { 574 const error = this.checkCustomLayerValues(fieldValues).error; 575 if (error) { 576 notify(error); 577 return; 578 } 579 580 const duplicateLayer = this.customLayerExists(fieldValues); 581 if (duplicateLayer) { 582 let msg = 'Same layer already exists'; 583 if (!duplicateLayer.enabled) { 584 msg += ' but it is hidden. You can enable it in layers setting.'; 585 } 586 notify(msg); 587 return; 588 } 589 590 const layer = this.createCustomLayer(fieldValues); 591 layer.enabled = true; 592 this._customLayers.push(layer); 593 this.hideCustomLayerForm(); 594 this.updateLayers(); 595 }, 596 597 createCustomLayer: function(fieldValues) { 598 const serialized = this.serializeCustomLayer(fieldValues); 599 const tileLayer = new L.Layer.CustomLayer(fieldValues.url, { 600 isOverlay: fieldValues.isOverlay, 601 tms: fieldValues.tms, 602 maxNativeZoom: fieldValues.maxZoom, 603 scaleDependent: fieldValues.scaleDependent, 604 print: true, 605 jnx: true, 606 code: serialized, 607 noCors: true, 608 isTop: fieldValues.isTop 609 } 610 ); 611 612 const customLayer = { 613 title: fieldValues.name, 614 isDefault: false, 615 isCustom: true, 616 serialized: serialized, 617 layer: tileLayer, 618 order: 619 (fieldValues.isOverlay && fieldValues.isTop) ? customLayersOrder.top : customLayersOrder.bottom, 620 fieldValues: fieldValues, 621 enabled: true, 622 checked: ko.observable(true) 623 }; 624 tileLayer.__customLayer = customLayer; 625 return customLayer; 626 }, 627 628 onCustomLayerCancelClicked: function() { 629 this.hideCustomLayerForm(); 630 }, 631 632 hideCustomLayerForm: function() { 633 if (!this._customLayerWindow) { 634 return; 635 } 636 this._customLayerWindow.parentNode.removeChild(this._customLayerWindow); 637 this._customLayerWindow = null; 638 }, 639 640 onCustomLayerEditClicked: function(layer, e) { 641 L.DomEvent.stop(e); 642 this.showCustomLayerForm([ 643 { 644 caption: 'Save', 645 callback: (fieldValues) => this.onCustomLayerChangeClicked(layer, fieldValues), 646 }, 647 {caption: 'Delete', callback: () => this.onCustomLayerDeleteClicked(layer)}, 648 {caption: 'Cancel', callback: () => this.onCustomLayerCancelClicked()} 649 ], layer.fieldValues 650 ); 651 }, 652 653 onCustomLayerChangeClicked: function(layer, newFieldValues) { 654 const error = this.checkCustomLayerValues(newFieldValues).error; 655 if (error) { 656 notify(error); 657 return; 658 } 659 const duplicateLayer = this.customLayerExists(newFieldValues, layer); 660 if (duplicateLayer) { 661 let msg = 'Same layer already exists'; 662 if (!duplicateLayer.enabled) { 663 msg += ' but it is hidden. You can enable it in layers setting.'; 664 } 665 notify(msg); 666 return; 667 } 668 669 const newLayer = this.createCustomLayer(newFieldValues); 670 newLayer.layer.hotkey = layer.layer.hotkey; 671 this._customLayers.splice(this._customLayers.indexOf(layer), 1, newLayer); 672 const newLayerVisible = ( 673 this._map.hasLayer(layer.layer) && 674 // turn off layer if changing from overlay to baselayer 675 (!layer.layer.options.isOverlay || newLayer.layer.options.isOverlay) 676 ); 677 if (newLayerVisible) { 678 this._map.addLayer(newLayer.layer); 679 } 680 this._map.removeLayer(layer.layer); 681 this.updateLayers(); 682 if (newLayerVisible) { 683 newLayer.layer.fire('add'); 684 } 685 this.hideCustomLayerForm(); 686 }, 687 688 onCustomLayerDeleteClicked: function(layer) { 689 this._map.removeLayer(layer.layer); 690 this._customLayers.splice(this._customLayers.indexOf(layer), 1); 691 this.updateLayers(); 692 this.hideCustomLayerForm(); 693 }, 694 695 loadCustomLayerFromString: function(s) { 696 let fieldValues; 697 const m = s.match(/^-cs(.+)$/u); 698 if (m) { 699 s = m[1].replace(/-/ug, '+').replace(/_/ug, '/'); 700 try { 701 s = atob(s); 702 fieldValues = JSON.parse(s); 703 } catch (e) { 704 // ignore malformed data 705 } 706 707 if (fieldValues) { 708 // upgrade 709 if (fieldValues.isTop === undefined) { 710 fieldValues.isTop = true; 711 } 712 if (!this.customLayerExists(fieldValues)) { 713 this._customLayers.push(this.createCustomLayer(fieldValues)); 714 } 715 return this.serializeCustomLayer(fieldValues); 716 } 717 } 718 return null; 719 } 720 } 721 ); 722 } 723 724 export default enableConfig;
