From 7aecea002585a7849864f2aca399e64f8e17dd97 Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 23 Mar 2026 20:37:40 +0100 Subject: [PATCH 1/5] initial version --- src/sidebar/search/AddressInput.tsx | 70 +++++++++++++++-- .../AddressInputAutocomplete.module.css | 23 ++++++ .../search/AddressInputAutocomplete.tsx | 74 ++++++++++++++++-- src/sidebar/search/RecentLocations.ts | 75 +++++++++++++++++++ 4 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 src/sidebar/search/RecentLocations.ts diff --git a/src/sidebar/search/AddressInput.tsx b/src/sidebar/search/AddressInput.tsx index d87b385e..922c53ed 100644 --- a/src/sidebar/search/AddressInput.tsx +++ b/src/sidebar/search/AddressInput.tsx @@ -1,7 +1,8 @@ import { JSX, ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { QueryPoint, QueryPointType } from '@/stores/QueryStore' import { Bbox, GeocodingHit, ReverseGeocodingHit } from '@/api/graphhopper' -import Autocomplete, { AutocompleteItem, GeocodingItem, POIQueryItem } from '@/sidebar/search/AddressInputAutocomplete' +import Autocomplete, { AutocompleteItem, GeocodingItem, POIQueryItem, RecentLocationItem } from '@/sidebar/search/AddressInputAutocomplete' +import { clearRecentLocations, getRecentLocations, saveRecentLocation } from '@/sidebar/search/RecentLocations' import ArrowBack from './arrow_back.svg' import Cross from '@/sidebar/times-solid-thin.svg' import CurrentLocationIcon from './current-location.svg' @@ -73,13 +74,23 @@ export default function AddressInput(props: AddressInputProps) { const [poiSearch] = useState(new ReverseGeocoder(getApi(), props.point, AddressParseResult.handleGeocodingResponse)) // if item is selected we need to clear the autocompletion list - useEffect(() => setAutocompleteItems([]), [props.point]) + useEffect(() => { + if (pendingItemsRef.current) { + setAutocompleteItems(pendingItemsRef.current) + pendingItemsRef.current = null + } else { + setAutocompleteItems([]) + } + }, [props.point]) // highlighted result of geocoding results. Keep track which index is highlighted and change things on ArrowUp and Down // on Enter select highlighted result or the 0th if nothing is highlighted const [highlightedResult, setHighlightedResult] = useState(-1) useEffect(() => setHighlightedResult(-1), [autocompleteItems]) + // items to restore after the props.point-change effect clears autocomplete + const pendingItemsRef = useRef(null) + // for positioning of the autocomplete we need: const searchInputContainer = useRef(null) @@ -106,7 +117,7 @@ export default function AddressInput(props: AddressInputProps) { setText(origText) } else if (nextIndex >= 0) { const item = autocompleteItems[nextIndex] - if (item instanceof GeocodingItem) setText(item.mainText) + if (item instanceof GeocodingItem || item instanceof RecentLocationItem) setText(item.mainText) else setText(origText) } } @@ -127,6 +138,8 @@ export default function AddressInput(props: AddressInputProps) { if (item instanceof POIQueryItem) { handlePoiSearch(poiSearch, item.result, props.map) props.onAddressSelected(item.result.text(item.result.poi), undefined) + } else if (item instanceof RecentLocationItem) { + props.onAddressSelected(item.toText(), item.point) } else if (highlightedResult < 0 && !props.point.isInitialized) { // by default use the first result, otherwise the highlighted one getApi() @@ -136,12 +149,15 @@ export default function AddressInput(props: AddressInputProps) { const hit: GeocodingHit = result.hits[0] const res = nominatimHitToItem(hit) props.onAddressSelected(res.mainText + ', ' + res.secondText, hit.point) + saveRecentLocation(res.mainText, res.secondText, hit.point) } else if (item instanceof GeocodingItem) { props.onAddressSelected(item.toText(), item.point) + saveRecentLocation(item.mainText, item.secondText, item.point) } }) } else if (item instanceof GeocodingItem) { props.onAddressSelected(item.toText(), item.point) + saveRecentLocation(item.mainText, item.secondText, item.point) } } // Enter: focus next address input, or blur if last @@ -201,8 +217,21 @@ export default function AddressInput(props: AddressInputProps) { onChange={e => { const query = e.target.value setText(query) - const coordinate = textToCoordinate(query) - if (!coordinate) geocoder.request(e.target.value, biasCoord, getMap().getView().getZoom()) + if (query === '') { + geocoder.cancel() + const recents = buildRecentItems(undefined, 5) + pendingItemsRef.current = recents.length > 0 ? recents : null + if (recents.length > 0) setAutocompleteItems(recents) + else setAutocompleteItems([]) + } else { + const coordinate = textToCoordinate(query) + if (!coordinate) { + const recents = buildRecentItems(query) + pendingItemsRef.current = recents.length > 0 ? recents : null + if (recents.length > 0) setAutocompleteItems(recents) + geocoder.request(query, biasCoord, getMap().getView().getZoom()) + } + } props.onChange(query) }} onKeyDown={onKeypress} @@ -210,6 +239,10 @@ export default function AddressInput(props: AddressInputProps) { setHasFocus(true) props.clearDragDrop() if (origAutocompleteItems.length > 0) setAutocompleteItems(origAutocompleteItems) + else if (text === '') { + const recents = buildRecentItems(undefined, 5) + if (recents.length > 0) setAutocompleteItems(recents) + } }} onBlur={() => { setHasFocus(false) @@ -233,6 +266,9 @@ export default function AddressInput(props: AddressInputProps) { onClick={e => { setText('') props.onChange('') + const recents = buildRecentItems(undefined, 5) + if (recents.length > 0) setAutocompleteItems(recents) + else setAutocompleteItems([]) // if we clear the text without focus then explicitly request it to improve usability: searchInput.current!.focus() }} @@ -268,12 +304,19 @@ export default function AddressInput(props: AddressInputProps) { onSelect={item => { if (item instanceof GeocodingItem) { props.onAddressSelected(item.toText(), item.point) + saveRecentLocation(item.mainText, item.secondText, item.point) + } else if (item instanceof RecentLocationItem) { + props.onAddressSelected(item.toText(), item.point) } else if (item instanceof POIQueryItem) { handlePoiSearch(poiSearch, item.result, props.map) setText(item.result.text(item.result.poi)) } searchInput.current!.blur() // see also AutocompleteEntry->onMouseDown }} + onClearRecents={() => { + clearRecentLocations() + setAutocompleteItems([]) + }} /> )} @@ -282,6 +325,23 @@ export default function AddressInput(props: AddressInputProps) { ) } +function buildRecentItems(filter?: string, limit?: number): RecentLocationItem[] { + let recents = getRecentLocations(1) + if (filter) { + const lower = filter.toLowerCase() + recents = recents.filter( + e => + e.mainText.toLowerCase().startsWith(lower) || + e.secondText.toLowerCase().split(/[\s,]+/).some(word => word.startsWith(lower)), + ) + } + if (limit) recents = recents.slice(0, limit) + return recents.map( + e => + new RecentLocationItem(e.mainText, e.secondText, { lat: e.lat, lng: e.lng }, getBBoxFromCoord({ lat: e.lat, lng: e.lng })), + ) +} + function handlePoiSearch(poiSearch: ReverseGeocoder, result: AddressParseResult, map: Map) { if (!result.hasPOIs()) return diff --git a/src/sidebar/search/AddressInputAutocomplete.module.css b/src/sidebar/search/AddressInputAutocomplete.module.css index 6fa88ed9..2b69511a 100644 --- a/src/sidebar/search/AddressInputAutocomplete.module.css +++ b/src/sidebar/search/AddressInputAutocomplete.module.css @@ -61,3 +61,26 @@ font-size: small; color: #5b616a; } + +.recentHeader { + display: flex; + justify-content: space-between; + align-items: center; + font-size: small; + font-weight: bold; + color: #5b616a; + padding: 0.3rem 0.5rem 0; +} + +.clearRecentsButton { + border: none; + background: none; + color: #5b616a; + font-size: small; + cursor: pointer; + padding: 0; +} + +.clearRecentsButton:hover { + color: #333; +} diff --git a/src/sidebar/search/AddressInputAutocomplete.tsx b/src/sidebar/search/AddressInputAutocomplete.tsx index 292a8cf7..2ba2bebe 100644 --- a/src/sidebar/search/AddressInputAutocomplete.tsx +++ b/src/sidebar/search/AddressInputAutocomplete.tsx @@ -22,6 +22,24 @@ export class GeocodingItem implements AutocompleteItem { } } +export class RecentLocationItem implements AutocompleteItem { + mainText: string + secondText: string + point: { lat: number; lng: number } + bbox: Bbox + + constructor(mainText: string, secondText: string, point: { lat: number; lng: number }, bbox: Bbox) { + this.mainText = mainText + this.secondText = secondText + this.point = point + this.bbox = bbox + } + + toText() { + return this.mainText + ', ' + this.secondText + } +} + export class POIQueryItem implements AutocompleteItem { result: AddressParseResult @@ -34,16 +52,39 @@ export interface AutocompleteProps { items: AutocompleteItem[] highlightedItem: AutocompleteItem onSelect: (hit: AutocompleteItem) => void + onClearRecents?: () => void } -export default function Autocomplete({ items, highlightedItem, onSelect }: AutocompleteProps) { +export default function Autocomplete({ items, highlightedItem, onSelect, onClearRecents }: AutocompleteProps) { + let recentHeaderShown = false return (
    - {items.map((item, i) => ( -
  • - {mapToComponent(item, highlightedItem === item, onSelect)} -
  • - ))} + {items.map((item, i) => { + let header = null + if (item instanceof RecentLocationItem && !recentHeaderShown) { + recentHeaderShown = true + header = ( +
    + Recent + {onClearRecents && ( + + )} +
    + ) + } + return ( +
  • + {header} + {mapToComponent(item, highlightedItem === item, onSelect)} +
  • + ) + })}
) } @@ -51,6 +92,8 @@ export default function Autocomplete({ items, highlightedItem, onSelect }: Autoc function mapToComponent(item: AutocompleteItem, isHighlighted: boolean, onSelect: (hit: AutocompleteItem) => void) { if (item instanceof GeocodingItem) return + else if (item instanceof RecentLocationItem) + return else if (item instanceof POIQueryItem) return else throw Error('Unsupported item type: ' + typeof item) @@ -95,6 +138,25 @@ function GeocodingEntry({ ) } +function RecentLocationEntry({ + item, + isHighlighted, + onSelect, +}: { + item: RecentLocationItem + isHighlighted: boolean + onSelect: (item: RecentLocationItem) => void +}) { + return ( + onSelect(item)}> +
+ {item.mainText} + {item.secondText} +
+
+ ) +} + function AutocompleteEntry({ isHighlighted, children, diff --git a/src/sidebar/search/RecentLocations.ts b/src/sidebar/search/RecentLocations.ts new file mode 100644 index 00000000..fb184ed7 --- /dev/null +++ b/src/sidebar/search/RecentLocations.ts @@ -0,0 +1,75 @@ +import { calcDist, Coordinate } from '@/utils' +import { tr } from '@/translation/Translation' +import { textToCoordinate } from '@/Converters' + +const STORAGE_KEY = 'recentLocations' +const MAX_ENTRIES = 20 +const DEDUP_DISTANCE_METERS = 100 + +export interface RecentLocation { + mainText: string + secondText: string + lat: number + lng: number + timestamp: number + count: number +} + +export function getRecentLocations(minCount: number = 0): RecentLocation[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed + .filter( + (e: any) => + typeof e.mainText === 'string' && + typeof e.secondText === 'string' && + typeof e.lat === 'number' && + typeof e.lng === 'number' && + typeof e.timestamp === 'number', + ) + .map((e: any) => ({ ...e, count: typeof e.count === 'number' ? e.count : 1 })) + .filter((e: RecentLocation) => e.count > minCount) + .sort((a: RecentLocation, b: RecentLocation) => b.timestamp - a.timestamp) + } catch { + return [] + } +} + +export function clearRecentLocations(): void { + try { + localStorage.removeItem(STORAGE_KEY) + } catch { + // localStorage unavailable + } +} + +export function saveRecentLocation(mainText: string, secondText: string, coordinate: Coordinate): void { + if (mainText === tr('current_location')) return + if (textToCoordinate(mainText)) return + + try { + const all = getRecentLocations() + const existing = all.find( + e => calcDist({ lat: e.lat, lng: e.lng }, coordinate) <= DEDUP_DISTANCE_METERS, + ) + const prevCount = existing ? existing.count : 0 + const filtered = all.filter( + e => calcDist({ lat: e.lat, lng: e.lng }, coordinate) > DEDUP_DISTANCE_METERS, + ) + + filtered.unshift({ + mainText, + secondText, + lat: coordinate.lat, + lng: coordinate.lng, + timestamp: Date.now(), + count: prevCount + 1, + }) + localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered.slice(0, MAX_ENTRIES))) + } catch { + // localStorage unavailable (private browsing, quota exceeded) + } +} From 86256101327a70372d734825123702ec2ca05ca9 Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 23 Mar 2026 20:43:54 +0100 Subject: [PATCH 2/5] simpler --- src/sidebar/search/AddressInput.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/sidebar/search/AddressInput.tsx b/src/sidebar/search/AddressInput.tsx index 922c53ed..09caa960 100644 --- a/src/sidebar/search/AddressInput.tsx +++ b/src/sidebar/search/AddressInput.tsx @@ -75,12 +75,7 @@ export default function AddressInput(props: AddressInputProps) { // if item is selected we need to clear the autocompletion list useEffect(() => { - if (pendingItemsRef.current) { - setAutocompleteItems(pendingItemsRef.current) - pendingItemsRef.current = null - } else { - setAutocompleteItems([]) - } + if (props.point.isInitialized) setAutocompleteItems([]) }, [props.point]) // highlighted result of geocoding results. Keep track which index is highlighted and change things on ArrowUp and Down @@ -88,9 +83,6 @@ export default function AddressInput(props: AddressInputProps) { const [highlightedResult, setHighlightedResult] = useState(-1) useEffect(() => setHighlightedResult(-1), [autocompleteItems]) - // items to restore after the props.point-change effect clears autocomplete - const pendingItemsRef = useRef(null) - // for positioning of the autocomplete we need: const searchInputContainer = useRef(null) @@ -220,14 +212,12 @@ export default function AddressInput(props: AddressInputProps) { if (query === '') { geocoder.cancel() const recents = buildRecentItems(undefined, 5) - pendingItemsRef.current = recents.length > 0 ? recents : null if (recents.length > 0) setAutocompleteItems(recents) else setAutocompleteItems([]) } else { const coordinate = textToCoordinate(query) if (!coordinate) { const recents = buildRecentItems(query) - pendingItemsRef.current = recents.length > 0 ? recents : null if (recents.length > 0) setAutocompleteItems(recents) geocoder.request(query, biasCoord, getMap().getView().getZoom()) } From 8a0645bd6b2b0641de077c9d20f9f3b0dd480e73 Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 23 Mar 2026 20:54:49 +0100 Subject: [PATCH 3/5] minor fixes --- src/sidebar/search/AddressInput.tsx | 11 +++++----- .../search/AddressInputAutocomplete.tsx | 21 ++++++++++++++----- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/sidebar/search/AddressInput.tsx b/src/sidebar/search/AddressInput.tsx index 09caa960..b86717a1 100644 --- a/src/sidebar/search/AddressInput.tsx +++ b/src/sidebar/search/AddressInput.tsx @@ -217,8 +217,10 @@ export default function AddressInput(props: AddressInputProps) { } else { const coordinate = textToCoordinate(query) if (!coordinate) { - const recents = buildRecentItems(query) - if (recents.length > 0) setAutocompleteItems(recents) + if (query.length < 2) { + const recents = buildRecentItems(query) + if (recents.length > 0) setAutocompleteItems(recents) + } geocoder.request(query, biasCoord, getMap().getView().getZoom()) } } @@ -326,10 +328,7 @@ function buildRecentItems(filter?: string, limit?: number): RecentLocationItem[] ) } if (limit) recents = recents.slice(0, limit) - return recents.map( - e => - new RecentLocationItem(e.mainText, e.secondText, { lat: e.lat, lng: e.lng }, getBBoxFromCoord({ lat: e.lat, lng: e.lng })), - ) + return recents.map(e => new RecentLocationItem(e.mainText, e.secondText, { lat: e.lat, lng: e.lng })) } function handlePoiSearch(poiSearch: ReverseGeocoder, result: AddressParseResult, map: Map) { diff --git a/src/sidebar/search/AddressInputAutocomplete.tsx b/src/sidebar/search/AddressInputAutocomplete.tsx index 2ba2bebe..88c026e0 100644 --- a/src/sidebar/search/AddressInputAutocomplete.tsx +++ b/src/sidebar/search/AddressInputAutocomplete.tsx @@ -26,13 +26,11 @@ export class RecentLocationItem implements AutocompleteItem { mainText: string secondText: string point: { lat: number; lng: number } - bbox: Bbox - constructor(mainText: string, secondText: string, point: { lat: number; lng: number }, bbox: Bbox) { + constructor(mainText: string, secondText: string, point: { lat: number; lng: number }) { this.mainText = mainText this.secondText = secondText this.point = point - this.bbox = bbox } toText() { @@ -65,14 +63,27 @@ export default function Autocomplete({ items, highlightedItem, onSelect, onClear recentHeaderShown = true header = (
- Recent + + + + + {onClearRecents && ( )}
From bacbd8952f31ec34338492c4cee4113b7452cd28 Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 23 Mar 2026 21:00:10 +0100 Subject: [PATCH 4/5] sort favs first by count --- src/sidebar/search/AddressInputAutocomplete.tsx | 2 +- src/sidebar/search/RecentLocations.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sidebar/search/AddressInputAutocomplete.tsx b/src/sidebar/search/AddressInputAutocomplete.tsx index 88c026e0..de2cf185 100644 --- a/src/sidebar/search/AddressInputAutocomplete.tsx +++ b/src/sidebar/search/AddressInputAutocomplete.tsx @@ -63,7 +63,7 @@ export default function Autocomplete({ items, highlightedItem, onSelect, onClear recentHeaderShown = true header = (
- + ({ ...e, count: typeof e.count === 'number' ? e.count : 1 })) .filter((e: RecentLocation) => e.count > minCount) - .sort((a: RecentLocation, b: RecentLocation) => b.timestamp - a.timestamp) + .sort((a: RecentLocation, b: RecentLocation) => b.count - a.count || b.timestamp - a.timestamp) } catch { return [] } From 844c5fd17f85862ab2523738d3f8cca0c44ff839 Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 23 Mar 2026 21:17:35 +0100 Subject: [PATCH 5/5] limit to 5 --- src/sidebar/search/AddressInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sidebar/search/AddressInput.tsx b/src/sidebar/search/AddressInput.tsx index b86717a1..12a55081 100644 --- a/src/sidebar/search/AddressInput.tsx +++ b/src/sidebar/search/AddressInput.tsx @@ -218,7 +218,7 @@ export default function AddressInput(props: AddressInputProps) { const coordinate = textToCoordinate(query) if (!coordinate) { if (query.length < 2) { - const recents = buildRecentItems(query) + const recents = buildRecentItems(query, 5) if (recents.length > 0) setAutocompleteItems(recents) } geocoder.request(query, biasCoord, getMap().getView().getZoom())