diff --git a/src/sidebar/search/AddressInput.tsx b/src/sidebar/search/AddressInput.tsx index d87b385e..12a55081 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,7 +74,9 @@ 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 (props.point.isInitialized) 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 @@ -106,7 +109,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 +130,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 +141,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 +209,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) + if (recents.length > 0) setAutocompleteItems(recents) + else setAutocompleteItems([]) + } else { + const coordinate = textToCoordinate(query) + if (!coordinate) { + if (query.length < 2) { + const recents = buildRecentItems(query, 5) + if (recents.length > 0) setAutocompleteItems(recents) + } + geocoder.request(query, biasCoord, getMap().getView().getZoom()) + } + } props.onChange(query) }} onKeyDown={onKeypress} @@ -210,6 +231,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 +258,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 +296,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 +317,20 @@ 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 })) +} + 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..de2cf185 100644 --- a/src/sidebar/search/AddressInputAutocomplete.tsx +++ b/src/sidebar/search/AddressInputAutocomplete.tsx @@ -22,6 +22,22 @@ export class GeocodingItem implements AutocompleteItem { } } +export class RecentLocationItem implements AutocompleteItem { + mainText: string + secondText: string + point: { lat: number; lng: number } + + constructor(mainText: string, secondText: string, point: { lat: number; lng: number }) { + this.mainText = mainText + this.secondText = secondText + this.point = point + } + + toText() { + return this.mainText + ', ' + this.secondText + } +} + export class POIQueryItem implements AutocompleteItem { result: AddressParseResult @@ -34,16 +50,52 @@ 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 ( ) } @@ -51,6 +103,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 +149,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..b5a084eb --- /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.count - a.count || 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) + } +}