@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,121 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ findUrls,
4
+ splitUrl,
5
+ truncateUrlLabel,
6
+ faviconUrl,
7
+ normalizeHref,
8
+ } from '../detect';
9
+
10
+ describe('findUrls', () => {
11
+ it('detects an https URL', () => {
12
+ const matches = findUrls('see https://github.com/wailsapp/wails here');
13
+ expect(matches).toHaveLength(1);
14
+ expect(matches[0]!.href).toBe('https://github.com/wailsapp/wails');
15
+ });
16
+
17
+ it('detects an http URL', () => {
18
+ const matches = findUrls('http://example.com/x');
19
+ expect(matches[0]!.href).toBe('http://example.com/x');
20
+ });
21
+
22
+ it('prepends https to a bare www. host', () => {
23
+ const matches = findUrls('visit www.djangocfg.com/docs now');
24
+ expect(matches).toHaveLength(1);
25
+ expect(matches[0]!.href).toBe('https://www.djangocfg.com/docs');
26
+ expect(matches[0]!.raw).toBe('www.djangocfg.com/docs');
27
+ });
28
+
29
+ it('detects a mailto link', () => {
30
+ const matches = findUrls('mail me@example.com? no — mailto:me@example.com please');
31
+ expect(matches).toHaveLength(1);
32
+ expect(matches[0]!.href).toBe('mailto:me@example.com');
33
+ });
34
+
35
+ it('trims trailing prose punctuation', () => {
36
+ const matches = findUrls('go to https://djangocfg.com/docs.');
37
+ expect(matches[0]!.href).toBe('https://djangocfg.com/docs');
38
+ expect(matches[0]!.raw).toBe('https://djangocfg.com/docs');
39
+ });
40
+
41
+ it('does NOT match a bare file path', () => {
42
+ expect(findUrls('/Users/me/dev/manifest.md')).toHaveLength(0);
43
+ });
44
+
45
+ it('does NOT match a dotted filename', () => {
46
+ expect(findUrls('see manifest.md please')).toHaveLength(0);
47
+ });
48
+
49
+ it('does NOT match a URL glued onto a preceding non-space char', () => {
50
+ // `xhttps://...` — not a real URL boundary.
51
+ expect(findUrls('xhttps://evil.com')).toHaveLength(0);
52
+ });
53
+
54
+ it('offsets slice back to the matched raw text', () => {
55
+ const text = 'prefix https://a.com/b suffix';
56
+ const m = findUrls(text)[0]!;
57
+ expect(text.slice(m.start, m.end)).toBe('https://a.com/b');
58
+ });
59
+ });
60
+
61
+ describe('splitUrl', () => {
62
+ it('splits domain (www. stripped) and rest', () => {
63
+ const s = splitUrl('https://www.github.com/wailsapp/wails');
64
+ expect(s.domain).toBe('github.com');
65
+ expect(s.host).toBe('www.github.com');
66
+ expect(s.rest).toBe('/wailsapp/wails');
67
+ });
68
+
69
+ it('yields empty rest for a bare host', () => {
70
+ const s = splitUrl('https://github.com');
71
+ expect(s.domain).toBe('github.com');
72
+ expect(s.rest).toBe('');
73
+ });
74
+
75
+ it('splits a mailto address', () => {
76
+ const s = splitUrl('mailto:me@example.com');
77
+ expect(s.mail).toBe('me@example.com');
78
+ expect(s.domain).toBe('example.com');
79
+ });
80
+ });
81
+
82
+ describe('truncateUrlLabel', () => {
83
+ it('keeps a short URL as domain + path', () => {
84
+ expect(truncateUrlLabel('https://github.com/wailsapp/wails')).toBe(
85
+ 'github.com/wailsapp/wails',
86
+ );
87
+ });
88
+
89
+ it('middle-ellipsises a long path, keeping domain + last segment', () => {
90
+ const label = truncateUrlLabel(
91
+ 'https://github.com/wailsapp/wails/blob/main/v3/README.md',
92
+ );
93
+ expect(label).toContain('github.com');
94
+ expect(label).toContain('README.md');
95
+ expect(label).toContain('…');
96
+ expect(label.length).toBeLessThanOrEqual(40);
97
+ });
98
+
99
+ it('renders a mailto as the bare address', () => {
100
+ expect(truncateUrlLabel('mailto:me@example.com')).toBe('me@example.com');
101
+ });
102
+ });
103
+
104
+ describe('normalizeHref', () => {
105
+ it('prepends https to www.', () => {
106
+ expect(normalizeHref('www.x.com')).toBe('https://www.x.com');
107
+ });
108
+ it('leaves an explicit scheme untouched', () => {
109
+ expect(normalizeHref('http://x.com')).toBe('http://x.com');
110
+ });
111
+ });
112
+
113
+ describe('faviconUrl', () => {
114
+ it('builds a Google S2 favicon URL', () => {
115
+ expect(faviconUrl('github.com')).toContain('s2/favicons');
116
+ expect(faviconUrl('github.com')).toContain('domain=github.com');
117
+ });
118
+ it('returns empty for an empty host', () => {
119
+ expect(faviconUrl('')).toBe('');
120
+ });
121
+ });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * URL detection + display helpers for the chat editor's "URL chip" node.
3
+ * Pure, side-effect-free, and unit-testable — no TipTap / ProseMirror /
4
+ * React imports live here so the same functions back the editor node and
5
+ * any standalone use alike.
6
+ *
7
+ * Design goal mirrors `../filePath/detect.ts`: **LOW false-positive**. We
8
+ * only treat a token as a URL when it carries an explicit web scheme
9
+ * (`http`/`https`/`ftp`/`mailto`/`file`) or a bare `www.` host — exactly
10
+ * the set the editor's `shouldAutoLinkUrl` whitelist already accepts, so
11
+ * the chip never disagrees with the autolinker. Bare file paths and dotted
12
+ * filenames (`manifest.md`) are intentionally NOT matched here — those are
13
+ * the file-path node's job.
14
+ */
15
+
16
+ // ── Match result ──
17
+
18
+ export interface UrlMatch {
19
+ /** Inclusive start offset into the scanned string. */
20
+ start: number;
21
+ /** Exclusive end offset into the scanned string. */
22
+ end: number;
23
+ /**
24
+ * The matched URL text, normalised to a navigable href (a bare `www.`
25
+ * host gets an `https://` prefix so the chip's anchor works). Trailing
26
+ * prose punctuation is trimmed.
27
+ */
28
+ href: string;
29
+ /** The raw matched text exactly as it appeared (no scheme added). */
30
+ raw: string;
31
+ }
32
+
33
+ /**
34
+ * Trailing sentence punctuation that, when it hangs off the END of a
35
+ * detected URL token, is almost certainly prose and not part of the URL —
36
+ * trim it. (A URL effectively never ends in these for display purposes;
37
+ * a real trailing `)` that balances an opening `(` is the classic edge
38
+ * case but rare in a chat composer and not worth the complexity.)
39
+ */
40
+ const TRAILING_PUNCT = new Set([
41
+ '.', ',', ')', ']', '}', ';', ':', '!', '?',
42
+ '«', '»', '"', "'", '”', '’', '…',
43
+ ]);
44
+
45
+ /**
46
+ * URL shapes, as a single alternation. Each alternative is whitespace-
47
+ * bounded by construction (`\S` runs). Order: explicit schemes first, then
48
+ * the bare `www.` host.
49
+ *
50
+ * - `mailto:user@host` — mail scheme (no `//`).
51
+ * - `scheme://host/...` — http/https/ftp/file with authority.
52
+ * - `www.host...` — schemeless web host (we prepend https).
53
+ */
54
+ const URL_RE = new RegExp(
55
+ [
56
+ String.raw`mailto:[^\s]+`,
57
+ String.raw`(?:https?|ftp|file):\/\/[^\s]+`,
58
+ String.raw`www\.[^\s/$.?#][^\s]*`,
59
+ ].join('|'),
60
+ 'giu',
61
+ );
62
+
63
+ function trimTrailingPunct(raw: string): string {
64
+ let end = raw.length;
65
+ while (end > 1 && TRAILING_PUNCT.has(raw[end - 1]!)) {
66
+ end -= 1;
67
+ }
68
+ return raw.slice(0, end);
69
+ }
70
+
71
+ /** Normalise a raw token to a navigable href (prepend https for `www.`). */
72
+ export function normalizeHref(raw: string): string {
73
+ if (/^www\./i.test(raw)) return `https://${raw}`;
74
+ return raw;
75
+ }
76
+
77
+ /**
78
+ * Find every URL in `text`. Returns matches in document order with
79
+ * `{ start, end, href, raw }` where `[start, end)` are offsets into the
80
+ * ORIGINAL string (after trailing-punctuation trimming). File paths and
81
+ * bare relative tokens are intentionally NOT matched.
82
+ */
83
+ export function findUrls(text: string): UrlMatch[] {
84
+ if (!text) return [];
85
+
86
+ const out: UrlMatch[] = [];
87
+ URL_RE.lastIndex = 0;
88
+ let m: RegExpExecArray | null;
89
+
90
+ while ((m = URL_RE.exec(text)) !== null) {
91
+ const raw = m[0];
92
+ const start = m.index;
93
+
94
+ if (raw.length === 0) {
95
+ URL_RE.lastIndex += 1;
96
+ continue;
97
+ }
98
+
99
+ // Leading-boundary guard: a real URL token starts the string or
100
+ // follows whitespace / an opening delimiter. Glued onto a preceding
101
+ // non-space char it's a substring of a larger token — reject.
102
+ if (start > 0) {
103
+ const prev = text[start - 1]!;
104
+ if (!/[\s([{<«"'‘“]/.test(prev)) continue;
105
+ }
106
+
107
+ const trimmed = trimTrailingPunct(raw);
108
+ if (trimmed.length < 4) continue;
109
+
110
+ out.push({
111
+ start,
112
+ end: start + trimmed.length,
113
+ href: normalizeHref(trimmed),
114
+ raw: trimmed,
115
+ });
116
+ }
117
+
118
+ return out;
119
+ }
120
+
121
+ // ── Split / label (Chrome-style domain + middle-ellipsis path) ──
122
+
123
+ export interface SplitUrl {
124
+ /** Host without a leading `www.` (e.g. `github.com`). Empty if unparseable. */
125
+ domain: string;
126
+ /** Host for favicon lookup (with `www.` stripped). */
127
+ host: string;
128
+ /** Path + query + hash, leading `/` included (empty for a bare host). */
129
+ rest: string;
130
+ /** `mailto:` address (no scheme) when the URL is a mail link, else ''. */
131
+ mail: string;
132
+ }
133
+
134
+ /**
135
+ * Split an href into `{ domain, host, rest, mail }`. Uses the platform
136
+ * `URL` parser; falls back to a light regex when `URL` throws (it
137
+ * shouldn't for our matched shapes, but stay robust).
138
+ */
139
+ export function splitUrl(href: string): SplitUrl {
140
+ if (/^mailto:/i.test(href)) {
141
+ const mail = href.slice('mailto:'.length);
142
+ const at = mail.lastIndexOf('@');
143
+ const domain = at >= 0 ? mail.slice(at + 1) : '';
144
+ return { domain, host: domain, rest: '', mail };
145
+ }
146
+
147
+ try {
148
+ const u = new URL(normalizeHref(href));
149
+ const host = u.hostname.replace(/^www\./i, '');
150
+ const rest = `${u.pathname === '/' ? '' : u.pathname}${u.search}${u.hash}`;
151
+ return { domain: host, host: u.hostname, rest, mail: '' };
152
+ } catch {
153
+ // Best-effort fallback for an unparseable token.
154
+ const noScheme = normalizeHref(href).replace(/^[a-z]+:\/\//i, '');
155
+ const slash = noScheme.indexOf('/');
156
+ const host = (slash >= 0 ? noScheme.slice(0, slash) : noScheme).replace(/^www\./i, '');
157
+ const rest = slash >= 0 ? noScheme.slice(slash) : '';
158
+ return { domain: host, host, rest, mail: '' };
159
+ }
160
+ }
161
+
162
+ const ELLIPSIS = '…';
163
+
164
+ export interface UrlLabelOptions {
165
+ /**
166
+ * Approximate max characters for the WHOLE label (domain + path). The
167
+ * domain is always kept in full; only the path's middle collapses.
168
+ * Default 40.
169
+ */
170
+ maxChars?: number;
171
+ }
172
+
173
+ /**
174
+ * Produce a compact, Chrome-omnibox-style label for a URL: the domain is
175
+ * sacred (always shown in full), then the path's MIDDLE collapses to `…`
176
+ * while the LAST path segment (often the meaningful slug / filename) is
177
+ * kept.
178
+ *
179
+ * Examples (maxChars ≈ 40):
180
+ * https://github.com/wailsapp/wails/blob/main/v3/README.md
181
+ * → github.com/…/README.md
182
+ * https://github.com/wailsapp/wails
183
+ * → github.com/wailsapp/wails (short — unchanged)
184
+ * mailto:me@example.com → me@example.com
185
+ */
186
+ export function truncateUrlLabel(href: string, opts: UrlLabelOptions = {}): string {
187
+ const maxChars = opts.maxChars ?? 40;
188
+ const { domain, rest, mail } = splitUrl(href);
189
+
190
+ if (mail) {
191
+ return mail.length <= maxChars ? mail : `${ELLIPSIS}${mail.slice(mail.length - (maxChars - 1))}`;
192
+ }
193
+
194
+ const full = `${domain}${rest}`;
195
+ if (full.length <= maxChars) return full;
196
+
197
+ // Collapse the path's middle, keeping the last segment.
198
+ const segments = rest.split('/').filter((s) => s.length > 0);
199
+ if (segments.length === 0) {
200
+ // Only a domain, but it's longer than maxChars — return as-is (CSS
201
+ // ellipsis is the safety net in the chip).
202
+ return domain;
203
+ }
204
+ const last = segments[segments.length - 1]!;
205
+
206
+ const candidates: string[] = [];
207
+ if (segments.length >= 2) {
208
+ candidates.push(`${domain}/${segments[0]!}/${ELLIPSIS}/${last}`);
209
+ }
210
+ candidates.push(`${domain}/${ELLIPSIS}/${last}`);
211
+ candidates.push(`${domain}/${ELLIPSIS}`);
212
+
213
+ for (const c of candidates) {
214
+ if (c.length <= maxChars) return c;
215
+ }
216
+ return `${domain}/${ELLIPSIS}/${last}`;
217
+ }
218
+
219
+ /**
220
+ * Favicon URL for a host via Google's S2 service. Degrades to the globe
221
+ * icon on error (the chip wires an `onerror` handler). Returns '' when the
222
+ * host is empty.
223
+ */
224
+ export function faviconUrl(host: string, size = 32): string {
225
+ if (!host) return '';
226
+ return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=${size}`;
227
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `BookmarkNode` — a NotionEditor block ATOM node that renders a link-preview
3
+ * "bookmark" card (the shared `common/link-preview/LinkPreviewCard`). It holds
4
+ * a single `url` attribute and renders via a React NodeView (mirrors
5
+ * `TaskItemView` → `ReactNodeViewRenderer`).
6
+ *
7
+ * Trigger — paste a BARE URL on an EMPTY line/block:
8
+ *
9
+ * The `handlePaste` plugin only hijacks the paste when the clipboard is a
10
+ * SINGLE bare URL AND the current block is empty (an empty paragraph). In
11
+ * that case it replaces the empty block with a `bookmark` node. A URL
12
+ * pasted mid-text, a labelled link, or multi-line clipboard content all
13
+ * fall through to the editor's normal paste (the URL stays inline). This is
14
+ * the conservative Notion rule — bookmark only on an explicit empty line.
15
+ *
16
+ * Resolver injection:
17
+ *
18
+ * `notionExtensions({ resolveLinkPreview })` threads a host resolver into
19
+ * the node's `options`. The NodeView reads it from `extension.options` and
20
+ * passes it to the card, so the host (Wails/Go, a Next.js API route) unfurls
21
+ * the URL exactly like chat does. Default `undefined` → the card renders its
22
+ * own hostname/favicon fallback.
23
+ *
24
+ * Serialisation (markdown fidelity):
25
+ *
26
+ * `renderMarkdown` emits the bare `url`, so `getMarkdown()` yields the raw
27
+ * URL (NOT card markup) — same pattern the MarkdownEditor chip node uses.
28
+ * On the next `setContent`/paste of a bare URL on an empty line the node is
29
+ * re-created, so the round-trip is stable.
30
+ */
31
+
32
+ import { Node, mergeAttributes } from '@tiptap/core';
33
+ import { ReactNodeViewRenderer } from '@tiptap/react';
34
+ import { Plugin, PluginKey } from '@tiptap/pm/state';
35
+ import type { ResolveLinkPreview } from '../../../common/link-preview';
36
+ import { BookmarkView } from './BookmarkView';
37
+
38
+ export interface BookmarkNodeOptions {
39
+ /** Host resolver for URL unfurl. Default `undefined` → card fallback. */
40
+ resolveLinkPreview?: ResolveLinkPreview;
41
+ HTMLAttributes: Record<string, unknown>;
42
+ }
43
+
44
+ declare module '@tiptap/core' {
45
+ interface Commands<ReturnType> {
46
+ bookmark: {
47
+ /** Insert a bookmark (link-preview) block for the given URL. */
48
+ setBookmark: (url: string) => ReturnType;
49
+ };
50
+ }
51
+ }
52
+
53
+ const bookmarkPasteKey = new PluginKey('notion-bookmark-paste');
54
+
55
+ /** A single bare URL (http/https), nothing else on the line. */
56
+ const BARE_URL_RE = /^https?:\/\/[^\s<>]+$/i;
57
+
58
+ /** True when `text` is exactly one bare URL (no surrounding prose). */
59
+ function isBareUrl(text: string): boolean {
60
+ const trimmed = text.trim();
61
+ if (!trimmed || /\s/.test(trimmed)) return false;
62
+ return BARE_URL_RE.test(trimmed);
63
+ }
64
+
65
+ export const BookmarkNode = Node.create<BookmarkNodeOptions>({
66
+ name: 'bookmark',
67
+ group: 'block',
68
+ atom: true,
69
+ draggable: true,
70
+ selectable: true,
71
+
72
+ addOptions() {
73
+ return {
74
+ resolveLinkPreview: undefined,
75
+ HTMLAttributes: {},
76
+ };
77
+ },
78
+
79
+ addAttributes() {
80
+ return {
81
+ url: {
82
+ default: '',
83
+ parseHTML: (el) => el.getAttribute('data-url') ?? '',
84
+ renderHTML: (attrs) => (attrs.url ? { 'data-url': attrs.url as string } : {}),
85
+ },
86
+ };
87
+ },
88
+
89
+ parseHTML() {
90
+ return [{ tag: 'div[data-type="bookmark"]' }];
91
+ },
92
+
93
+ renderHTML({ HTMLAttributes }) {
94
+ return [
95
+ 'div',
96
+ mergeAttributes({ 'data-type': 'bookmark' }, this.options.HTMLAttributes, HTMLAttributes),
97
+ ];
98
+ },
99
+
100
+ addNodeView() {
101
+ return ReactNodeViewRenderer(BookmarkView);
102
+ },
103
+
104
+ addCommands() {
105
+ return {
106
+ setBookmark:
107
+ (url: string) =>
108
+ ({ commands }) =>
109
+ commands.insertContent({ type: this.name, attrs: { url } }),
110
+ };
111
+ },
112
+
113
+ // `@tiptap/markdown` reads this — emit the bare URL so a round-trip through
114
+ // `getMarkdown()` yields the literal URL, never card markup.
115
+ renderMarkdown(node: { attrs: Record<string, unknown> }) {
116
+ return (node.attrs.url as string) ?? '';
117
+ },
118
+
119
+ addProseMirrorPlugins() {
120
+ const type = this.type;
121
+
122
+ return [
123
+ new Plugin({
124
+ key: bookmarkPasteKey,
125
+ props: {
126
+ // Bare-URL-on-empty-line → bookmark. Conservative: only when the
127
+ // clipboard is a SINGLE bare URL and the current textblock is empty.
128
+ handlePaste: (view, event) => {
129
+ const text = event.clipboardData?.getData('text/plain');
130
+ if (!text || !isBareUrl(text)) return false;
131
+
132
+ const { state } = view;
133
+ const { selection } = state;
134
+ const { $from, empty } = selection;
135
+ // Only replace when the caret sits in an EMPTY textblock with a
136
+ // collapsed selection — i.e. the user is on a fresh empty line.
137
+ if (!empty) return false;
138
+ const parent = $from.parent;
139
+ if (!parent.isTextblock || parent.content.size !== 0) return false;
140
+
141
+ const url = text.trim();
142
+ // Replace the empty block with the bookmark node.
143
+ const node = type.create({ url });
144
+ const tr = state.tr.replaceWith(
145
+ $from.before(),
146
+ $from.after(),
147
+ node,
148
+ );
149
+ view.dispatch(tr.scrollIntoView());
150
+ return true;
151
+ },
152
+ },
153
+ }),
154
+ ];
155
+ },
156
+ });
157
+
158
+ export default BookmarkNode;
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * React NodeView for the NotionEditor `bookmark` node. Renders the shared
5
+ * `LinkPreviewCard` (`appearance="full"`) inside a `NodeViewWrapper`, reading
6
+ * the host resolver injected via the node's `extension.options`
7
+ * (`resolveLinkPreview`). Mirrors `TaskItemView` — we only render the UI; the
8
+ * node owns the `url` attribute + markdown serialisation.
9
+ */
10
+
11
+ import type { ReactNode } from 'react';
12
+ import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react';
13
+ import { LinkPreviewCard } from '../../../common/link-preview';
14
+ import { BlockError, LinkPreviewDataSchema, safeParseBlock } from '../../../common/blocks';
15
+ import type { BookmarkNodeOptions } from './BookmarkNode';
16
+
17
+ export function BookmarkView({ node, extension }: NodeViewProps) {
18
+ const url = (node.attrs.url as string) || '';
19
+ const resolver = (extension.options as BookmarkNodeOptions).resolveLinkPreview;
20
+
21
+ // An empty url is a legitimate "nothing to show yet" state (freshly inserted
22
+ // node) — render nothing. A NON-empty but malformed url fails the schema and
23
+ // shows a safe fallback instead of risking a throw downstream.
24
+ const validated = url ? safeParseBlock(LinkPreviewDataSchema, { url }) : null;
25
+
26
+ let content: ReactNode = null;
27
+ if (validated && validated.ok === true) {
28
+ content = <LinkPreviewCard url={validated.data.url} resolver={resolver} appearance="full" />;
29
+ } else if (validated && validated.ok === false) {
30
+ content = <BlockError label="bookmark" reason={validated.error} />;
31
+ }
32
+
33
+ return (
34
+ <NodeViewWrapper
35
+ data-type="bookmark"
36
+ className="notion-bookmark"
37
+ // The drag handle reorders the whole block; keep the card from stealing
38
+ // the drag target by marking the wrapper draggable=false (the node is
39
+ // already `draggable: true` at the schema level for the global handle).
40
+ draggable={false}
41
+ >
42
+ {content}
43
+ </NodeViewWrapper>
44
+ );
45
+ }