@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +22 -0
  3. package/dist/editor/editor-peers.js +1 -21
  4. package/dist/files/file-card.d.ts +30 -0
  5. package/dist/files/file-card.js +162 -0
  6. package/dist/files/file-list.d.ts +25 -0
  7. package/dist/files/file-list.js +88 -0
  8. package/dist/files/file-preview.js +16 -62
  9. package/dist/files/pdf-loader.d.ts +22 -0
  10. package/dist/files/pdf-loader.js +82 -0
  11. package/dist/files/pdf-viewer.d.ts +32 -0
  12. package/dist/files/pdf-viewer.js +567 -0
  13. package/dist/files.d.ts +5 -1
  14. package/dist/files.js +5 -1
  15. package/dist/index.d.ts +7 -3
  16. package/dist/index.js +5 -1
  17. package/dist/lib/optional-peer.js +29 -0
  18. package/dist/markdown/markdown.d.ts +3 -2
  19. package/dist/markdown/markdown.js +70 -4
  20. package/dist/primitives.d.ts +2 -2
  21. package/package.json +7 -2
  22. package/src/editor/editor-peers.ts +3 -25
  23. package/src/files/file-artifact-pane.test.tsx +12 -2
  24. package/src/files/file-card.test.tsx +62 -0
  25. package/src/files/file-card.tsx +210 -0
  26. package/src/files/file-list.stories.tsx +121 -0
  27. package/src/files/file-list.test.tsx +81 -0
  28. package/src/files/file-list.tsx +167 -0
  29. package/src/files/file-preview.tsx +25 -94
  30. package/src/files/index.ts +9 -0
  31. package/src/files/pdf-loader.test.ts +130 -0
  32. package/src/files/pdf-loader.ts +169 -0
  33. package/src/files/pdf-viewer.stories.tsx +81 -0
  34. package/src/files/pdf-viewer.test.tsx +245 -0
  35. package/src/files/pdf-viewer.tsx +677 -0
  36. package/src/files/pdfjs-worker.d.ts +3 -0
  37. package/src/lib/optional-peer.ts +30 -0
  38. package/src/markdown/markdown.test.tsx +32 -3
  39. package/src/markdown/markdown.tsx +85 -4
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared handling for optional peers that an entry reaches through a dynamic
3
+ * `import()`: `./editor` for tiptap, yjs and Hocuspocus, `./files` for
4
+ * pdfjs-dist. `scripts/validate-dist.mjs` holds the rules these helpers serve.
5
+ */
6
+
7
+ /** The messages a bundler or a runtime gives for a module it cannot resolve. */
8
+ const RESOLUTION_FAILURE =
9
+ /could not resolve|cannot find (?:module|package)|can't resolve|failed to resolve|module not found/i;
10
+
11
+ /**
12
+ * True when the rejection says the package is not installed. Only such an
13
+ * error gets the install list: a transient chunk-fetch failure that reads as
14
+ * "install the peers" sends the reader to the wrong fix. An error this
15
+ * predicate does not match keeps its own message, so it can only
16
+ * under-report.
17
+ */
18
+ export function isMissingPeerError(error: unknown): boolean {
19
+ return error instanceof Error && RESOLUTION_FAILURE.test(error.message);
20
+ }
21
+
22
+ /**
23
+ * Hands an `import()` rejection on unchanged. esbuild reports an unresolvable
24
+ * literal `import()` as a build error and defers it to run time only when the
25
+ * call carries a `.catch()`, so every peer import attaches this handler. It
26
+ * changes nothing at run time.
27
+ */
28
+ export function rethrow(error: unknown): never {
29
+ throw error;
30
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import { render, screen } from "@testing-library/react";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
3
  import { getSanitizedMarkdownHeadingIdFromRawFragment, Markdown } from "./markdown";
4
4
 
5
5
  describe("Markdown", () => {
@@ -60,7 +60,7 @@ describe("Markdown", () => {
60
60
  "src",
61
61
  "https://example.com/image.png",
62
62
  );
63
- expect(screen.getByRole("img", { name: "Unsafe" })).not.toHaveAttribute("src");
63
+ expect(screen.getByRole("img", { name: "Image unavailable: Unsafe" })).not.toHaveAttribute("src");
64
64
  expect(urlTransform).toHaveBeenCalledWith(
65
65
  "https://example.com/image.png",
66
66
  "src",
@@ -100,5 +100,34 @@ describe("Markdown", () => {
100
100
  expect.objectContaining({ tagName: "a" }),
101
101
  );
102
102
  });
103
- });
104
103
 
104
+ it("links an image to its full-size source unless a Markdown link already wraps it", () => {
105
+ render(
106
+ <Markdown>
107
+ {"![Ad](https://example.com/ad.png)\n\n[![Badge](https://example.com/badge.svg)](https://example.com/docs)"}
108
+ </Markdown>,
109
+ );
110
+
111
+ const ad = screen.getByRole("img", { name: "Ad" });
112
+ expect(ad).toHaveAttribute("src", "https://example.com/ad.png");
113
+ expect(ad.closest("a")).toHaveAttribute("href", "https://example.com/ad.png");
114
+ expect(ad.closest("a")).toHaveAttribute("target", "_blank");
115
+ expect(ad.closest("a")).toHaveAttribute("rel", "noopener noreferrer");
116
+
117
+ const badge = screen.getByRole("img", { name: "Badge" });
118
+ expect(badge.closest("a")).toHaveAttribute("href", "https://example.com/docs");
119
+ expect(badge.closest("a")?.parentElement?.closest("a")).toBeNull();
120
+ });
121
+
122
+ it("replaces an image that fails to load with a labelled placeholder", () => {
123
+ render(<Markdown>{"![Final ad](campaigns/missing.png)"}</Markdown>);
124
+
125
+ fireEvent.error(screen.getByRole("img", { name: "Final ad" }));
126
+
127
+ expect(screen.queryByRole("img", { name: "Final ad" })).toBeNull();
128
+ expect(screen.getByRole("img", { name: "Image unavailable: Final ad" })).toHaveTextContent(
129
+ "Image unavailable: Final ad",
130
+ );
131
+ expect(screen.queryByRole("link")).toBeNull();
132
+ });
133
+ });
@@ -1,9 +1,18 @@
1
- import { memo } from "react";
2
- import ReactMarkdown, { defaultUrlTransform, type UrlTransform } from "react-markdown";
1
+ import {
2
+ createContext,
3
+ memo,
4
+ useContext,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ type ComponentProps,
9
+ } from "react";
10
+ import ReactMarkdown, { defaultUrlTransform, type ExtraProps, type UrlTransform } from "react-markdown";
3
11
  import remarkGfm from "remark-gfm";
4
12
  import rehypeSanitize from "rehype-sanitize";
5
13
  import rehypeSlug from "rehype-slug";
6
14
  import GithubSlugger from "github-slugger";
15
+ import { ImageOff } from "lucide-react";
7
16
  import { CodeBlock, CopyButton } from "./code-block";
8
17
  import { cn } from "../lib/utils";
9
18
 
@@ -41,6 +50,75 @@ function transformMarkdownUrl(
41
50
  return safeUrl(transformed);
42
51
  }
43
52
 
53
+ // An image already wrapped in a Markdown link keeps that link: anchors cannot nest.
54
+ const MarkdownLinkContext = createContext(false);
55
+
56
+ function MarkdownLink({ node: _node, ...props }: ComponentProps<"a"> & ExtraProps) {
57
+ return (
58
+ <MarkdownLinkContext.Provider value>
59
+ <a {...props} />
60
+ </MarkdownLinkContext.Provider>
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Renders a Markdown image that opens full size in a new tab, or a labelled
66
+ * placeholder when its URL is unsafe or fails to load, instead of the
67
+ * browser's broken-image icon.
68
+ */
69
+ function MarkdownImage({ node: _node, src, alt, className, ...props }: ComponentProps<"img"> & ExtraProps) {
70
+ const insideLink = useContext(MarkdownLinkContext);
71
+ const imageRef = useRef<HTMLImageElement>(null);
72
+ const url = typeof src === "string" && src ? src : undefined;
73
+ const [failedUrl, setFailedUrl] = useState<string>();
74
+
75
+ // A server-rendered image can fail before hydration attaches onError.
76
+ useEffect(() => {
77
+ const image = imageRef.current;
78
+ if (!url || !image?.complete || typeof image.decode !== "function") return;
79
+ let current = true;
80
+ image.decode().catch(() => {
81
+ if (current) setFailedUrl(url);
82
+ });
83
+ return () => {
84
+ current = false;
85
+ };
86
+ }, [url]);
87
+
88
+ if (!url || failedUrl === url) {
89
+ const label = alt ? `Image unavailable: ${alt}` : "Image unavailable";
90
+ return (
91
+ <span
92
+ role="img"
93
+ aria-label={label}
94
+ 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"
95
+ >
96
+ <ImageOff aria-hidden className="size-4 shrink-0" />
97
+ <span className="min-w-0 truncate">{label}</span>
98
+ </span>
99
+ );
100
+ }
101
+
102
+ const image = (
103
+ <img
104
+ {...props}
105
+ ref={imageRef}
106
+ src={url}
107
+ alt={alt ?? ""}
108
+ loading="lazy"
109
+ decoding="async"
110
+ onError={() => setFailedUrl(url)}
111
+ className={cn("h-auto max-w-full rounded-md", className)}
112
+ />
113
+ );
114
+ if (insideLink) return image;
115
+ return (
116
+ <a href={url} target="_blank" rel="noopener noreferrer" className="inline-block max-w-full">
117
+ {image}
118
+ </a>
119
+ );
120
+ }
121
+
44
122
  export interface MarkdownProps {
45
123
  children: string;
46
124
  className?: string;
@@ -49,8 +127,9 @@ export interface MarkdownProps {
49
127
  }
50
128
 
51
129
  /**
52
- * Renders Markdown content with GFM support, XSS sanitisation, and
53
- * custom code block rendering via our CodeBlock component.
130
+ * Renders Markdown content with GFM support, XSS sanitisation, custom code
131
+ * block rendering via our CodeBlock component, and images that open full size
132
+ * or show a labelled placeholder when unavailable.
54
133
  */
55
134
  export const Markdown = memo(({ children, className, urlTransform }: MarkdownProps) => {
56
135
  return (
@@ -62,6 +141,8 @@ export const Markdown = memo(({ children, className, urlTransform }: MarkdownPro
62
141
  rehypePlugins={[rehypeSlug, rehypeSanitize]}
63
142
  urlTransform={(url, key, node) => transformMarkdownUrl(url, key, node, urlTransform)}
64
143
  components={{
144
+ a: MarkdownLink,
145
+ img: MarkdownImage,
65
146
  pre({ children: preChildren }) {
66
147
  return <>{preChildren}</>;
67
148
  },