@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.
- package/package.json +28 -14
- package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
- package/src/common/CodePanelHeader/index.ts +1 -0
- package/src/common/blocks/BlockError.tsx +46 -0
- package/src/common/blocks/index.ts +34 -0
- package/src/common/blocks/schemas.ts +143 -0
- package/src/common/blocks/types.ts +76 -0
- package/src/common/chips/README.md +67 -0
- package/src/common/chips/UrlChip.tsx +105 -0
- package/src/common/chips/index.ts +20 -0
- package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
- package/src/common/link-preview/index.ts +12 -0
- package/src/common/link-preview/types.ts +44 -0
- package/src/common/tiptap/index.ts +10 -0
- package/src/common/tiptap/useNodeAttrs.ts +128 -0
- package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
- package/src/tools/chat/README.md +88 -2
- package/src/tools/chat/composer/Composer.tsx +45 -12
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
- package/src/tools/chat/constants.ts +9 -2
- package/src/tools/chat/core/extractFirstUrl.ts +45 -0
- package/src/tools/chat/core/reducer.ts +32 -8
- package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
- package/src/tools/chat/core/transport/mock.ts +4 -1
- package/src/tools/chat/hooks/useChat.ts +20 -20
- package/src/tools/chat/lazy.tsx +10 -0
- package/src/tools/chat/messages/Attachments.tsx +267 -42
- package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
- package/src/tools/chat/messages/MessageBubble.tsx +175 -31
- package/src/tools/chat/messages/MessageList.tsx +107 -21
- package/src/tools/chat/messages/ToolCalls.tsx +28 -13
- package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
- package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
- package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
- package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
- package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
- package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
- package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
- package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
- package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
- package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
- package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
- package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
- package/src/tools/chat/messages/index.ts +13 -0
- package/src/tools/chat/public.ts +5 -1
- package/src/tools/chat/shell/ChatRoot.tsx +29 -0
- package/src/tools/chat/styles/bubbleTokens.ts +18 -0
- package/src/tools/chat/styles/index.ts +1 -0
- package/src/tools/chat/types/attachment.ts +26 -2
- package/src/tools/chat/types/block.ts +56 -9
- package/src/tools/chat/types/blockSchemas.ts +197 -0
- package/src/tools/chat/types/config.ts +33 -0
- package/src/tools/chat/types/events.ts +14 -7
- package/src/tools/chat/types/index.ts +4 -2
- package/src/tools/chat/types/message.ts +7 -0
- package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
- package/src/tools/dev/Map/README.md +30 -345
- package/src/tools/dev/Map/cards/index.ts +6 -0
- package/src/tools/dev/Map/cards/types.ts +67 -0
- package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
- package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
- package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
- package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
- package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
- package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
- package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
- package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
- package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
- package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
- package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
- package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
- package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
- package/src/tools/dev/Map/components/MapControls.tsx +27 -0
- package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
- package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
- package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
- package/src/tools/dev/Map/components/index.ts +35 -1
- package/src/tools/dev/Map/geocode/index.ts +2 -0
- package/src/tools/dev/Map/geocode/photon.ts +265 -0
- package/src/tools/dev/Map/hooks/index.ts +37 -0
- package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
- package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
- package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
- package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
- package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
- package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
- package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
- package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
- package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
- package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
- package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
- package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
- package/src/tools/dev/Map/index.ts +59 -2
- package/src/tools/dev/Map/layers/index.ts +1 -0
- package/src/tools/dev/Map/layers/symbol.ts +93 -0
- package/src/tools/dev/Map/lazy.tsx +120 -0
- package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
- package/src/tools/dev/Map/styles/index.ts +62 -0
- package/src/tools/dev/Map/styles/terrain.ts +29 -0
- package/src/tools/dev/Map/types.ts +31 -3
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
- package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
- package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
- package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
- package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
- package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
- package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
- package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
- package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
- package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
- package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
- package/src/tools/forms/MarkdownEditor/README.md +84 -0
- package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
- package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
- package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
- package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
- package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
- package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
- package/src/tools/forms/MarkdownEditor/index.ts +19 -0
- package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
- package/src/tools/forms/MarkdownEditor/styles.css +134 -2
- package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
- package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
- package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
- package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
- package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
- package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
- package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
- package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
- package/src/tools/forms/NotionEditor/README.md +109 -188
- package/src/tools/forms/NotionEditor/extensions.ts +15 -0
- package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
- package/src/tools/forms/NotionEditor/styles.css +25 -0
- package/src/tools/forms/NotionEditor/types.ts +9 -0
- package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
- package/src/tools/input/SpeechRecognition/types.ts +10 -0
- package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
- package/src/tools/media/ImageViewer/README.md +9 -0
- package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
- package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
- package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
- package/src/tools/media/ImageViewer/components/index.ts +2 -0
- package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
- package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
- package/src/tools/media/ImageViewer/types.ts +10 -0
- package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
- package/src/tools/media/ImageViewer/utils/index.ts +4 -0
- package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
- package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
- package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
- package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
- package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
- package/src/tools/visual/charts/SmoothLine/README.md +97 -0
- package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
- package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
- package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
- package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
- package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
- package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
- package/src/tools/visual/indicators/Fps/types.ts +1 -1
- package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
- package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
|
@@ -0,0 +1,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;
|