@djangocfg/ui-tools 2.1.431 → 2.1.433
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/package.json +28 -14
- package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
- package/src/common/CodePanelHeader/index.ts +1 -0
- package/src/common/blocks/BlockError.tsx +46 -0
- package/src/common/blocks/index.ts +34 -0
- package/src/common/blocks/schemas.ts +143 -0
- package/src/common/blocks/types.ts +76 -0
- package/src/common/chips/README.md +67 -0
- package/src/common/chips/UrlChip.tsx +105 -0
- package/src/common/chips/index.ts +20 -0
- package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
- package/src/common/link-preview/index.ts +12 -0
- package/src/common/link-preview/types.ts +44 -0
- package/src/common/tiptap/index.ts +10 -0
- package/src/common/tiptap/useNodeAttrs.ts +128 -0
- package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
- package/src/tools/chat/README.md +88 -2
- package/src/tools/chat/composer/Composer.tsx +45 -12
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
- package/src/tools/chat/constants.ts +9 -2
- package/src/tools/chat/core/extractFirstUrl.ts +45 -0
- package/src/tools/chat/core/reducer.ts +32 -8
- package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
- package/src/tools/chat/core/transport/mock.ts +4 -1
- package/src/tools/chat/hooks/useChat.ts +20 -20
- package/src/tools/chat/lazy.tsx +10 -0
- package/src/tools/chat/messages/Attachments.tsx +267 -42
- package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
- package/src/tools/chat/messages/MessageBubble.tsx +175 -31
- package/src/tools/chat/messages/MessageList.tsx +107 -21
- package/src/tools/chat/messages/ToolCalls.tsx +28 -13
- package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
- package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
- package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
- package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
- package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
- package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
- package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
- package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
- package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
- package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
- package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
- package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
- package/src/tools/chat/messages/index.ts +13 -0
- package/src/tools/chat/public.ts +5 -1
- package/src/tools/chat/shell/ChatRoot.tsx +29 -0
- package/src/tools/chat/styles/bubbleTokens.ts +18 -0
- package/src/tools/chat/styles/index.ts +1 -0
- package/src/tools/chat/types/attachment.ts +26 -2
- package/src/tools/chat/types/block.ts +56 -9
- package/src/tools/chat/types/blockSchemas.ts +197 -0
- package/src/tools/chat/types/config.ts +33 -0
- package/src/tools/chat/types/events.ts +14 -7
- package/src/tools/chat/types/index.ts +4 -2
- package/src/tools/chat/types/message.ts +7 -0
- package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
- package/src/tools/dev/Map/README.md +30 -345
- package/src/tools/dev/Map/cards/index.ts +6 -0
- package/src/tools/dev/Map/cards/types.ts +67 -0
- package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
- package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
- package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
- package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
- package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
- package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
- package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
- package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
- package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
- package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
- package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
- package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
- package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
- package/src/tools/dev/Map/components/MapControls.tsx +27 -0
- package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
- package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
- package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
- package/src/tools/dev/Map/components/index.ts +35 -1
- package/src/tools/dev/Map/geocode/index.ts +2 -0
- package/src/tools/dev/Map/geocode/photon.ts +265 -0
- package/src/tools/dev/Map/hooks/index.ts +37 -0
- package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
- package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
- package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
- package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
- package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
- package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
- package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
- package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
- package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
- package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
- package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
- package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
- package/src/tools/dev/Map/index.ts +59 -2
- package/src/tools/dev/Map/layers/index.ts +1 -0
- package/src/tools/dev/Map/layers/symbol.ts +93 -0
- package/src/tools/dev/Map/lazy.tsx +120 -0
- package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
- package/src/tools/dev/Map/styles/index.ts +62 -0
- package/src/tools/dev/Map/styles/terrain.ts +29 -0
- package/src/tools/dev/Map/types.ts +31 -3
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
- package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
- package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
- package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
- package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
- package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
- package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
- package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
- package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
- package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
- package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
- package/src/tools/forms/MarkdownEditor/README.md +84 -0
- package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
- package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
- package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
- package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
- package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
- package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
- package/src/tools/forms/MarkdownEditor/index.ts +19 -0
- package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
- package/src/tools/forms/MarkdownEditor/styles.css +134 -2
- package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
- package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
- package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
- package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
- package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
- package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
- package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
- package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
- package/src/tools/forms/NotionEditor/README.md +109 -188
- package/src/tools/forms/NotionEditor/extensions.ts +15 -0
- package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
- package/src/tools/forms/NotionEditor/styles.css +25 -0
- package/src/tools/forms/NotionEditor/types.ts +9 -0
- package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
- package/src/tools/input/SpeechRecognition/types.ts +10 -0
- package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
- package/src/tools/media/ImageViewer/README.md +9 -0
- package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
- package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
- package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
- package/src/tools/media/ImageViewer/components/index.ts +2 -0
- package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
- package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
- package/src/tools/media/ImageViewer/types.ts +10 -0
- package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
- package/src/tools/media/ImageViewer/utils/index.ts +4 -0
- package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
- package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
- package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
- package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
- package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
- package/src/tools/visual/charts/SmoothLine/README.md +97 -0
- package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
- package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
- package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
- package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
- package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
- package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
- package/src/tools/visual/indicators/Fps/types.ts +1 -1
- package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
- package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
|
@@ -1,70 +1,155 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* GeocoderControl -
|
|
4
|
+
* GeocoderControl — the thin map-bound wrapper around `GeocoderInput`.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Renders the autocomplete as an on-map control (a positioned overlay, top-
|
|
7
|
+
* left by default). On select it flies the camera to the picked coordinate
|
|
8
|
+
* via `useMapControl().flyTo`, optionally drops a marker, and calls an
|
|
9
|
+
* optional `onResult`. Biases search to the current map center (`near`).
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* import { useControl, Marker } from '@carapis/map'
|
|
13
|
-
* import MaplibreGeocoder from '@maplibre/maplibre-gl-geocoder'
|
|
14
|
-
*
|
|
15
|
-
* const geocoderApi = {
|
|
16
|
-
* forwardGeocode: async (config) => {
|
|
17
|
-
* const features = []
|
|
18
|
-
* const response = await fetch(
|
|
19
|
-
* `https://nominatim.openstreetmap.org/search?q=${config.query}&format=geojson`
|
|
20
|
-
* )
|
|
21
|
-
* const geojson = await response.json()
|
|
22
|
-
* for (const feature of geojson.features) {
|
|
23
|
-
* const center = [
|
|
24
|
-
* feature.bbox[0] + (feature.bbox[2] - feature.bbox[0]) / 2,
|
|
25
|
-
* feature.bbox[1] + (feature.bbox[3] - feature.bbox[1]) / 2,
|
|
26
|
-
* ]
|
|
27
|
-
* features.push({
|
|
28
|
-
* type: 'Feature',
|
|
29
|
-
* geometry: { type: 'Point', coordinates: center },
|
|
30
|
-
* place_name: feature.properties.display_name,
|
|
31
|
-
* properties: feature.properties,
|
|
32
|
-
* text: feature.properties.display_name,
|
|
33
|
-
* center,
|
|
34
|
-
* })
|
|
35
|
-
* }
|
|
36
|
-
* return { features }
|
|
37
|
-
* },
|
|
38
|
-
* }
|
|
39
|
-
*
|
|
40
|
-
* function GeocoderControl({ position, onResult }) {
|
|
41
|
-
* const [marker, setMarker] = useState(null)
|
|
42
|
-
*
|
|
43
|
-
* useControl(
|
|
44
|
-
* ({ mapLib }) => {
|
|
45
|
-
* const ctrl = new MaplibreGeocoder(geocoderApi, {
|
|
46
|
-
* maplibregl: mapLib,
|
|
47
|
-
* marker: false,
|
|
48
|
-
* })
|
|
49
|
-
* ctrl.on('result', (evt) => {
|
|
50
|
-
* onResult?.(evt)
|
|
51
|
-
* const { result } = evt
|
|
52
|
-
* if (result?.center) {
|
|
53
|
-
* setMarker(
|
|
54
|
-
* <Marker longitude={result.center[0]} latitude={result.center[1]} />
|
|
55
|
-
* )
|
|
56
|
-
* }
|
|
57
|
-
* })
|
|
58
|
-
* return ctrl
|
|
59
|
-
* },
|
|
60
|
-
* { position }
|
|
61
|
-
* )
|
|
62
|
-
*
|
|
63
|
-
* return marker
|
|
64
|
-
* }
|
|
65
|
-
* ```
|
|
11
|
+
* Must live inside a `<MapProvider>` (i.e. rendered as a child of
|
|
12
|
+
* `MapContainer`). The `data-map-control` attribute is load-bearing: the
|
|
13
|
+
* scroll-protection re-lock skips clicks inside it so the input stays usable.
|
|
66
14
|
*/
|
|
67
15
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
16
|
+
import { useCallback, useState } from 'react'
|
|
17
|
+
|
|
18
|
+
import { cn } from '@djangocfg/ui-core/lib'
|
|
19
|
+
|
|
20
|
+
import { useMapControl } from '../hooks/useMapControl'
|
|
21
|
+
import { useMapViewport } from '../hooks/useMapViewport'
|
|
22
|
+
import { GeocoderInput } from './GeocoderInput'
|
|
23
|
+
import { MapMarker } from './MapMarker'
|
|
24
|
+
import { cameraForResult } from '../geocode'
|
|
25
|
+
import type { GeocodeResult, ResolveGeocode } from '../geocode'
|
|
26
|
+
import type { MarkerData } from '../types'
|
|
27
|
+
|
|
28
|
+
type ControlCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
|
|
29
|
+
|
|
30
|
+
const cornerClass: Record<ControlCorner, string> = {
|
|
31
|
+
'top-left': 'top-3 left-3',
|
|
32
|
+
'top-right': 'top-3 right-3',
|
|
33
|
+
'bottom-left': 'bottom-3 left-3',
|
|
34
|
+
'bottom-right': 'bottom-3 right-3',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GeocoderControlProps {
|
|
38
|
+
/** Injectable resolver. Defaults to public Photon (`photonResolve`). */
|
|
39
|
+
resolve?: ResolveGeocode
|
|
40
|
+
/** On-map corner for the search box. Default `top-left`. */
|
|
41
|
+
position?: ControlCorner
|
|
42
|
+
/** Input placeholder. */
|
|
43
|
+
placeholder?: string
|
|
44
|
+
/**
|
|
45
|
+
* Override the smart zoom. By default the camera fits the result's extent
|
|
46
|
+
* (`bbox`) when available, else flies to a zoom chosen by the result kind
|
|
47
|
+
* (country/city/street/house…). Set this to force a fixed fly-to zoom for
|
|
48
|
+
* every pick instead.
|
|
49
|
+
*/
|
|
50
|
+
zoom?: number
|
|
51
|
+
/** Padding (px) around a fitted bounding box. Default 48. */
|
|
52
|
+
fitPadding?: number
|
|
53
|
+
/** Cap the zoom when fitting tiny extents (avoid over-zoom). Default 16. */
|
|
54
|
+
fitMaxZoom?: number
|
|
55
|
+
/** Drop a pin at the picked location. Default `true`. */
|
|
56
|
+
marker?: boolean
|
|
57
|
+
/** Called with the chosen result after the camera flies. */
|
|
58
|
+
onResult?: (result: GeocodeResult) => void
|
|
59
|
+
/** Max results. Default 5. */
|
|
60
|
+
limit?: number
|
|
61
|
+
/**
|
|
62
|
+
* Controlled visibility of the search input. When omitted the control is
|
|
63
|
+
* uncontrolled and the input is always visible (the standalone, always-on
|
|
64
|
+
* usage). Pass `open` (with `onClose`) to drive it from a toolbar toggle.
|
|
65
|
+
*/
|
|
66
|
+
open?: boolean
|
|
67
|
+
/** Called when the input requests to close (Esc / select). Controlled mode. */
|
|
68
|
+
onClose?: () => void
|
|
69
|
+
/** Focus the input when it appears. Default `false`. */
|
|
70
|
+
autoFocus?: boolean
|
|
71
|
+
className?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function GeocoderControl({
|
|
75
|
+
resolve,
|
|
76
|
+
position = 'top-left',
|
|
77
|
+
placeholder,
|
|
78
|
+
zoom,
|
|
79
|
+
fitPadding = 48,
|
|
80
|
+
fitMaxZoom = 16,
|
|
81
|
+
marker = true,
|
|
82
|
+
onResult,
|
|
83
|
+
limit,
|
|
84
|
+
open,
|
|
85
|
+
onClose,
|
|
86
|
+
autoFocus = false,
|
|
87
|
+
className,
|
|
88
|
+
}: GeocoderControlProps) {
|
|
89
|
+
const { flyTo, fitBounds } = useMapControl()
|
|
90
|
+
const { center } = useMapViewport()
|
|
91
|
+
const [pin, setPin] = useState<MarkerData | null>(null)
|
|
92
|
+
|
|
93
|
+
// Uncontrolled (no `open` prop) → always visible, like the original
|
|
94
|
+
// always-on control. Controlled → the host (toolbar chip) owns visibility.
|
|
95
|
+
const isControlled = open !== undefined
|
|
96
|
+
const inputVisible = isControlled ? open : true
|
|
97
|
+
|
|
98
|
+
const handleSelect = useCallback(
|
|
99
|
+
(result: GeocodeResult) => {
|
|
100
|
+
// Smart zoom: a fixed `zoom` override wins; otherwise fit the result's
|
|
101
|
+
// extent (island, street, district frame themselves) and fall back to a
|
|
102
|
+
// kind-based zoom for point results with no bbox.
|
|
103
|
+
const cam = cameraForResult(result)
|
|
104
|
+
if (zoom !== undefined) {
|
|
105
|
+
flyTo([result.lng, result.lat], zoom)
|
|
106
|
+
} else if (cam.type === 'fit') {
|
|
107
|
+
fitBounds(cam.bbox, { padding: fitPadding, maxZoom: fitMaxZoom })
|
|
108
|
+
} else {
|
|
109
|
+
flyTo(cam.center, cam.zoom)
|
|
110
|
+
}
|
|
111
|
+
if (marker) {
|
|
112
|
+
setPin({
|
|
113
|
+
id: `geocoder-${result.id}`,
|
|
114
|
+
longitude: result.lng,
|
|
115
|
+
latitude: result.lat,
|
|
116
|
+
data: { label: result.label },
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
onResult?.(result)
|
|
120
|
+
// Picking a result dismisses the input in controlled (toolbar) mode.
|
|
121
|
+
onClose?.()
|
|
122
|
+
},
|
|
123
|
+
[flyTo, fitBounds, zoom, fitPadding, fitMaxZoom, marker, onResult, onClose],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<>
|
|
128
|
+
{inputVisible ? (
|
|
129
|
+
<div
|
|
130
|
+
data-map-control
|
|
131
|
+
className={cn('absolute z-20 w-72 max-w-[calc(100%-1.5rem)]', cornerClass[position], className)}
|
|
132
|
+
// Esc closes in controlled mode (the input also stops propagation
|
|
133
|
+
// of its own Escape, but the wrapper catch covers the case where
|
|
134
|
+
// focus has left the cmdk input).
|
|
135
|
+
onKeyDown={(e) => {
|
|
136
|
+
if (e.key === 'Escape' && isControlled) {
|
|
137
|
+
e.stopPropagation()
|
|
138
|
+
onClose?.()
|
|
139
|
+
}
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
<GeocoderInput
|
|
143
|
+
resolve={resolve}
|
|
144
|
+
onSelect={handleSelect}
|
|
145
|
+
placeholder={placeholder}
|
|
146
|
+
near={{ lng: center[0], lat: center[1] }}
|
|
147
|
+
limit={limit}
|
|
148
|
+
autoFocus={autoFocus}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
) : null}
|
|
152
|
+
{pin ? <MapMarker marker={pin} ariaLabel={String(pin.data?.label ?? 'Result')} /> : null}
|
|
153
|
+
</>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GeocoderInput — a self-contained, map-agnostic address/place autocomplete.
|
|
5
|
+
*
|
|
6
|
+
* A text field + a results dropdown (primary `label` + muted `secondary`),
|
|
7
|
+
* with loading / empty states and keyboard nav (↑/↓ move, Enter select, Esc
|
|
8
|
+
* close). Resolution is INJECTABLE: pass a `resolve` (default = public
|
|
9
|
+
* Photon) — the component never hard-couples to a backend.
|
|
10
|
+
*
|
|
11
|
+
* Built on ui-core's `Command` primitives (cmdk). cmdk's built-in fuzzy
|
|
12
|
+
* filter is DISABLED (`shouldFilter={false}`) because results already come
|
|
13
|
+
* pre-ranked from the resolver — we drive selection state manually so it
|
|
14
|
+
* works as a live remote-search box.
|
|
15
|
+
*
|
|
16
|
+
* Usable WITHOUT a map (plain forms). `GeocoderControl` is the thin map-bound
|
|
17
|
+
* wrapper that flies the camera on select.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
21
|
+
|
|
22
|
+
import { cn } from '@djangocfg/ui-core/lib'
|
|
23
|
+
import {
|
|
24
|
+
Command,
|
|
25
|
+
CommandEmpty,
|
|
26
|
+
CommandInput,
|
|
27
|
+
CommandItem,
|
|
28
|
+
CommandList,
|
|
29
|
+
} from '@djangocfg/ui-core/components'
|
|
30
|
+
|
|
31
|
+
import { useGeocoder } from '../hooks/useGeocoder'
|
|
32
|
+
import type { GeocodeResult, ResolveGeocode } from '../geocode'
|
|
33
|
+
|
|
34
|
+
export interface GeocoderInputProps {
|
|
35
|
+
/** Injectable resolver. Defaults to public Photon (`photonResolve`). */
|
|
36
|
+
resolve?: ResolveGeocode
|
|
37
|
+
/** Called with the chosen result when the user picks one. */
|
|
38
|
+
onSelect?: (result: GeocodeResult) => void
|
|
39
|
+
/** Input placeholder. */
|
|
40
|
+
placeholder?: string
|
|
41
|
+
/** Geo-bias — rank results near this point (e.g. the map center). */
|
|
42
|
+
near?: { lng: number; lat: number }
|
|
43
|
+
/** Minimum query length before searching. Default 3. */
|
|
44
|
+
minLength?: number
|
|
45
|
+
/** Max results. Default 5. */
|
|
46
|
+
limit?: number
|
|
47
|
+
/**
|
|
48
|
+
* Focus the text field on mount (e.g. when revealed by a toolbar toggle).
|
|
49
|
+
* @default false
|
|
50
|
+
*/
|
|
51
|
+
autoFocus?: boolean
|
|
52
|
+
className?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function GeocoderInput({
|
|
56
|
+
resolve,
|
|
57
|
+
onSelect,
|
|
58
|
+
placeholder = 'Search address or place…',
|
|
59
|
+
near,
|
|
60
|
+
minLength = 3,
|
|
61
|
+
limit,
|
|
62
|
+
autoFocus = false,
|
|
63
|
+
className,
|
|
64
|
+
}: GeocoderInputProps) {
|
|
65
|
+
const { query, setQuery, results, loading, error, select } = useGeocoder({
|
|
66
|
+
resolve,
|
|
67
|
+
near,
|
|
68
|
+
minLength,
|
|
69
|
+
limit,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Show the dropdown only while focused with a meaningful query.
|
|
73
|
+
const [open, setOpen] = useState(false)
|
|
74
|
+
|
|
75
|
+
// Optional autofocus when revealed (e.g. by the toolbar search chip).
|
|
76
|
+
// cmdk's `CommandInput` doesn't forward a ref reliably across versions, so
|
|
77
|
+
// query the rendered `[cmdk-input]` element from the Command root instead.
|
|
78
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!autoFocus) return
|
|
81
|
+
const input = rootRef.current?.querySelector<HTMLInputElement>('[cmdk-input]')
|
|
82
|
+
input?.focus()
|
|
83
|
+
}, [autoFocus])
|
|
84
|
+
|
|
85
|
+
const handleSelect = useCallback(
|
|
86
|
+
(result: GeocodeResult) => {
|
|
87
|
+
onSelect?.(select(result))
|
|
88
|
+
setOpen(false)
|
|
89
|
+
},
|
|
90
|
+
[onSelect, select],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const showList = open && query.trim().length >= minLength
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
// The Command wrapper is a TRANSPARENT positioning context only — the
|
|
97
|
+
// popover chrome (bg/border/shadow) lives on the input and on the
|
|
98
|
+
// floating list separately, so nothing peeks out beneath the input when
|
|
99
|
+
// the list is closed.
|
|
100
|
+
<Command
|
|
101
|
+
ref={rootRef}
|
|
102
|
+
shouldFilter={false}
|
|
103
|
+
// Esc closes the dropdown without clearing; cmdk handles ↑/↓/Enter.
|
|
104
|
+
onKeyDown={(e) => {
|
|
105
|
+
if (e.key === 'Escape') {
|
|
106
|
+
e.preventDefault()
|
|
107
|
+
setOpen(false)
|
|
108
|
+
}
|
|
109
|
+
}}
|
|
110
|
+
className={cn(
|
|
111
|
+
'relative overflow-visible bg-transparent',
|
|
112
|
+
// Turn cmdk's input WRAPPER into the search field itself: a rounded
|
|
113
|
+
// popover surface with its own border (drop cmdk's default bottom
|
|
114
|
+
// border so no line/stub shows beneath the input when closed).
|
|
115
|
+
'[&_[cmdk-input-wrapper]]:h-10 [&_[cmdk-input-wrapper]]:rounded-lg',
|
|
116
|
+
'[&_[cmdk-input-wrapper]]:border [&_[cmdk-input-wrapper]]:border-border',
|
|
117
|
+
'[&_[cmdk-input-wrapper]]:bg-popover [&_[cmdk-input-wrapper]]:text-popover-foreground',
|
|
118
|
+
'[&_[cmdk-input-wrapper]]:shadow-sm',
|
|
119
|
+
className,
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
<CommandInput
|
|
123
|
+
value={query}
|
|
124
|
+
onValueChange={(v) => {
|
|
125
|
+
setQuery(v)
|
|
126
|
+
setOpen(true)
|
|
127
|
+
}}
|
|
128
|
+
onFocus={() => setOpen(true)}
|
|
129
|
+
onBlur={() => {
|
|
130
|
+
// Delay so an item click registers before the list unmounts.
|
|
131
|
+
window.setTimeout(() => setOpen(false), 120)
|
|
132
|
+
}}
|
|
133
|
+
placeholder={placeholder}
|
|
134
|
+
aria-label={placeholder}
|
|
135
|
+
/>
|
|
136
|
+
|
|
137
|
+
{showList ? (
|
|
138
|
+
// Floating result panel — its OWN popover surface, anchored under the
|
|
139
|
+
// input, shown only when there's a query (never an empty stub).
|
|
140
|
+
<CommandList className="absolute inset-x-0 top-full z-10 mt-1.5 max-h-72 overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-md">
|
|
141
|
+
{loading ? (
|
|
142
|
+
<div
|
|
143
|
+
className="px-3 py-6 text-center text-sm text-muted-foreground"
|
|
144
|
+
aria-busy="true"
|
|
145
|
+
>
|
|
146
|
+
Searching…
|
|
147
|
+
</div>
|
|
148
|
+
) : error ? (
|
|
149
|
+
<div className="px-3 py-6 text-center text-sm text-destructive">
|
|
150
|
+
Search failed. Try again.
|
|
151
|
+
</div>
|
|
152
|
+
) : (
|
|
153
|
+
<>
|
|
154
|
+
<CommandEmpty className="py-6 text-center text-sm text-muted-foreground">
|
|
155
|
+
No results.
|
|
156
|
+
</CommandEmpty>
|
|
157
|
+
{results.map((result) => (
|
|
158
|
+
<CommandItem
|
|
159
|
+
key={result.id}
|
|
160
|
+
// cmdk matches `value`; keep it unique + searchable-ish.
|
|
161
|
+
value={`${result.id} ${result.label}`}
|
|
162
|
+
onSelect={() => handleSelect(result)}
|
|
163
|
+
className="flex-col items-start gap-0.5"
|
|
164
|
+
>
|
|
165
|
+
<span className="truncate font-medium text-foreground">
|
|
166
|
+
{result.label}
|
|
167
|
+
</span>
|
|
168
|
+
{result.secondary ? (
|
|
169
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
170
|
+
{result.secondary}
|
|
171
|
+
</span>
|
|
172
|
+
) : null}
|
|
173
|
+
</CommandItem>
|
|
174
|
+
))}
|
|
175
|
+
</>
|
|
176
|
+
)}
|
|
177
|
+
</CommandList>
|
|
178
|
+
) : null}
|
|
179
|
+
</Command>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogTitle,
|
|
8
|
+
} from '@djangocfg/ui-core/components'
|
|
9
|
+
|
|
10
|
+
import type { MapViewport, MapStyleKey } from '../../types'
|
|
11
|
+
import type { GeocodeResult, ResolveGeocode } from '../../geocode'
|
|
12
|
+
import type { MapContainerProps, GeolocateOptions, GeolocatePosition } from './types'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fullscreen dialog — a fresh map seeded with the current viewport.
|
|
16
|
+
*
|
|
17
|
+
* The nested map is the public `MapContainer`, passed in as a prop so this
|
|
18
|
+
* module never imports the folder index (which would form a cycle:
|
|
19
|
+
* index → MapInner → FullscreenDialog → index).
|
|
20
|
+
*/
|
|
21
|
+
export function FullscreenDialog({
|
|
22
|
+
MapContainer,
|
|
23
|
+
open,
|
|
24
|
+
onOpenChange,
|
|
25
|
+
fullscreenTitle,
|
|
26
|
+
viewport,
|
|
27
|
+
effectiveStyle,
|
|
28
|
+
interactiveLayerIds,
|
|
29
|
+
openInMapsUrl,
|
|
30
|
+
openInMapsLabel,
|
|
31
|
+
googleMapsButton,
|
|
32
|
+
basemapSwitcher,
|
|
33
|
+
basemaps,
|
|
34
|
+
onBasemapChange,
|
|
35
|
+
terrainOn,
|
|
36
|
+
terrainButton,
|
|
37
|
+
terrainLabel,
|
|
38
|
+
onTerrainChange,
|
|
39
|
+
compassButton,
|
|
40
|
+
compassLabel,
|
|
41
|
+
geolocate,
|
|
42
|
+
geolocateLabel,
|
|
43
|
+
geolocateOptions,
|
|
44
|
+
onGeolocate,
|
|
45
|
+
onGeolocateError,
|
|
46
|
+
geocoder,
|
|
47
|
+
geocoderResolve,
|
|
48
|
+
geocoderPlaceholder,
|
|
49
|
+
onGeocoderResult,
|
|
50
|
+
showResetButton,
|
|
51
|
+
ariaLabel,
|
|
52
|
+
onClose,
|
|
53
|
+
children,
|
|
54
|
+
}: {
|
|
55
|
+
MapContainer: ComponentType<MapContainerProps>
|
|
56
|
+
open: boolean
|
|
57
|
+
onOpenChange: (open: boolean) => void
|
|
58
|
+
fullscreenTitle: string
|
|
59
|
+
viewport: MapViewport
|
|
60
|
+
effectiveStyle: MapStyleKey | string
|
|
61
|
+
interactiveLayerIds: string[] | undefined
|
|
62
|
+
openInMapsUrl: string | undefined
|
|
63
|
+
openInMapsLabel: string
|
|
64
|
+
googleMapsButton: boolean
|
|
65
|
+
basemapSwitcher: boolean
|
|
66
|
+
basemaps: MapStyleKey[] | undefined
|
|
67
|
+
onBasemapChange: ((key: MapStyleKey) => void) | undefined
|
|
68
|
+
terrainOn: boolean
|
|
69
|
+
terrainButton: boolean
|
|
70
|
+
terrainLabel: string
|
|
71
|
+
onTerrainChange: ((enabled: boolean) => void) | undefined
|
|
72
|
+
compassButton: boolean
|
|
73
|
+
compassLabel: string
|
|
74
|
+
geolocate: boolean
|
|
75
|
+
geolocateLabel: string
|
|
76
|
+
geolocateOptions: GeolocateOptions | undefined
|
|
77
|
+
onGeolocate: ((pos: GeolocatePosition) => void) | undefined
|
|
78
|
+
onGeolocateError: ((err: GeolocationPositionError) => void) | undefined
|
|
79
|
+
geocoder: boolean
|
|
80
|
+
geocoderResolve: ResolveGeocode | undefined
|
|
81
|
+
geocoderPlaceholder: string | undefined
|
|
82
|
+
onGeocoderResult: ((result: GeocodeResult) => void) | undefined
|
|
83
|
+
showResetButton: boolean
|
|
84
|
+
ariaLabel: string
|
|
85
|
+
onClose: () => void
|
|
86
|
+
children?: ReactNode
|
|
87
|
+
}) {
|
|
88
|
+
return (
|
|
89
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
90
|
+
<DialogContent
|
|
91
|
+
className="h-[90vh] w-[90vw] max-w-[1400px] overflow-hidden p-0 sm:rounded-xl"
|
|
92
|
+
// The dialog's own X is suppressed — the map renders close as the
|
|
93
|
+
// first chip in its top-right toolbar (via `onClose`), so close /
|
|
94
|
+
// Google / reset share one tidy column like the inline map.
|
|
95
|
+
closeButton={false}
|
|
96
|
+
>
|
|
97
|
+
<DialogTitle className="sr-only">{fullscreenTitle}</DialogTitle>
|
|
98
|
+
{open && (
|
|
99
|
+
<MapContainer
|
|
100
|
+
initialViewport={viewport}
|
|
101
|
+
// Carry the user's current basemap choice into fullscreen.
|
|
102
|
+
mapStyle={effectiveStyle}
|
|
103
|
+
interactiveLayerIds={interactiveLayerIds}
|
|
104
|
+
attributionControl={false}
|
|
105
|
+
reuseMaps={false}
|
|
106
|
+
openInMapsUrl={openInMapsUrl}
|
|
107
|
+
openInMapsLabel={openInMapsLabel}
|
|
108
|
+
googleMapsButton={googleMapsButton}
|
|
109
|
+
basemapSwitcher={basemapSwitcher}
|
|
110
|
+
basemaps={basemaps}
|
|
111
|
+
onBasemapChange={onBasemapChange}
|
|
112
|
+
terrain={terrainOn}
|
|
113
|
+
terrainButton={terrainButton}
|
|
114
|
+
terrainLabel={terrainLabel}
|
|
115
|
+
onTerrainChange={onTerrainChange}
|
|
116
|
+
compassButton={compassButton}
|
|
117
|
+
compassLabel={compassLabel}
|
|
118
|
+
geolocate={geolocate}
|
|
119
|
+
geolocateLabel={geolocateLabel}
|
|
120
|
+
geolocateOptions={geolocateOptions}
|
|
121
|
+
onGeolocate={onGeolocate}
|
|
122
|
+
onGeolocateError={onGeolocateError}
|
|
123
|
+
geocoder={geocoder}
|
|
124
|
+
geocoderResolve={geocoderResolve}
|
|
125
|
+
geocoderPlaceholder={geocoderPlaceholder}
|
|
126
|
+
onGeocoderResult={onGeocoderResult}
|
|
127
|
+
showResetButton={showResetButton}
|
|
128
|
+
ariaLabel={ariaLabel}
|
|
129
|
+
scrollProtection={false}
|
|
130
|
+
fullscreenButton={false}
|
|
131
|
+
onClose={onClose}
|
|
132
|
+
className="h-full w-full"
|
|
133
|
+
>
|
|
134
|
+
{children}
|
|
135
|
+
</MapContainer>
|
|
136
|
+
)}
|
|
137
|
+
</DialogContent>
|
|
138
|
+
</Dialog>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { forwardRef } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
GeolocateControl,
|
|
6
|
+
type GeolocateControlInstance,
|
|
7
|
+
type GeolocateResultEvent,
|
|
8
|
+
type GeolocateErrorEvent,
|
|
9
|
+
} from 'react-map-gl/maplibre'
|
|
10
|
+
|
|
11
|
+
import type { GeolocateOptions, GeolocatePosition } from './types'
|
|
12
|
+
|
|
13
|
+
/** The follow-state our chip reflects, derived from the control's events. */
|
|
14
|
+
export type GeolocateState = 'idle' | 'active' | 'error'
|
|
15
|
+
|
|
16
|
+
interface GeolocateHandleProps {
|
|
17
|
+
options: GeolocateOptions
|
|
18
|
+
onGeolocate?: (pos: GeolocatePosition) => void
|
|
19
|
+
onGeolocateError?: (err: GeolocationPositionError) => void
|
|
20
|
+
onStateChange: (state: GeolocateState) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The maplibre `GeolocateControl`, rendered as a child of `<Map>` so its
|
|
25
|
+
* location dot + accuracy circle draw on the map, but with the default
|
|
26
|
+
* control button **hidden** (`display:none`) — our `MapToolbar` chip is the
|
|
27
|
+
* UI and drives it via the forwarded instance ref (`control.trigger()`).
|
|
28
|
+
*
|
|
29
|
+
* `trackUserLocation` gives Google-style follow: first activation centers +
|
|
30
|
+
* follows; panning away drops to passive; re-trigger re-follows. We reflect
|
|
31
|
+
* active/error back to the chip through `onStateChange`.
|
|
32
|
+
*
|
|
33
|
+
* Note: maplibre-gl 4.7.1's `GeolocateControlOptions` has no `showUserHeading`
|
|
34
|
+
* field, so heading is surfaced via `onGeolocate` (and `showHeading` is a
|
|
35
|
+
* forward-compatible no-op on this version).
|
|
36
|
+
*/
|
|
37
|
+
export const GeolocateHandle = forwardRef<GeolocateControlInstance, GeolocateHandleProps>(
|
|
38
|
+
function GeolocateHandle({ options, onGeolocate, onGeolocateError, onStateChange }, ref) {
|
|
39
|
+
return (
|
|
40
|
+
<GeolocateControl
|
|
41
|
+
ref={ref}
|
|
42
|
+
// Hidden: our chip is the UI. The dot + accuracy circle are map
|
|
43
|
+
// layers (not this button), so they still render.
|
|
44
|
+
style={{ display: 'none' }}
|
|
45
|
+
trackUserLocation
|
|
46
|
+
showUserLocation
|
|
47
|
+
showAccuracyCircle={options.showAccuracyCircle ?? true}
|
|
48
|
+
positionOptions={{ enableHighAccuracy: options.highAccuracy ?? false }}
|
|
49
|
+
fitBoundsOptions={
|
|
50
|
+
options.fitBoundsMaxZoom != null ? { maxZoom: options.fitBoundsMaxZoom } : undefined
|
|
51
|
+
}
|
|
52
|
+
onGeolocate={(e: GeolocateResultEvent) => {
|
|
53
|
+
const c = e.coords
|
|
54
|
+
onGeolocate?.({
|
|
55
|
+
lat: c.latitude,
|
|
56
|
+
lng: c.longitude,
|
|
57
|
+
accuracy: c.accuracy,
|
|
58
|
+
heading: c.heading != null && !Number.isNaN(c.heading) ? c.heading : null,
|
|
59
|
+
})
|
|
60
|
+
}}
|
|
61
|
+
onError={(e: GeolocateErrorEvent) => {
|
|
62
|
+
onStateChange('error')
|
|
63
|
+
onGeolocateError?.(e)
|
|
64
|
+
}}
|
|
65
|
+
onTrackUserLocationStart={() => onStateChange('active')}
|
|
66
|
+
onTrackUserLocationEnd={() => onStateChange('idle')}
|
|
67
|
+
/>
|
|
68
|
+
)
|
|
69
|
+
},
|
|
70
|
+
)
|