@tangle-network/ui 11.26.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 11.27.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3ceb5fc: Markdown images open full size in a new tab, and an image that is unsafe or fails to load renders a labelled "Image unavailable" placeholder instead of the browser's broken-image icon. Images already wrapped in a Markdown link keep that link.
8
+
3
9
  ## 11.26.0
4
10
 
5
11
  ### Minor Changes
@@ -9,8 +9,9 @@ export interface MarkdownProps {
9
9
  urlTransform?: UrlTransform;
10
10
  }
11
11
  /**
12
- * Renders Markdown content with GFM support, XSS sanitisation, and
13
- * custom code block rendering via our CodeBlock component.
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, and
32
- * custom code block rendering via our CodeBlock component.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.26.0",
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,7 +149,7 @@
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.0",
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",
@@ -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
  },