@djangocfg/ui-tools 2.1.431 → 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 (171) hide show
  1. package/package.json +28 -14
  2. package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
  3. package/src/common/CodePanelHeader/index.ts +1 -0
  4. package/src/common/blocks/BlockError.tsx +46 -0
  5. package/src/common/blocks/index.ts +34 -0
  6. package/src/common/blocks/schemas.ts +143 -0
  7. package/src/common/blocks/types.ts +76 -0
  8. package/src/common/chips/README.md +67 -0
  9. package/src/common/chips/UrlChip.tsx +105 -0
  10. package/src/common/chips/index.ts +20 -0
  11. package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
  12. package/src/common/link-preview/index.ts +12 -0
  13. package/src/common/link-preview/types.ts +44 -0
  14. package/src/common/tiptap/index.ts +10 -0
  15. package/src/common/tiptap/useNodeAttrs.ts +128 -0
  16. package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
  17. package/src/tools/chat/README.md +88 -2
  18. package/src/tools/chat/composer/Composer.tsx +45 -12
  19. package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
  20. package/src/tools/chat/constants.ts +9 -2
  21. package/src/tools/chat/core/extractFirstUrl.ts +45 -0
  22. package/src/tools/chat/core/reducer.ts +32 -8
  23. package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
  24. package/src/tools/chat/core/transport/mock.ts +4 -1
  25. package/src/tools/chat/hooks/useChat.ts +20 -20
  26. package/src/tools/chat/lazy.tsx +10 -0
  27. package/src/tools/chat/messages/Attachments.tsx +267 -42
  28. package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
  29. package/src/tools/chat/messages/MessageBubble.tsx +175 -31
  30. package/src/tools/chat/messages/MessageList.tsx +107 -21
  31. package/src/tools/chat/messages/ToolCalls.tsx +28 -13
  32. package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
  33. package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
  34. package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
  35. package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
  36. package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
  37. package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
  38. package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
  39. package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
  40. package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
  41. package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
  42. package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
  43. package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
  44. package/src/tools/chat/messages/index.ts +13 -0
  45. package/src/tools/chat/public.ts +5 -1
  46. package/src/tools/chat/shell/ChatRoot.tsx +29 -0
  47. package/src/tools/chat/styles/bubbleTokens.ts +18 -0
  48. package/src/tools/chat/styles/index.ts +1 -0
  49. package/src/tools/chat/types/attachment.ts +26 -2
  50. package/src/tools/chat/types/block.ts +56 -9
  51. package/src/tools/chat/types/blockSchemas.ts +197 -0
  52. package/src/tools/chat/types/config.ts +33 -0
  53. package/src/tools/chat/types/events.ts +14 -7
  54. package/src/tools/chat/types/index.ts +4 -2
  55. package/src/tools/chat/types/message.ts +7 -0
  56. package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
  57. package/src/tools/dev/Map/README.md +30 -345
  58. package/src/tools/dev/Map/cards/index.ts +6 -0
  59. package/src/tools/dev/Map/cards/types.ts +67 -0
  60. package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
  61. package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
  62. package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
  63. package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
  64. package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
  65. package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
  66. package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
  67. package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
  68. package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
  69. package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
  70. package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
  71. package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
  72. package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
  73. package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
  74. package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
  75. package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
  76. package/src/tools/dev/Map/components/MapControls.tsx +27 -0
  77. package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
  78. package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
  79. package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
  80. package/src/tools/dev/Map/components/index.ts +35 -1
  81. package/src/tools/dev/Map/geocode/index.ts +2 -0
  82. package/src/tools/dev/Map/geocode/photon.ts +265 -0
  83. package/src/tools/dev/Map/hooks/index.ts +37 -0
  84. package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
  85. package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
  86. package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
  87. package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
  88. package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
  89. package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
  90. package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
  91. package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
  92. package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
  93. package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
  94. package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
  95. package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
  96. package/src/tools/dev/Map/index.ts +59 -2
  97. package/src/tools/dev/Map/layers/index.ts +1 -0
  98. package/src/tools/dev/Map/layers/symbol.ts +93 -0
  99. package/src/tools/dev/Map/lazy.tsx +120 -0
  100. package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
  101. package/src/tools/dev/Map/styles/index.ts +62 -0
  102. package/src/tools/dev/Map/styles/terrain.ts +29 -0
  103. package/src/tools/dev/Map/types.ts +31 -3
  104. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
  105. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
  106. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
  107. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
  108. package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
  109. package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
  110. package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
  111. package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
  112. package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
  113. package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
  114. package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
  115. package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
  116. package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
  117. package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
  118. package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
  119. package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
  120. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
  121. package/src/tools/forms/MarkdownEditor/README.md +84 -0
  122. package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
  123. package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
  124. package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
  125. package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
  126. package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
  127. package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
  128. package/src/tools/forms/MarkdownEditor/index.ts +19 -0
  129. package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
  130. package/src/tools/forms/MarkdownEditor/styles.css +134 -2
  131. package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
  132. package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
  133. package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
  134. package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
  135. package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
  136. package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
  137. package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
  138. package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
  139. package/src/tools/forms/NotionEditor/README.md +109 -188
  140. package/src/tools/forms/NotionEditor/extensions.ts +15 -0
  141. package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
  142. package/src/tools/forms/NotionEditor/styles.css +25 -0
  143. package/src/tools/forms/NotionEditor/types.ts +9 -0
  144. package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
  145. package/src/tools/input/SpeechRecognition/types.ts +10 -0
  146. package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
  147. package/src/tools/media/ImageViewer/README.md +9 -0
  148. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
  149. package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
  150. package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
  151. package/src/tools/media/ImageViewer/components/index.ts +2 -0
  152. package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
  153. package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
  154. package/src/tools/media/ImageViewer/types.ts +10 -0
  155. package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
  156. package/src/tools/media/ImageViewer/utils/index.ts +4 -0
  157. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
  158. package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
  159. package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
  160. package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
  161. package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
  162. package/src/tools/visual/charts/SmoothLine/README.md +97 -0
  163. package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
  164. package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
  165. package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
  166. package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
  167. package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
  168. package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
  169. package/src/tools/visual/indicators/Fps/types.ts +1 -1
  170. package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
  171. package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
@@ -0,0 +1,454 @@
1
+ /**
2
+ * `ChipNode` — a single TipTap inline ATOM node that renders BOTH file-path
3
+ * chips and URL chips, distinguished by a `kind` attribute. Replaces the old
4
+ * `FilePathChipExtension` decoration approach.
5
+ *
6
+ * Why a node (not a decoration), and why VSCode-style middle-ellipsis:
7
+ *
8
+ * The previous decoration was an OVERLAY — it could add a class + icon
9
+ * widget over the raw path text, but it could NOT replace the visible
10
+ * text. A long path therefore rendered in FULL and wrapped to several
11
+ * lines. An atomic inline node OWNS its rendering, so it can show a
12
+ * middle-ellipsised label (`…/dev/Map/index.ts`) on a single line while
13
+ * the FULL path lives only in the node's `path` attribute (and in copy /
14
+ * markdown output). This mirrors how `@tiptap/extension-mention` and the
15
+ * `SlashCommandNode` are atomic inline nodes that flatten back to plain
16
+ * text on serialise.
17
+ *
18
+ * Two kinds, one node (DRY):
19
+ *
20
+ * - `kind: 'path'` — attr `path`. Icon = `getFileIcon(base).svg` (folder
21
+ * glyph when `isDir`). Label = `truncatePathLabel(path)`. Serialises to
22
+ * the RAW `path` string.
23
+ * - `kind: 'url'` — attr `href`. Icon = a favicon (Google S2) that
24
+ * degrades to a globe glyph on error. Label = domain + middle-ellipsis
25
+ * of the path (`github.com/…/README.md`). Clickable (opens in a new
26
+ * tab). Serialises to the RAW `href` string.
27
+ *
28
+ * Serialisation (the whole point — text/markdown fidelity):
29
+ *
30
+ * `renderText` and `renderMarkdown` BOTH emit the literal raw path/href,
31
+ * so `editor.getText()`, copy, the submitted message, and `getMarkdown()`
32
+ * all contain the real string — NEVER chip markup. URLs serialise as a
33
+ * bare URL (the editor's autolinker re-links a bare URL on the next
34
+ * `setContent`, matching how the editor already treats pasted URLs).
35
+ *
36
+ * Auto-conversion:
37
+ *
38
+ * An input rule fires when the user types a path/URL followed by a
39
+ * boundary (space / newline / punctuation) and replaces the matched range
40
+ * with a chip node. A paste rule does the same for pasted text. Both reuse
41
+ * the pure detectors (`findFilePaths`, `findUrls`) so detection never
42
+ * drifts from the standalone chip / autolinker.
43
+ *
44
+ * The node renders via a DOM `addNodeView` (no React mount) — robust, cheap,
45
+ * and consistent with the React-free icon the old decoration used.
46
+ */
47
+
48
+ import { Node, mergeAttributes, InputRule } from '@tiptap/core';
49
+ import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
50
+ import { Fragment, Slice, type Node as PMNode } from '@tiptap/pm/model';
51
+ import { findFilePaths, splitPath, truncatePathLabel } from '../filePath/detect';
52
+ import { findUrls, splitUrl, truncateUrlLabel, faviconUrl } from '../url/detect';
53
+ import { getFileIcon } from '../../../visual/design/FileIcon/get-file-icon';
54
+
55
+ export type ChipKind = 'path' | 'url';
56
+
57
+ export interface ChipNodeOptions {
58
+ /** Enable auto-conversion of typed/pasted file paths into `path` chips. */
59
+ enablePaths: boolean;
60
+ /** Enable auto-conversion of typed/pasted URLs into `url` chips. */
61
+ enableUrls: boolean;
62
+ HTMLAttributes: Record<string, unknown>;
63
+ }
64
+
65
+ const chipPasteKey = new PluginKey('editorChipPaste');
66
+
67
+ const FOLDER_SVG =
68
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" ' +
69
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
70
+ 'stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2' +
71
+ 'h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 ' +
72
+ '2 0 0 0 2 2Z"/></svg>';
73
+
74
+ const GLOBE_SVG =
75
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" ' +
76
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
77
+ 'stroke-linejoin="round"><circle cx="12" cy="12" r="10"/>' +
78
+ '<path d="M2 12h20"/>' +
79
+ '<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
80
+
81
+ /** Build the leading icon element for a path chip. */
82
+ function buildPathIcon(path: string): HTMLElement {
83
+ const { base, isDir } = splitPath(path);
84
+ const span = document.createElement('span');
85
+ span.className = 'editor-chip__icon';
86
+ span.setAttribute('aria-hidden', 'true');
87
+ if (isDir) {
88
+ span.classList.add('editor-chip__icon--folder');
89
+ span.innerHTML = FOLDER_SVG;
90
+ } else {
91
+ span.innerHTML = getFileIcon(base).svg;
92
+ }
93
+ return span;
94
+ }
95
+
96
+ /** Build the leading icon element for a URL chip (favicon → globe fallback). */
97
+ function buildUrlIcon(href: string): HTMLElement {
98
+ const span = document.createElement('span');
99
+ span.className = 'editor-chip__icon editor-chip__icon--url';
100
+ span.setAttribute('aria-hidden', 'true');
101
+
102
+ const { host } = splitUrl(href);
103
+ const favicon = faviconUrl(host);
104
+ if (favicon) {
105
+ const img = document.createElement('img');
106
+ img.src = favicon;
107
+ img.alt = '';
108
+ img.width = 14;
109
+ img.height = 14;
110
+ img.className = 'editor-chip__favicon';
111
+ // Degrade to the globe glyph if the favicon fails (offline / blocked /
112
+ // unknown host) so the chip never shows a broken-image marker.
113
+ img.addEventListener('error', () => {
114
+ span.innerHTML = GLOBE_SVG;
115
+ span.classList.add('editor-chip__icon--globe');
116
+ });
117
+ span.appendChild(img);
118
+ } else {
119
+ span.innerHTML = GLOBE_SVG;
120
+ span.classList.add('editor-chip__icon--globe');
121
+ }
122
+ return span;
123
+ }
124
+
125
+ /** Build the full chip DOM for a node's attrs. */
126
+ function buildChipDom(kind: ChipKind, path: string, href: string): HTMLElement {
127
+ const dom = document.createElement(kind === 'url' ? 'a' : 'span');
128
+ dom.className = `editor-chip editor-chip--${kind}`;
129
+ dom.setAttribute('data-type', 'editorChip');
130
+ dom.setAttribute('data-kind', kind);
131
+ dom.setAttribute('contenteditable', 'false');
132
+
133
+ const labelSpan = document.createElement('span');
134
+ labelSpan.className = 'editor-chip__label';
135
+
136
+ if (kind === 'url') {
137
+ const a = dom as HTMLAnchorElement;
138
+ a.href = href;
139
+ a.target = '_blank';
140
+ a.rel = 'noopener noreferrer';
141
+ a.title = href;
142
+ a.setAttribute('data-href', href);
143
+ a.appendChild(buildUrlIcon(href));
144
+ labelSpan.textContent = truncateUrlLabel(href);
145
+ } else {
146
+ dom.title = path;
147
+ dom.setAttribute('data-path', path);
148
+ dom.appendChild(buildPathIcon(path));
149
+ labelSpan.textContent = truncatePathLabel(path);
150
+ }
151
+
152
+ dom.appendChild(labelSpan);
153
+ return dom;
154
+ }
155
+
156
+ /** Raw string a chip serialises to (text / markdown). */
157
+ function chipRawText(attrs: { kind?: ChipKind; path?: string | null; href?: string | null }): string {
158
+ if (attrs.kind === 'url') return attrs.href ?? '';
159
+ return attrs.path ?? '';
160
+ }
161
+
162
+ export const ChipNode = Node.create<ChipNodeOptions>({
163
+ name: 'editorChip',
164
+ group: 'inline',
165
+ inline: true,
166
+ atom: true,
167
+ selectable: true,
168
+ // Above plain text so paste rules don't try to re-parse our chip.
169
+ priority: 101,
170
+
171
+ addOptions() {
172
+ return {
173
+ enablePaths: true,
174
+ enableUrls: true,
175
+ HTMLAttributes: {},
176
+ };
177
+ },
178
+
179
+ addAttributes() {
180
+ return {
181
+ kind: {
182
+ default: 'path',
183
+ parseHTML: (el) => el.getAttribute('data-kind') ?? 'path',
184
+ renderHTML: (attrs) => ({ 'data-kind': (attrs.kind as string) || 'path' }),
185
+ },
186
+ path: {
187
+ default: null,
188
+ parseHTML: (el) => el.getAttribute('data-path'),
189
+ renderHTML: (attrs) => (attrs.path ? { 'data-path': attrs.path as string } : {}),
190
+ },
191
+ href: {
192
+ default: null,
193
+ parseHTML: (el) => el.getAttribute('data-href'),
194
+ renderHTML: (attrs) => (attrs.href ? { 'data-href': attrs.href as string } : {}),
195
+ },
196
+ };
197
+ },
198
+
199
+ parseHTML() {
200
+ return [{ tag: 'a[data-type="editorChip"]' }, { tag: 'span[data-type="editorChip"]' }];
201
+ },
202
+
203
+ renderHTML({ node, HTMLAttributes }) {
204
+ const kind = (node.attrs.kind as ChipKind) || 'path';
205
+ const tag = kind === 'url' ? 'a' : 'span';
206
+ const extra: Record<string, unknown> = {
207
+ 'data-type': 'editorChip',
208
+ class: `editor-chip editor-chip--${kind}`,
209
+ };
210
+ if (kind === 'url' && node.attrs.href) {
211
+ extra.href = node.attrs.href;
212
+ extra.target = '_blank';
213
+ extra.rel = 'noopener noreferrer';
214
+ }
215
+ return [
216
+ tag,
217
+ mergeAttributes(extra, this.options.HTMLAttributes, HTMLAttributes),
218
+ chipRawText(node.attrs as { kind?: ChipKind; path?: string | null; href?: string | null }),
219
+ ];
220
+ },
221
+
222
+ // DOM nodeview — renders the icon + middle-ellipsis label and owns the
223
+ // visible text (that's what fixes the long-path wrap).
224
+ addNodeView() {
225
+ return ({ node }) => {
226
+ const kind = (node.attrs.kind as ChipKind) || 'path';
227
+ const dom = buildChipDom(
228
+ kind,
229
+ (node.attrs.path as string | null) ?? '',
230
+ (node.attrs.href as string | null) ?? '',
231
+ );
232
+ return { dom };
233
+ };
234
+ },
235
+
236
+ // `editor.getText()` + markdown text fallback read this — the chip
237
+ // flattens to its raw path/href.
238
+ renderText({ node }) {
239
+ return chipRawText(node.attrs as { kind?: ChipKind; path?: string | null; href?: string | null });
240
+ },
241
+
242
+ // `@tiptap/markdown` reads this at registration time. Emit the bare raw
243
+ // string (NOT a shortcode), so a round-trip through `getMarkdown()`
244
+ // yields the literal path / URL.
245
+ renderMarkdown(node: { attrs: Record<string, unknown> }) {
246
+ return chipRawText(node.attrs as { kind?: ChipKind; path?: string | null; href?: string | null });
247
+ },
248
+
249
+ addInputRules() {
250
+ const type = this.type;
251
+ const rules: InputRule[] = [];
252
+
253
+ // The input rule fires when WHITESPACE is typed right after a path/URL
254
+ // token. We detect on the token BEFORE the space and replace that slice
255
+ // (keeping the space) with a chip node.
256
+ //
257
+ // ── Why we DON'T trust `match` / `range` from TipTap's `find` ──
258
+ //
259
+ // TipTap matches `rule.find` against `getTextContentFromNodes($from)`,
260
+ // which — for an ATOM node like our chip — appends the node's *full*
261
+ // `renderText` output (the raw URL/path string) to the text-before. So
262
+ // once a chip exists, the text-before the cursor is e.g.
263
+ // `http://localhost:6017/x` + whatever the user just typed, glued with
264
+ // NO separator (the chip serialises without a trailing space).
265
+ //
266
+ // That breaks the rule two ways:
267
+ // 1) `/(\S+)(\s)$/` re-matches the chip text fused to the next word
268
+ // (`http://localhost:6017/xпривет`) — still a valid URL — so the
269
+ // rule RE-FIRES on every word typed after a chip.
270
+ // 2) `range.from = from - (match[0].length - text.length)` assumes
271
+ // each char of `match[0]` is one doc position, but the chip is a
272
+ // single atom position contributing ~20 chars. So `range.from`
273
+ // lands far to the LEFT of the real token and the replace range
274
+ // swallows the typed space (the reported `приветкакделаутебя` bug).
275
+ //
276
+ // Fix: ignore `match`/`range` entirely and recompute the token from the
277
+ // REAL document — the contiguous text node that ends at the cursor.
278
+ // That text node starts AFTER any preceding chip (chips are separate
279
+ // sibling nodes), so chip text can never leak into the token.
280
+ //
281
+ // The boundary is WHITESPACE ONLY — deliberately NOT punctuation. Paths
282
+ // and URLs legitimately contain `.` `/` `:` `-` etc., so a punctuation
283
+ // boundary would fire mid-token (e.g. converting `…/Map/index` the
284
+ // instant the `.` of `.ts` is typed). Punctuation-terminated tokens are
285
+ // still handled on paste / `setContent` by the detectors' trailing-punct
286
+ // trim; live typing only chips on a real word boundary.
287
+ const makeRule = (
288
+ detect: (token: string) => { start: number; end: number; attrs: Record<string, unknown> } | null,
289
+ ) =>
290
+ new InputRule({
291
+ find: /(\S+)(\s)$/u,
292
+ handler: ({ range, match, chain, state }) => {
293
+ // The boundary whitespace the user just typed. CRUCIAL: TipTap's
294
+ // `handleTextInput` fires BEFORE the char is inserted, so this
295
+ // boundary is NOT yet in the document — it lives only in `match`.
296
+ // We must therefore re-insert it ourselves below.
297
+ const boundary = match[2] ?? '';
298
+ // `range.to` is the real cursor (insertion point). Unlike `match`
299
+ // it is a genuine doc position untouched by atom-text pollution.
300
+ const cursor = range.to;
301
+ const $cursor = state.doc.resolve(cursor);
302
+
303
+ // The text node immediately before the cursor (the run that holds
304
+ // the typed token). If the char before the cursor isn't text —
305
+ // e.g. the cursor sits right after a chip — there's nothing to
306
+ // convert. This text node starts AFTER any preceding chip (chips
307
+ // are separate sibling nodes), so chip text can never leak in.
308
+ const before = $cursor.nodeBefore;
309
+ if (!before || !before.isText || !before.text) return null;
310
+
311
+ const textNodeText = before.text;
312
+ const textNodeStart = cursor - textNodeText.length;
313
+
314
+ // Take the contiguous non-whitespace run at the END of the text
315
+ // node — that's the token candidate (the boundary char is not in
316
+ // the doc yet, so the node tail IS the token).
317
+ const lastWsIdx = textNodeText.search(/\s\S*$/u);
318
+ const tokenStartInText = lastWsIdx === -1 ? 0 : lastWsIdx + 1;
319
+ const token = textNodeText.slice(tokenStartInText);
320
+ if (!token) return null;
321
+
322
+ const found = detect(token);
323
+ if (!found) return null;
324
+
325
+ // Map the detector's offsets (relative to `token`) to absolute doc
326
+ // positions.
327
+ const tokenStart = textNodeStart + tokenStartInText;
328
+ const sliceFrom = tokenStart + found.start;
329
+ const sliceTo = tokenStart + found.end;
330
+ // Any token chars the detector trimmed off the end (e.g. trailing
331
+ // prose punctuation) stay as text, then the boundary char.
332
+ const tail = token.slice(found.end) + boundary;
333
+
334
+ chain()
335
+ // Replace [chip-slice .. cursor) with the chip node followed by
336
+ // the leftover tail + boundary char. Doing both in one
337
+ // `insertContentAt` keeps positions simple: the boundary space is
338
+ // a real text node AFTER the atom, giving the caret a valid
339
+ // landing spot (an atom at the very end of a textblock otherwise
340
+ // traps the caret and swallows subsequently typed spaces).
341
+ .insertContentAt({ from: sliceFrom, to: cursor }, [
342
+ { type: type.name, attrs: found.attrs },
343
+ { type: 'text', text: tail },
344
+ ])
345
+ // Land the caret at the END of the inserted content (after the
346
+ // boundary space) so typing continues as plain text.
347
+ .command(({ tr, dispatch }) => {
348
+ if (dispatch) {
349
+ // chip = 1 pos, then `tail` chars. Caret sits past them.
350
+ const pos = Math.min(sliceFrom + 1 + tail.length, tr.doc.content.size);
351
+ tr.setSelection(TextSelection.create(tr.doc, pos));
352
+ }
353
+ return true;
354
+ })
355
+ .run();
356
+ return;
357
+ },
358
+ });
359
+
360
+ if (this.options.enablePaths) {
361
+ rules.push(
362
+ makeRule((token) => {
363
+ const m = findFilePaths(token);
364
+ if (m.length === 0) return null;
365
+ const hit = m[0]!;
366
+ return { start: hit.start, end: hit.end, attrs: { kind: 'path', path: hit.path } };
367
+ }),
368
+ );
369
+ }
370
+ if (this.options.enableUrls) {
371
+ rules.push(
372
+ makeRule((token) => {
373
+ const m = findUrls(token);
374
+ if (m.length === 0) return null;
375
+ const hit = m[0]!;
376
+ return { start: hit.start, end: hit.end, attrs: { kind: 'url', href: hit.href } };
377
+ }),
378
+ );
379
+ }
380
+
381
+ return rules;
382
+ },
383
+
384
+ addProseMirrorPlugins() {
385
+ const enablePaths = this.options.enablePaths;
386
+ const enableUrls = this.options.enableUrls;
387
+ const type = this.type;
388
+
389
+ return [
390
+ new Plugin({
391
+ key: chipPasteKey,
392
+ props: {
393
+ // On paste of plain text, convert every detected path/URL token
394
+ // into a chip node, preserving the surrounding text. Returning
395
+ // `true` tells ProseMirror we handled the paste.
396
+ handlePaste: (view, event) => {
397
+ const text = event.clipboardData?.getData('text/plain');
398
+ if (!text) return false;
399
+ if (!enablePaths && !enableUrls) return false;
400
+
401
+ // Collect all matches, tagged by kind, sorted by start.
402
+ type Hit = { start: number; end: number; attrs: Record<string, unknown> };
403
+ const hits: Hit[] = [];
404
+ if (enablePaths) {
405
+ for (const m of findFilePaths(text)) {
406
+ hits.push({ start: m.start, end: m.end, attrs: { kind: 'path', path: m.path } });
407
+ }
408
+ }
409
+ if (enableUrls) {
410
+ for (const m of findUrls(text)) {
411
+ hits.push({ start: m.start, end: m.end, attrs: { kind: 'url', href: m.href } });
412
+ }
413
+ }
414
+ if (hits.length === 0) return false;
415
+ hits.sort((a, b) => a.start - b.start);
416
+
417
+ // Drop overlaps (a path inside a URL etc.) — first wins.
418
+ const merged: Hit[] = [];
419
+ let cursor = 0;
420
+ for (const h of hits) {
421
+ if (h.start < cursor) continue;
422
+ merged.push(h);
423
+ cursor = h.end;
424
+ }
425
+
426
+ const { schema } = view.state;
427
+ const nodes: PMNode[] = [];
428
+ let last = 0;
429
+ const pushText = (s: string) => {
430
+ if (s) nodes.push(schema.text(s));
431
+ };
432
+ for (const h of merged) {
433
+ pushText(text.slice(last, h.start));
434
+ nodes.push(type.create(h.attrs));
435
+ last = h.end;
436
+ }
437
+ pushText(text.slice(last));
438
+
439
+ // Insert the mixed text + chip nodes as one inline slice,
440
+ // replacing the current selection.
441
+ const fragment = Fragment.fromArray(nodes);
442
+ const slice = new Slice(fragment, 0, 0);
443
+ const { from, to } = view.state.selection;
444
+ const tr = view.state.tr.replaceRange(from, to, slice);
445
+ view.dispatch(tr.scrollIntoView());
446
+ return true;
447
+ },
448
+ },
449
+ }),
450
+ ];
451
+ },
452
+ });
453
+
454
+ export default ChipNode;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `syncChips` — convert detected file-path / URL text in the document into
3
+ * `editorChip` atom nodes. The analogue of `syncLeadingSlashNode` for the
4
+ * chip node.
5
+ *
6
+ * Input/paste rules cover live typing + paste, but content arriving via
7
+ * `setContent(markdown)` (the controlled `value` sync) is parsed straight
8
+ * to plain text — a bare path or URL in the initial `value` would render
9
+ * un-chipped without this pass. We scan every text node, run the pure
10
+ * detectors, and replace each match range with a chip, walking
11
+ * right-to-left within each text node so earlier offsets stay valid.
12
+ *
13
+ * Returns `true` when at least one replacement happened (callers may
14
+ * suppress a feedback `onUpdate`).
15
+ */
16
+ import type { Editor } from '@tiptap/react';
17
+ import { findFilePaths } from '../filePath/detect';
18
+ import { findUrls } from '../url/detect';
19
+
20
+ interface ChipOpts {
21
+ paths: boolean;
22
+ urls: boolean;
23
+ }
24
+
25
+ interface PendingHit {
26
+ /** Absolute doc start. */
27
+ from: number;
28
+ /** Absolute doc end. */
29
+ to: number;
30
+ attrs: Record<string, unknown>;
31
+ }
32
+
33
+ export function syncChips(editor: Editor, opts: ChipOpts): boolean {
34
+ if (!editor || editor.isDestroyed) return false;
35
+ if (!opts.paths && !opts.urls) return false;
36
+ const chipType = editor.schema.nodes.editorChip;
37
+ if (!chipType) return false;
38
+
39
+ const hits: PendingHit[] = [];
40
+
41
+ editor.state.doc.descendants((node, pos) => {
42
+ if (!node.isText || !node.text) return;
43
+ const text = node.text;
44
+
45
+ const local: { start: number; end: number; attrs: Record<string, unknown> }[] = [];
46
+ if (opts.paths) {
47
+ for (const m of findFilePaths(text)) {
48
+ local.push({ start: m.start, end: m.end, attrs: { kind: 'path', path: m.path } });
49
+ }
50
+ }
51
+ if (opts.urls) {
52
+ for (const m of findUrls(text)) {
53
+ local.push({ start: m.start, end: m.end, attrs: { kind: 'url', href: m.href } });
54
+ }
55
+ }
56
+ if (local.length === 0) return;
57
+
58
+ // Drop overlaps (e.g. a path shape inside a URL path) — earliest start
59
+ // wins, longest at equal start.
60
+ local.sort((a, b) => a.start - b.start || b.end - a.end);
61
+ let cursor = 0;
62
+ for (const h of local) {
63
+ if (h.start < cursor) continue;
64
+ hits.push({ from: pos + h.start, to: pos + h.end, attrs: h.attrs });
65
+ cursor = h.end;
66
+ }
67
+ });
68
+
69
+ if (hits.length === 0) return false;
70
+
71
+ // Apply right-to-left so each replacement doesn't shift the positions of
72
+ // the ones still pending.
73
+ hits.sort((a, b) => b.from - a.from);
74
+ let tr = editor.state.tr;
75
+ for (const h of hits) {
76
+ tr = tr.replaceWith(h.from, h.to, chipType.create(h.attrs));
77
+ }
78
+ editor.view.dispatch(tr);
79
+ return true;
80
+ }
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `<FilePathChip>` — a compact, presentational chip for a LOCAL file path
5
+ * (Cursor / VSCode / Finder style): the extension-correct file icon +
6
+ * a smart middle-ellipsized label, monospace, with the full path on hover.
7
+ *
8
+ * Standalone by design — it imports only the pure detector helpers and the
9
+ * shared `FileIcon`, so it can render a known path in a chat message,
10
+ * a file list, etc., entirely outside the TipTap editor.
11
+ *
12
+ * Presentational only: it never mutates or links anything. Inside the
13
+ * editor the SAME visual is reproduced by the `editorChip` atom NODE
14
+ * (see `../chip/ChipNode.ts`, `kind: 'path'`) — this component is the
15
+ * React/standalone counterpart, still useful for rendering a known path
16
+ * outside the editor (chat messages, file lists).
17
+ */
18
+
19
+ import { cn } from '@djangocfg/ui-core/lib';
20
+ import { FileIcon } from '../../../visual/design/FileIcon';
21
+ import { splitPath, truncatePathLabel } from './detect';
22
+
23
+ export interface FilePathChipProps {
24
+ /** The full, raw file path (Unix / macOS / Windows / UNC / `file://`). */
25
+ path: string;
26
+ /**
27
+ * Approximate max characters for the truncated label. The basename is
28
+ * always preserved; only the middle collapses. Default 40.
29
+ */
30
+ maxChars?: number;
31
+ /** Icon size in px. Default 14 (matches inline text). */
32
+ iconSize?: 12 | 14 | 16;
33
+ className?: string;
34
+ }
35
+
36
+ /**
37
+ * Render a path as an inline chip. Uses the folder icon when the path
38
+ * resolves to a directory (no file extension / trailing separator).
39
+ */
40
+ export function FilePathChip({
41
+ path,
42
+ maxChars = 40,
43
+ iconSize = 14,
44
+ className,
45
+ }: FilePathChipProps) {
46
+ const { base, isDir } = splitPath(path);
47
+ const label = truncatePathLabel(path, { maxChars });
48
+
49
+ return (
50
+ <span
51
+ className={cn(
52
+ // Inline pill: muted surface + border, monospace, semantic tokens
53
+ // only (mirrors the inline-code look). `max-w` + `truncate` is the
54
+ // CSS safety net under the semantic middle-ellipsis label.
55
+ 'inline-flex max-w-[22rem] items-center gap-1 rounded',
56
+ 'border border-border bg-muted px-1.5 py-0.5 align-baseline',
57
+ 'font-mono text-[0.8125em] leading-tight text-foreground',
58
+ className,
59
+ )}
60
+ title={path}
61
+ >
62
+ <FileIcon name={base} isFolder={isDir} size={iconSize} />
63
+ <span className="truncate">{label}</span>
64
+ </span>
65
+ );
66
+ }
67
+
68
+ export default FilePathChip;