@djangocfg/ui-tools 2.1.430 → 2.1.432
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tree/index.cjs +43 -8
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +31 -1
- package/dist/tree/index.d.ts +31 -1
- package/dist/tree/index.mjs +43 -8
- package/dist/tree/index.mjs.map +1 -1
- 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 +109 -2
- package/src/tools/chat/composer/Composer.tsx +45 -12
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
- package/src/tools/chat/composer/PastedTextDialog.tsx +88 -0
- package/src/tools/chat/composer/index.ts +9 -0
- package/src/tools/chat/composer/textToAttachment.ts +69 -0
- package/src/tools/chat/composer/types.ts +13 -0
- package/src/tools/chat/composer/useComposerAttach.tsx +47 -0
- 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 +335 -46
- 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 +38 -3
- 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/data/Tree/README.md +30 -0
- package/src/tools/data/Tree/components/TreeRow.tsx +11 -2
- package/src/tools/data/Tree/context/menu/render.tsx +56 -27
- package/src/tools/data/Tree/data/appearance.ts +65 -3
- package/src/tools/data/Tree/data/index.ts +2 -0
- package/src/tools/data/Tree/index.tsx +2 -0
- 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,21 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react';
|
|
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';
|
|
18
|
+
import { PastedTextDialog } from '../composer/PastedTextDialog';
|
|
8
19
|
import { useChatDestructiveStyles } from '../styles';
|
|
9
20
|
import type { ChatAttachment } from '../types';
|
|
10
21
|
|
|
@@ -19,6 +30,7 @@ export interface AttachmentRendererArgs {
|
|
|
19
30
|
export type AttachmentRenderer = (args: AttachmentRendererArgs) => ReactNode;
|
|
20
31
|
|
|
21
32
|
export interface AttachmentRendererMap {
|
|
33
|
+
text?: AttachmentRenderer;
|
|
22
34
|
image?: AttachmentRenderer;
|
|
23
35
|
audio?: AttachmentRenderer;
|
|
24
36
|
video?: AttachmentRenderer;
|
|
@@ -36,6 +48,118 @@ interface CommonProps {
|
|
|
36
48
|
className?: string;
|
|
37
49
|
}
|
|
38
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
|
+
|
|
39
163
|
// ---------------------------------------------------------------------------
|
|
40
164
|
// AttachmentsGrid — flex-wrap, ideal for thumbnails / file chips.
|
|
41
165
|
// ---------------------------------------------------------------------------
|
|
@@ -56,22 +180,27 @@ export function AttachmentsGrid({
|
|
|
56
180
|
if (!attachments?.length) return null;
|
|
57
181
|
const visible = maxVisible ? attachments.slice(0, maxVisible) : attachments;
|
|
58
182
|
return (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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>
|
|
75
204
|
);
|
|
76
205
|
}
|
|
77
206
|
|
|
@@ -97,26 +226,30 @@ export function AttachmentsList({
|
|
|
97
226
|
if (!attachments?.length) return null;
|
|
98
227
|
const visible = maxVisible ? attachments.slice(0, maxVisible) : attachments;
|
|
99
228
|
return (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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>
|
|
120
253
|
);
|
|
121
254
|
}
|
|
122
255
|
|
|
@@ -143,6 +276,28 @@ export function Attachments(props: AttachmentsProps) {
|
|
|
143
276
|
// ---------------------------------------------------------------------------
|
|
144
277
|
|
|
145
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
|
+
|
|
285
|
+
// "Pasted text" chunks are self-contained — they open their own preview
|
|
286
|
+
// dialog on click rather than going through the generic `onClick`.
|
|
287
|
+
if (attachment.type === 'text') {
|
|
288
|
+
return <TextChunkTile attachment={attachment} onRemove={onRemove} />;
|
|
289
|
+
}
|
|
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
|
+
|
|
146
301
|
const isImage = attachment.type === 'image';
|
|
147
302
|
const isUploading = attachment.status === 'uploading';
|
|
148
303
|
|
|
@@ -150,27 +305,48 @@ function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArg
|
|
|
150
305
|
<img
|
|
151
306
|
src={attachment.thumbnailUrl ?? attachment.url}
|
|
152
307
|
alt={attachment.name ?? 'attachment'}
|
|
153
|
-
|
|
308
|
+
// `--radius-control` (~10px) — the design-system token for value
|
|
309
|
+
// chips / controls (see ui-core base.css). Keeps every chip rounded
|
|
310
|
+
// consistently, a touch softer than the old `rounded-md`.
|
|
311
|
+
className="h-16 w-16 rounded-[var(--radius-control)] object-cover"
|
|
154
312
|
loading="lazy"
|
|
155
313
|
/>
|
|
156
314
|
) : (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
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">
|
|
319
|
+
<FileGlyph aria-hidden className="size-4 shrink-0 text-muted-foreground" />
|
|
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>
|
|
160
328
|
</div>
|
|
161
329
|
);
|
|
162
330
|
|
|
163
331
|
return (
|
|
164
332
|
<div className="relative">
|
|
165
|
-
{
|
|
166
|
-
|
|
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.
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
onClick={effectiveOnClick}
|
|
340
|
+
aria-label={isImage ? `Open image: ${attachment.name ?? 'image'}` : undefined}
|
|
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"
|
|
342
|
+
>
|
|
167
343
|
{inner}
|
|
168
344
|
</button>
|
|
169
345
|
) : (
|
|
170
346
|
inner
|
|
171
347
|
)}
|
|
172
348
|
{isUploading ? (
|
|
173
|
-
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-
|
|
349
|
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[var(--radius-control)] bg-background/70 text-[10px] font-medium">
|
|
174
350
|
{attachment.progress != null ? `${Math.round(attachment.progress * 100)}%` : '…'}
|
|
175
351
|
</div>
|
|
176
352
|
) : null}
|
|
@@ -179,6 +355,119 @@ function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArg
|
|
|
179
355
|
);
|
|
180
356
|
}
|
|
181
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
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Tile for a `type:'text'` chunk — a two-line "Pasted text" chip in the
|
|
426
|
+
* same surface style as file chips (title + "Pasted text" sublabel).
|
|
427
|
+
* Clicking it opens a read-only `PastedTextDialog` preview of the full
|
|
428
|
+
* payload. Mirrors ChatGPT's pasted-text card + click-to-expand.
|
|
429
|
+
*/
|
|
430
|
+
function TextChunkTile({
|
|
431
|
+
attachment,
|
|
432
|
+
onRemove,
|
|
433
|
+
}: {
|
|
434
|
+
attachment: ChatAttachment;
|
|
435
|
+
onRemove?: () => void;
|
|
436
|
+
}) {
|
|
437
|
+
const [open, setOpen] = useState(false);
|
|
438
|
+
const title = attachment.name ?? 'Pasted text';
|
|
439
|
+
return (
|
|
440
|
+
<div className="relative">
|
|
441
|
+
<button
|
|
442
|
+
type="button"
|
|
443
|
+
onClick={() => setOpen(true)}
|
|
444
|
+
aria-label={`Preview ${title}`}
|
|
445
|
+
className="block cursor-pointer rounded-[var(--radius-control)] text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
446
|
+
>
|
|
447
|
+
<div className="flex max-w-56 items-center gap-2 rounded-[var(--radius-control)] border border-border bg-background/60 px-2 py-1.5 transition-colors hover:bg-background">
|
|
448
|
+
{/* Inner glyph plate keeps the tighter `rounded-md` — a nested
|
|
449
|
+
surface should round a notch less than its container
|
|
450
|
+
(concentric radii) so the corners read cleanly. */}
|
|
451
|
+
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-primary/15 text-primary">
|
|
452
|
+
<FileText aria-hidden className="size-4" />
|
|
453
|
+
</span>
|
|
454
|
+
<span className="flex min-w-0 flex-col">
|
|
455
|
+
<span className="truncate text-xs font-medium">{title}</span>
|
|
456
|
+
<span className="truncate text-[11px] text-muted-foreground">Pasted text</span>
|
|
457
|
+
</span>
|
|
458
|
+
</div>
|
|
459
|
+
</button>
|
|
460
|
+
{onRemove ? <RemoveBtn onRemove={onRemove} /> : null}
|
|
461
|
+
<PastedTextDialog
|
|
462
|
+
open={open}
|
|
463
|
+
onOpenChange={setOpen}
|
|
464
|
+
text={attachment.text ?? ''}
|
|
465
|
+
title={title}
|
|
466
|
+
/>
|
|
467
|
+
</div>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
182
471
|
function RemoveBtn({ onRemove }: { onRemove: () => void }) {
|
|
183
472
|
const styles = useChatDestructiveStyles();
|
|
184
473
|
return (
|
|
@@ -187,7 +476,7 @@ function RemoveBtn({ onRemove }: { onRemove: () => void }) {
|
|
|
187
476
|
aria-label="Remove attachment"
|
|
188
477
|
onClick={onRemove}
|
|
189
478
|
className={cn(
|
|
190
|
-
'absolute -right-1.5 -top-1.5 grid h-4 w-4 place-items-center rounded-full border border-border bg-background text-muted-foreground',
|
|
479
|
+
'absolute -right-1.5 -top-1.5 grid h-4 w-4 cursor-pointer place-items-center rounded-full border border-border bg-background text-muted-foreground',
|
|
191
480
|
styles.hoverStrong,
|
|
192
481
|
)}
|
|
193
482
|
>
|
|
@@ -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
|
+
}
|