-
-
Notifications
You must be signed in to change notification settings - Fork 19
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
16 changed files
with
469 additions
and
76 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
224 changes: 224 additions & 0 deletions
224
src/components/FeaturePanel/EditDialog/EditContent/PresetSearchBox.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,224 @@ | ||
import React, { useMemo, useState } from 'react'; | ||
import { | ||
Box, | ||
Button, | ||
InputBase, | ||
ListSubheader, | ||
MenuItem, | ||
Select, | ||
} from '@mui/material'; | ||
import SearchIcon from '@mui/icons-material/Search'; | ||
import styled from '@emotion/styled'; | ||
import Maki from '../../../utils/Maki'; | ||
import { TranslatedPreset } from './PresetSelect'; | ||
import { Setter } from '../../../../types'; | ||
import { t } from '../../../../services/intl'; | ||
import { SelectChangeEvent } from '@mui/material/Select/SelectInput'; | ||
import { useEditContext } from '../EditContext'; | ||
import { useBoolState } from '../../../helpers'; | ||
import { useFeatureContext } from '../../../utils/FeatureContext'; | ||
import { PROJECT_ID } from '../../../../services/project'; | ||
import { useOsmAuthContext } from '../../../utils/OsmAuthContext'; | ||
import { OsmType } from '../../../../services/types'; | ||
import { geometryMatchesOsmType } from '../../../../services/tagging/presets'; | ||
|
||
// https://stackoverflow.com/a/70918883/671880 | ||
|
||
const containsText = (text: string, searchText: string) => | ||
text.toLowerCase().indexOf(searchText.toLowerCase()) > -1; | ||
|
||
const StyledListSubheader = styled(ListSubheader)` | ||
display: flex; | ||
align-items: center; | ||
border-bottom: 1px solid rgba(0, 0, 0, 0.12); | ||
padding: 6px 13px; | ||
& > svg:first-of-type { | ||
margin-right: 8px; | ||
} | ||
`; | ||
|
||
const emptyOptions = [ | ||
'amenity/cafe', | ||
'amenity/restaurant', | ||
'amenity/fast_food', | ||
'amenity/bar', | ||
'shop', | ||
'leisure/park', | ||
'amenity/place_of_worship', | ||
...(PROJECT_ID === 'openclimbing' | ||
? [ | ||
'climbing/route_bottom', | ||
'climbing/route', | ||
'climbing/crag', | ||
// 'climbing/area', | ||
] | ||
: []), | ||
]; | ||
|
||
const Placeholder = styled.span` | ||
color: ${({ theme }) => theme.palette.text.secondary}; | ||
`; | ||
|
||
const renderOption = (option: TranslatedPreset) => | ||
!option ? ( | ||
<Placeholder>{t('editdialog.preset_select.placeholder')}</Placeholder> | ||
) : ( | ||
<> | ||
<Maki ico={option.icon} size={16} middle themed /> | ||
<span style={{ paddingLeft: 5 }} /> | ||
{option.name} | ||
</> | ||
); | ||
|
||
const getFilteredOptions = ( | ||
options: TranslatedPreset[], | ||
searchText: string, | ||
osmType: OsmType | undefined, | ||
) => { | ||
const filteredOptions = options | ||
.filter(({ geometry }) => geometryMatchesOsmType(geometry, osmType)) | ||
.filter(({ searchable }) => searchable === undefined || searchable) | ||
.filter((option) => containsText(option.name, searchText)) | ||
.map((option) => option.presetKey); | ||
|
||
if (searchText.length <= 2) { | ||
return filteredOptions.splice(0, 50); // too many rows in select are slow | ||
} | ||
|
||
return filteredOptions; | ||
}; | ||
|
||
const useDisplayedOptions = ( | ||
searchText: string, | ||
options: TranslatedPreset[], | ||
): string[] => { | ||
const { feature } = useFeatureContext(); | ||
return useMemo<string[]>( | ||
() => | ||
searchText.length | ||
? getFilteredOptions(options, searchText, feature.osmMeta?.type) | ||
: emptyOptions, | ||
[feature.osmMeta?.type, options, searchText], | ||
); | ||
}; | ||
|
||
const SearchRow = ({ | ||
onChange, | ||
}: { | ||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; | ||
}) => ( | ||
<StyledListSubheader> | ||
<SearchIcon fontSize="small" /> | ||
<InputBase | ||
size="small" | ||
autoFocus | ||
placeholder={t('editdialog.preset_select.search_placeholder')} | ||
fullWidth | ||
onChange={onChange} | ||
onKeyDown={(e) => { | ||
if (e.key !== 'Escape') { | ||
e.stopPropagation(); | ||
} | ||
}} | ||
/> | ||
</StyledListSubheader> | ||
); | ||
|
||
const useGetOnChange = ( | ||
options: TranslatedPreset[], | ||
value: string, | ||
setValue: Setter<string>, | ||
) => { | ||
const { setTagsEntries } = useEditContext().tags; | ||
|
||
return (e: SelectChangeEvent<string>) => { | ||
const oldPreset = options.find((o) => o.presetKey === value); | ||
if (oldPreset) { | ||
Object.entries(oldPreset.addTags ?? oldPreset.tags ?? {}).forEach( | ||
(tag) => { | ||
setTagsEntries((state) => | ||
state.filter(([key, value]) => key !== tag[0] && value !== tag[1]), | ||
); | ||
}, | ||
); | ||
} | ||
|
||
const newPreset = options.find((o) => o.presetKey === e.target.value); | ||
if (newPreset) { | ||
const newTags = Object.entries(newPreset.addTags ?? newPreset.tags ?? {}); | ||
setTagsEntries((state) => [...newTags, ...state]); | ||
} | ||
setValue(newPreset.presetKey); | ||
}; | ||
}; | ||
|
||
const getPaperMaxHeight = ( | ||
selectRef: React.MutableRefObject<HTMLDivElement>, | ||
) => { | ||
if (!selectRef.current) { | ||
return undefined; | ||
} | ||
const BOTTOM_PADDING = 50; | ||
const rect = selectRef.current.getBoundingClientRect(); | ||
const height = window.innerHeight - (rect.top + rect.height) - BOTTOM_PADDING; | ||
return { | ||
style: { | ||
maxHeight: height, | ||
}, | ||
}; | ||
}; | ||
|
||
type Props = { | ||
value: string; | ||
setValue: Setter<string>; | ||
options: TranslatedPreset[]; | ||
}; | ||
export const PresetSearchBox = ({ value, setValue, options }: Props) => { | ||
const selectRef = React.useRef<HTMLDivElement>(null); | ||
const { feature } = useFeatureContext(); | ||
const { loggedIn } = useOsmAuthContext(); | ||
const [enabled, enable] = useBoolState(feature.point || !loggedIn); | ||
|
||
const [searchText, setSearchText] = useState(''); | ||
const displayedOptions = useDisplayedOptions(searchText, options); | ||
|
||
const onChange = useGetOnChange(options, value, setValue); | ||
|
||
return ( | ||
<> | ||
<Select | ||
disabled={!enabled} | ||
MenuProps={{ | ||
autoFocus: false, | ||
slotProps: { paper: getPaperMaxHeight(selectRef) }, | ||
}} | ||
value={value} | ||
onChange={onChange} | ||
onClose={() => setSearchText('')} | ||
renderValue={() => | ||
renderOption(options.find((o) => o.presetKey === value)) | ||
} | ||
size="small" | ||
variant="outlined" | ||
fullWidth | ||
displayEmpty | ||
ref={selectRef} | ||
> | ||
<SearchRow onChange={(e) => setSearchText(e.target.value)} /> | ||
{displayedOptions.map((option) => ( | ||
<MenuItem key={option} component="li" value={option}> | ||
{renderOption(options.find((o) => o.presetKey === option))} | ||
</MenuItem> | ||
))} | ||
</Select> | ||
{!enabled && ( | ||
// TODO we may warn users that this is not usual operation, if this becomes an issue | ||
<Box ml={1}> | ||
<Button color="secondary" onClick={enable}> | ||
{t('editdialog.preset_select.edit_button')} | ||
</Button> | ||
</Box> | ||
)} | ||
</> | ||
); | ||
}; |
114 changes: 114 additions & 0 deletions
114
src/components/FeaturePanel/EditDialog/EditContent/PresetSelect.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
import React, { useEffect, useState } from 'react'; | ||
import { Box, Typography } from '@mui/material'; | ||
import styled from '@emotion/styled'; | ||
import { getPoiClass } from '../../../../services/getPoiClass'; | ||
import { allPresets } from '../../../../services/tagging/data'; | ||
import { | ||
fetchSchemaTranslations, | ||
getPresetTermsTranslation, | ||
getPresetTranslation, | ||
} from '../../../../services/tagging/translations'; | ||
import { useFeatureContext } from '../../../utils/FeatureContext'; | ||
import { PresetSearchBox } from './PresetSearchBox'; | ||
import { useEditContext } from '../EditContext'; | ||
import { Preset } from '../../../../services/tagging/types/Presets'; | ||
import { getPresetForFeature } from '../../../../services/tagging/presets'; | ||
import { Feature, FeatureTags } from '../../../../services/types'; | ||
import { t } from '../../../../services/intl'; | ||
import { Setter } from '../../../../types'; | ||
|
||
export type TranslatedPreset = Preset & { | ||
name: string; | ||
icon: string; | ||
}; | ||
|
||
type PresetCacheItem = Preset & { name: string; icon: string; terms: string[] }; | ||
type PresetsCache = PresetCacheItem[]; | ||
|
||
let presetsCache: PresetsCache | null = null; | ||
const getTranslatedPresets = async (): Promise<PresetsCache> => { | ||
if (presetsCache) { | ||
return presetsCache; | ||
} | ||
|
||
await fetchSchemaTranslations(); | ||
|
||
// resolve symlinks to {landuse...} etc | ||
presetsCache = Object.values(allPresets) | ||
.filter(({ locationSet }) => !locationSet?.include) | ||
.filter(({ tags }) => Object.keys(tags).length > 0) | ||
.map((preset) => { | ||
return { | ||
...preset, | ||
name: getPresetTranslation(preset.presetKey) ?? preset.presetKey, | ||
icon: getPoiClass(preset.tags).class, | ||
terms: getPresetTermsTranslation(preset.presetKey) ?? preset.terms, | ||
}; | ||
}) | ||
.sort((a, b) => a.name.localeCompare(b.name)); | ||
|
||
return presetsCache; | ||
}; | ||
|
||
const Row = styled(Box)` | ||
display: flex; | ||
align-items: center; | ||
`; | ||
|
||
const LabelWrapper = styled.div` | ||
min-width: 44px; | ||
margin-right: 1em; | ||
`; | ||
|
||
const useMatchTags = ( | ||
feature: Feature, | ||
tags: FeatureTags, | ||
setPreset: Setter<string>, | ||
) => { | ||
useEffect(() => { | ||
(async () => { | ||
const updatedFeature: Feature = { | ||
...feature, | ||
...(feature.point ? { osmMeta: { type: 'node', id: -1 } } : {}), | ||
tags, | ||
}; | ||
const foundPreset = getPresetForFeature(updatedFeature); // takes ~ 1 ms | ||
const translatedPreset = (await getTranslatedPresets()).find( | ||
(option) => option.presetKey === foundPreset.presetKey, | ||
); | ||
setPreset(translatedPreset?.presetKey ?? ''); | ||
})(); | ||
}, [tags, feature, setPreset]); | ||
}; | ||
|
||
const useOptions = () => { | ||
const [options, setOptions] = useState<PresetsCache>([]); | ||
useEffect(() => { | ||
getTranslatedPresets().then((presets) => setOptions(presets)); | ||
}, []); | ||
return options; | ||
}; | ||
|
||
export const PresetSelect = () => { | ||
const { tags } = useEditContext().tags; | ||
const [preset, setPreset] = useState(''); | ||
const { feature } = useFeatureContext(); | ||
const options = useOptions(); | ||
useMatchTags(feature, tags, setPreset); | ||
|
||
if (options.length === 0) { | ||
return null; | ||
} | ||
|
||
return ( | ||
<Row mb={3}> | ||
<LabelWrapper> | ||
<Typography variant="body1" component="span" color="textSecondary"> | ||
{t('editdialog.preset_select.label')} | ||
</Typography> | ||
</LabelWrapper> | ||
|
||
<PresetSearchBox value={preset} setValue={setPreset} options={options} /> | ||
</Row> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.