@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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `MapEditLayer` — the EDITABLE inner layer of the NotionEditor `mapBlock`
|
|
5
|
+
* NodeView. Rendered as a child of `LazyMapContainer` (so it sits inside the
|
|
6
|
+
* `MapProvider` and can use the map's hooks). Composes the Map tool's existing
|
|
7
|
+
* `DraggableMarkers` + `useMapEvents`; it does NOT rebuild map internals.
|
|
8
|
+
*
|
|
9
|
+
* Two-way binding: every edit (drag a pin, add a pin, remove a pin) is mapped
|
|
10
|
+
* back to the node's `MapMarkerPayload[]` and committed through the
|
|
11
|
+
* `onMarkersChange` callback, which the NodeView wires to
|
|
12
|
+
* `useNodeAttrs().patch({ markers })` → the ```map JSON updates.
|
|
13
|
+
*
|
|
14
|
+
* Affordances (editable only; keyboard-reachable, semantic tokens):
|
|
15
|
+
* - "Add pin" toggles a click-to-place mode; the next map click appends a
|
|
16
|
+
* marker at the clicked lng/lat.
|
|
17
|
+
* - "Remove" toggles a delete mode; a small ✕ badge appears on each pin and
|
|
18
|
+
* clicking it drops that marker.
|
|
19
|
+
* The basemap switch is handled by `LazyMapContainer`'s own switcher (the
|
|
20
|
+
* NodeView wires `onBasemapChange`), so it isn't duplicated here.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useCallback, useMemo, useState, type ReactNode } from 'react';
|
|
24
|
+
import { MapPin, Plus, Trash2, X } from 'lucide-react';
|
|
25
|
+
import {
|
|
26
|
+
DraggableMarkers,
|
|
27
|
+
useMapEvents,
|
|
28
|
+
type DraggablePoint,
|
|
29
|
+
type MarkerData,
|
|
30
|
+
} from '../../dev/Map/lazy';
|
|
31
|
+
import type { MapMarkerPayload } from '../../../common/blocks';
|
|
32
|
+
|
|
33
|
+
export interface MapEditLayerProps {
|
|
34
|
+
/** Current markers (the node's committed payload). */
|
|
35
|
+
markers: MapMarkerPayload[];
|
|
36
|
+
/** Commit a new marker array back to the node (→ markdown). */
|
|
37
|
+
onMarkersChange: (next: MapMarkerPayload[]) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Generate a stable-ish unique marker id. */
|
|
41
|
+
function nextMarkerId(existing: MapMarkerPayload[]): string {
|
|
42
|
+
let n = existing.length + 1;
|
|
43
|
+
const ids = new Set(existing.map((m) => m.id));
|
|
44
|
+
let id = `m${n}`;
|
|
45
|
+
while (ids.has(id)) {
|
|
46
|
+
n += 1;
|
|
47
|
+
id = `m${n}`;
|
|
48
|
+
}
|
|
49
|
+
return id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function MapEditLayer({ markers, onMarkersChange }: MapEditLayerProps) {
|
|
53
|
+
// Mutually-exclusive edit modes. `null` = plain drag-only editing.
|
|
54
|
+
const [mode, setMode] = useState<'add' | 'delete' | null>(null);
|
|
55
|
+
|
|
56
|
+
// ── Map → payload: DraggablePoint {id,lng,lat} ⇄ MapMarkerPayload ──────────
|
|
57
|
+
// DraggableMarkers is controlled by `positions`; on drag-END it hands back
|
|
58
|
+
// the full updated array. We map each point back onto its source payload so
|
|
59
|
+
// labels / colours / icons / cards are PRESERVED (DraggablePoint only carries
|
|
60
|
+
// id/lng/lat/label).
|
|
61
|
+
const positions = useMemo<DraggablePoint[]>(
|
|
62
|
+
() =>
|
|
63
|
+
markers.map((m) => ({
|
|
64
|
+
id: m.id,
|
|
65
|
+
lng: m.lng,
|
|
66
|
+
lat: m.lat,
|
|
67
|
+
...(m.label !== undefined ? { label: m.label } : {}),
|
|
68
|
+
})),
|
|
69
|
+
[markers],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const handleDragChange = useCallback(
|
|
73
|
+
(next: DraggablePoint[]) => {
|
|
74
|
+
const byId = new Map(markers.map((m) => [m.id, m]));
|
|
75
|
+
const merged: MapMarkerPayload[] = next.map((p) => {
|
|
76
|
+
const prev = byId.get(p.id);
|
|
77
|
+
// Preserve everything but the moved coordinates.
|
|
78
|
+
return prev
|
|
79
|
+
? { ...prev, lat: p.lat, lng: p.lng }
|
|
80
|
+
: { id: p.id, lat: p.lat, lng: p.lng, ...(p.label ? { label: p.label } : {}) };
|
|
81
|
+
});
|
|
82
|
+
onMarkersChange(merged);
|
|
83
|
+
},
|
|
84
|
+
[markers, onMarkersChange],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// ── Click-to-place (add mode) ──────────────────────────────────────────────
|
|
88
|
+
useMapEvents({
|
|
89
|
+
onClick: (event) => {
|
|
90
|
+
if (mode !== 'add') return;
|
|
91
|
+
const { lng, lat } = event.lngLat;
|
|
92
|
+
const id = nextMarkerId(markers);
|
|
93
|
+
onMarkersChange([...markers, { id, lat, lng }]);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── Remove (delete mode): a ✕ badge per pin ────────────────────────────────
|
|
98
|
+
const removeMarker = useCallback(
|
|
99
|
+
(id: string) => {
|
|
100
|
+
onMarkersChange(markers.filter((m) => m.id !== id));
|
|
101
|
+
},
|
|
102
|
+
[markers, onMarkersChange],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Only supplied to DraggableMarkers in delete mode (see render below), so it
|
|
106
|
+
// always renders the ✕ badge that replaces the default pin.
|
|
107
|
+
const renderMarker = useCallback(
|
|
108
|
+
(marker: MarkerData) => {
|
|
109
|
+
return (
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
aria-label={`Remove pin ${marker.id}`}
|
|
113
|
+
// Stop the click from reaching the map / starting a drag.
|
|
114
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
115
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
116
|
+
onClick={(e) => {
|
|
117
|
+
e.stopPropagation();
|
|
118
|
+
removeMarker(marker.id);
|
|
119
|
+
}}
|
|
120
|
+
className="flex h-6 w-6 items-center justify-center rounded-full border border-border bg-destructive text-destructive-foreground shadow-md transition-transform hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
121
|
+
>
|
|
122
|
+
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
|
123
|
+
</button>
|
|
124
|
+
);
|
|
125
|
+
},
|
|
126
|
+
[removeMarker],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<>
|
|
131
|
+
<DraggableMarkers
|
|
132
|
+
positions={positions}
|
|
133
|
+
onChange={handleDragChange}
|
|
134
|
+
renderMarker={mode === 'delete' ? renderMarker : undefined}
|
|
135
|
+
/>
|
|
136
|
+
|
|
137
|
+
{/* Edit control overlay — top-left so it clears the map toolbar chips. */}
|
|
138
|
+
<div className="pointer-events-none absolute left-2 top-2 z-10 flex flex-col gap-1">
|
|
139
|
+
<div className="pointer-events-auto flex gap-1 rounded-lg border border-border bg-card/95 p-1 shadow-md backdrop-blur">
|
|
140
|
+
<ControlButton
|
|
141
|
+
active={mode === 'add'}
|
|
142
|
+
label="Add pin"
|
|
143
|
+
onClick={() => setMode((m) => (m === 'add' ? null : 'add'))}
|
|
144
|
+
>
|
|
145
|
+
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
|
146
|
+
<span>Add pin</span>
|
|
147
|
+
</ControlButton>
|
|
148
|
+
<ControlButton
|
|
149
|
+
active={mode === 'delete'}
|
|
150
|
+
label="Remove pins"
|
|
151
|
+
disabled={markers.length === 0}
|
|
152
|
+
onClick={() => setMode((m) => (m === 'delete' ? null : 'delete'))}
|
|
153
|
+
>
|
|
154
|
+
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
|
|
155
|
+
<span>Remove</span>
|
|
156
|
+
</ControlButton>
|
|
157
|
+
</div>
|
|
158
|
+
{mode ? (
|
|
159
|
+
<div className="pointer-events-none flex items-center gap-1.5 self-start rounded-md border border-border bg-card/95 px-2 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur">
|
|
160
|
+
<MapPin className="h-3 w-3" aria-hidden="true" />
|
|
161
|
+
{mode === 'add' ? 'Click the map to place a pin' : 'Click a ✕ to remove a pin'}
|
|
162
|
+
</div>
|
|
163
|
+
) : null}
|
|
164
|
+
</div>
|
|
165
|
+
</>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function ControlButton({
|
|
170
|
+
active,
|
|
171
|
+
disabled,
|
|
172
|
+
label,
|
|
173
|
+
onClick,
|
|
174
|
+
children,
|
|
175
|
+
}: {
|
|
176
|
+
active: boolean;
|
|
177
|
+
disabled?: boolean;
|
|
178
|
+
label: string;
|
|
179
|
+
onClick: () => void;
|
|
180
|
+
children: ReactNode;
|
|
181
|
+
}) {
|
|
182
|
+
return (
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
aria-label={label}
|
|
186
|
+
aria-pressed={active}
|
|
187
|
+
disabled={disabled}
|
|
188
|
+
onClick={onClick}
|
|
189
|
+
className={
|
|
190
|
+
'inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 ' +
|
|
191
|
+
(active
|
|
192
|
+
? 'bg-primary text-primary-foreground'
|
|
193
|
+
: 'text-foreground hover:bg-muted')
|
|
194
|
+
}
|
|
195
|
+
>
|
|
196
|
+
{children}
|
|
197
|
+
</button>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MapNode` — a NotionEditor block ATOM node that renders an interactive map
|
|
3
|
+
* (`LazyMapContainer`) from a serializable `MapBlockPayload` held in the
|
|
4
|
+
* node's attrs. Inserted via the slash menu (`/map`) — NOT auto from paste,
|
|
5
|
+
* since a map has no natural paste trigger.
|
|
6
|
+
*
|
|
7
|
+
* Markdown round-trip (a fenced ```` ```map ```` JSON block):
|
|
8
|
+
*
|
|
9
|
+
* `renderMarkdown` emits a fenced code block tagged `map` whose body is the
|
|
10
|
+
* pretty-printed payload JSON. A custom block `markdownTokenizer` recognises
|
|
11
|
+
* that exact fence on parse and emits a `mapBlock` token (its OWN token
|
|
12
|
+
* type — it never collides with the `code` token the lowlight code block
|
|
13
|
+
* owns). `parseMarkdown` then turns the token back into the node with its
|
|
14
|
+
* attrs. So `getMarkdown()` ⇄ `setContent()` round-trips losslessly while
|
|
15
|
+
* staying human-readable in the markdown.
|
|
16
|
+
*
|
|
17
|
+
* Attrs are the flattened `MapBlockPayload` fields (`center`/`zoom`/`basemap`/
|
|
18
|
+
* `terrain`/`markers`) — plain JSON, so they serialise to TipTap node attrs
|
|
19
|
+
* and the fenced JSON cleanly.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { Node, mergeAttributes } from '@tiptap/core';
|
|
23
|
+
import type { MarkdownToken, MarkdownParseHelpers } from '@tiptap/core';
|
|
24
|
+
import { ReactNodeViewRenderer } from '@tiptap/react';
|
|
25
|
+
import type { MapBlockPayload } from '../../../common/blocks';
|
|
26
|
+
import { MapBlockPayloadSchema } from '../../../common/blocks';
|
|
27
|
+
import { MapView } from './MapView';
|
|
28
|
+
|
|
29
|
+
export interface MapNodeOptions {
|
|
30
|
+
HTMLAttributes: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
declare module '@tiptap/core' {
|
|
34
|
+
interface Commands<ReturnType> {
|
|
35
|
+
mapBlock: {
|
|
36
|
+
/** Insert a map block from a payload (markers / center / zoom). */
|
|
37
|
+
setMapBlock: (payload: MapBlockPayload) => ReturnType;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A sensible default payload so `/map` shows a live map immediately. */
|
|
43
|
+
export const DEFAULT_MAP_PAYLOAD: MapBlockPayload = {
|
|
44
|
+
center: { lat: 48.8584, lng: 2.2945 },
|
|
45
|
+
zoom: 13,
|
|
46
|
+
markers: [{ id: 'm1', lat: 48.8584, lng: 2.2945, label: 'Eiffel Tower' }],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Pull the flattened payload off a node's attrs. */
|
|
50
|
+
function payloadFromAttrs(attrs: Record<string, unknown>): MapBlockPayload {
|
|
51
|
+
return {
|
|
52
|
+
center: (attrs.center as MapBlockPayload['center']) ?? DEFAULT_MAP_PAYLOAD.center,
|
|
53
|
+
zoom: attrs.zoom as number | undefined,
|
|
54
|
+
basemap: attrs.basemap as string | undefined,
|
|
55
|
+
terrain: attrs.terrain as boolean | undefined,
|
|
56
|
+
markers: (attrs.markers as MapBlockPayload['markers']) ?? [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Drop undefined fields so the serialised JSON stays tidy. */
|
|
61
|
+
function compactPayload(payload: MapBlockPayload): MapBlockPayload {
|
|
62
|
+
const out: MapBlockPayload = { center: payload.center };
|
|
63
|
+
if (payload.zoom !== undefined) out.zoom = payload.zoom;
|
|
64
|
+
if (payload.basemap !== undefined) out.basemap = payload.basemap;
|
|
65
|
+
if (payload.terrain !== undefined) out.terrain = payload.terrain;
|
|
66
|
+
if (payload.markers && payload.markers.length) out.markers = payload.markers;
|
|
67
|
+
if (payload.routes && payload.routes.length) out.routes = payload.routes;
|
|
68
|
+
if (payload.polygons && payload.polygons.length) out.polygons = payload.polygons;
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Matches a fenced ```map … ``` block at the START of the source. Captures the
|
|
73
|
+
// JSON body (group 1). Tolerates trailing whitespace + the optional closing
|
|
74
|
+
// newline before the fence.
|
|
75
|
+
const MAP_FENCE_RE = /^```map[ \t]*\r?\n([\s\S]*?)\r?\n?```[ \t]*(?:\r?\n|$)/;
|
|
76
|
+
|
|
77
|
+
export const MapNode = Node.create<MapNodeOptions>({
|
|
78
|
+
name: 'mapBlock',
|
|
79
|
+
group: 'block',
|
|
80
|
+
atom: true,
|
|
81
|
+
draggable: true,
|
|
82
|
+
selectable: true,
|
|
83
|
+
|
|
84
|
+
addOptions() {
|
|
85
|
+
return { HTMLAttributes: {} };
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
addAttributes() {
|
|
89
|
+
return {
|
|
90
|
+
center: {
|
|
91
|
+
default: DEFAULT_MAP_PAYLOAD.center,
|
|
92
|
+
parseHTML: (el) => safeJson(el.getAttribute('data-center')) ?? DEFAULT_MAP_PAYLOAD.center,
|
|
93
|
+
renderHTML: (attrs) => ({ 'data-center': JSON.stringify(attrs.center) }),
|
|
94
|
+
},
|
|
95
|
+
zoom: {
|
|
96
|
+
default: DEFAULT_MAP_PAYLOAD.zoom,
|
|
97
|
+
parseHTML: (el) => numAttr(el.getAttribute('data-zoom')),
|
|
98
|
+
renderHTML: (attrs) => (attrs.zoom != null ? { 'data-zoom': String(attrs.zoom) } : {}),
|
|
99
|
+
},
|
|
100
|
+
basemap: {
|
|
101
|
+
default: undefined,
|
|
102
|
+
parseHTML: (el) => el.getAttribute('data-basemap') ?? undefined,
|
|
103
|
+
renderHTML: (attrs) => (attrs.basemap ? { 'data-basemap': attrs.basemap as string } : {}),
|
|
104
|
+
},
|
|
105
|
+
terrain: {
|
|
106
|
+
default: undefined,
|
|
107
|
+
parseHTML: (el) => el.getAttribute('data-terrain') === 'true' || undefined,
|
|
108
|
+
renderHTML: (attrs) => (attrs.terrain ? { 'data-terrain': 'true' } : {}),
|
|
109
|
+
},
|
|
110
|
+
markers: {
|
|
111
|
+
default: [],
|
|
112
|
+
parseHTML: (el) => safeJson(el.getAttribute('data-markers')) ?? [],
|
|
113
|
+
renderHTML: (attrs) =>
|
|
114
|
+
attrs.markers ? { 'data-markers': JSON.stringify(attrs.markers) } : {},
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
parseHTML() {
|
|
120
|
+
return [{ tag: 'div[data-type="mapBlock"]' }];
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
renderHTML({ HTMLAttributes }) {
|
|
124
|
+
return [
|
|
125
|
+
'div',
|
|
126
|
+
mergeAttributes({ 'data-type': 'mapBlock' }, this.options.HTMLAttributes, HTMLAttributes),
|
|
127
|
+
];
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
addNodeView() {
|
|
131
|
+
return ReactNodeViewRenderer(MapView);
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
addCommands() {
|
|
135
|
+
return {
|
|
136
|
+
setMapBlock:
|
|
137
|
+
(payload: MapBlockPayload) =>
|
|
138
|
+
({ commands }) =>
|
|
139
|
+
commands.insertContent({ type: this.name, attrs: { ...payload } }),
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// ── Markdown round-trip ────────────────────────────────────────────────
|
|
144
|
+
// Own token type — never collides with the lowlight `code` token.
|
|
145
|
+
markdownTokenName: 'mapBlock',
|
|
146
|
+
|
|
147
|
+
markdownTokenizer: {
|
|
148
|
+
name: 'mapBlock',
|
|
149
|
+
level: 'block',
|
|
150
|
+
start: (src: string) => {
|
|
151
|
+
const idx = src.indexOf('```map');
|
|
152
|
+
return idx;
|
|
153
|
+
},
|
|
154
|
+
tokenize: (src: string) => {
|
|
155
|
+
const match = MAP_FENCE_RE.exec(src);
|
|
156
|
+
if (!match) return undefined;
|
|
157
|
+
return {
|
|
158
|
+
type: 'mapBlock',
|
|
159
|
+
raw: match[0],
|
|
160
|
+
text: match[1] ?? '',
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
parseMarkdown(token: MarkdownToken, helpers: MarkdownParseHelpers) {
|
|
166
|
+
// Never throw during parse. Three cases:
|
|
167
|
+
// 1. Empty/whitespace body → a default map (a bare ```map``` fence).
|
|
168
|
+
// 2. Valid JSON matching the schema → the parsed payload (compacted).
|
|
169
|
+
// 3. Malformed JSON or a payload that fails the schema → pass the raw
|
|
170
|
+
// parsed value straight through as attrs so the NodeView's own schema
|
|
171
|
+
// check renders `<BlockError>` (a visible, non-crashing error block)
|
|
172
|
+
// rather than silently masquerading as the default map. If JSON.parse
|
|
173
|
+
// itself fails we keep an obviously-invalid sentinel so the same path
|
|
174
|
+
// surfaces the error.
|
|
175
|
+
const text = (token.text ?? '').trim();
|
|
176
|
+
if (!text) return helpers.createNode('mapBlock', { ...DEFAULT_MAP_PAYLOAD });
|
|
177
|
+
|
|
178
|
+
const raw = safeJson(token.text);
|
|
179
|
+
const result = MapBlockPayloadSchema.safeParse(raw);
|
|
180
|
+
if (result.success) {
|
|
181
|
+
return helpers.createNode('mapBlock', { ...compactPayload(result.data) });
|
|
182
|
+
}
|
|
183
|
+
// Invalid: surface the broken payload as attrs (a plain object so it
|
|
184
|
+
// serialises). `center` must be PRESENT-but-invalid — if it were
|
|
185
|
+
// `undefined`/absent TipTap would substitute the attr default and the
|
|
186
|
+
// NodeView schema would wrongly pass. When the broken payload has no
|
|
187
|
+
// usable center we inject an explicit invalid marker object so the
|
|
188
|
+
// NodeView's `safeParse` fails and renders `<BlockError>`.
|
|
189
|
+
const broken = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
|
190
|
+
const center =
|
|
191
|
+
broken.center !== undefined ? broken.center : { __invalid: true };
|
|
192
|
+
return helpers.createNode('mapBlock', { ...broken, center });
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
renderMarkdown(node: { attrs: Record<string, unknown> }) {
|
|
196
|
+
const payload = compactPayload(payloadFromAttrs(node.attrs));
|
|
197
|
+
return '```map\n' + JSON.stringify(payload, null, 2) + '\n```';
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/** Parse a JSON string, returning null on any failure (never throws). */
|
|
202
|
+
function safeJson(value: string | null | undefined): unknown {
|
|
203
|
+
if (!value) return null;
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(value);
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Parse a numeric attribute, or undefined. */
|
|
212
|
+
function numAttr(value: string | null): number | undefined {
|
|
213
|
+
if (value == null) return undefined;
|
|
214
|
+
const n = Number(value);
|
|
215
|
+
return Number.isFinite(n) ? n : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export default MapNode;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* React NodeView for the NotionEditor `mapBlock` node. A wrapper around
|
|
5
|
+
* `LazyMapContainer` (mirrors the chat `MapBlock` renderer) seeded from the
|
|
6
|
+
* node's serializable `MapBlockPayload` attrs: viewport from `center`/`zoom`,
|
|
7
|
+
* markers via `MapMarker`. The heavy MapLibre bundle only loads when the node
|
|
8
|
+
* actually mounts (the lazy container handles that).
|
|
9
|
+
*
|
|
10
|
+
* INTERACTIVE when the editor is editable: markers render through the Map
|
|
11
|
+
* tool's `DraggableMarkers` inside `<MapEditLayer>` and every edit (drag a pin,
|
|
12
|
+
* add / remove a pin, switch basemap) is written back into the node attrs via
|
|
13
|
+
* `useNodeAttrs().patch(...)`, so the change survives the ```map markdown
|
|
14
|
+
* round-trip. READ-ONLY editors keep static, non-draggable pins.
|
|
15
|
+
*
|
|
16
|
+
* Payloads are validated through `MapBlockPayloadSchema` first — a corrupted
|
|
17
|
+
* ```map JSON renders a safe `<BlockError>` card instead of throwing.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useCallback } from 'react';
|
|
21
|
+
import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react';
|
|
22
|
+
import { LazyMapContainer, MapMarker } from '../../dev/Map/lazy';
|
|
23
|
+
import type { MarkerData, MapStyleKey } from '../../dev/Map/lazy';
|
|
24
|
+
import type { MapContainerProps } from '../../dev/Map/components';
|
|
25
|
+
import { BlockError, MapBlockPayloadSchema, safeParseBlock } from '../../../common/blocks';
|
|
26
|
+
import type { MapMarkerPayload } from '../../../common/blocks';
|
|
27
|
+
import { useNodeAttrs } from '../../../common/tiptap';
|
|
28
|
+
import { MapEditLayer } from './MapEditLayer';
|
|
29
|
+
|
|
30
|
+
export function MapView(props: NodeViewProps) {
|
|
31
|
+
const { node, editor } = props;
|
|
32
|
+
const editable = editor.isEditable;
|
|
33
|
+
|
|
34
|
+
// Single write-back seam: discrete edits (drag-end, add/remove pin, basemap)
|
|
35
|
+
// commit immediately — they don't fire per-frame, so no debounce needed.
|
|
36
|
+
const { patch } = useNodeAttrs<Record<string, unknown>>(props);
|
|
37
|
+
|
|
38
|
+
const onMarkersChange = useCallback(
|
|
39
|
+
(markers: MapMarkerPayload[]) => patch({ markers }),
|
|
40
|
+
[patch],
|
|
41
|
+
);
|
|
42
|
+
const onBasemapChange = useCallback(
|
|
43
|
+
(key: MapStyleKey) => patch({ basemap: key }),
|
|
44
|
+
[patch],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Validate the node's attrs through the shared schema BEFORE touching the
|
|
48
|
+
// map — a hand-authored / corrupted ```map JSON renders a safe `<BlockError>`
|
|
49
|
+
// card instead of throwing inside MapLibre and taking down the editor.
|
|
50
|
+
const parsed = safeParseBlock(MapBlockPayloadSchema, node.attrs);
|
|
51
|
+
if (!parsed.ok) {
|
|
52
|
+
return (
|
|
53
|
+
<NodeViewWrapper data-type="mapBlock" className="notion-map" draggable={false}>
|
|
54
|
+
<BlockError label="map" reason={parsed.error} />
|
|
55
|
+
</NodeViewWrapper>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const payload = parsed.data;
|
|
60
|
+
const markers = payload.markers ?? [];
|
|
61
|
+
|
|
62
|
+
const initialViewport = {
|
|
63
|
+
longitude: payload.center.lng,
|
|
64
|
+
latitude: payload.center.lat,
|
|
65
|
+
zoom: payload.zoom ?? 11,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Build optional map props as ONE typed literal (same TS2769-avoidance
|
|
69
|
+
// reasoning as the chat MapBlock renderer) before spreading. The basemap
|
|
70
|
+
// switcher write-back is only wired when the editor is editable.
|
|
71
|
+
const mapOptions: Partial<MapContainerProps> = {
|
|
72
|
+
...(payload.basemap ? { mapStyle: payload.basemap } : {}),
|
|
73
|
+
...(payload.terrain ? { terrain: true } : {}),
|
|
74
|
+
...(editable ? { onBasemapChange } : {}),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<NodeViewWrapper data-type="mapBlock" className="notion-map" draggable={false}>
|
|
79
|
+
<div className="w-full overflow-hidden rounded-2xl border border-border" style={{ height: 360 }}>
|
|
80
|
+
<LazyMapContainer initialViewport={initialViewport} fullscreenTitle="Map" {...mapOptions}>
|
|
81
|
+
{editable ? (
|
|
82
|
+
// Interactive: draggable pins + add/remove affordances, all writing
|
|
83
|
+
// back through `onMarkersChange` → `patch({ markers })`.
|
|
84
|
+
<MapEditLayer markers={markers} onMarkersChange={onMarkersChange} />
|
|
85
|
+
) : (
|
|
86
|
+
// Read-only: static, non-draggable pins (original behaviour).
|
|
87
|
+
markers.map((m) => {
|
|
88
|
+
const marker: MarkerData = {
|
|
89
|
+
id: m.id,
|
|
90
|
+
longitude: m.lng,
|
|
91
|
+
latitude: m.lat,
|
|
92
|
+
data: m.label ? { label: m.label } : undefined,
|
|
93
|
+
};
|
|
94
|
+
return (
|
|
95
|
+
<MapMarker key={m.id} marker={marker} color={m.color} ariaLabel={m.label} />
|
|
96
|
+
);
|
|
97
|
+
})
|
|
98
|
+
)}
|
|
99
|
+
</LazyMapContainer>
|
|
100
|
+
</div>
|
|
101
|
+
</NodeViewWrapper>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -66,6 +66,7 @@ export const NotionEditor = forwardRef<NotionEditorHandle, NotionEditorProps>(
|
|
|
66
66
|
onSave,
|
|
67
67
|
className = '',
|
|
68
68
|
minHeight = 320,
|
|
69
|
+
resolveLinkPreview,
|
|
69
70
|
},
|
|
70
71
|
ref,
|
|
71
72
|
) {
|
|
@@ -74,8 +75,12 @@ export const NotionEditor = forwardRef<NotionEditorHandle, NotionEditorProps>(
|
|
|
74
75
|
|
|
75
76
|
// Build extensions once. Placeholder is captured by closure on first
|
|
76
77
|
// render — same constraint as MarkdownEditor; mentions / slash items
|
|
77
|
-
// don't change at runtime here.
|
|
78
|
-
|
|
78
|
+
// don't change at runtime here. The link-preview resolver is likewise
|
|
79
|
+
// captured on first build; pass a stable reference (it's a host method).
|
|
80
|
+
const extensions = useMemo(
|
|
81
|
+
() => notionExtensions({ placeholder, resolveLinkPreview }),
|
|
82
|
+
[placeholder, resolveLinkPreview],
|
|
83
|
+
);
|
|
79
84
|
|
|
80
85
|
const editor = useEditor({
|
|
81
86
|
immediatelyRender: false,
|