index.js (10691B)
1 import ko from 'knockout'; 2 import L from 'leaflet'; 3 4 import '~/lib/leaflet.placemark'; // eslint-disable-line import/no-unassigned-import 5 import {stopContainerEvents} from '~/lib/leaflet.control.commons'; 6 import '~/lib/leaflet.hashState/leaflet.hashState'; // eslint-disable-line import/no-unassigned-import 7 8 import controlTemplate from './control.html'; 9 import {providers, magicProviders} from './providers'; 10 import './style.css'; 11 12 ko.bindingHandlers.hasFocusNested = { 13 init: function (element, valueAccessor) { 14 function hasFocusNested() { 15 let active = document.activeElement; 16 while (active) { 17 if (element === active) { 18 return true; 19 } 20 active = active.parentElement; 21 } 22 return false; 23 } 24 25 function handleFocusChange() { 26 // wait for all related focus/blur events to fire 27 setTimeout(() => { 28 valueAccessor()(hasFocusNested()); 29 }, 0); 30 } 31 element.addEventListener('focus', handleFocusChange, true); 32 element.addEventListener('blur', handleFocusChange, true); 33 }, 34 }; 35 36 class SearchViewModel { 37 query = ko.observable(''); 38 inputPlaceholder = ko.observable(null); 39 helpText = ko.observable(null); 40 items = ko.observableArray([]); 41 error = ko.observable(null); 42 inputHasFocus = ko.observable(false); 43 controlOrChildHasFocus = ko.observable(false); 44 highlightedIndex = ko.observable(null); 45 attribution = ko.observable(null); 46 47 allowMinimize = ko.observable(true); 48 49 /* eslint-disable no-invalid-this */ 50 controlHasFocus = ko.pureComputed(function () { 51 return this.inputHasFocus() || this.controlOrChildHasFocus(); 52 }, this); 53 54 showResults = ko.pureComputed(function () { 55 return this.items().length > 0 && this.controlHasFocus(); 56 }, this); 57 58 showError = ko.pureComputed(function () { 59 return this.error() !== null && this.controlHasFocus(); 60 }, this); 61 62 isQueryLengthOk = ko.computed(function () { 63 return this.query().trim().length >= this.minSearchQueryLength; 64 }, this); 65 66 showWarningTooShort = ko.pureComputed(function () { 67 return this.controlHasFocus() && this.query() && !this.isQueryLengthOk(); 68 }, this); 69 70 minimizeToButton = ko.pureComputed(function () { 71 return this.allowMinimize() && !this.controlHasFocus(); 72 }, this); 73 74 onItemMouseOver = (item) => { 75 this.highlightedIndex(this.items.indexOf(item)); 76 }; 77 78 onItemClick = (item) => { 79 this.itemSelected(item); 80 }; 81 82 onButtonClick = (_, e) => { 83 L.DomEvent.preventDefault(e); 84 this.inputHasFocus(true); 85 }; 86 87 onShowResults(show) { 88 if (show) { 89 this.highlightedIndex(0); 90 } 91 } 92 93 onControlHasFocusChange(active) { 94 if (!active) { 95 this.items.removeAll(); 96 this.error(null); 97 } 98 } 99 100 onInputHasFocusChange(active) { 101 if (active) { 102 this.maybeRequestSearch(this.query()); 103 } 104 } 105 106 onClearClick() { 107 this.query(''); 108 this.inputHasFocus(true); 109 } 110 111 defaultEventHandle(_, e) { 112 L.DomEvent.stopPropagation(e); 113 return true; 114 } 115 116 onQueryChange() { 117 this.items.removeAll(); 118 this.error(null); 119 this.searchAborted(null); 120 this.maybeRequestSearch(); 121 } 122 123 onKeyDown = (_, e) => { 124 let n; 125 switch (e.which) { 126 case 38: // up 127 n = this.highlightedIndex(); 128 if (n === null) { 129 n = this.items().length - 1; 130 } else { 131 n -= 1; 132 } 133 if (n === -1) { 134 n = this.items().length - 1; 135 } 136 this.highlightedIndex(n); 137 break; 138 case 40: // down 139 n = this.highlightedIndex(); 140 if (n === null) { 141 n = 0; 142 } else { 143 n += 1; 144 } 145 if (n === this.items().length) { 146 n = 0; 147 } 148 this.highlightedIndex(n); 149 break; 150 case 13: // enter 151 if (this.items().length > 0) { 152 this.itemSelected(this.items()[this.highlightedIndex()]); 153 } 154 break; 155 case 27: // esc 156 this.escapePressed(null); 157 break; 158 default: 159 return true; 160 } 161 return false; 162 }; 163 /* eslint-enable no-invalid-this */ 164 165 maybeRequestSearch() { 166 if (this.isQueryLengthOk() && this.controlHasFocus()) { 167 this.searchRequested(null); 168 } 169 } 170 171 // public events 172 itemSelected = ko.observable().extend({notify: 'always'}); 173 searchRequested = ko.observable().extend({notify: 'always'}); 174 searchAborted = ko.observable().extend({notify: 'always'}); 175 escapePressed = ko.observable().extend({notify: 'always'}); 176 177 // public methods 178 setResult(items, attribution) { 179 this.items.splice(0, this.items().length, ...items); 180 this.error(null); 181 this.attribution(attribution); 182 } 183 184 setResultError(error) { 185 this.items.removeAll(); 186 this.error(error); 187 } 188 189 setFocus() { 190 this.inputHasFocus(true); 191 } 192 193 setInputPlaceholder(s) { 194 this.inputPlaceholder(s); 195 } 196 197 setHelpText(s) { 198 this.helpText(s); 199 } 200 201 setMinimizeAllowed(allowed) { 202 this.allowMinimize(allowed); 203 } 204 205 constructor(minSearchQueryLength) { 206 this.minSearchQueryLength = minSearchQueryLength; 207 this.query.subscribe(this.onQueryChange.bind(this)); 208 this.showResults.subscribe(this.onShowResults.bind(this)); 209 this.controlHasFocus.subscribe(this.onControlHasFocusChange.bind(this)); 210 this.inputHasFocus.subscribe(this.onInputHasFocusChange.bind(this)); 211 } 212 } 213 214 const SearchControl = L.Control.extend({ 215 includes: L.Mixin.Events, 216 217 options: { 218 provider: 'mapycz', 219 providerOptions: { 220 maxResponses: 5, 221 }, 222 minQueryLength: 3, 223 hotkey: 'L', 224 maxMapHeightToMinimize: 567, 225 maxMapWidthToMinimize: 450, 226 tooltip: 'Search places, coordinates, links (Alt-{hotkey})', 227 help: 'Coordinates in any format. Links to maps: Yandex, Google, OSM, Mapy.cz, Nakarte', 228 }, 229 230 initialize: function (options) { 231 L.Control.prototype.initialize.call(this, options); 232 this.provider = new providers[this.options.provider](this.options.providerOptions); 233 this.magicProviders = magicProviders.map((Cls) => new Cls()); 234 this.searchPromise = null; 235 this.viewModel = new SearchViewModel(this.options.minQueryLength); 236 this.viewModel.setInputPlaceholder(L.Util.template(this.options.tooltip, this.options)); 237 this.viewModel.setHelpText(this.options.help); 238 this.viewModel.searchRequested.subscribe(this.onSearchRequested.bind(this)); 239 this.viewModel.searchAborted.subscribe(this.onSearchAborted.bind(this)); 240 this.viewModel.itemSelected.subscribe(this.onResultItemClicked.bind(this)); 241 this.viewModel.query.subscribe(() => this.fire('querychange')); 242 this.viewModel.escapePressed.subscribe(this.setFocusToMap.bind(this)); 243 }, 244 245 onAdd: function (map) { 246 this._map = map; 247 const container = L.DomUtil.create('div', 'leaflet-search-container'); 248 container.innerHTML = controlTemplate; 249 stopContainerEvents(container); 250 ko.applyBindings(this.viewModel, container); 251 252 this.searchPromise = null; 253 254 L.DomEvent.on(document, 'keyup', this.onDocumentKeyUp, this); 255 map.on('resize', this.updateMinimizeAllowed, this); 256 this.updateMinimizeAllowed(); 257 258 // enable setting focus to map container 259 const mapContainer = map.getContainer(); 260 if (mapContainer.tabIndex === undefined) { 261 mapContainer.tabIndex = -1; 262 } 263 return container; 264 }, 265 266 setFocusToMap: function () { 267 this._map.getContainer().focus(); 268 }, 269 270 onSearchRequested: async function () { 271 const query = this.viewModel.query(); 272 const searchOptions = { 273 bbox: this._map.getBounds(), 274 latlng: this._map.getCenter(), 275 zoom: this._map.getZoom(), 276 }; 277 let provider = this.provider; 278 for (const magicProvider of this.magicProviders) { 279 if (magicProvider.isOurQuery(query)) { 280 provider = magicProvider; 281 } 282 } 283 this.searchPromise = provider.search(query, searchOptions); 284 const searchPromise = this.searchPromise; 285 const result = await searchPromise; 286 this.fire('resultreceived', {provider: provider.name, query, result}); 287 if (this.searchPromise === searchPromise) { 288 if (result.error) { 289 this.viewModel.setResultError(result.error); 290 } else if (result.results.length === 0) { 291 this.viewModel.setResultError('Nothing found'); 292 } else { 293 this.viewModel.setResult(result.results, provider.attribution); 294 } 295 } 296 }, 297 298 onSearchAborted: function () { 299 this.searchPromise = null; 300 }, 301 302 onResultItemClicked: function (item) { 303 if (item.bbox) { 304 this._map.fitBounds(item.bbox); 305 } else { 306 this._map.setView(item.latlng, item.zoom); 307 } 308 this._map.showPlacemark(item.latlng, item.title); 309 this.setFocusToMap(); 310 }, 311 312 onDocumentKeyUp: function (e) { 313 if (e.keyCode === this.options.hotkey.codePointAt(0) && e.altKey) { 314 this.viewModel.setFocus(); 315 } 316 }, 317 318 updateMinimizeAllowed: function () { 319 const mapSize = this._map.getSize(); 320 this.viewModel.setMinimizeAllowed( 321 mapSize.y < this.options.maxMapHeightToMinimize || mapSize.x < this.options.maxMapWidthToMinimize 322 ); 323 }, 324 }); 325 326 SearchControl.include(L.Mixin.HashState); 327 SearchControl.include({ 328 stateChangeEvents: ['querychange'], 329 330 serializeState: function () { 331 const query = this.viewModel.query(); 332 if (query) { 333 return [encodeURIComponent(query)]; 334 } 335 return null; 336 }, 337 338 unserializeState: function (state) { 339 if (state?.length === 1) { 340 this.viewModel.query(decodeURIComponent(state[0])); 341 return true; 342 } 343 return false; 344 }, 345 }); 346 347 export {SearchControl};