@djangocfg/ui-tools 2.1.430 → 2.1.432

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.
Files changed (188) hide show
  1. package/dist/tree/index.cjs +43 -8
  2. package/dist/tree/index.cjs.map +1 -1
  3. package/dist/tree/index.d.cts +31 -1
  4. package/dist/tree/index.d.ts +31 -1
  5. package/dist/tree/index.mjs +43 -8
  6. package/dist/tree/index.mjs.map +1 -1
  7. package/package.json +28 -14
  8. package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
  9. package/src/common/CodePanelHeader/index.ts +1 -0
  10. package/src/common/blocks/BlockError.tsx +46 -0
  11. package/src/common/blocks/index.ts +34 -0
  12. package/src/common/blocks/schemas.ts +143 -0
  13. package/src/common/blocks/types.ts +76 -0
  14. package/src/common/chips/README.md +67 -0
  15. package/src/common/chips/UrlChip.tsx +105 -0
  16. package/src/common/chips/index.ts +20 -0
  17. package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
  18. package/src/common/link-preview/index.ts +12 -0
  19. package/src/common/link-preview/types.ts +44 -0
  20. package/src/common/tiptap/index.ts +10 -0
  21. package/src/common/tiptap/useNodeAttrs.ts +128 -0
  22. package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
  23. package/src/tools/chat/README.md +109 -2
  24. package/src/tools/chat/composer/Composer.tsx +45 -12
  25. package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
  26. package/src/tools/chat/composer/PastedTextDialog.tsx +88 -0
  27. package/src/tools/chat/composer/index.ts +9 -0
  28. package/src/tools/chat/composer/textToAttachment.ts +69 -0
  29. package/src/tools/chat/composer/types.ts +13 -0
  30. package/src/tools/chat/composer/useComposerAttach.tsx +47 -0
  31. package/src/tools/chat/constants.ts +9 -2
  32. package/src/tools/chat/core/extractFirstUrl.ts +45 -0
  33. package/src/tools/chat/core/reducer.ts +32 -8
  34. package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
  35. package/src/tools/chat/core/transport/mock.ts +4 -1
  36. package/src/tools/chat/hooks/useChat.ts +20 -20
  37. package/src/tools/chat/lazy.tsx +10 -0
  38. package/src/tools/chat/messages/Attachments.tsx +335 -46
  39. package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
  40. package/src/tools/chat/messages/MessageBubble.tsx +175 -31
  41. package/src/tools/chat/messages/MessageList.tsx +107 -21
  42. package/src/tools/chat/messages/ToolCalls.tsx +28 -13
  43. package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
  44. package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
  45. package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
  46. package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
  47. package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
  48. package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
  49. package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
  50. package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
  51. package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
  52. package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
  53. package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
  54. package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
  55. package/src/tools/chat/messages/index.ts +13 -0
  56. package/src/tools/chat/public.ts +5 -1
  57. package/src/tools/chat/shell/ChatRoot.tsx +29 -0
  58. package/src/tools/chat/styles/bubbleTokens.ts +18 -0
  59. package/src/tools/chat/styles/index.ts +1 -0
  60. package/src/tools/chat/types/attachment.ts +38 -3
  61. package/src/tools/chat/types/block.ts +56 -9
  62. package/src/tools/chat/types/blockSchemas.ts +197 -0
  63. package/src/tools/chat/types/config.ts +33 -0
  64. package/src/tools/chat/types/events.ts +14 -7
  65. package/src/tools/chat/types/index.ts +4 -2
  66. package/src/tools/chat/types/message.ts +7 -0
  67. package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
  68. package/src/tools/data/Tree/README.md +30 -0
  69. package/src/tools/data/Tree/components/TreeRow.tsx +11 -2
  70. package/src/tools/data/Tree/context/menu/render.tsx +56 -27
  71. package/src/tools/data/Tree/data/appearance.ts +65 -3
  72. package/src/tools/data/Tree/data/index.ts +2 -0
  73. package/src/tools/data/Tree/index.tsx +2 -0
  74. package/src/tools/dev/Map/README.md +30 -345
  75. package/src/tools/dev/Map/cards/index.ts +6 -0
  76. package/src/tools/dev/Map/cards/types.ts +67 -0
  77. package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
  78. package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
  79. package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
  80. package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
  81. package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
  82. package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
  83. package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
  84. package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
  85. package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
  86. package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
  87. package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
  88. package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
  89. package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
  90. package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
  91. package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
  92. package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
  93. package/src/tools/dev/Map/components/MapControls.tsx +27 -0
  94. package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
  95. package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
  96. package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
  97. package/src/tools/dev/Map/components/index.ts +35 -1
  98. package/src/tools/dev/Map/geocode/index.ts +2 -0
  99. package/src/tools/dev/Map/geocode/photon.ts +265 -0
  100. package/src/tools/dev/Map/hooks/index.ts +37 -0
  101. package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
  102. package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
  103. package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
  104. package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
  105. package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
  106. package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
  107. package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
  108. package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
  109. package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
  110. package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
  111. package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
  112. package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
  113. package/src/tools/dev/Map/index.ts +59 -2
  114. package/src/tools/dev/Map/layers/index.ts +1 -0
  115. package/src/tools/dev/Map/layers/symbol.ts +93 -0
  116. package/src/tools/dev/Map/lazy.tsx +120 -0
  117. package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
  118. package/src/tools/dev/Map/styles/index.ts +62 -0
  119. package/src/tools/dev/Map/styles/terrain.ts +29 -0
  120. package/src/tools/dev/Map/types.ts +31 -3
  121. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
  122. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
  123. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
  124. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
  125. package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
  126. package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
  127. package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
  128. package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
  129. package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
  130. package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
  131. package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
  132. package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
  133. package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
  134. package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
  135. package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
  136. package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
  137. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
  138. package/src/tools/forms/MarkdownEditor/README.md +84 -0
  139. package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
  140. package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
  141. package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
  142. package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
  143. package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
  144. package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
  145. package/src/tools/forms/MarkdownEditor/index.ts +19 -0
  146. package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
  147. package/src/tools/forms/MarkdownEditor/styles.css +134 -2
  148. package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
  149. package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
  150. package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
  151. package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
  152. package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
  153. package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
  154. package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
  155. package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
  156. package/src/tools/forms/NotionEditor/README.md +109 -188
  157. package/src/tools/forms/NotionEditor/extensions.ts +15 -0
  158. package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
  159. package/src/tools/forms/NotionEditor/styles.css +25 -0
  160. package/src/tools/forms/NotionEditor/types.ts +9 -0
  161. package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
  162. package/src/tools/input/SpeechRecognition/types.ts +10 -0
  163. package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
  164. package/src/tools/media/ImageViewer/README.md +9 -0
  165. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
  166. package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
  167. package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
  168. package/src/tools/media/ImageViewer/components/index.ts +2 -0
  169. package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
  170. package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
  171. package/src/tools/media/ImageViewer/types.ts +10 -0
  172. package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
  173. package/src/tools/media/ImageViewer/utils/index.ts +4 -0
  174. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
  175. package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
  176. package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
  177. package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
  178. package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
  179. package/src/tools/visual/charts/SmoothLine/README.md +97 -0
  180. package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
  181. package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
  182. package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
  183. package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
  184. package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
  185. package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
  186. package/src/tools/visual/indicators/Fps/types.ts +1 -1
  187. package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
  188. 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
- const extensions = useMemo(() => notionExtensions({ placeholder }), [placeholder]);
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,