@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
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react';
|
|
4
11
|
import { File as FileGlyph, FileText, X } from 'lucide-react';
|
|
5
12
|
|
|
13
|
+
import { Dialog, DialogContent, DialogTitle } from '@djangocfg/ui-core/components';
|
|
6
14
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
15
|
|
|
16
|
+
import { formatFileSize } from '../../forms/Uploader';
|
|
17
|
+
import { LazyImageViewer } from '../../media/ImageViewer/lazy';
|
|
8
18
|
import { PastedTextDialog } from '../composer/PastedTextDialog';
|
|
9
19
|
import { useChatDestructiveStyles } from '../styles';
|
|
10
20
|
import type { ChatAttachment } from '../types';
|
|
@@ -38,6 +48,118 @@ interface CommonProps {
|
|
|
38
48
|
className?: string;
|
|
39
49
|
}
|
|
40
50
|
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Default image lightbox — image tiles open the shared `ImageViewer` in a
|
|
53
|
+
// dialog by default. All image attachments of the SAME attachments group feed
|
|
54
|
+
// the viewer's `images` array, so the lightbox has prev/next across the
|
|
55
|
+
// message's images and opens at the clicked index.
|
|
56
|
+
//
|
|
57
|
+
// Composition with a host `onClick`: the lightbox owns IMAGES, the host
|
|
58
|
+
// `onClick` owns NON-IMAGE files. A host `onClick` does NOT suppress the image
|
|
59
|
+
// lightbox — clicking an image opens the gallery, clicking a file fires the
|
|
60
|
+
// host handler (e.g. reveal-in-file-manager). See `useImageTileClick`.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
interface ImageLightboxContextValue {
|
|
64
|
+
/** Opens the default lightbox at the given attachment id. */
|
|
65
|
+
openImage: (attachmentId: string) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ImageLightboxContext = createContext<ImageLightboxContextValue | null>(null);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Provides a single shared lightbox dialog for a group of attachments and
|
|
72
|
+
* renders it once. Image tiles call `openImage(id)` to open at their index.
|
|
73
|
+
* Active only when `enabled` (i.e. the host did NOT wire `onClick`).
|
|
74
|
+
*/
|
|
75
|
+
function ImageLightboxProvider({
|
|
76
|
+
attachments,
|
|
77
|
+
enabled,
|
|
78
|
+
children,
|
|
79
|
+
}: {
|
|
80
|
+
attachments: ChatAttachment[];
|
|
81
|
+
enabled: boolean;
|
|
82
|
+
children: ReactNode;
|
|
83
|
+
}) {
|
|
84
|
+
const [open, setOpen] = useState(false);
|
|
85
|
+
const [index, setIndex] = useState(0);
|
|
86
|
+
|
|
87
|
+
// Only image attachments seed the viewer — non-image files are skipped, so
|
|
88
|
+
// the gallery index maps onto the image subset (not the full attachment list).
|
|
89
|
+
const images = useMemo(
|
|
90
|
+
() => attachments.filter((a) => a.type === 'image'),
|
|
91
|
+
[attachments],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const openImage = useCallback(
|
|
95
|
+
(attachmentId: string) => {
|
|
96
|
+
const i = images.findIndex((a) => a.id === attachmentId);
|
|
97
|
+
if (i < 0) return;
|
|
98
|
+
setIndex(i);
|
|
99
|
+
setOpen(true);
|
|
100
|
+
},
|
|
101
|
+
[images],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const ctx = useMemo<ImageLightboxContextValue>(() => ({ openImage }), [openImage]);
|
|
105
|
+
|
|
106
|
+
if (!enabled || images.length === 0) return <>{children}</>;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<ImageLightboxContext.Provider value={ctx}>
|
|
110
|
+
{children}
|
|
111
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
112
|
+
{/* Borderless, full-bleed lightbox: the viewer paints its own dark
|
|
113
|
+
backdrop + idle-fading chrome, so the dialog provides only the
|
|
114
|
+
blurred overlay. No card chrome, no built-in close (the viewer's
|
|
115
|
+
macOS-grade close button + Esc own dismissal). */}
|
|
116
|
+
<DialogContent
|
|
117
|
+
fullscreen
|
|
118
|
+
closeButton={false}
|
|
119
|
+
className="border-0 bg-transparent p-0 shadow-none"
|
|
120
|
+
>
|
|
121
|
+
<DialogTitle className="sr-only">
|
|
122
|
+
Image {index + 1} of {images.length}
|
|
123
|
+
</DialogTitle>
|
|
124
|
+
{open && (
|
|
125
|
+
<LazyImageViewer
|
|
126
|
+
images={images.map((a) => ({
|
|
127
|
+
file: { name: a.name ?? 'image', path: a.url },
|
|
128
|
+
src: a.url,
|
|
129
|
+
}))}
|
|
130
|
+
initialIndex={index}
|
|
131
|
+
lightbox
|
|
132
|
+
autoFocus
|
|
133
|
+
onRequestClose={() => setOpen(false)}
|
|
134
|
+
/>
|
|
135
|
+
)}
|
|
136
|
+
</DialogContent>
|
|
137
|
+
</Dialog>
|
|
138
|
+
</ImageLightboxContext.Provider>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolves the click handler for an attachment tile, splitting ownership by
|
|
144
|
+
* type:
|
|
145
|
+
* - IMAGE → the default lightbox wins when its provider is mounted (so the
|
|
146
|
+
* gallery opens with prev/next across the message's images), regardless of
|
|
147
|
+
* a host `onClick`. Falls back to the host `onClick` only if no provider.
|
|
148
|
+
* - NON-IMAGE → the host `onClick` (e.g. reveal-in-file-manager).
|
|
149
|
+
* This lets a host wire `onClick` for files without suppressing the image
|
|
150
|
+
* lightbox (the two no longer fight over the same group-level flag).
|
|
151
|
+
*/
|
|
152
|
+
function useImageTileClick(
|
|
153
|
+
attachment: ChatAttachment,
|
|
154
|
+
hostOnClick?: () => void,
|
|
155
|
+
): (() => void) | undefined {
|
|
156
|
+
const lightbox = useContext(ImageLightboxContext);
|
|
157
|
+
if (attachment.type === 'image' && lightbox) {
|
|
158
|
+
return () => lightbox.openImage(attachment.id);
|
|
159
|
+
}
|
|
160
|
+
return hostOnClick;
|
|
161
|
+
}
|
|
162
|
+
|
|
41
163
|
// ---------------------------------------------------------------------------
|
|
42
164
|
// AttachmentsGrid — flex-wrap, ideal for thumbnails / file chips.
|
|
43
165
|
// ---------------------------------------------------------------------------
|
|
@@ -58,22 +180,27 @@ export function AttachmentsGrid({
|
|
|
58
180
|
if (!attachments?.length) return null;
|
|
59
181
|
const visible = maxVisible ? attachments.slice(0, maxVisible) : attachments;
|
|
60
182
|
return (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
183
|
+
// Lightbox owns images even when the host wired `onClick` (that handler is
|
|
184
|
+
// for non-image files). `enabled` only gates the no-images case inside the
|
|
185
|
+
// provider, so passing `true` is safe — see `useImageTileClick`.
|
|
186
|
+
<ImageLightboxProvider attachments={attachments} enabled>
|
|
187
|
+
<div
|
|
188
|
+
className={cn(
|
|
189
|
+
layout === 'grid' ? 'grid grid-cols-3 gap-2' : 'flex flex-wrap gap-2',
|
|
190
|
+
className,
|
|
191
|
+
)}
|
|
192
|
+
>
|
|
193
|
+
{visible.map((a) => (
|
|
194
|
+
<AttachmentTile
|
|
195
|
+
key={a.id}
|
|
196
|
+
attachment={a}
|
|
197
|
+
isInComposer={isInComposer}
|
|
198
|
+
onClick={onClick ? () => onClick(a) : undefined}
|
|
199
|
+
onRemove={onRemove ? () => onRemove(a) : undefined}
|
|
200
|
+
/>
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
</ImageLightboxProvider>
|
|
77
204
|
);
|
|
78
205
|
}
|
|
79
206
|
|
|
@@ -99,26 +226,30 @@ export function AttachmentsList({
|
|
|
99
226
|
if (!attachments?.length) return null;
|
|
100
227
|
const visible = maxVisible ? attachments.slice(0, maxVisible) : attachments;
|
|
101
228
|
return (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
229
|
+
// Lightbox owns images even when the host wired `onClick` (that handler is
|
|
230
|
+
// for non-image files). See `useImageTileClick` + AttachmentsGrid note.
|
|
231
|
+
<ImageLightboxProvider attachments={attachments} enabled>
|
|
232
|
+
<div className={cn('flex w-full flex-col gap-2', className)}>
|
|
233
|
+
{visible.map((a) => {
|
|
234
|
+
const renderer = renderers?.[a.type] ?? renderers?.default;
|
|
235
|
+
const args: AttachmentRendererArgs = {
|
|
236
|
+
attachment: a,
|
|
237
|
+
isInComposer,
|
|
238
|
+
onClick: onClick ? () => onClick(a) : undefined,
|
|
239
|
+
onRemove: onRemove ? () => onRemove(a) : undefined,
|
|
240
|
+
};
|
|
241
|
+
if (renderer) {
|
|
242
|
+
return (
|
|
243
|
+
<div key={a.id} className="relative w-full min-w-0">
|
|
244
|
+
{renderer(args)}
|
|
245
|
+
{args.onRemove ? <RemoveBtn onRemove={args.onRemove} /> : null}
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return <AttachmentTile key={a.id} {...args} />;
|
|
250
|
+
})}
|
|
251
|
+
</div>
|
|
252
|
+
</ImageLightboxProvider>
|
|
122
253
|
);
|
|
123
254
|
}
|
|
124
255
|
|
|
@@ -145,12 +276,28 @@ export function Attachments(props: AttachmentsProps) {
|
|
|
145
276
|
// ---------------------------------------------------------------------------
|
|
146
277
|
|
|
147
278
|
function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArgs) {
|
|
279
|
+
// Host `onClick` wins; otherwise image tiles fall back to the default
|
|
280
|
+
// lightbox (when an `ImageLightboxProvider` is mounted). Called before any
|
|
281
|
+
// early return to keep hook order stable. Non-image files stay interactive
|
|
282
|
+
// only when the host wired `onClick`.
|
|
283
|
+
const effectiveOnClick = useImageTileClick(attachment, onClick);
|
|
284
|
+
|
|
148
285
|
// "Pasted text" chunks are self-contained — they open their own preview
|
|
149
286
|
// dialog on click rather than going through the generic `onClick`.
|
|
150
287
|
if (attachment.type === 'text') {
|
|
151
288
|
return <TextChunkTile attachment={attachment} onRemove={onRemove} />;
|
|
152
289
|
}
|
|
153
290
|
|
|
291
|
+
// Rich-preview bag present → render the compact localpreview card
|
|
292
|
+
// (thumbnail + title + label/value fields). Absent → fall through to the
|
|
293
|
+
// existing name+thumbnail chip. Per-type `renderers` overrides still win
|
|
294
|
+
// upstream (AttachmentsList resolves them before reaching this default).
|
|
295
|
+
if (attachment.preview) {
|
|
296
|
+
return (
|
|
297
|
+
<AttachmentPreviewCard attachment={attachment} onClick={onClick} onRemove={onRemove} />
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
154
301
|
const isImage = attachment.type === 'image';
|
|
155
302
|
const isUploading = attachment.status === 'uploading';
|
|
156
303
|
|
|
@@ -165,20 +312,32 @@ function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArg
|
|
|
165
312
|
loading="lazy"
|
|
166
313
|
/>
|
|
167
314
|
) : (
|
|
315
|
+
// Two-line file chip: name on top, formatted size on the meta line
|
|
316
|
+
// (Telegram/Slack convention — "report.pdf" / "1.2 KB"). The size row
|
|
317
|
+
// is omitted when the byte count is unknown so the chip stays single-line.
|
|
168
318
|
<div className="flex max-w-44 items-center gap-2 rounded-[var(--radius-control)] border border-border bg-background/60 px-2 py-1.5 text-xs">
|
|
169
319
|
<FileGlyph aria-hidden className="size-4 shrink-0 text-muted-foreground" />
|
|
170
|
-
<span className="
|
|
320
|
+
<span className="flex min-w-0 flex-col items-start text-left">
|
|
321
|
+
<span className="w-full truncate">{attachment.name ?? 'file'}</span>
|
|
322
|
+
{attachment.sizeBytes != null && attachment.sizeBytes > 0 ? (
|
|
323
|
+
<span className="w-full truncate text-[11px] text-muted-foreground">
|
|
324
|
+
{formatFileSize(attachment.sizeBytes)}
|
|
325
|
+
</span>
|
|
326
|
+
) : null}
|
|
327
|
+
</span>
|
|
171
328
|
</div>
|
|
172
329
|
);
|
|
173
330
|
|
|
174
331
|
return (
|
|
175
332
|
<div className="relative">
|
|
176
|
-
{
|
|
177
|
-
// Interactive
|
|
178
|
-
// the chip
|
|
333
|
+
{effectiveOnClick ? (
|
|
334
|
+
// Interactive when the host wired `onClick` OR (for images) the
|
|
335
|
+
// default lightbox is active — the chip reads as clickable (cursor +
|
|
336
|
+
// hover lift + focus ring) and is a real keyboard-focusable button.
|
|
179
337
|
<button
|
|
180
338
|
type="button"
|
|
181
|
-
onClick={
|
|
339
|
+
onClick={effectiveOnClick}
|
|
340
|
+
aria-label={isImage ? `Open image: ${attachment.name ?? 'image'}` : undefined}
|
|
182
341
|
className="block cursor-pointer rounded-[var(--radius-control)] transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
183
342
|
>
|
|
184
343
|
{inner}
|
|
@@ -196,6 +355,72 @@ function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArg
|
|
|
196
355
|
);
|
|
197
356
|
}
|
|
198
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Compact rich-preview card driven by `attachment.preview` (the serializable
|
|
360
|
+
* `AttachmentPreview` bag). Renders a thumbnail (cover / first page), a title,
|
|
361
|
+
* and an ordered label/value field list (duration, dimensions, pages, EXIF…).
|
|
362
|
+
* Kind-agnostic: the same layout serves audio/image/pdf/doc — the host fills
|
|
363
|
+
* `preview.fields` per media kind. Clickable only when the host wired `onClick`.
|
|
364
|
+
*/
|
|
365
|
+
function AttachmentPreviewCard({
|
|
366
|
+
attachment,
|
|
367
|
+
onClick,
|
|
368
|
+
onRemove,
|
|
369
|
+
}: {
|
|
370
|
+
attachment: ChatAttachment;
|
|
371
|
+
onClick?: () => void;
|
|
372
|
+
onRemove?: () => void;
|
|
373
|
+
}) {
|
|
374
|
+
const preview = attachment.preview!;
|
|
375
|
+
const title = preview.title ?? attachment.name ?? 'attachment';
|
|
376
|
+
const fields = preview.fields ?? [];
|
|
377
|
+
|
|
378
|
+
const card = (
|
|
379
|
+
<div className="flex max-w-72 items-center gap-3 rounded-[var(--radius-control)] border border-border bg-background/60 px-2.5 py-2 text-left">
|
|
380
|
+
{preview.thumbnail ? (
|
|
381
|
+
<img
|
|
382
|
+
src={preview.thumbnail}
|
|
383
|
+
alt={title}
|
|
384
|
+
className="h-12 w-12 shrink-0 rounded-md object-cover"
|
|
385
|
+
loading="lazy"
|
|
386
|
+
/>
|
|
387
|
+
) : (
|
|
388
|
+
<span className="grid size-12 shrink-0 place-items-center rounded-md bg-primary/15 text-primary">
|
|
389
|
+
<FileGlyph aria-hidden className="size-5" />
|
|
390
|
+
</span>
|
|
391
|
+
)}
|
|
392
|
+
<span className="flex min-w-0 flex-col gap-0.5">
|
|
393
|
+
<span className="truncate text-xs font-medium">{title}</span>
|
|
394
|
+
{fields.slice(0, 3).map((f, i) => (
|
|
395
|
+
<span
|
|
396
|
+
key={`${f.label}-${i}`}
|
|
397
|
+
className="truncate text-[11px] text-muted-foreground"
|
|
398
|
+
>
|
|
399
|
+
<span className="font-medium">{f.label}:</span> {f.value}
|
|
400
|
+
</span>
|
|
401
|
+
))}
|
|
402
|
+
</span>
|
|
403
|
+
</div>
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
return (
|
|
407
|
+
<div className="relative">
|
|
408
|
+
{onClick ? (
|
|
409
|
+
<button
|
|
410
|
+
type="button"
|
|
411
|
+
onClick={onClick}
|
|
412
|
+
className="block cursor-pointer rounded-[var(--radius-control)] transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
413
|
+
>
|
|
414
|
+
{card}
|
|
415
|
+
</button>
|
|
416
|
+
) : (
|
|
417
|
+
card
|
|
418
|
+
)}
|
|
419
|
+
{onRemove ? <RemoveBtn onRemove={onRemove} /> : null}
|
|
420
|
+
</div>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
199
424
|
/**
|
|
200
425
|
* Tile for a `type:'text'` chunk — a two-line "Pasted text" chip in the
|
|
201
426
|
* same surface style as file chips (title + "Pasted text" sublabel).
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Fragment, memo, useMemo, type ReactNode } from 'react';
|
|
4
|
+
import { Copy, Pencil, RefreshCw, Trash } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ContextMenu,
|
|
8
|
+
ContextMenuContent,
|
|
9
|
+
ContextMenuItem,
|
|
10
|
+
ContextMenuSeparator,
|
|
11
|
+
ContextMenuTrigger,
|
|
12
|
+
} from '@djangocfg/ui-core/components';
|
|
13
|
+
|
|
14
|
+
import type { ChatMessage, ChatRole } from '../types';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A single entry in a message bubble's right-click / long-press context
|
|
18
|
+
* menu. Typed + serializable-ish (icon is a ReactNode, the rest is data).
|
|
19
|
+
*
|
|
20
|
+
* Hosts inject extra items — "Reveal in Finder", "Copy as markdown",
|
|
21
|
+
* "Inspect" — by passing `bubbleMenuItems` / `getBubbleMenuItems` on
|
|
22
|
+
* `<MessageBubble>` (and the matching `<ChatRoot messages.bubbleMenu*>`).
|
|
23
|
+
* The built-in Copy / Regenerate / Delete actions are produced as default
|
|
24
|
+
* items by `buildDefaultBubbleMenuItems` so a host can keep, reorder, or
|
|
25
|
+
* fully replace them.
|
|
26
|
+
*/
|
|
27
|
+
export interface BubbleMenuItem {
|
|
28
|
+
/** Stable id — used as the React key. */
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
icon?: ReactNode;
|
|
32
|
+
/** Fires on click / Enter. Receives the message the menu opened on. */
|
|
33
|
+
onSelect: (message: ChatMessage) => void;
|
|
34
|
+
/** Red destructive styling (e.g. Delete). */
|
|
35
|
+
danger?: boolean;
|
|
36
|
+
/** Render a divider BEFORE this item. */
|
|
37
|
+
separator?: boolean;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Handlers the default Copy / Regenerate / Delete items wire to. Mirrors
|
|
43
|
+
* the existing `<MessageBubble>` props so a host can keep passing the same
|
|
44
|
+
* callbacks it always did and get a context menu instead of a button row.
|
|
45
|
+
*
|
|
46
|
+
* Role-gated to match the old button row: Regenerate shows only on
|
|
47
|
+
* assistant turns, Edit only on user turns, and items whose handler is
|
|
48
|
+
* absent are dropped.
|
|
49
|
+
*/
|
|
50
|
+
export interface DefaultBubbleMenuHandlers {
|
|
51
|
+
role: ChatRole;
|
|
52
|
+
onCopy?: () => void;
|
|
53
|
+
onRegenerate?: () => void;
|
|
54
|
+
/**
|
|
55
|
+
* Fires the host's composer-edit trigger for a USER message — gated to
|
|
56
|
+
* `role === 'user'` (mirrors the assistant-only Regenerate gate). This
|
|
57
|
+
* does NOT edit in the bubble: the item just hands the message to the
|
|
58
|
+
* host, which loads its text into the real composer and re-runs the turn
|
|
59
|
+
* via `chat.editMessage` on submit. Absent → no Edit item.
|
|
60
|
+
*/
|
|
61
|
+
onEdit?: () => void;
|
|
62
|
+
onDelete?: () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the built-in Copy / Regenerate / Delete items as `BubbleMenuItem`s.
|
|
67
|
+
* Hosts compose these with their own extras (see `composeBubbleMenuItems`)
|
|
68
|
+
* — keep them, drop one, reorder, or ignore them entirely and pass a fully
|
|
69
|
+
* custom list. An item is omitted when its handler is undefined; Regenerate
|
|
70
|
+
* is additionally gated to assistant turns.
|
|
71
|
+
*/
|
|
72
|
+
export function buildDefaultBubbleMenuItems({
|
|
73
|
+
role,
|
|
74
|
+
onCopy,
|
|
75
|
+
onRegenerate,
|
|
76
|
+
onEdit,
|
|
77
|
+
onDelete,
|
|
78
|
+
}: DefaultBubbleMenuHandlers): BubbleMenuItem[] {
|
|
79
|
+
const items: BubbleMenuItem[] = [];
|
|
80
|
+
if (onCopy) {
|
|
81
|
+
items.push({
|
|
82
|
+
id: 'copy',
|
|
83
|
+
label: 'Copy',
|
|
84
|
+
icon: <Copy aria-hidden className="size-4" />,
|
|
85
|
+
onSelect: () => onCopy(),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (onRegenerate && role === 'assistant') {
|
|
89
|
+
items.push({
|
|
90
|
+
id: 'regenerate',
|
|
91
|
+
label: 'Regenerate',
|
|
92
|
+
icon: <RefreshCw aria-hidden className="size-4" />,
|
|
93
|
+
onSelect: () => onRegenerate(),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// Edit — user turns only (mirrors the assistant-only Regenerate gate).
|
|
97
|
+
// Fires the host trigger; the actual edit happens in the composer.
|
|
98
|
+
if (onEdit && role === 'user') {
|
|
99
|
+
items.push({
|
|
100
|
+
id: 'edit',
|
|
101
|
+
label: 'Edit',
|
|
102
|
+
icon: <Pencil aria-hidden className="size-4" />,
|
|
103
|
+
onSelect: () => onEdit(),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (onDelete) {
|
|
107
|
+
items.push({
|
|
108
|
+
id: 'delete',
|
|
109
|
+
label: 'Delete',
|
|
110
|
+
icon: <Trash aria-hidden className="size-4" />,
|
|
111
|
+
onSelect: () => onDelete(),
|
|
112
|
+
danger: true,
|
|
113
|
+
// Divide built-in destructive action from the rest only when it
|
|
114
|
+
// isn't the very first item (e.g. a copy-only user bubble).
|
|
115
|
+
separator: items.length > 0,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return items;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Merge the built-in default items with host-provided extras. Defaults
|
|
123
|
+
* come first, then a divider, then host items — unless the host's first
|
|
124
|
+
* extra already declares its own `separator`. Either side may be empty.
|
|
125
|
+
*/
|
|
126
|
+
export function composeBubbleMenuItems(
|
|
127
|
+
defaults: BubbleMenuItem[],
|
|
128
|
+
hostItems?: BubbleMenuItem[],
|
|
129
|
+
): BubbleMenuItem[] {
|
|
130
|
+
if (!hostItems?.length) return defaults;
|
|
131
|
+
if (!defaults.length) return hostItems;
|
|
132
|
+
const [first, ...restHost] = hostItems;
|
|
133
|
+
const bridged: BubbleMenuItem = first.separator ? first : { ...first, separator: true };
|
|
134
|
+
return [...defaults, bridged, ...restHost];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface BubbleContextMenuProps {
|
|
138
|
+
message: ChatMessage;
|
|
139
|
+
items: BubbleMenuItem[];
|
|
140
|
+
children: ReactNode;
|
|
141
|
+
/** Wrapper className (defaults to `contents` — adds no layout box). */
|
|
142
|
+
className?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* BubbleContextMenu — wraps a bubble in a Radix context menu (right-click
|
|
147
|
+
* + touch long-press, Esc / arrow-key accessible, all handled by the
|
|
148
|
+
* primitive). Renders `items` as menu rows.
|
|
149
|
+
*
|
|
150
|
+
* No items → renders `children` bare (no trigger overhead). The wrapper is
|
|
151
|
+
* `display:contents` by default so it never perturbs the bubble's layout.
|
|
152
|
+
*/
|
|
153
|
+
function BubbleContextMenuInner({
|
|
154
|
+
message,
|
|
155
|
+
items,
|
|
156
|
+
children,
|
|
157
|
+
className = 'contents',
|
|
158
|
+
}: BubbleContextMenuProps) {
|
|
159
|
+
if (items.length === 0) return <>{children}</>;
|
|
160
|
+
return (
|
|
161
|
+
<ContextMenu>
|
|
162
|
+
<ContextMenuTrigger className={className}>{children}</ContextMenuTrigger>
|
|
163
|
+
<ContextMenuContent className="min-w-40">
|
|
164
|
+
{items.map((item) => (
|
|
165
|
+
<Fragment key={item.id}>
|
|
166
|
+
{item.separator ? <ContextMenuSeparator /> : null}
|
|
167
|
+
<ContextMenuItem
|
|
168
|
+
variant={item.danger ? 'destructive' : 'default'}
|
|
169
|
+
disabled={item.disabled}
|
|
170
|
+
onSelect={() => item.onSelect(message)}
|
|
171
|
+
>
|
|
172
|
+
{item.icon}
|
|
173
|
+
{item.label}
|
|
174
|
+
</ContextMenuItem>
|
|
175
|
+
</Fragment>
|
|
176
|
+
))}
|
|
177
|
+
</ContextMenuContent>
|
|
178
|
+
</ContextMenu>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export const BubbleContextMenu = memo(BubbleContextMenuInner);
|
|
183
|
+
BubbleContextMenu.displayName = 'BubbleContextMenu';
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Resolve the effective menu items for a bubble — combines a static list
|
|
187
|
+
* and/or a per-message builder with the built-in defaults. Used by
|
|
188
|
+
* `<MessageBubble>` so callers can pass any of:
|
|
189
|
+
* - nothing → defaults only
|
|
190
|
+
* - `bubbleMenuItems` (static host extras) → defaults + extras
|
|
191
|
+
* - `getBubbleMenuItems(message, defaults)` → host owns the full list
|
|
192
|
+
*/
|
|
193
|
+
export function useResolvedBubbleMenuItems(
|
|
194
|
+
message: ChatMessage,
|
|
195
|
+
defaults: BubbleMenuItem[],
|
|
196
|
+
bubbleMenuItems?: BubbleMenuItem[],
|
|
197
|
+
getBubbleMenuItems?: (message: ChatMessage, defaults: BubbleMenuItem[]) => BubbleMenuItem[],
|
|
198
|
+
): BubbleMenuItem[] {
|
|
199
|
+
return useMemo(() => {
|
|
200
|
+
if (getBubbleMenuItems) return getBubbleMenuItems(message, defaults);
|
|
201
|
+
return composeBubbleMenuItems(defaults, bubbleMenuItems);
|
|
202
|
+
}, [message, defaults, bubbleMenuItems, getBubbleMenuItems]);
|
|
203
|
+
}
|