@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,
|
|
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/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,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.
|
|
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
|
+
{"\n\n[](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>{""}</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 {
|
|
2
|
-
|
|
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,
|
|
53
|
-
*
|
|
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
|
},
|