mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Glue icon to link text, add external-link indicator
PAP-2240: the GitHub icon on github.com markdown links could wrap onto the previous line, separated from the first character of the URL. Pin the icon and first character inside a white-space:nowrap span so they never split across a line break. Also add an ExternalLink (lucide) icon on the right of any target="_blank" link, glued to the last character with the same no-wrap treatment so the "opens in new tab" cue stays with the link text. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
69fa65328a
commit
25c9e92ca4
2 files changed
+77
-5
No files matched your search
@@ -316,12 +316,15 @@ describe("MarkdownBody", () => {
|
||||
expect(html).toContain('rel="noreferrer"');
|
||||
});
|
||||
|
||||
it("prefixes GitHub markdown links with the GitHub icon", () => {
|
||||
it("prefixes GitHub markdown links with the GitHub icon glued to the first character", () => {
|
||||
const html = renderMarkdown("[https://github.com/paperclipai/paperclip/pull/4099](https://github.com/paperclipai/paperclip/pull/4099)");
|
||||
|
||||
expect(html).toContain('<a href="https://github.com/paperclipai/paperclip/pull/4099"');
|
||||
expect(html).toContain('class="lucide lucide-github mr-1 inline h-3.5 w-3.5 align-[-0.125em]"');
|
||||
expect(html).toContain(">https://github.com/paperclipai/paperclip/pull/4099</a>");
|
||||
// The icon and first character "h" must sit in a no-wrap span so the
|
||||
// icon can never be orphaned on the previous line from the URL text.
|
||||
expect(html).toMatch(/<span style="white-space:nowrap">.*lucide-github.*?<\/svg>h<\/span>/);
|
||||
expect(html).toContain("ttps://github.com/paperclipai/paperclip/pull/4099");
|
||||
});
|
||||
|
||||
it("prefixes GitHub autolinks with the GitHub icon", () => {
|
||||
@@ -338,6 +341,22 @@ describe("MarkdownBody", () => {
|
||||
expect(html).not.toContain("lucide-github");
|
||||
});
|
||||
|
||||
it("suffixes external links with a new-tab icon glued to the last character", () => {
|
||||
const html = renderMarkdown("[docs](https://example.com/docs)");
|
||||
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain("lucide-external-link");
|
||||
// Last character "s" must sit in a no-wrap span with the icon so the
|
||||
// indicator never wraps away from the link text.
|
||||
expect(html).toMatch(/<span style="white-space:nowrap">s<svg[^>]*lucide-external-link/);
|
||||
});
|
||||
|
||||
it("does not render the new-tab icon on internal links", () => {
|
||||
const html = renderMarkdown("[settings](/company/settings)");
|
||||
|
||||
expect(html).not.toContain("lucide-external-link");
|
||||
});
|
||||
|
||||
it("keeps fenced code blocks width-bounded and horizontally scrollable", () => {
|
||||
const html = renderMarkdown("```text\nGET /heartbeat-runs/ca5d23fc-c15b-4826-8ff1-2b6dd11be096/log?offset=2062357&limitBytes=256000\n```");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isValidElement, useEffect, useId, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Github } from "lucide-react";
|
||||
import { ExternalLink, Github } from "lucide-react";
|
||||
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -133,6 +133,54 @@ function isExternalHttpUrl(href: string | null | undefined): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function renderLinkBody(
|
||||
children: ReactNode,
|
||||
leadingIcon: ReactNode,
|
||||
trailingIcon: ReactNode,
|
||||
): ReactNode {
|
||||
if (!leadingIcon && !trailingIcon) return children;
|
||||
|
||||
if (typeof children === "string" && children.length > 0) {
|
||||
if (children.length === 1) {
|
||||
return (
|
||||
<span style={{ whiteSpace: "nowrap" }}>
|
||||
{leadingIcon}
|
||||
{children}
|
||||
{trailingIcon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const first = children[0];
|
||||
const last = children[children.length - 1];
|
||||
const middle = children.slice(1, -1);
|
||||
return (
|
||||
<>
|
||||
{leadingIcon ? (
|
||||
<span style={{ whiteSpace: "nowrap" }}>
|
||||
{leadingIcon}
|
||||
{first}
|
||||
</span>
|
||||
) : first}
|
||||
{middle}
|
||||
{trailingIcon ? (
|
||||
<span style={{ whiteSpace: "nowrap" }}>
|
||||
{last}
|
||||
{trailingIcon}
|
||||
</span>
|
||||
) : last}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{leadingIcon}
|
||||
{children}
|
||||
{trailingIcon}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) {
|
||||
const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
const [svg, setSvg] = useState<string | null>(null);
|
||||
@@ -281,6 +329,12 @@ export function MarkdownBody({
|
||||
}
|
||||
const isGitHubLink = isGitHubUrl(href);
|
||||
const isExternal = isExternalHttpUrl(href);
|
||||
const leadingIcon = isGitHubLink ? (
|
||||
<Github aria-hidden="true" className="mr-1 inline h-3.5 w-3.5 align-[-0.125em]" />
|
||||
) : null;
|
||||
const trailingIcon = isExternal ? (
|
||||
<ExternalLink aria-hidden="true" className="ml-1 inline h-3 w-3 align-[-0.125em]" />
|
||||
) : null;
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
@@ -289,8 +343,7 @@ export function MarkdownBody({
|
||||
: { rel: "noreferrer" })}
|
||||
style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}
|
||||
>
|
||||
{isGitHubLink ? <Github aria-hidden="true" className="mr-1 inline h-3.5 w-3.5 align-[-0.125em]" /> : null}
|
||||
{linkChildren}
|
||||
{renderLinkBody(linkChildren, leadingIcon, trailingIcon)}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user