fix: prevent inline links rendering in 2-column grid layout (#767)

This commit is contained in:
Taylor Ho
2026-05-28 17:34:35 +00:00
committed by GitHub
parent 9a10e6281e
commit 835a44aad0
3 changed files with 174 additions and 122 deletions
+88 -51
View File
@@ -28,17 +28,19 @@ function isValidElement(obj) {
); );
} }
function fakeElement(type) { function fakeElement(type, props = {}) {
return { $$typeof: REACT_ELEMENT_TYPE, type, props: {}, key: null }; return { $$typeof: REACT_ELEMENT_TYPE, type, props, key: null };
}
function isBlockMedia(child) {
return isValidElement(child) && child.props?.["data-block-media"] != null;
} }
function classifyChildren(childArray) { function classifyChildren(childArray) {
const imageChildren = childArray.filter( const imageChildren = childArray.filter(isBlockMedia);
(child) => isValidElement(child) && typeof child.type !== "string",
);
const nonImageChildren = childArray.filter( const nonImageChildren = childArray.filter(
(child) => (child) =>
!(isValidElement(child) && typeof child.type !== "string") && !isBlockMedia(child) &&
!(typeof child === "string" && child.trim() === "") && !(typeof child === "string" && child.trim() === "") &&
!(isValidElement(child) && child.type === "br"), !(isValidElement(child) && child.type === "br"),
); );
@@ -153,14 +155,21 @@ test("shallowArrayEqual: empty arrays return true", () => {
// ── classifyChildren ────────────────────────────────────────────────── // ── classifyChildren ──────────────────────────────────────────────────
test("classifyChildren: React component elements are image children", () => { test("classifyChildren: elements with data-block-media are image children", () => {
const ImgComponent = () => null; const children = [fakeElement("span", { "data-block-media": "" })];
const children = [fakeElement(ImgComponent)];
const { imageChildren, nonImageChildren } = classifyChildren(children); const { imageChildren, nonImageChildren } = classifyChildren(children);
assert.equal(imageChildren.length, 1); assert.equal(imageChildren.length, 1);
assert.equal(nonImageChildren.length, 0); assert.equal(nonImageChildren.length, 0);
}); });
test("classifyChildren: React component elements without data-block-media are non-image", () => {
const LinkComponent = () => null;
const children = [fakeElement(LinkComponent)];
const { imageChildren, nonImageChildren } = classifyChildren(children);
assert.equal(imageChildren.length, 0);
assert.equal(nonImageChildren.length, 1);
});
test("classifyChildren: plain HTML elements are non-image children", () => { test("classifyChildren: plain HTML elements are non-image children", () => {
const children = [fakeElement("span")]; const children = [fakeElement("span")];
const { imageChildren, nonImageChildren } = classifyChildren(children); const { imageChildren, nonImageChildren } = classifyChildren(children);
@@ -189,26 +198,24 @@ test("classifyChildren: <br> elements are excluded from non-image", () => {
assert.equal(nonImageChildren.length, 0); assert.equal(nonImageChildren.length, 0);
}); });
test("classifyChildren: mixed images, text, and br", () => { test("classifyChildren: mixed media, text, and br", () => {
const Img = () => null;
const children = [ const children = [
fakeElement(Img), fakeElement("span", { "data-block-media": "" }),
"some text", "some text",
fakeElement("br"), fakeElement("br"),
fakeElement(Img), fakeElement("span", { "data-block-media": "" }),
]; ];
const { imageChildren, nonImageChildren } = classifyChildren(children); const { imageChildren, nonImageChildren } = classifyChildren(children);
assert.equal(imageChildren.length, 2); assert.equal(imageChildren.length, 2);
assert.equal(nonImageChildren.length, 1); // "some text" assert.equal(nonImageChildren.length, 1); // "some text"
}); });
test("classifyChildren: images with only whitespace and br between them", () => { test("classifyChildren: media with only whitespace and br between them", () => {
const Img = () => null;
const children = [ const children = [
fakeElement(Img), fakeElement("span", { "data-block-media": "" }),
" ", " ",
fakeElement("br"), fakeElement("br"),
fakeElement(Img), fakeElement("span", { "data-block-media": "" }),
]; ];
const { imageChildren, nonImageChildren } = classifyChildren(children); const { imageChildren, nonImageChildren } = classifyChildren(children);
assert.equal(imageChildren.length, 2); assert.equal(imageChildren.length, 2);
@@ -217,21 +224,28 @@ test("classifyChildren: images with only whitespace and br between them", () =>
// ── isImageOnlyParagraph ────────────────────────────────────────────── // ── isImageOnlyParagraph ──────────────────────────────────────────────
test("isImageOnlyParagraph: two images with br returns true", () => { test("isImageOnlyParagraph: two media with br returns true", () => {
const Img = () => null; const media = { "data-block-media": "" };
const children = [fakeElement(Img), fakeElement("br"), fakeElement(Img)]; const children = [
fakeElement("span", media),
fakeElement("br"),
fakeElement("span", media),
];
assert.equal(isImageOnlyParagraph(children), true); assert.equal(isImageOnlyParagraph(children), true);
}); });
test("isImageOnlyParagraph: single image returns false (needs 2+)", () => { test("isImageOnlyParagraph: single media returns false (needs 2+)", () => {
const Img = () => null; const children = [fakeElement("span", { "data-block-media": "" })];
const children = [fakeElement(Img)];
assert.equal(isImageOnlyParagraph(children), false); assert.equal(isImageOnlyParagraph(children), false);
}); });
test("isImageOnlyParagraph: images with text returns false", () => { test("isImageOnlyParagraph: media with text returns false", () => {
const Img = () => null; const media = { "data-block-media": "" };
const children = [fakeElement(Img), "caption text", fakeElement(Img)]; const children = [
fakeElement("span", media),
"caption text",
fakeElement("span", media),
];
assert.equal(isImageOnlyParagraph(children), false); assert.equal(isImageOnlyParagraph(children), false);
}); });
@@ -239,40 +253,56 @@ test("isImageOnlyParagraph: no children returns false", () => {
assert.equal(isImageOnlyParagraph([]), false); assert.equal(isImageOnlyParagraph([]), false);
}); });
test("isImageOnlyParagraph: three images returns true", () => { test("isImageOnlyParagraph: three media returns true", () => {
const Img = () => null; const media = { "data-block-media": "" };
const children = [fakeElement(Img), fakeElement(Img), fakeElement(Img)]; const children = [
fakeElement("span", media),
fakeElement("span", media),
fakeElement("span", media),
];
assert.equal(isImageOnlyParagraph(children), true); assert.equal(isImageOnlyParagraph(children), true);
}); });
test("isImageOnlyParagraph: plain HTML img tags are non-image (string type)", () => { test("isImageOnlyParagraph: plain HTML img tags without data-block-media are non-image", () => {
// <img> has type "img" (a string) — classified as non-image
const children = [fakeElement("img"), fakeElement("img")]; const children = [fakeElement("img"), fakeElement("img")];
assert.equal(isImageOnlyParagraph(children), false); assert.equal(isImageOnlyParagraph(children), false);
}); });
test("isImageOnlyParagraph: mention span + images is not image-only", () => { test("isImageOnlyParagraph: non-media component + media is not image-only", () => {
const Img = () => null; const LinkComponent = () => null;
const children = [fakeElement("span"), fakeElement(Img), fakeElement(Img)]; const media = { "data-block-media": "" };
const children = [
fakeElement(LinkComponent),
fakeElement("span", media),
fakeElement("span", media),
];
assert.equal(isImageOnlyParagraph(children), false); assert.equal(isImageOnlyParagraph(children), false);
}); });
// ── hasBlockMedia ───────────────────────────────────────────────────── // ── hasBlockMedia ─────────────────────────────────────────────────────
test("hasBlockMedia: single image component returns true", () => { test("hasBlockMedia: single media element returns true", () => {
const Img = () => null;
assert.equal(hasBlockMedia([fakeElement(Img)]), true);
});
test("hasBlockMedia: two images returns true", () => {
const Img = () => null;
assert.equal(hasBlockMedia([fakeElement(Img), fakeElement(Img)]), true);
});
test("hasBlockMedia: image with whitespace and br returns true", () => {
const Img = () => null;
assert.equal( assert.equal(
hasBlockMedia([fakeElement(Img), " ", fakeElement("br")]), hasBlockMedia([fakeElement("span", { "data-block-media": "" })]),
true,
);
});
test("hasBlockMedia: two media returns true", () => {
const media = { "data-block-media": "" };
assert.equal(
hasBlockMedia([fakeElement("span", media), fakeElement("span", media)]),
true,
);
});
test("hasBlockMedia: media with whitespace and br returns true", () => {
assert.equal(
hasBlockMedia([
fakeElement("span", { "data-block-media": "" }),
" ",
fakeElement("br"),
]),
true, true,
); );
}); });
@@ -285,15 +315,22 @@ test("hasBlockMedia: text only returns false", () => {
assert.equal(hasBlockMedia(["hello"]), false); assert.equal(hasBlockMedia(["hello"]), false);
}); });
test("hasBlockMedia: image with text returns false", () => { test("hasBlockMedia: media with text returns false", () => {
const Img = () => null; assert.equal(
assert.equal(hasBlockMedia([fakeElement(Img), "caption"]), false); hasBlockMedia([fakeElement("span", { "data-block-media": "" }), "caption"]),
false,
);
}); });
test("hasBlockMedia: plain HTML img (string type) returns false", () => { test("hasBlockMedia: plain HTML img without data-block-media returns false", () => {
assert.equal(hasBlockMedia([fakeElement("img")]), false); assert.equal(hasBlockMedia([fakeElement("img")]), false);
}); });
test("hasBlockMedia: React component without data-block-media returns false", () => {
const LinkComponent = () => null;
assert.equal(hasBlockMedia([fakeElement(LinkComponent)]), false);
});
// ── rehypeImageGallery (HAST-level grouping) ────────────────────────── // ── rehypeImageGallery (HAST-level grouping) ──────────────────────────
function hastImg(src) { function hastImg(src) {
+66 -62
View File
@@ -314,76 +314,80 @@ function createMarkdownComponents(
? rewriteRelayUrl(posterUrl) ? rewriteRelayUrl(posterUrl)
: undefined; : undefined;
return ( return (
<VideoPlayer <span data-block-media="">
key={resolvedSrc} <VideoPlayer
src={resolvedSrc} key={resolvedSrc}
poster={resolvedPoster} src={resolvedSrc}
/> poster={resolvedPoster}
/>
</span>
); );
} }
return ( return (
<ImageContextMenu src={src}> <span data-block-media="">
<DialogPrimitive.Root> <ImageContextMenu src={src}>
<DialogPrimitive.Trigger asChild> <DialogPrimitive.Root>
<div className="mt-1 max-w-sm cursor-pointer transition-opacity hover:opacity-90"> <DialogPrimitive.Trigger asChild>
<img <div className="mt-1 max-w-sm cursor-pointer transition-opacity hover:opacity-90">
alt={alt}
className="max-h-64 max-w-full rounded-xl object-contain"
src={resolvedSrc}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className="fixed inset-0 z-50 flex items-center justify-center p-8"
// Let clicks on the backdrop (the content container itself) close the lightbox
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogPrimitive.Title className="sr-only">
{alt || "Image preview"}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Full-size image preview. Press Escape or click outside the
image to close.
</DialogPrimitive.Description>
{/* Close region: clicking anywhere except the image closes the dialog */}
<DialogPrimitive.Close
className="absolute inset-0 cursor-default"
aria-label="Close lightbox"
/>
<ImageContextMenu src={src}>
<img <img
alt={alt} alt={alt}
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain" className="max-h-64 max-w-full rounded-xl object-contain"
src={resolvedSrc} src={resolvedSrc}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
/> />
</ImageContextMenu> </div>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-hidden focus:ring-2 focus:ring-white/30"> </DialogPrimitive.Trigger>
<svg <DialogPrimitive.Portal>
aria-hidden="true" <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
xmlns="http://www.w3.org/2000/svg" <DialogPrimitive.Content
width="20" className="fixed inset-0 z-50 flex items-center justify-center p-8"
height="20" // Let clicks on the backdrop (the content container itself) close the lightbox
viewBox="0 0 24 24" onPointerDownOutside={(e) => e.preventDefault()}
fill="none" onInteractOutside={(e) => e.preventDefault()}
stroke="currentColor" >
strokeWidth="2" <DialogPrimitive.Title className="sr-only">
strokeLinecap="round" {alt || "Image preview"}
strokeLinejoin="round" </DialogPrimitive.Title>
> <DialogPrimitive.Description className="sr-only">
<line x1="18" y1="6" x2="6" y2="18" /> Full-size image preview. Press Escape or click outside the
<line x1="6" y1="6" x2="18" y2="18" /> image to close.
</svg> </DialogPrimitive.Description>
<span className="sr-only">Close</span> {/* Close region: clicking anywhere except the image closes the dialog */}
</DialogPrimitive.Close> <DialogPrimitive.Close
</DialogPrimitive.Content> className="absolute inset-0 cursor-default"
</DialogPrimitive.Portal> aria-label="Close lightbox"
</DialogPrimitive.Root> />
</ImageContextMenu> <ImageContextMenu src={src}>
<img
alt={alt}
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
src={resolvedSrc}
onContextMenu={(e) => e.preventDefault()}
/>
</ImageContextMenu>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-hidden focus:ring-2 focus:ring-white/30">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</ImageContextMenu>
</span>
); );
}, },
li: ({ children }) => <li className={listItemClassName}>{children}</li>, li: ({ children }) => <li className={listItemClassName}>{children}</li>,
+20 -9
View File
@@ -1,12 +1,25 @@
import * as React from "react"; import * as React from "react";
/** /**
* Classifies an array of React children into image vs non-image buckets. * Returns true when a React element is a block-level media wrapper (image or
* Used by both the `p` component and `ImageGalleryGrouper` to detect * video). The `img` component in `createMarkdownComponents` marks its output
* image-only paragraphs for gallery rendering. * with a `data-block-media` prop so we can reliably distinguish media from
* other custom components (links, mentions, etc.) that also have non-string
* types in react-markdown v10.
*/
function isBlockMedia(child: React.ReactNode): boolean {
return (
React.isValidElement(child) &&
(child.props as Record<string, unknown>)?.["data-block-media"] != null
);
}
/**
* Classifies an array of React children into media vs non-media buckets.
* Used by the `p` component to detect image-only paragraphs for gallery
* rendering.
* *
* "Image children" = any React element whose type is not a plain HTML string * "Image children" = elements marked with `data-block-media` (images/videos).
* (i.e. a React component like DialogPrimitive.Root wrapping an img).
* "Non-image children" = everything else, excluding whitespace-only strings * "Non-image children" = everything else, excluding whitespace-only strings
* and `<br>` elements (injected by remarkBreaks between images). * and `<br>` elements (injected by remarkBreaks between images).
*/ */
@@ -14,12 +27,10 @@ export function classifyChildren(childArray: React.ReactNode[]): {
imageChildren: React.ReactNode[]; imageChildren: React.ReactNode[];
nonImageChildren: React.ReactNode[]; nonImageChildren: React.ReactNode[];
} { } {
const imageChildren = childArray.filter( const imageChildren = childArray.filter(isBlockMedia);
(child) => React.isValidElement(child) && typeof child.type !== "string",
);
const nonImageChildren = childArray.filter( const nonImageChildren = childArray.filter(
(child) => (child) =>
!(React.isValidElement(child) && typeof child.type !== "string") && !isBlockMedia(child) &&
!(typeof child === "string" && child.trim() === "") && !(typeof child === "string" && child.trim() === "") &&
!(React.isValidElement(child) && child.type === "br"), !(React.isValidElement(child) && child.type === "br"),
); );