@sanity/google-maps-input 4.1.1 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,8 +1,20 @@
1
1
  export {GeopointInput, type GeopointInputProps} from './input/GeopointInput'
2
+ export {GeopointRadiusInput, type GeopointRadiusInputProps} from './input/GeopointRadiusInput'
2
3
 
3
4
  export {GeopointArrayDiff, type DiffProps as GeopointArrayDiffProps} from './diff/GeopointArrayDiff'
4
5
  export {GeopointFieldDiff, type DiffProps as GeopointFieldDiffProps} from './diff/GeopointFieldDiff'
6
+ export {
7
+ GeopointRadiusFieldDiff,
8
+ type DiffProps as GeopointRadiusFieldDiffProps,
9
+ } from './diff/GeopointRadiusFieldDiff'
5
10
 
6
- export type {LatLng, GeopointSchemaType, Geopoint, GoogleMapsInputConfig} from './types'
11
+ export type {
12
+ LatLng,
13
+ GeopointSchemaType,
14
+ Geopoint,
15
+ GeopointRadius,
16
+ GeopointRadiusSchemaType,
17
+ GoogleMapsInputConfig,
18
+ } from './types'
7
19
 
8
20
  export {googleMapsInput} from './plugin'
@@ -0,0 +1,258 @@
1
+ import React, {useCallback, useEffect, useId, useRef, useState} from 'react'
2
+ import {Box, Button, Dialog, Grid, Stack, TextInput, Label} from '@sanity/ui'
3
+ import {EditIcon, TrashIcon} from '@sanity/icons'
4
+ import {ObjectInputProps, set, setIfMissing, unset, ChangeIndicator, Path} from 'sanity'
5
+ import {GoogleMapsLoadProxy} from '../loader/GoogleMapsLoadProxy'
6
+ import type {
7
+ GeopointRadius,
8
+ GeopointRadiusSchemaType,
9
+ GoogleMapsInputConfig,
10
+ LatLng,
11
+ } from '../types'
12
+ import {getGeoConfig} from '../global-workaround'
13
+ import {DialogInnerContainer, PreviewImage} from './GeopointInput.styles'
14
+ import {GeopointRadiusSelect} from './GeopointRadiusSelect'
15
+
16
+ const EMPTY_PATH: Path = []
17
+
18
+ // Helper function to generate circle points
19
+ const generateCirclePoints = (
20
+ lat: number,
21
+ lng: number,
22
+ radius: number,
23
+ ): Array<{lat: number; lng: number}> => {
24
+ const points = []
25
+ const steps = 32 // Number of points to create the circle
26
+
27
+ for (let i = 0; i <= steps; i++) {
28
+ const angle = (i / steps) * 2 * Math.PI
29
+ const latOffset = (radius / 111000) * Math.cos(angle) // Rough conversion to degrees
30
+ const lngOffset = (radius / (111000 * Math.cos((lat * Math.PI) / 180))) * Math.sin(angle)
31
+
32
+ points.push({
33
+ lat: lat + latOffset,
34
+ lng: lng + lngOffset,
35
+ })
36
+ }
37
+
38
+ return points
39
+ }
40
+
41
+ const getStaticImageUrl = (value: LatLng & {radius?: number}, apiKey: string) => {
42
+ const loc = `${value.lat},${value.lng}`
43
+
44
+ // Calculate appropriate zoom level based on radius
45
+ let zoom = 13
46
+ if (value.radius) {
47
+ // Use logarithmic formula for better zoom calculation
48
+ // Add padding to ensure circle is fully visible
49
+ const radius = value.radius + value.radius / 2
50
+ const scale = radius / 500
51
+ const calculatedZoom = 16 - Math.log(scale) / Math.log(2)
52
+ // Add small offset to ensure circle fits well in view
53
+ zoom = Math.max(8, Math.min(16, Math.round(calculatedZoom - 0.4)))
54
+ }
55
+
56
+ const qs = new URLSearchParams({
57
+ key: apiKey,
58
+ center: loc,
59
+ markers: loc,
60
+ zoom: zoom.toString(),
61
+ scale: '2',
62
+ size: '640x300',
63
+ })
64
+
65
+ // Add circle if radius is present
66
+ if (value.radius) {
67
+ // Create a circle path using multiple points
68
+ const points = generateCirclePoints(value.lat, value.lng, value.radius)
69
+ const path = points.map((p) => `${p.lat},${p.lng}`).join('|')
70
+ qs.append('path', `fillcolor:0x4285F480|color:0x4285F4|weight:2|${path}`)
71
+ }
72
+
73
+ return `https://maps.googleapis.com/maps/api/staticmap?${qs.toString()}`
74
+ }
75
+
76
+ export type GeopointRadiusInputProps = ObjectInputProps<
77
+ GeopointRadius,
78
+ GeopointRadiusSchemaType
79
+ > & {
80
+ geoConfig: GoogleMapsInputConfig
81
+ }
82
+
83
+ export function GeopointRadiusInput(props: GeopointRadiusInputProps) {
84
+ const {
85
+ changed,
86
+ elementProps,
87
+ focused,
88
+ geoConfig: config,
89
+ onChange,
90
+ onPathFocus,
91
+ path,
92
+ readOnly,
93
+ schemaType,
94
+ value,
95
+ } = props
96
+
97
+ const {
98
+ id,
99
+ ref: inputRef,
100
+ onBlur: handleBlur,
101
+ onFocus: handleFocus,
102
+ 'aria-describedby': ariaDescribedBy,
103
+ } = elementProps
104
+
105
+ const schemaTypeName = schemaType.name
106
+ const dialogId = useId()
107
+ const dialogRef = useRef<HTMLDivElement | null>(null)
108
+ const handleFocusButton = useCallback(() => inputRef?.current?.focus(), [inputRef])
109
+ const [modalOpen, setModalOpen] = useState(false)
110
+
111
+ const handleCloseModal = useCallback(() => {
112
+ if (dialogRef.current) dialogRef.current.blur()
113
+ setModalOpen(false)
114
+ handleFocusButton()
115
+ }, [setModalOpen, handleFocusButton])
116
+
117
+ const handleToggleModal = useCallback(
118
+ () => setModalOpen((currentState) => !currentState),
119
+ [setModalOpen],
120
+ )
121
+
122
+ const handleChange = useCallback(
123
+ (latLng: google.maps.LatLng, radius?: number) => {
124
+ const currentRadius = radius ?? value?.radius ?? config.defaultRadius ?? 1000
125
+ onChange([
126
+ setIfMissing({_type: schemaTypeName}),
127
+ set(latLng.lat(), ['lat']),
128
+ set(latLng.lng(), ['lng']),
129
+ set(currentRadius, ['radius']),
130
+ ])
131
+ },
132
+ [schemaTypeName, onChange, value?.radius, config.defaultRadius],
133
+ )
134
+
135
+ const handleRadiusChange = useCallback(
136
+ (event: React.ChangeEvent<HTMLInputElement>) => {
137
+ if (value) {
138
+ onChange([set(Math.round(Number(event.currentTarget.value)), ['radius'])])
139
+ }
140
+ },
141
+ [onChange, value],
142
+ )
143
+
144
+ const handleClear = useCallback(() => {
145
+ onChange(unset())
146
+ }, [onChange])
147
+
148
+ useEffect(() => {
149
+ if (modalOpen) {
150
+ onPathFocus(EMPTY_PATH)
151
+ }
152
+ }, [modalOpen, onPathFocus])
153
+
154
+ if (!config || !config.apiKey) {
155
+ return (
156
+ <div>
157
+ <p>
158
+ The <a href="https://sanity.io/docs/schema-types/geopoint-type">Geopoint Radius type</a>{' '}
159
+ needs a Google Maps API key with access to:
160
+ </p>
161
+ <ul>
162
+ <li>Google Maps JavaScript API</li>
163
+ <li>Google Places API Web Service</li>
164
+ <li>Google Static Maps API</li>
165
+ </ul>
166
+ <p>
167
+ Please enter the API key with access to these services in your googleMapsInput plugin
168
+ config.
169
+ </p>
170
+ </div>
171
+ )
172
+ }
173
+
174
+ return (
175
+ <Stack space={3}>
176
+ {value && (
177
+ <ChangeIndicator path={path} isChanged={changed} hasFocus={!!focused}>
178
+ <PreviewImage
179
+ src={getStaticImageUrl(value, config.apiKey)}
180
+ alt="Map location with radius"
181
+ onClick={handleFocusButton}
182
+ onDoubleClick={handleToggleModal}
183
+ />
184
+ </ChangeIndicator>
185
+ )}
186
+
187
+ {value && (
188
+ <Stack space={2}>
189
+ <Label>Radius (meters)</Label>
190
+ <TextInput
191
+ type="number"
192
+ value={Math.round(value.radius || config.defaultRadius || 1000)}
193
+ onChange={handleRadiusChange}
194
+ disabled={readOnly}
195
+ min={1}
196
+ max={50000}
197
+ step={1}
198
+ />
199
+ </Stack>
200
+ )}
201
+
202
+ <Box>
203
+ <Grid columns={value ? 2 : 1} gap={3}>
204
+ <Button
205
+ aria-describedby={ariaDescribedBy}
206
+ disabled={readOnly}
207
+ icon={value && EditIcon}
208
+ id={id}
209
+ mode="ghost"
210
+ onClick={handleToggleModal}
211
+ onFocus={handleFocus}
212
+ padding={3}
213
+ ref={inputRef}
214
+ text={value ? 'Edit' : 'Set location and radius'}
215
+ />
216
+
217
+ {value && (
218
+ <Button
219
+ disabled={readOnly}
220
+ icon={TrashIcon}
221
+ mode="ghost"
222
+ onClick={handleClear}
223
+ padding={3}
224
+ text="Remove"
225
+ tone="critical"
226
+ />
227
+ )}
228
+ </Grid>
229
+ </Box>
230
+
231
+ {modalOpen && (
232
+ <Dialog
233
+ header="Place the marker and set radius on the map"
234
+ id={`${dialogId}_dialog`}
235
+ onBlur={handleBlur}
236
+ onClose={handleCloseModal}
237
+ ref={dialogRef}
238
+ width={1}
239
+ >
240
+ <DialogInnerContainer>
241
+ <GoogleMapsLoadProxy config={getGeoConfig()}>
242
+ {(api) => (
243
+ <GeopointRadiusSelect
244
+ api={api}
245
+ value={value || undefined}
246
+ onChange={readOnly ? undefined : handleChange}
247
+ defaultLocation={config.defaultLocation}
248
+ defaultRadiusZoom={config.defaultRadiusZoom}
249
+ defaultRadius={config.defaultRadius}
250
+ />
251
+ )}
252
+ </GoogleMapsLoadProxy>
253
+ </DialogInnerContainer>
254
+ </Dialog>
255
+ )}
256
+ </Stack>
257
+ )
258
+ }
@@ -0,0 +1,193 @@
1
+ import React, {type FC, useCallback, useEffect, useRef} from 'react'
2
+ import {SearchInput} from '../map/SearchInput'
3
+ import {GoogleMap} from '../map/Map'
4
+ import {Marker} from '../map/Marker'
5
+ import type {LatLng, GeopointRadius} from '../types'
6
+
7
+ const fallbackLatLng: LatLng = {lat: 40.7058254, lng: -74.1180863}
8
+
9
+ // Component to sync marker drag with circle position
10
+ const MarkerDragSync: FC<{
11
+ api: typeof window.google.maps
12
+ marker: google.maps.Marker
13
+ circleRef: React.MutableRefObject<google.maps.Circle | null>
14
+ isMarkerDragging: React.MutableRefObject<boolean>
15
+ }> = ({api, marker, circleRef, isMarkerDragging}) => {
16
+ useEffect(() => {
17
+ const handleDrag = () => {
18
+ isMarkerDragging.current = true
19
+ }
20
+
21
+ const handleDragEnd = () => {
22
+ isMarkerDragging.current = false
23
+ }
24
+
25
+ const dragListener = api.event.addListener(marker, 'drag', handleDrag)
26
+ const dragEndListener = api.event.addListener(marker, 'dragend', handleDragEnd)
27
+
28
+ return () => {
29
+ api.event.removeListener(dragListener)
30
+ api.event.removeListener(dragEndListener)
31
+ }
32
+ }, [api, marker, circleRef, isMarkerDragging])
33
+
34
+ return null
35
+ }
36
+
37
+ interface SelectProps {
38
+ api: typeof window.google.maps
39
+ value?: GeopointRadius
40
+ onChange?: (latLng: google.maps.LatLng, radius?: number) => void
41
+ defaultLocation?: LatLng
42
+ defaultRadiusZoom?: number
43
+ defaultRadius?: number
44
+ }
45
+
46
+ export const GeopointRadiusSelect: FC<SelectProps> = ({
47
+ api,
48
+ value,
49
+ onChange,
50
+ defaultLocation = {lng: 10.74609, lat: 59.91273},
51
+ defaultRadiusZoom = 12,
52
+ defaultRadius = 1000,
53
+ }) => {
54
+ const circleRef = useRef<google.maps.Circle | null>(null)
55
+ const markerRef = useRef<google.maps.Marker | undefined>(undefined)
56
+ const isMarkerDragging = useRef(false)
57
+
58
+ const getCenter = useCallback(() => {
59
+ const point: LatLng = {...fallbackLatLng, ...defaultLocation, ...value}
60
+ return point
61
+ }, [value, defaultLocation])
62
+
63
+ const setValue = useCallback(
64
+ (geoPoint: google.maps.LatLng, radius?: number) => {
65
+ if (onChange) {
66
+ const roundedRadius = radius ? Math.round(radius) : undefined
67
+ onChange(geoPoint, roundedRadius)
68
+ }
69
+ },
70
+ [onChange],
71
+ )
72
+
73
+ const handlePlaceChanged = useCallback(
74
+ (place: google.maps.places.PlaceResult) => {
75
+ if (!place.geometry?.location) {
76
+ return
77
+ }
78
+ setValue(place.geometry.location, value?.radius || defaultRadius)
79
+ },
80
+ [setValue, value?.radius, defaultRadius],
81
+ )
82
+
83
+ const handleMarkerDragEnd = useCallback(
84
+ (event: google.maps.MapMouseEvent) => {
85
+ if (event.latLng) {
86
+ // Update circle position when marker drag ends
87
+ if (circleRef.current) {
88
+ circleRef.current.setCenter(event.latLng)
89
+ }
90
+ setValue(event.latLng, value?.radius || defaultRadius)
91
+ }
92
+ },
93
+ [setValue, value?.radius, defaultRadius],
94
+ )
95
+
96
+ const handleMapClick = useCallback(
97
+ (event: google.maps.MapMouseEvent) => {
98
+ if (event.latLng) {
99
+ setValue(event.latLng, value?.radius || defaultRadius)
100
+ }
101
+ },
102
+ [setValue, value?.radius, defaultRadius],
103
+ )
104
+
105
+ // Create or update circle when value changes
106
+ useEffect(() => {
107
+ if (value && circleRef.current) {
108
+ circleRef.current.setCenter({lat: value.lat, lng: value.lng})
109
+ circleRef.current.setRadius(value.radius)
110
+ }
111
+ }, [value])
112
+
113
+ return (
114
+ <GoogleMap
115
+ api={api}
116
+ location={getCenter()}
117
+ onClick={handleMapClick}
118
+ defaultZoom={defaultRadiusZoom}
119
+ >
120
+ {(map) => {
121
+ // Create circle if it doesn't exist and we have a value
122
+ if (value && !circleRef.current) {
123
+ circleRef.current = new api.Circle({
124
+ map,
125
+ center: {lat: value.lat, lng: value.lng},
126
+ radius: value.radius,
127
+ fillColor: '#4285F4',
128
+ fillOpacity: 0.2,
129
+ strokeColor: '#4285F4',
130
+ strokeOpacity: 0.8,
131
+ strokeWeight: 2,
132
+ editable: true,
133
+ })
134
+
135
+ // Add event listeners for circle interactions
136
+ circleRef.current.addListener('center_changed', () => {
137
+ if (circleRef.current && markerRef.current && !isMarkerDragging.current) {
138
+ // When circle center is dragged, move the marker to match
139
+ const circleCenter = circleRef.current.getCenter()
140
+ if (circleCenter) {
141
+ markerRef.current.setPosition(circleCenter)
142
+ }
143
+ }
144
+ })
145
+
146
+ circleRef.current.addListener('radius_changed', () => {
147
+ if (circleRef.current) {
148
+ const center = circleRef.current.getCenter()
149
+ const radius = circleRef.current.getRadius()
150
+ if (center) {
151
+ setValue(center, Math.round(radius))
152
+ }
153
+ }
154
+ })
155
+
156
+ circleRef.current.addListener('dragend', () => {
157
+ if (circleRef.current) {
158
+ const center = circleRef.current.getCenter()
159
+ const radius = circleRef.current.getRadius()
160
+ if (center) {
161
+ setValue(center, Math.round(radius))
162
+ }
163
+ }
164
+ })
165
+ }
166
+
167
+ return (
168
+ <>
169
+ <SearchInput api={api} map={map} onChange={handlePlaceChanged} />
170
+ {value && (
171
+ <Marker
172
+ api={api}
173
+ map={map}
174
+ position={value}
175
+ onMove={onChange ? handleMarkerDragEnd : undefined}
176
+ markerRef={markerRef}
177
+ />
178
+ )}
179
+ {/* Add drag event listener to marker for circle sync */}
180
+ {value && markerRef.current && (
181
+ <MarkerDragSync
182
+ api={api}
183
+ marker={markerRef.current}
184
+ circleRef={circleRef}
185
+ isMarkerDragging={isMarkerDragging}
186
+ />
187
+ )}
188
+ </>
189
+ )
190
+ }}
191
+ </GoogleMap>
192
+ )
193
+ }
package/src/plugin.tsx CHANGED
@@ -1,12 +1,60 @@
1
1
  import {definePlugin, type SchemaType} from 'sanity'
2
2
  import {GeopointInput, type GeopointInputProps} from './input/GeopointInput'
3
+ import {GeopointRadiusInput, type GeopointRadiusInputProps} from './input/GeopointRadiusInput'
3
4
  import {setGeoConfig} from './global-workaround'
4
- import type {GeopointSchemaType, GoogleMapsInputConfig} from './types'
5
+ import type {GeopointSchemaType, GeopointRadiusSchemaType, GoogleMapsInputConfig} from './types'
5
6
 
6
7
  export const googleMapsInput = definePlugin<GoogleMapsInputConfig>((config) => {
7
8
  setGeoConfig(config)
8
9
  return {
9
10
  name: 'google-maps-input',
11
+ schema: {
12
+ types: [
13
+ {
14
+ name: 'geopointRadius',
15
+ title: 'Geopoint with Radius',
16
+ type: 'object',
17
+ fields: [
18
+ {
19
+ name: 'lat',
20
+ title: 'Latitude',
21
+ type: 'number',
22
+ validation: (Rule: any) => Rule.required().min(-90).max(90),
23
+ },
24
+ {
25
+ name: 'lng',
26
+ title: 'Longitude',
27
+ type: 'number',
28
+ validation: (Rule: any) => Rule.required().min(-180).max(180),
29
+ },
30
+ {
31
+ name: 'alt',
32
+ title: 'Altitude',
33
+ type: 'number',
34
+ },
35
+ {
36
+ name: 'radius',
37
+ title: 'Radius (meters)',
38
+ type: 'number',
39
+ validation: (Rule: any) => Rule.required().min(1).max(50000),
40
+ },
41
+ ],
42
+ preview: {
43
+ select: {
44
+ lat: 'lat',
45
+ lng: 'lng',
46
+ radius: 'radius',
47
+ },
48
+ prepare({lat, lng, radius}: {lat: number; lng: number; radius: number}) {
49
+ return {
50
+ title: `${lat.toFixed(6)}, ${lng.toFixed(6)}`,
51
+ subtitle: radius ? `Radius: ${radius}m` : 'No radius set',
52
+ }
53
+ },
54
+ },
55
+ },
56
+ ],
57
+ },
10
58
  form: {
11
59
  components: {
12
60
  input(props) {
@@ -14,6 +62,10 @@ export const googleMapsInput = definePlugin<GoogleMapsInputConfig>((config) => {
14
62
  const castedProps = props as unknown as Omit<GeopointInputProps, 'geoConfig'>
15
63
  return <GeopointInput {...castedProps} geoConfig={config} />
16
64
  }
65
+ if (isGeopointRadius(props.schemaType)) {
66
+ const castedProps = props as unknown as Omit<GeopointRadiusInputProps, 'geoConfig'>
67
+ return <GeopointRadiusInput {...castedProps} geoConfig={config} />
68
+ }
17
69
  return props.renderDefault(props)
18
70
  },
19
71
  },
@@ -25,6 +77,10 @@ function isGeopoint(schemaType: SchemaType): schemaType is GeopointSchemaType {
25
77
  return isType('geopoint', schemaType)
26
78
  }
27
79
 
80
+ function isGeopointRadius(schemaType: SchemaType): schemaType is GeopointRadiusSchemaType {
81
+ return isType('geopointRadius', schemaType)
82
+ }
83
+
28
84
  function isType(name: string, schema?: SchemaType): boolean {
29
85
  if (schema?.name === name) {
30
86
  return true
package/src/types.ts CHANGED
@@ -14,10 +14,25 @@ export interface Geopoint {
14
14
  alt?: number
15
15
  }
16
16
 
17
+ export interface GeopointRadius {
18
+ _type: 'geopointRadius'
19
+ _key?: string
20
+ lat: number
21
+ lng: number
22
+ alt?: number
23
+ radius: number
24
+ }
25
+
17
26
  export interface GeopointSchemaType extends ObjectSchemaType {
18
27
  diffComponent?: DiffComponent<ObjectDiff<Geopoint>> | DiffComponentOptions<ObjectDiff<Geopoint>>
19
28
  }
20
29
 
30
+ export interface GeopointRadiusSchemaType extends ObjectSchemaType {
31
+ diffComponent?:
32
+ | DiffComponent<ObjectDiff<GeopointRadius>>
33
+ | DiffComponentOptions<ObjectDiff<GeopointRadius>>
34
+ }
35
+
21
36
  export interface GoogleMapsInputConfig {
22
37
  apiKey: string
23
38
  defaultZoom?: number
@@ -26,4 +41,6 @@ export interface GoogleMapsInputConfig {
26
41
  lat: number
27
42
  lng: number
28
43
  }
44
+ defaultRadiusZoom?: number
45
+ defaultRadius?: number
29
46
  }