@tangle-network/ui 11.25.0 → 11.27.0
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/CHANGELOG.md +12 -0
- package/README.md +22 -0
- package/dist/editor/editor-peers.js +1 -21
- package/dist/files/file-card.d.ts +30 -0
- package/dist/files/file-card.js +162 -0
- package/dist/files/file-list.d.ts +25 -0
- package/dist/files/file-list.js +88 -0
- package/dist/files/file-preview.js +16 -62
- package/dist/files/pdf-loader.d.ts +22 -0
- package/dist/files/pdf-loader.js +82 -0
- package/dist/files/pdf-viewer.d.ts +32 -0
- package/dist/files/pdf-viewer.js +567 -0
- package/dist/files.d.ts +5 -1
- package/dist/files.js +5 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.js +5 -1
- package/dist/lib/optional-peer.js +29 -0
- package/dist/markdown/markdown.d.ts +3 -2
- package/dist/markdown/markdown.js +70 -4
- package/dist/primitives.d.ts +2 -2
- package/package.json +7 -2
- package/src/editor/editor-peers.ts +3 -25
- package/src/files/file-artifact-pane.test.tsx +12 -2
- package/src/files/file-card.test.tsx +62 -0
- package/src/files/file-card.tsx +210 -0
- package/src/files/file-list.stories.tsx +121 -0
- package/src/files/file-list.test.tsx +81 -0
- package/src/files/file-list.tsx +167 -0
- package/src/files/file-preview.tsx +25 -94
- package/src/files/index.ts +9 -0
- package/src/files/pdf-loader.test.ts +130 -0
- package/src/files/pdf-loader.ts +169 -0
- package/src/files/pdf-viewer.stories.tsx +81 -0
- package/src/files/pdf-viewer.test.tsx +245 -0
- package/src/files/pdf-viewer.tsx +677 -0
- package/src/files/pdfjs-worker.d.ts +3 -0
- package/src/lib/optional-peer.ts +30 -0
- package/src/markdown/markdown.test.tsx +32 -3
- package/src/markdown/markdown.tsx +85 -4
package/dist/index.js
CHANGED
|
@@ -82,7 +82,11 @@ import { LiveDuration } from "./run/run-item-primitives.js";
|
|
|
82
82
|
import { ToolCallFeed, parseToolEvent } from "./run/tool-call-feed.js";
|
|
83
83
|
import { FileTree, filterFileTree } from "./files/file-tree.js";
|
|
84
84
|
import { RichFileTree } from "./files/rich-file-tree.js";
|
|
85
|
+
import { FileCard } from "./files/file-card.js";
|
|
86
|
+
import { configurePdfViewer } from "./files/pdf-loader.js";
|
|
87
|
+
import { PdfViewer } from "./files/pdf-viewer.js";
|
|
85
88
|
import { CSV_PREVIEW_ROW_LIMIT, FilePreview, parseCsv } from "./files/file-preview.js";
|
|
89
|
+
import { FileList } from "./files/file-list.js";
|
|
86
90
|
import { FileTabs } from "./files/file-tabs.js";
|
|
87
91
|
import { FileArtifactPane } from "./files/file-artifact-pane.js";
|
|
88
92
|
import { AuthHeader, GitHubLoginButton, UserMenu } from "./auth/auth.js";
|
|
@@ -99,4 +103,4 @@ import { addMessage, addParts, clearChat, isStreamingAtom, messagesAtom, partMap
|
|
|
99
103
|
import { copyText } from "./utils/copy-text.js";
|
|
100
104
|
import { timeAgo } from "./utils/time-ago.js";
|
|
101
105
|
import { RedactedDocument } from "./redaction/redacted-document.js";
|
|
102
|
-
export { AgentTimeline, Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, AlertTitle, ArtifactPane, AuthHeader, Avatar, AvatarFallback, AvatarImage, Badge, Button, CATEGORY_TONES, CSV_PREVIEW_ROW_LIMIT, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, ChatContainer, ChatMessage, Checkbox, Chip, CodeBlock, Collapsible, CollapsibleContent, CollapsibleTrigger, CommandPreview, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, CopyButton, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DiffPreview, DropZone, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, ExpandedToolDetail, FileArtifactPane, FilePreview, FileTabs, FileTree, FilterField, GitHubLoginButton, GlobResultsPreview, GrepResultsPreview, Heading, IconTile, InlineCode, InlineThinkingItem, InlineToolItem, Input, Label, LiveDuration, LoginLayout, Logo, Markdown, MessageAuthor, MessageList, Metric, MetricStrip, OPENUI_NODE_JSON_SCHEMA, OPENUI_NODE_TYPES, OpenUIArtifactRenderer, PageHeader, PageShell, Pagination, Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger, Progress, QuestionPreview, RealtimeSessionRegistry, RedactedDocument, RichFileTree, RunGroup, RunRowShell, RunRowStatusDot, SegmentedControl, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, SidebarDropZone, Skeleton, SkeletonCard, SkeletonTable, Slider, StatCard, StatusPill, Switch, TONE_CLASSES, TOOL_CATEGORY_ICONS, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Tag, TangleKnot, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, TextShimmer, Textarea, ThemeToggle, ThinkingIndicator, ToastContainer, ToastProvider, ToolCallFeed, Toolbar, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, UploadProgress, UserMenu, UserMessage, WebSearchPreview, WriteFilePreview, activeSessionsAtom, addMessage, addParts, badgeVariants, bumpActiveSessionActivity, buttonVariants, clearChat, cn, copyText, createAuthFetcher, detectFileFormat, fileExtension, filterFileTree, findUnsupportedOpenUINode, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getActiveSession, getAllActiveSessions, getAllProjectActivity, getCodeLanguage, getFormatLabel, getSanitizedMarkdownHeadingIdFromRawFragment, getSessionsByActivity, getSessionsForNavbar, getSessionsForProject, getSyntaxLanguage, getToolCategory, getToolDisplayMetadata, getToolErrorText, getTotalRunningSessionCount, hasBackgroundRunningSessions, initialsOf, isStreamingAtom, isViewerMessage, messagesAtom, parseCsv, parseToolEvent, partMapAtom, registerActiveSession, resetActiveSessions, resolveFilePreviewKind, setActiveSessionAttention, setActiveSessionConnection, setActiveSessionError, setActiveSessionRunning, setForegroundActiveSession, timeAgo, toneFor, truncateText, unregisterActiveSession, updateActiveSessionMeta, updatePart, useActiveSession, useActiveSessions, useActiveSessionsState, useApiKey, useAuth, useAutoScroll, useDropdownMenu, useHasBackgroundRunningSessions, useLiveTime, useNavbarSessions, useProjectActivity, useProjectSessions, useRealtimeSession, useRunCollapseState, useRunGroups, useSSEStream, useSdkSession, useSessionsByActivity, useTheme, useToast, useToolCallStream, useTotalRunningSessions, validateOpenUIJsonArtifact, validateOpenUIJsonNode };
|
|
106
|
+
export { AgentTimeline, Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, AlertTitle, ArtifactPane, AuthHeader, Avatar, AvatarFallback, AvatarImage, Badge, Button, CATEGORY_TONES, CSV_PREVIEW_ROW_LIMIT, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, ChatContainer, ChatMessage, Checkbox, Chip, CodeBlock, Collapsible, CollapsibleContent, CollapsibleTrigger, CommandPreview, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, CopyButton, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DiffPreview, DropZone, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, ExpandedToolDetail, FileArtifactPane, FileCard, FileList, FilePreview, FileTabs, FileTree, FilterField, GitHubLoginButton, GlobResultsPreview, GrepResultsPreview, Heading, IconTile, InlineCode, InlineThinkingItem, InlineToolItem, Input, Label, LiveDuration, LoginLayout, Logo, Markdown, MessageAuthor, MessageList, Metric, MetricStrip, OPENUI_NODE_JSON_SCHEMA, OPENUI_NODE_TYPES, OpenUIArtifactRenderer, PageHeader, PageShell, Pagination, PdfViewer, Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger, Progress, QuestionPreview, RealtimeSessionRegistry, RedactedDocument, RichFileTree, RunGroup, RunRowShell, RunRowStatusDot, SegmentedControl, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, SidebarDropZone, Skeleton, SkeletonCard, SkeletonTable, Slider, StatCard, StatusPill, Switch, TONE_CLASSES, TOOL_CATEGORY_ICONS, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Tag, TangleKnot, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, TextShimmer, Textarea, ThemeToggle, ThinkingIndicator, ToastContainer, ToastProvider, ToolCallFeed, Toolbar, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, UploadProgress, UserMenu, UserMessage, WebSearchPreview, WriteFilePreview, activeSessionsAtom, addMessage, addParts, badgeVariants, bumpActiveSessionActivity, buttonVariants, clearChat, cn, configurePdfViewer, copyText, createAuthFetcher, detectFileFormat, fileExtension, filterFileTree, findUnsupportedOpenUINode, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getActiveSession, getAllActiveSessions, getAllProjectActivity, getCodeLanguage, getFormatLabel, getSanitizedMarkdownHeadingIdFromRawFragment, getSessionsByActivity, getSessionsForNavbar, getSessionsForProject, getSyntaxLanguage, getToolCategory, getToolDisplayMetadata, getToolErrorText, getTotalRunningSessionCount, hasBackgroundRunningSessions, initialsOf, isStreamingAtom, isViewerMessage, messagesAtom, parseCsv, parseToolEvent, partMapAtom, registerActiveSession, resetActiveSessions, resolveFilePreviewKind, setActiveSessionAttention, setActiveSessionConnection, setActiveSessionError, setActiveSessionRunning, setForegroundActiveSession, timeAgo, toneFor, truncateText, unregisterActiveSession, updateActiveSessionMeta, updatePart, useActiveSession, useActiveSessions, useActiveSessionsState, useApiKey, useAuth, useAutoScroll, useDropdownMenu, useHasBackgroundRunningSessions, useLiveTime, useNavbarSessions, useProjectActivity, useProjectSessions, useRealtimeSession, useRunCollapseState, useRunGroups, useSSEStream, useSdkSession, useSessionsByActivity, useTheme, useToast, useToolCallStream, useTotalRunningSessions, validateOpenUIJsonArtifact, validateOpenUIJsonNode };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//#region src/lib/optional-peer.ts
|
|
2
|
+
/**
|
|
3
|
+
* Shared handling for optional peers that an entry reaches through a dynamic
|
|
4
|
+
* `import()`: `./editor` for tiptap, yjs and Hocuspocus, `./files` for
|
|
5
|
+
* pdfjs-dist. `scripts/validate-dist.mjs` holds the rules these helpers serve.
|
|
6
|
+
*/
|
|
7
|
+
/** The messages a bundler or a runtime gives for a module it cannot resolve. */
|
|
8
|
+
const RESOLUTION_FAILURE = /could not resolve|cannot find (?:module|package)|can't resolve|failed to resolve|module not found/i;
|
|
9
|
+
/**
|
|
10
|
+
* True when the rejection says the package is not installed. Only such an
|
|
11
|
+
* error gets the install list: a transient chunk-fetch failure that reads as
|
|
12
|
+
* "install the peers" sends the reader to the wrong fix. An error this
|
|
13
|
+
* predicate does not match keeps its own message, so it can only
|
|
14
|
+
* under-report.
|
|
15
|
+
*/
|
|
16
|
+
function isMissingPeerError(error) {
|
|
17
|
+
return error instanceof Error && RESOLUTION_FAILURE.test(error.message);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Hands an `import()` rejection on unchanged. esbuild reports an unresolvable
|
|
21
|
+
* literal `import()` as a build error and defers it to run time only when the
|
|
22
|
+
* call carries a `.catch()`, so every peer import attaches this handler. It
|
|
23
|
+
* changes nothing at run time.
|
|
24
|
+
*/
|
|
25
|
+
function rethrow(error) {
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
export { isMissingPeerError, rethrow };
|
|
@@ -9,8 +9,9 @@ export interface MarkdownProps {
|
|
|
9
9
|
urlTransform?: UrlTransform;
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
* Renders Markdown content with GFM support, XSS sanitisation,
|
|
13
|
-
*
|
|
12
|
+
* Renders Markdown content with GFM support, XSS sanitisation, custom code
|
|
13
|
+
* block rendering via our CodeBlock component, and images that open full size
|
|
14
|
+
* or show a labelled placeholder when unavailable.
|
|
14
15
|
*/
|
|
15
16
|
export declare const Markdown: import("react").MemoExoticComponent<({ children, className, urlTransform }: MarkdownProps) => import("react").JSX.Element>;
|
|
16
17
|
//#endregion
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { cn } from "../lib/utils.js";
|
|
2
2
|
import { CodeBlock, CopyButton } from "./code-block.js";
|
|
3
|
-
import { memo } from "react";
|
|
4
|
-
import { Fragment as Fragment$1, jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, memo, useContext, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { ImageOff } from "lucide-react";
|
|
5
6
|
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
|
6
7
|
import remarkGfm from "remark-gfm";
|
|
7
8
|
import rehypeSanitize from "rehype-sanitize";
|
|
@@ -27,9 +28,72 @@ function transformMarkdownUrl(url, key, node, urlTransform) {
|
|
|
27
28
|
if (key === "href" && transformed === url && url.startsWith("#")) return safeUrl("#" + getSanitizedMarkdownHeadingIdFromRawFragment(url.slice(1)));
|
|
28
29
|
return safeUrl(transformed);
|
|
29
30
|
}
|
|
31
|
+
const MarkdownLinkContext = createContext(false);
|
|
32
|
+
function MarkdownLink({ node: _node, ...props }) {
|
|
33
|
+
return /* @__PURE__ */ jsx(MarkdownLinkContext.Provider, {
|
|
34
|
+
value: true,
|
|
35
|
+
children: /* @__PURE__ */ jsx("a", { ...props })
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Renders a Markdown image that opens full size in a new tab, or a labelled
|
|
40
|
+
* placeholder when its URL is unsafe or fails to load, instead of the
|
|
41
|
+
* browser's broken-image icon.
|
|
42
|
+
*/
|
|
43
|
+
function MarkdownImage({ node: _node, src, alt, className, ...props }) {
|
|
44
|
+
const insideLink = useContext(MarkdownLinkContext);
|
|
45
|
+
const imageRef = useRef(null);
|
|
46
|
+
const url = typeof src === "string" && src ? src : void 0;
|
|
47
|
+
const [failedUrl, setFailedUrl] = useState();
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const image = imageRef.current;
|
|
50
|
+
if (!url || !image?.complete || typeof image.decode !== "function") return;
|
|
51
|
+
let current = true;
|
|
52
|
+
image.decode().catch(() => {
|
|
53
|
+
if (current) setFailedUrl(url);
|
|
54
|
+
});
|
|
55
|
+
return () => {
|
|
56
|
+
current = false;
|
|
57
|
+
};
|
|
58
|
+
}, [url]);
|
|
59
|
+
if (!url || failedUrl === url) {
|
|
60
|
+
const label = alt ? `Image unavailable: ${alt}` : "Image unavailable";
|
|
61
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
62
|
+
role: "img",
|
|
63
|
+
"aria-label": label,
|
|
64
|
+
className: "inline-flex max-w-full items-center gap-2 rounded-md border border-dashed border-border bg-muted px-3 py-2 text-sm text-muted-foreground",
|
|
65
|
+
children: [/* @__PURE__ */ jsx(ImageOff, {
|
|
66
|
+
"aria-hidden": true,
|
|
67
|
+
className: "size-4 shrink-0"
|
|
68
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
69
|
+
className: "min-w-0 truncate",
|
|
70
|
+
children: label
|
|
71
|
+
})]
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const image = /* @__PURE__ */ jsx("img", {
|
|
75
|
+
...props,
|
|
76
|
+
ref: imageRef,
|
|
77
|
+
src: url,
|
|
78
|
+
alt: alt ?? "",
|
|
79
|
+
loading: "lazy",
|
|
80
|
+
decoding: "async",
|
|
81
|
+
onError: () => setFailedUrl(url),
|
|
82
|
+
className: cn("h-auto max-w-full rounded-md", className)
|
|
83
|
+
});
|
|
84
|
+
if (insideLink) return image;
|
|
85
|
+
return /* @__PURE__ */ jsx("a", {
|
|
86
|
+
href: url,
|
|
87
|
+
target: "_blank",
|
|
88
|
+
rel: "noopener noreferrer",
|
|
89
|
+
className: "inline-block max-w-full",
|
|
90
|
+
children: image
|
|
91
|
+
});
|
|
92
|
+
}
|
|
30
93
|
/**
|
|
31
|
-
* Renders Markdown content with GFM support, XSS sanitisation,
|
|
32
|
-
*
|
|
94
|
+
* Renders Markdown content with GFM support, XSS sanitisation, custom code
|
|
95
|
+
* block rendering via our CodeBlock component, and images that open full size
|
|
96
|
+
* or show a labelled placeholder when unavailable.
|
|
33
97
|
*/
|
|
34
98
|
const Markdown = memo(({ children, className, urlTransform }) => {
|
|
35
99
|
return /* @__PURE__ */ jsx("div", {
|
|
@@ -39,6 +103,8 @@ const Markdown = memo(({ children, className, urlTransform }) => {
|
|
|
39
103
|
rehypePlugins: [rehypeSlug, rehypeSanitize],
|
|
40
104
|
urlTransform: (url, key, node) => transformMarkdownUrl(url, key, node, urlTransform),
|
|
41
105
|
components: {
|
|
106
|
+
a: MarkdownLink,
|
|
107
|
+
img: MarkdownImage,
|
|
42
108
|
pre({ children: preChildren }) {
|
|
43
109
|
return /* @__PURE__ */ jsx(Fragment$1, { children: preChildren });
|
|
44
110
|
},
|
package/dist/primitives.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Button, ButtonProps, buttonVariants } from "./primitives/button.js";
|
|
2
2
|
import { ArtifactPane, ArtifactPaneProps } from "./primitives/artifact-pane.js";
|
|
3
|
+
import { CATEGORY_TONES, CategoryTone, TONE_CLASSES, Tone, ToneClasses, toneFor } from "./primitives/tone.js";
|
|
4
|
+
import { IconTile, IconTileProps, initialsOf } from "./primitives/icon-tile.js";
|
|
3
5
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, CardTitleProps } from "./primitives/card.js";
|
|
4
6
|
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from "./primitives/dialog.js";
|
|
5
7
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger } from "./primitives/alert-dialog.js";
|
|
@@ -40,10 +42,8 @@ import { PageHeader, PageHeaderProps } from "./primitives/page-header.js";
|
|
|
40
42
|
import { StatusPill, StatusPillProps, StatusTone } from "./primitives/status-pill.js";
|
|
41
43
|
import { Alert, AlertDescription, AlertProps, AlertTitle, AlertTone } from "./primitives/alert.js";
|
|
42
44
|
import { Pagination, PaginationProps } from "./primitives/pagination.js";
|
|
43
|
-
import { CATEGORY_TONES, CategoryTone, TONE_CLASSES, Tone, ToneClasses, toneFor } from "./primitives/tone.js";
|
|
44
45
|
import { Tag, TagProps } from "./primitives/tag.js";
|
|
45
46
|
import { Chip, ChipProps } from "./primitives/chip.js";
|
|
46
|
-
import { IconTile, IconTileProps, initialsOf } from "./primitives/icon-tile.js";
|
|
47
47
|
import { Metric, MetricProps, MetricStrip, MetricStripProps } from "./primitives/metric-strip.js";
|
|
48
48
|
import { FilterField, FilterFieldProps, Toolbar, ToolbarProps } from "./primitives/toolbar.js";
|
|
49
49
|
import { Logo, LogoProps, TangleKnot } from "@tangle-network/brand";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/ui",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.27.0",
|
|
4
4
|
"description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -149,13 +149,14 @@
|
|
|
149
149
|
"peerDependencies": {
|
|
150
150
|
"@hocuspocus/provider": ">=2.15.0 <5.0.0",
|
|
151
151
|
"@nanostores/react": ">=0.8.4 <3.0.0",
|
|
152
|
-
"@tangle-network/brand": "^1.17.
|
|
152
|
+
"@tangle-network/brand": "^1.17.1",
|
|
153
153
|
"@tiptap/core": ">=3.20.4 <4.0.0",
|
|
154
154
|
"@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
|
|
155
155
|
"@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
|
|
156
156
|
"@tiptap/react": ">=3.20.4 <4.0.0",
|
|
157
157
|
"@tiptap/starter-kit": ">=3.20.4 <4.0.0",
|
|
158
158
|
"nanostores": ">=0.11.3 <2.0.0",
|
|
159
|
+
"pdfjs-dist": ">=6.0.0 <7.0.0",
|
|
159
160
|
"react": "^18 || ^19",
|
|
160
161
|
"react-dom": "^18 || ^19",
|
|
161
162
|
"react-router": "^7 || ^8",
|
|
@@ -191,6 +192,9 @@
|
|
|
191
192
|
},
|
|
192
193
|
"react-router": {
|
|
193
194
|
"optional": true
|
|
195
|
+
},
|
|
196
|
+
"pdfjs-dist": {
|
|
197
|
+
"optional": true
|
|
194
198
|
}
|
|
195
199
|
},
|
|
196
200
|
"devDependencies": {
|
|
@@ -208,6 +212,7 @@
|
|
|
208
212
|
"@types/turndown": "^5.0.6",
|
|
209
213
|
"ajv": "8.17.1",
|
|
210
214
|
"nanostores": "^1.5.4",
|
|
215
|
+
"pdfjs-dist": "^6.4.299",
|
|
211
216
|
"react": "^19.3.0",
|
|
212
217
|
"react-dom": "^19.3.0",
|
|
213
218
|
"react-router": "^8.4.0",
|
|
@@ -32,6 +32,9 @@ import type * as TiptapCollaborationCaret from "@tiptap/extension-collaboration-
|
|
|
32
32
|
import type * as TiptapReact from "@tiptap/react";
|
|
33
33
|
import type * as TiptapStarterKit from "@tiptap/starter-kit";
|
|
34
34
|
import type * as Yjs from "yjs";
|
|
35
|
+
import { isMissingPeerError, rethrow } from "../lib/optional-peer";
|
|
36
|
+
|
|
37
|
+
export { isMissingPeerError };
|
|
35
38
|
|
|
36
39
|
/** Namespaces the local markdown editor needs. */
|
|
37
40
|
export interface DocumentEditorPeers {
|
|
@@ -89,31 +92,6 @@ export function isMissingEditorPeersError(error: unknown): boolean {
|
|
|
89
92
|
return error instanceof Error && error.name === "MissingEditorPeersError";
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
/** The messages a bundler or a runtime gives for a module it cannot resolve. */
|
|
93
|
-
const RESOLUTION_FAILURE =
|
|
94
|
-
/could not resolve|cannot find (?:module|package)|can't resolve|failed to resolve|module not found/i;
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* True when the rejection says the package is not installed. Only such an
|
|
98
|
-
* error gets the install list: a transient chunk-fetch failure that reads as
|
|
99
|
-
* "install the peers" sends the reader to the wrong fix. An error this
|
|
100
|
-
* predicate does not match keeps its own message, so it can only
|
|
101
|
-
* under-report.
|
|
102
|
-
*/
|
|
103
|
-
export function isMissingPeerError(error: unknown): boolean {
|
|
104
|
-
return error instanceof Error && RESOLUTION_FAILURE.test(error.message);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Hands an `import()` rejection on unchanged. esbuild reports an unresolvable
|
|
109
|
-
* literal `import()` as a build error and defers it to run time only when the
|
|
110
|
-
* call carries a `.catch()`, so every peer import attaches this handler. It
|
|
111
|
-
* changes nothing at run time.
|
|
112
|
-
*/
|
|
113
|
-
function rethrow(error: unknown): never {
|
|
114
|
-
throw error;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
95
|
/**
|
|
118
96
|
* Every optional peer the `./editor` entry needs at run time. The loaders
|
|
119
97
|
* import all but `@tiptap/core`, which the tiptap packages need in turn: a
|
|
@@ -2,6 +2,12 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
3
|
import { FileArtifactPane } from "./file-artifact-pane";
|
|
4
4
|
import { CSV_PREVIEW_ROW_LIMIT, parseCsv } from "./file-preview";
|
|
5
|
+
import { MissingPdfjsError, loadPdfjs } from "./pdf-loader";
|
|
6
|
+
|
|
7
|
+
vi.mock("./pdf-loader", async (importOriginal) => ({
|
|
8
|
+
...(await importOriginal<typeof import("./pdf-loader")>()),
|
|
9
|
+
loadPdfjs: vi.fn(),
|
|
10
|
+
}));
|
|
5
11
|
|
|
6
12
|
const BLOB_URL = "blob:https://app.tangle.tools/1f2e3d4c";
|
|
7
13
|
|
|
@@ -32,16 +38,20 @@ describe("FileArtifactPane previews", () => {
|
|
|
32
38
|
expect(container.querySelector("img")).toHaveClass("max-w-none");
|
|
33
39
|
});
|
|
34
40
|
|
|
35
|
-
it("renders a PDF
|
|
41
|
+
it("renders a PDF in PdfViewer, and in an <object> of the blobUrl without pdfjs-dist", async () => {
|
|
42
|
+
vi.mocked(loadPdfjs).mockRejectedValue(new MissingPdfjsError());
|
|
43
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
36
44
|
const { container } = render(
|
|
37
45
|
<FileArtifactPane filename="report.pdf" blobUrl={BLOB_URL} mimeType="application/pdf" />,
|
|
38
46
|
);
|
|
39
47
|
|
|
48
|
+
expect(screen.getByRole("status")).toHaveTextContent("Loading report.pdf");
|
|
49
|
+
expect(await screen.findByText(/does not include the page viewer/)).toBeInTheDocument();
|
|
40
50
|
const viewer = container.querySelector("object");
|
|
41
|
-
expect(viewer).not.toBeNull();
|
|
42
51
|
expect(viewer).toHaveAttribute("data", BLOB_URL);
|
|
43
52
|
expect(viewer).toHaveAttribute("type", "application/pdf");
|
|
44
53
|
expect(viewer).toHaveAttribute("title", "report.pdf");
|
|
54
|
+
consoleError.mockRestore();
|
|
45
55
|
});
|
|
46
56
|
|
|
47
57
|
it("renders video and audio with native controls", () => {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { FileCard } from "./file-card";
|
|
4
|
+
|
|
5
|
+
const UPLOADED = new Date("2026-10-06T15:30:00Z");
|
|
6
|
+
|
|
7
|
+
describe("FileCard", () => {
|
|
8
|
+
it("names the file with its size, date and uploader", () => {
|
|
9
|
+
const { container } = render(
|
|
10
|
+
<FileCard
|
|
11
|
+
filename="Engagement letter.pdf"
|
|
12
|
+
size={2_516_582}
|
|
13
|
+
date={UPLOADED}
|
|
14
|
+
uploader={{ name: "Dana Ruiz", role: "Client" }}
|
|
15
|
+
/>,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
expect(screen.getByText("Engagement letter.pdf")).toBeInTheDocument();
|
|
19
|
+
expect(screen.getByText("2.4 MB")).toBeInTheDocument();
|
|
20
|
+
expect(container.querySelector("time")).toHaveAttribute("datetime", UPLOADED.toISOString());
|
|
21
|
+
expect(screen.getByText("Uploaded by Dana Ruiz (Client)")).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("leaves out what it does not know", () => {
|
|
25
|
+
const { container } = render(<FileCard filename="notes.txt" date="not a date" />);
|
|
26
|
+
expect(container.querySelector("time")).toBeNull();
|
|
27
|
+
expect(screen.queryByText(/Uploaded by/)).toBeNull();
|
|
28
|
+
expect(screen.queryByRole("button")).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("takes its icon tone from the file kind, with the MIME type outranking the name", () => {
|
|
32
|
+
const pdf = render(<FileCard filename="brief.pdf" />);
|
|
33
|
+
expect(pdf.container.querySelector("[aria-hidden='true']")?.className).toContain(
|
|
34
|
+
"--tone-orange-bg",
|
|
35
|
+
);
|
|
36
|
+
pdf.unmount();
|
|
37
|
+
|
|
38
|
+
const sheet = render(<FileCard filename="export" mimeType="text/csv" />);
|
|
39
|
+
expect(sheet.container.querySelector("[aria-hidden='true']")?.className).toContain(
|
|
40
|
+
"--tone-lime-bg",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("offers Download and the host's own actions", () => {
|
|
45
|
+
const onDownload = vi.fn();
|
|
46
|
+
render(
|
|
47
|
+
<FileCard
|
|
48
|
+
filename="book.xlsx"
|
|
49
|
+
description="Download to open this workbook in a spreadsheet app."
|
|
50
|
+
onDownload={onDownload}
|
|
51
|
+
actions={<button type="button">Open</button>}
|
|
52
|
+
/>,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(
|
|
56
|
+
screen.getByText("Download to open this workbook in a spreadsheet app."),
|
|
57
|
+
).toBeInTheDocument();
|
|
58
|
+
fireEvent.click(screen.getByRole("button", { name: "Download" }));
|
|
59
|
+
expect(onDownload).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileCard — one file, described: its kind, name, size, date and uploader,
|
|
3
|
+
* with Download and any actions the host adds. FilePreview shows it for a file
|
|
4
|
+
* it cannot render inline; a host can show it anywhere a file stands on its own.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
import {
|
|
9
|
+
Download,
|
|
10
|
+
File,
|
|
11
|
+
FileAudio,
|
|
12
|
+
FileCode,
|
|
13
|
+
FileImage,
|
|
14
|
+
FileJson,
|
|
15
|
+
FileSpreadsheet,
|
|
16
|
+
FileText,
|
|
17
|
+
FileVideo,
|
|
18
|
+
type LucideIcon,
|
|
19
|
+
} from "lucide-react";
|
|
20
|
+
import { cn } from "../lib/utils";
|
|
21
|
+
import { Button } from "../primitives/button";
|
|
22
|
+
import { IconTile, type IconTileProps } from "../primitives/icon-tile";
|
|
23
|
+
import type { CategoryTone } from "../primitives/tone";
|
|
24
|
+
import { formatBytes } from "../utils/format";
|
|
25
|
+
import { getFormatLabel, resolveFilePreviewKind, type FilePreviewKind } from "./file-format";
|
|
26
|
+
|
|
27
|
+
/** Who added a file, as a reader of the list should see them. */
|
|
28
|
+
export interface FileUploader {
|
|
29
|
+
name: string;
|
|
30
|
+
/** Their relationship to the work, e.g. "Client" or "Attorney". */
|
|
31
|
+
role?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What FileCard and FileList show about one file. */
|
|
35
|
+
export interface FileDetails {
|
|
36
|
+
filename: string;
|
|
37
|
+
/** Wins over the extension when choosing the kind, as in FilePreview. */
|
|
38
|
+
mimeType?: string;
|
|
39
|
+
/** Size in bytes. */
|
|
40
|
+
size?: number;
|
|
41
|
+
/** When the file was added or last changed. */
|
|
42
|
+
date?: Date | string | number;
|
|
43
|
+
uploader?: FileUploader;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// File kinds are categories, so each takes a categorical tone and never a
|
|
47
|
+
// status colour: a PDF is not an error. See "Color Roles" in the brand guide.
|
|
48
|
+
const KIND_ICON: Record<FilePreviewKind, { icon: LucideIcon; tone: CategoryTone }> = {
|
|
49
|
+
pdf: { icon: FileText, tone: "orange" },
|
|
50
|
+
image: { icon: FileImage, tone: "pink" },
|
|
51
|
+
video: { icon: FileVideo, tone: "violet" },
|
|
52
|
+
audio: { icon: FileAudio, tone: "cyan" },
|
|
53
|
+
csv: { icon: FileSpreadsheet, tone: "lime" },
|
|
54
|
+
spreadsheet: { icon: FileSpreadsheet, tone: "lime" },
|
|
55
|
+
code: { icon: FileCode, tone: "blue" },
|
|
56
|
+
json: { icon: FileJson, tone: "blue" },
|
|
57
|
+
yaml: { icon: FileCode, tone: "blue" },
|
|
58
|
+
markdown: { icon: FileText, tone: "teal" },
|
|
59
|
+
text: { icon: FileText, tone: "teal" },
|
|
60
|
+
binary: { icon: File, tone: "brown" },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The kind's icon on its category tone. Decorative: the filename names the file. */
|
|
64
|
+
export function FileKindTile({
|
|
65
|
+
filename,
|
|
66
|
+
mimeType,
|
|
67
|
+
size = "md",
|
|
68
|
+
className,
|
|
69
|
+
}: {
|
|
70
|
+
filename: string;
|
|
71
|
+
mimeType?: string;
|
|
72
|
+
size?: IconTileProps["size"];
|
|
73
|
+
className?: string;
|
|
74
|
+
}) {
|
|
75
|
+
const { icon: Icon, tone } = KIND_ICON[resolveFilePreviewKind(filename, mimeType)];
|
|
76
|
+
return <IconTile tone={tone} size={size} icon={<Icon />} className={className} />;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toDate(value: FileDetails["date"]): Date | undefined {
|
|
80
|
+
if (value === undefined) return undefined;
|
|
81
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
82
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A dot between facts for the eye, a comma for a screen reader, which would
|
|
89
|
+
* otherwise read the facts as one run-on word or announce "middle dot".
|
|
90
|
+
*/
|
|
91
|
+
export function FactSeparator() {
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
<span aria-hidden="true"> · </span>
|
|
95
|
+
<span className="sr-only">, </span>
|
|
96
|
+
</>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The kind, size and date as one muted line, each part only when known.
|
|
102
|
+
* The date follows the reader's locale, which a server render may not share,
|
|
103
|
+
* so the element tolerates a hydration difference.
|
|
104
|
+
*/
|
|
105
|
+
export function FileFacts({
|
|
106
|
+
filename,
|
|
107
|
+
mimeType,
|
|
108
|
+
size,
|
|
109
|
+
date,
|
|
110
|
+
showKind = false,
|
|
111
|
+
className,
|
|
112
|
+
}: Pick<FileDetails, "filename" | "mimeType" | "size" | "date"> & {
|
|
113
|
+
showKind?: boolean;
|
|
114
|
+
className?: string;
|
|
115
|
+
}) {
|
|
116
|
+
const parsed = toDate(date);
|
|
117
|
+
const parts: React.ReactNode[] = [];
|
|
118
|
+
if (showKind) parts.push(getFormatLabel(resolveFilePreviewKind(filename, mimeType)));
|
|
119
|
+
if (size !== undefined) parts.push(formatBytes(size));
|
|
120
|
+
if (parsed) {
|
|
121
|
+
parts.push(
|
|
122
|
+
<time key="date" dateTime={parsed.toISOString()} suppressHydrationWarning>
|
|
123
|
+
{DATE_FORMAT.format(parsed)}
|
|
124
|
+
</time>,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (parts.length === 0) return null;
|
|
128
|
+
return (
|
|
129
|
+
<span className={cn("text-xs text-muted-foreground", className)}>
|
|
130
|
+
{parts.map((part, index) => (
|
|
131
|
+
<React.Fragment key={index}>
|
|
132
|
+
{index > 0 && <FactSeparator />}
|
|
133
|
+
{part}
|
|
134
|
+
</React.Fragment>
|
|
135
|
+
))}
|
|
136
|
+
</span>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** "Dana Ruiz (Client)", or the name alone. */
|
|
141
|
+
export function uploaderLabel(uploader: FileUploader): string {
|
|
142
|
+
return uploader.role ? `${uploader.name} (${uploader.role})` : uploader.name;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface FileCardProps
|
|
146
|
+
extends FileDetails,
|
|
147
|
+
Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
148
|
+
/** One sentence under the details, such as why there is no preview. */
|
|
149
|
+
description?: React.ReactNode;
|
|
150
|
+
/** Adds a Download button. */
|
|
151
|
+
onDownload?: () => void;
|
|
152
|
+
/** Further buttons after Download, such as Open or Delete. */
|
|
153
|
+
actions?: React.ReactNode;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const FileCard = React.forwardRef<HTMLDivElement, FileCardProps>(
|
|
157
|
+
(
|
|
158
|
+
{
|
|
159
|
+
filename,
|
|
160
|
+
mimeType,
|
|
161
|
+
size,
|
|
162
|
+
date,
|
|
163
|
+
uploader,
|
|
164
|
+
description,
|
|
165
|
+
onDownload,
|
|
166
|
+
actions,
|
|
167
|
+
className,
|
|
168
|
+
...props
|
|
169
|
+
},
|
|
170
|
+
ref,
|
|
171
|
+
) => (
|
|
172
|
+
<div
|
|
173
|
+
ref={ref}
|
|
174
|
+
className={cn(
|
|
175
|
+
"flex min-w-0 flex-col items-center justify-center rounded-[var(--radius-md)] border border-border bg-card px-6 py-12 text-center",
|
|
176
|
+
className,
|
|
177
|
+
)}
|
|
178
|
+
{...props}
|
|
179
|
+
>
|
|
180
|
+
<FileKindTile filename={filename} mimeType={mimeType} size="lg" />
|
|
181
|
+
<p
|
|
182
|
+
className="mt-3 line-clamp-2 max-w-full text-sm font-medium text-foreground [overflow-wrap:anywhere]"
|
|
183
|
+
title={filename}
|
|
184
|
+
>
|
|
185
|
+
{filename}
|
|
186
|
+
</p>
|
|
187
|
+
<FileFacts filename={filename} size={size} date={date} className="mt-1" />
|
|
188
|
+
{uploader && (
|
|
189
|
+
<p className="mt-0.5 max-w-full truncate text-xs text-muted-foreground">
|
|
190
|
+
Uploaded by {uploaderLabel(uploader)}
|
|
191
|
+
</p>
|
|
192
|
+
)}
|
|
193
|
+
{description && (
|
|
194
|
+
<div className="mt-3 max-w-md text-sm text-muted-foreground">{description}</div>
|
|
195
|
+
)}
|
|
196
|
+
{(onDownload || actions) && (
|
|
197
|
+
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
|
198
|
+
{onDownload && (
|
|
199
|
+
<Button type="button" variant="outline" size="sm" onClick={onDownload}>
|
|
200
|
+
<Download />
|
|
201
|
+
Download
|
|
202
|
+
</Button>
|
|
203
|
+
)}
|
|
204
|
+
{actions}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
FileCard.displayName = "FileCard";
|