@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,161 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { findFilePaths, splitPath, truncatePathLabel } from '../detect';
3
+
4
+ describe('findFilePaths', () => {
5
+ it('detects the repro path embedded in Cyrillic prose', () => {
6
+ const text =
7
+ 'прочти манифест /Users/markinmatrix/workspace/@projects/cmdop/@docs/positioning/manifest.md интересно твоё мнение';
8
+ const matches = findFilePaths(text);
9
+ expect(matches).toHaveLength(1);
10
+ expect(matches[0]!.path).toBe(
11
+ '/Users/markinmatrix/workspace/@projects/cmdop/@docs/positioning/manifest.md',
12
+ );
13
+ // The slice at [start,end) must equal the matched path exactly.
14
+ expect(text.slice(matches[0]!.start, matches[0]!.end)).toBe(matches[0]!.path);
15
+ });
16
+
17
+ it('detects a Windows drive path with backslashes', () => {
18
+ const matches = findFilePaths('see C:\\Users\\me\\notes.md please');
19
+ expect(matches).toHaveLength(1);
20
+ expect(matches[0]!.path).toBe('C:\\Users\\me\\notes.md');
21
+ });
22
+
23
+ it('detects a Windows drive path with forward slashes', () => {
24
+ const matches = findFilePaths('open C:/Users/me/notes.md');
25
+ expect(matches[0]!.path).toBe('C:/Users/me/notes.md');
26
+ });
27
+
28
+ it('detects a UNC path', () => {
29
+ const matches = findFilePaths('on \\\\srv\\share\\x.md here');
30
+ expect(matches).toHaveLength(1);
31
+ expect(matches[0]!.path).toBe('\\\\srv\\share\\x.md');
32
+ });
33
+
34
+ it('detects a ~ home path', () => {
35
+ const matches = findFilePaths('edit ~/dev/a.ts now');
36
+ expect(matches).toHaveLength(1);
37
+ expect(matches[0]!.path).toBe('~/dev/a.ts');
38
+ });
39
+
40
+ it('detects a file:// URI', () => {
41
+ const matches = findFilePaths('go to file:///x/y.md ok');
42
+ expect(matches).toHaveLength(1);
43
+ expect(matches[0]!.path).toBe('file:///x/y.md');
44
+ });
45
+
46
+ it('detects a directory path ending in a folder', () => {
47
+ const matches = findFilePaths('cd /Users/me/positioning');
48
+ expect(matches).toHaveLength(1);
49
+ expect(matches[0]!.path).toBe('/Users/me/positioning');
50
+ });
51
+
52
+ it('detects a directory path ending in a trailing separator', () => {
53
+ const matches = findFilePaths('cd /Users/me/positioning/ then');
54
+ expect(matches[0]!.path).toBe('/Users/me/positioning/');
55
+ });
56
+
57
+ it('trims trailing sentence punctuation', () => {
58
+ const matches = findFilePaths('see /a/b/c.md. Done.');
59
+ expect(matches).toHaveLength(1);
60
+ expect(matches[0]!.path).toBe('/a/b/c.md');
61
+ });
62
+
63
+ it('trims a closing paren', () => {
64
+ const matches = findFilePaths('(at /a/b/c.md)');
65
+ expect(matches[0]!.path).toBe('/a/b/c.md');
66
+ });
67
+
68
+ // ── False positives ──
69
+
70
+ it('does NOT match a prose slash "a / b"', () => {
71
+ expect(findFilePaths('a / b')).toHaveLength(0);
72
+ });
73
+
74
+ it('does NOT match a single-segment root "/foo"', () => {
75
+ expect(findFilePaths('just /foo here')).toHaveLength(0);
76
+ });
77
+
78
+ it('does NOT match an https URL as a file path', () => {
79
+ expect(findFilePaths('visit https://x.com/y/z page')).toHaveLength(0);
80
+ });
81
+
82
+ it('does NOT match an http URL as a file path', () => {
83
+ expect(findFilePaths('http://example.com/a/b')).toHaveLength(0);
84
+ });
85
+
86
+ it('does NOT match bare relative tokens', () => {
87
+ expect(findFilePaths('run ./script and src/index.ts')).toHaveLength(0);
88
+ });
89
+
90
+ it('finds multiple paths in one string', () => {
91
+ const matches = findFilePaths('a /x/y.ts and ~/z/w.md done');
92
+ expect(matches.map((m) => m.path)).toEqual(['/x/y.ts', '~/z/w.md']);
93
+ });
94
+ });
95
+
96
+ describe('splitPath', () => {
97
+ it('splits a unix file path', () => {
98
+ expect(splitPath('/Users/me/manifest.md')).toEqual({
99
+ dir: 'Users/me',
100
+ base: 'manifest.md',
101
+ isDir: false,
102
+ });
103
+ });
104
+
105
+ it('treats an extensionless basename as a dir', () => {
106
+ const r = splitPath('/Users/me/positioning');
107
+ expect(r.base).toBe('positioning');
108
+ expect(r.isDir).toBe(true);
109
+ });
110
+
111
+ it('treats a trailing-separator path as a dir', () => {
112
+ const r = splitPath('/Users/me/docs/');
113
+ expect(r.base).toBe('docs');
114
+ expect(r.isDir).toBe(true);
115
+ });
116
+
117
+ it('is separator-agnostic for Windows paths', () => {
118
+ const r = splitPath('C:\\Users\\me\\notes.md');
119
+ expect(r.base).toBe('notes.md');
120
+ expect(r.isDir).toBe(false);
121
+ });
122
+
123
+ it('strips a file:// scheme', () => {
124
+ expect(splitPath('file:///x/y.md').base).toBe('y.md');
125
+ });
126
+
127
+ it('treats a dotfile as a file', () => {
128
+ expect(splitPath('/home/me/.gitignore').isDir).toBe(false);
129
+ });
130
+ });
131
+
132
+ describe('truncatePathLabel', () => {
133
+ it('keeps a short path unchanged', () => {
134
+ expect(truncatePathLabel('/a/b.md')).toBe('/a/b.md');
135
+ });
136
+
137
+ it('middle-ellipsizes a long path but keeps the basename', () => {
138
+ const label = truncatePathLabel(
139
+ '/Users/markinmatrix/workspace/@projects/cmdop/@docs/positioning/manifest.md',
140
+ );
141
+ expect(label).toContain('manifest.md');
142
+ expect(label).toContain('…');
143
+ // Basename is never cut.
144
+ expect(label.endsWith('manifest.md')).toBe(true);
145
+ });
146
+
147
+ it('respects a custom maxChars', () => {
148
+ const label = truncatePathLabel('/very/long/path/to/some/file.md', {
149
+ maxChars: 16,
150
+ });
151
+ expect(label.endsWith('file.md')).toBe(true);
152
+ expect(label).toContain('…');
153
+ });
154
+
155
+ it('keeps the parent folder hint for a long path', () => {
156
+ const label = truncatePathLabel(
157
+ '/Users/markinmatrix/workspace/@projects/cmdop/@docs/positioning/manifest.md',
158
+ );
159
+ expect(label).toContain('positioning');
160
+ });
161
+ });
@@ -0,0 +1,289 @@
1
+ /**
2
+ * File-path detection + display helpers for the chat editor's "file chip"
3
+ * decoration. Pure, side-effect-free, and unit-testable — no TipTap /
4
+ * ProseMirror / React imports live here so the same functions back the
5
+ * standalone `<FilePathChip>` and the editor decoration alike.
6
+ *
7
+ * Design goal: **LOW false-positive**. We only treat a token as a local
8
+ * file path when it matches an explicit, unambiguous ABSOLUTE shape —
9
+ * never a bare relative token (`./x`, `src/y`) and never a web URL. See
10
+ * `reports/research.md` for the rationale (there is no single universal
11
+ * path regex; detect by safe shapes only).
12
+ */
13
+
14
+ // ── Match result ──
15
+
16
+ export interface FilePathMatch {
17
+ /** Inclusive start offset into the scanned string. */
18
+ start: number;
19
+ /** Exclusive end offset into the scanned string. */
20
+ end: number;
21
+ /** The matched path text (already trailing-punctuation-trimmed). */
22
+ path: string;
23
+ }
24
+
25
+ // ── Detector ──
26
+
27
+ /**
28
+ * Trailing sentence punctuation that, when it hangs off the END of a
29
+ * detected path token, is almost certainly prose and not part of the
30
+ * filename — trim it. Real filenames effectively never end in these.
31
+ * (A leading `.` like a dotfile is fine — we only trim the tail.)
32
+ */
33
+ const TRAILING_PUNCT = new Set([
34
+ '.', ',', ')', ']', '}', ';', ':', '!', '?',
35
+ '«', '»', '"', "'", '”', '’', '…',
36
+ ]);
37
+
38
+ /**
39
+ * The four absolute path shapes, as a single alternation. Each alternative
40
+ * is whitespace-bounded by construction (`\S` runs, no raw spaces). Order
41
+ * matters: more specific schemes (`file://`, UNC, drive) come before the
42
+ * bare Unix root so the longest correct form wins.
43
+ *
44
+ * - `file://…` — `file:` URI (any non-space tail).
45
+ * - `\\server\share…` — Windows UNC (two leading backslashes).
46
+ * - `C:\…` / `C:/…` — Windows drive (single letter + `:` + slash).
47
+ * - `~/…` — home-relative (Unix/macOS).
48
+ * - `/seg/seg…` — Unix/macOS absolute, REQUIRES ≥2 segments so a
49
+ * lone `/` or prose `a / b` never matches.
50
+ *
51
+ * Unicode is allowed inside segments (`@projects`, Cyrillic dirs) — we
52
+ * match on "not whitespace" rather than an ASCII allow-list, then strip
53
+ * trailing punctuation afterwards.
54
+ */
55
+ const PATH_RE = new RegExp(
56
+ [
57
+ // file:// URI — scheme then a non-space tail.
58
+ String.raw`file:\/\/\S+`,
59
+ // UNC: \\server\share... (need a host char after the two slashes).
60
+ String.raw`\\\\[^\s\\/][^\s]*`,
61
+ // Windows drive: C:\... or C:/... (single drive letter).
62
+ String.raw`[A-Za-z]:[\\/][^\s]*`,
63
+ // Home: ~/...
64
+ String.raw`~[\\/][^\s]+`,
65
+ // Unix/macOS absolute: /seg/seg... — at least TWO segments. A
66
+ // segment is a run of non-space, non-separator chars; we require
67
+ // a separator + segment to repeat at least once after the root.
68
+ String.raw`\/[^\s\\/]+(?:[\\/][^\s\\/]+)+[\\/]?`,
69
+ ].join('|'),
70
+ 'gu',
71
+ );
72
+
73
+ /**
74
+ * Trim trailing prose punctuation from a raw token. Repeats so multiple
75
+ * trailing marks (`manifest.md).` → `manifest.md`) all come off, but
76
+ * stops as soon as the tail is a normal path char. We never trim into an
77
+ * empty string.
78
+ */
79
+ function trimTrailingPunct(raw: string): string {
80
+ let end = raw.length;
81
+ while (end > 1 && TRAILING_PUNCT.has(raw[end - 1]!)) {
82
+ end -= 1;
83
+ }
84
+ return raw.slice(0, end);
85
+ }
86
+
87
+ /**
88
+ * Find every absolute local file path in `text`.
89
+ *
90
+ * Returns matches in document order with `{ start, end, path }` where
91
+ * `[start, end)` are offsets into the ORIGINAL string (after trailing-
92
+ * punctuation trimming, `end` points just past the kept path — the
93
+ * trimmed punctuation is left out of the range so the chip doesn't cover
94
+ * it). Web URLs (`https://x.com/y`), bare relative tokens, and a lone
95
+ * prose slash are intentionally NOT matched.
96
+ */
97
+ export function findFilePaths(text: string): FilePathMatch[] {
98
+ if (!text) return [];
99
+
100
+ const out: FilePathMatch[] = [];
101
+ PATH_RE.lastIndex = 0;
102
+ let m: RegExpExecArray | null;
103
+
104
+ while ((m = PATH_RE.exec(text)) !== null) {
105
+ const raw = m[0];
106
+ const start = m.index;
107
+
108
+ // Guard against a zero-width match (shouldn't happen with these
109
+ // alternatives, but keep the loop safe).
110
+ if (raw.length === 0) {
111
+ PATH_RE.lastIndex += 1;
112
+ continue;
113
+ }
114
+
115
+ // Leading-boundary guard. A real path token starts the string or
116
+ // follows whitespace / an opening delimiter. If it's glued onto a
117
+ // preceding non-space char it's a SUBSTRING of a larger token — most
118
+ // importantly a web URL's path (`https://x.com/y/z` → the `/y/z`
119
+ // would otherwise match the Unix-absolute shape). Reject those.
120
+ if (start > 0) {
121
+ const prev = text[start - 1]!;
122
+ if (!/[\s([{<«"'‘“]/.test(prev)) continue;
123
+ }
124
+
125
+ const trimmed = trimTrailingPunct(raw);
126
+ // A bare drive root (`C:\`) or `~/` with nothing meaningful is not a
127
+ // useful chip — but the regexes above already require a tail segment,
128
+ // so `trimmed` here is always a real path. Still, skip if trimming
129
+ // collapsed it to a non-path remnant.
130
+ if (trimmed.length < 2) continue;
131
+
132
+ out.push({
133
+ start,
134
+ end: start + trimmed.length,
135
+ path: trimmed,
136
+ });
137
+ }
138
+
139
+ return out;
140
+ }
141
+
142
+ // ── Split (separator-agnostic) ──
143
+
144
+ export interface SplitPath {
145
+ /** Everything before the basename (no trailing separator). */
146
+ dir: string;
147
+ /** Last non-empty path segment (the file or folder name). */
148
+ base: string;
149
+ /**
150
+ * Heuristic: the path points at a directory rather than a file —
151
+ * either it ends in a separator, or its basename has no file
152
+ * extension (a dotted `manifest.md` reads as a file; `positioning`
153
+ * reads as a dir).
154
+ */
155
+ isDir: boolean;
156
+ }
157
+
158
+ /**
159
+ * Strip a `file://` (or `file:`) scheme prefix for display/splitting.
160
+ * `file:///Users/x` → `/Users/x`; `file://host/share` → `host/share`.
161
+ */
162
+ function stripFileScheme(path: string): string {
163
+ const lower = path.toLowerCase();
164
+ if (lower.startsWith('file://')) {
165
+ // file:///abs → /abs (three slashes: empty host, keep the root)
166
+ // file://host → host
167
+ return path.slice('file://'.length);
168
+ }
169
+ if (lower.startsWith('file:')) return path.slice('file:'.length);
170
+ return path;
171
+ }
172
+
173
+ /** True when `base` looks like it carries a real file extension. */
174
+ function hasExtension(base: string): boolean {
175
+ const dot = base.lastIndexOf('.');
176
+ // No dot, leading dot only (dotfile like `.gitignore` — treat as file),
177
+ // or a trailing dot → decide:
178
+ // - `.gitignore` (dot at 0) IS a file → has "extension".
179
+ // - `name.` (dot last) → not a real extension.
180
+ if (dot === -1) return false;
181
+ if (dot === base.length - 1) return false; // trailing dot
182
+ if (dot === 0) return true; // dotfile
183
+ return true;
184
+ }
185
+
186
+ /**
187
+ * Split a path into `{ dir, base, isDir }`, separator-agnostic (handles
188
+ * `/` and `\` mixed). The basename is the last NON-EMPTY segment so a
189
+ * trailing separator (`/Users/me/docs/`) still yields `base = "docs"`
190
+ * and `isDir = true`.
191
+ */
192
+ export function splitPath(path: string): SplitPath {
193
+ const cleaned = stripFileScheme(path);
194
+ const endsWithSep = /[\\/]$/.test(cleaned);
195
+
196
+ // Split on either separator, drop empty segments (collapses `//`,
197
+ // leading root, trailing sep).
198
+ const segments = cleaned.split(/[\\/]+/).filter((s) => s.length > 0);
199
+ const base = segments.length > 0 ? segments[segments.length - 1]! : cleaned;
200
+ const dirSegments = segments.slice(0, -1);
201
+ const dir = dirSegments.join('/');
202
+
203
+ const isDir = endsWithSep || !hasExtension(base);
204
+
205
+ return { dir, base, isDir };
206
+ }
207
+
208
+ // ── Truncate (Apple/Finder middle-ellipsis) ──
209
+
210
+ export interface TruncateOptions {
211
+ /**
212
+ * Approximate max characters for the label. The basename is ALWAYS
213
+ * kept in full (never cut); only the middle collapses. Default 40.
214
+ */
215
+ maxChars?: number;
216
+ }
217
+
218
+ const ELLIPSIS = '…';
219
+
220
+ /**
221
+ * Produce a compact, Finder/Chrome-style label for a path: the basename
222
+ * is sacred (always shown in full), the MIDDLE collapses to `…`, and a
223
+ * leading root hint is kept when there's room.
224
+ *
225
+ * Examples (maxChars ≈ 40):
226
+ * /Users/me/dev/proj/positioning/manifest.md
227
+ * → /Users/…/positioning/manifest.md
228
+ * /a/b.md → /a/b.md (already short — unchanged)
229
+ * C:\Users\me\notes.md → C:\…\notes.md (separator preserved? — we
230
+ * normalise display to `/` for compactness)
231
+ */
232
+ export function truncatePathLabel(path: string, opts: TruncateOptions = {}): string {
233
+ const maxChars = opts.maxChars ?? 40;
234
+ const display = stripFileScheme(path);
235
+
236
+ // Short enough — show as-is.
237
+ if (display.length <= maxChars) return display;
238
+
239
+ const endsWithSep = /[\\/]$/.test(display);
240
+ const segments = display.split(/[\\/]+/).filter((s) => s.length > 0);
241
+ if (segments.length <= 1) {
242
+ // Single segment longer than maxChars — keep the basename intact
243
+ // (CSS ellipsis is the safety net in the chip).
244
+ return display;
245
+ }
246
+
247
+ const base = segments[segments.length - 1]!;
248
+ const baseLabel = endsWithSep ? `${base}/` : base;
249
+
250
+ // Detect a leading root hint we can preserve:
251
+ // - Unix/macOS absolute → leading "/" + first segment.
252
+ // - Windows drive → "C:".
253
+ // - `~` home → "~".
254
+ let rootHint = '';
255
+ if (/^[A-Za-z]:[\\/]/.test(display)) {
256
+ rootHint = display.slice(0, 2); // "C:"
257
+ } else if (display.startsWith('~')) {
258
+ rootHint = '~';
259
+ } else if (/^[\\/]/.test(display)) {
260
+ rootHint = `/${segments[0]!}`;
261
+ }
262
+
263
+ // The penultimate segment (parent folder) is a useful hint to keep
264
+ // when room allows — `.../positioning/manifest.md`.
265
+ const parent = segments.length >= 2 ? segments[segments.length - 2]! : '';
266
+
267
+ // Build candidates from most-informative to least, pick the first that
268
+ // fits within maxChars (the basename is never sacrificed).
269
+ const candidates: string[] = [];
270
+ if (rootHint && parent) {
271
+ candidates.push(`${rootHint}/${ELLIPSIS}/${parent}/${baseLabel}`);
272
+ }
273
+ if (parent) {
274
+ candidates.push(`${ELLIPSIS}/${parent}/${baseLabel}`);
275
+ }
276
+ if (rootHint) {
277
+ candidates.push(`${rootHint}/${ELLIPSIS}/${baseLabel}`);
278
+ }
279
+ candidates.push(`${ELLIPSIS}/${baseLabel}`);
280
+
281
+ for (const c of candidates) {
282
+ if (c.length <= maxChars) return c;
283
+ }
284
+
285
+ // Even `…/base` overflows (a very long basename) — return it anyway;
286
+ // the chip's CSS `text-overflow: ellipsis` clips the tail visually
287
+ // while the full path stays in the tooltip.
288
+ return `${ELLIPSIS}/${baseLabel}`;
289
+ }
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+
3
+ export { FilePathChip } from './FilePathChip';
4
+ export type { FilePathChipProps } from './FilePathChip';
5
+
6
+ export {
7
+ findFilePaths,
8
+ splitPath,
9
+ truncatePathLabel,
10
+ } from './detect';
11
+ export type {
12
+ FilePathMatch,
13
+ SplitPath,
14
+ TruncateOptions,
15
+ } from './detect';
@@ -8,3 +8,22 @@ export type {
8
8
  } from './types';
9
9
  export { mentionPresets } from './mentionPresets';
10
10
  export type { SlashCommandInfo } from './slash/types';
11
+
12
+ // File-path chip — the standalone presentational chip plus the pure
13
+ // detector helpers (usable outside the editor, e.g. rendering a known
14
+ // path in a chat message).
15
+ export { FilePathChip } from './filePath/FilePathChip';
16
+ export type { FilePathChipProps } from './filePath/FilePathChip';
17
+ export { findFilePaths, splitPath, truncatePathLabel } from './filePath/detect';
18
+ export type { FilePathMatch, SplitPath, TruncateOptions } from './filePath/detect';
19
+
20
+ // URL chip — pure detector + display helpers backing the in-editor URL chip
21
+ // (the `editorChip` node's `kind: 'url'`). Usable standalone too.
22
+ export {
23
+ findUrls,
24
+ splitUrl,
25
+ truncateUrlLabel,
26
+ faviconUrl,
27
+ normalizeHref,
28
+ } from './url/detect';
29
+ export type { UrlMatch, SplitUrl, UrlLabelOptions } from './url/detect';
@@ -54,6 +54,16 @@ export { LazyMarkdownEditor as MarkdownEditor };
54
54
  // Light surface — pure helpers + types.
55
55
  export { mentionPresets } from './mentionPresets';
56
56
 
57
+ // File-path chip — the standalone presentational chip + the pure detector
58
+ // helpers. The chip is light enough to export eagerly (no TipTap at module
59
+ // scope — it only pulls `FileIcon` + the pure detector), so consumers can
60
+ // render a known path outside the editor without loading the ~200 KB
61
+ // TipTap chunk.
62
+ export { FilePathChip } from './filePath/FilePathChip';
63
+ export type { FilePathChipProps } from './filePath/FilePathChip';
64
+ export { findFilePaths, splitPath, truncatePathLabel } from './filePath/detect';
65
+ export type { FilePathMatch, SplitPath, TruncateOptions } from './filePath/detect';
66
+
57
67
  export type {
58
68
  MarkdownEditorProps,
59
69
  MarkdownEditorHandle,
@@ -79,6 +79,17 @@
79
79
  margin: 0.25em 0;
80
80
  }
81
81
 
82
+ /* The first / last block must hug the editor box — a leading/trailing
83
+ * 0.25em paragraph margin reads as extra air above the first line and a
84
+ * phantom empty line below the caret in the single-line chat composer.
85
+ * Inter-paragraph spacing (between two <p>) is untouched. */
86
+ .markdown-editor .tiptap > :first-child {
87
+ margin-top: 0;
88
+ }
89
+ .markdown-editor .tiptap > :last-child {
90
+ margin-bottom: 0;
91
+ }
92
+
82
93
  /* Lists — Notion uses tighter row spacing than browser default. */
83
94
  .markdown-editor .tiptap ul,
84
95
  .markdown-editor .tiptap ol {
@@ -246,9 +257,40 @@
246
257
  background: color-mix(in oklab, var(--color-primary, var(--primary)) 25%, transparent);
247
258
  }
248
259
 
249
- .markdown-editor .tiptap p.is-editor-empty:first-child::before {
260
+ /* Empty-editor placeholder.
261
+ *
262
+ * TipTap's stock recipe paints the placeholder as a `float: left;
263
+ * height: 0` `::before`. That float reparents the empty paragraph's
264
+ * inline content box, and Chromium then renders the text caret of the
265
+ * (otherwise empty) `<p>` at a zero-height / clipped position behind the
266
+ * float — so the caret is INVISIBLE until the first character is typed
267
+ * (after a space it suddenly blinks). Standard chat composers
268
+ * (ChatGPT / Claude) show a blinking caret in the empty box.
269
+ *
270
+ * Fix: take the placeholder OUT of inline flow — `position: absolute` +
271
+ * `pointer-events: none`. The empty paragraph keeps its natural,
272
+ * full-line-height caret slot (so the caret blinks immediately), and the
273
+ * placeholder text simply overlays the same spot without ever covering
274
+ * the 1px caret (the caret paints on top of the absolutely-positioned,
275
+ * pointer-events-none pseudo). `caret-color` is forced to the foreground
276
+ * so it can never inherit `transparent`. */
277
+ .markdown-editor .tiptap .ProseMirror,
278
+ .markdown-editor .tiptap,
279
+ .markdown-editor .ProseMirror {
280
+ caret-color: var(--color-foreground, var(--foreground));
281
+ }
282
+
283
+ .markdown-editor .tiptap p.is-editor-empty:first-child,
284
+ .markdown-editor .ProseMirror p.is-editor-empty:first-child {
285
+ position: relative;
286
+ }
287
+
288
+ .markdown-editor .tiptap p.is-editor-empty:first-child::before,
289
+ .markdown-editor .ProseMirror p.is-editor-empty:first-child::before {
250
290
  content: attr(data-placeholder);
251
- float: left;
291
+ position: absolute;
292
+ left: 0;
293
+ top: 0;
252
294
  pointer-events: none;
253
295
  height: 0;
254
296
  opacity: 0.4;
@@ -287,6 +329,96 @@
287
329
  display: inline;
288
330
  }
289
331
 
332
+ /* ─────────────────────────────────────────────────────────────────────
333
+ Editor chip — the `editorChip` atom NODE. ONE component renders both a
334
+ file-path chip (`--path`) and a URL chip (`--url`); each shows an icon +
335
+ a VSCode-style middle-ellipsis label and serialises back to the raw
336
+ path / URL. Unlike the old decoration, the node OWNS its rendering, so a
337
+ long path collapses to one line (no wrap). It's an atom, so caret never
338
+ lands inside — `user-select: none`, whole-chip delete.
339
+ ───────────────────────────────────────────────────────────────────── */
340
+ .markdown-editor .tiptap .editor-chip,
341
+ .markdown-editor .ProseMirror .editor-chip {
342
+ display: inline-flex;
343
+ align-items: center;
344
+ gap: 3px;
345
+ max-width: 22rem;
346
+ vertical-align: baseline;
347
+ background: var(--color-muted, var(--muted));
348
+ color: var(--color-foreground, var(--foreground));
349
+ border: 1px solid var(--color-border, var(--border));
350
+ border-radius: 4px;
351
+ padding: 0 5px;
352
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
353
+ font-size: 0.85em;
354
+ line-height: 1.45;
355
+ white-space: nowrap;
356
+ text-decoration: none;
357
+ user-select: none;
358
+ cursor: default;
359
+ }
360
+
361
+ /* URL chip is clickable — pointer cursor, but keep the chip look (no blue
362
+ underline; the link accent lives on hover only). */
363
+ .markdown-editor .tiptap a.editor-chip,
364
+ .markdown-editor .ProseMirror a.editor-chip {
365
+ cursor: pointer;
366
+ color: var(--color-foreground, var(--foreground));
367
+ }
368
+
369
+ .markdown-editor .tiptap a.editor-chip:hover,
370
+ .markdown-editor .ProseMirror a.editor-chip:hover {
371
+ border-color: var(--color-primary, var(--primary));
372
+ }
373
+
374
+ /* The middle-ellipsis label is precomputed; this is the CSS safety net for
375
+ an unusually long basename / slug. */
376
+ .markdown-editor .tiptap .editor-chip__label,
377
+ .markdown-editor .ProseMirror .editor-chip__label {
378
+ overflow: hidden;
379
+ text-overflow: ellipsis;
380
+ white-space: nowrap;
381
+ }
382
+
383
+ /* Leading icon box — fixed pixel size so the inner SVG (vendored FileIcon
384
+ markup, sized 100%) doesn't expand to the line box. */
385
+ .markdown-editor .tiptap .editor-chip__icon,
386
+ .markdown-editor .ProseMirror .editor-chip__icon {
387
+ display: inline-flex;
388
+ align-items: center;
389
+ justify-content: center;
390
+ width: 14px;
391
+ height: 14px;
392
+ flex: none;
393
+ }
394
+
395
+ .markdown-editor .tiptap .editor-chip__icon svg,
396
+ .markdown-editor .ProseMirror .editor-chip__icon svg,
397
+ .markdown-editor .tiptap .editor-chip__favicon,
398
+ .markdown-editor .ProseMirror .editor-chip__favicon {
399
+ width: 14px;
400
+ height: 14px;
401
+ display: block;
402
+ flex: none;
403
+ }
404
+
405
+ .markdown-editor .tiptap .editor-chip__favicon,
406
+ .markdown-editor .ProseMirror .editor-chip__favicon {
407
+ border-radius: 2px;
408
+ }
409
+
410
+ /* Folder glyph inherits the warning token tint (matches FileIcon). */
411
+ .markdown-editor .tiptap .editor-chip__icon--folder,
412
+ .markdown-editor .ProseMirror .editor-chip__icon--folder {
413
+ color: var(--color-warning, var(--warning, #d99a00));
414
+ }
415
+
416
+ /* URL icon (globe fallback) carries the link/primary accent. */
417
+ .markdown-editor .tiptap .editor-chip__icon--globe,
418
+ .markdown-editor .ProseMirror .editor-chip__icon--globe {
419
+ color: var(--color-primary, var(--primary));
420
+ }
421
+
290
422
  /* Mention inline chip */
291
423
  .markdown-mention {
292
424
  background: var(--color-primary, var(--primary));