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) {
return { $$typeof: REACT_ELEMENT_TYPE, type, props: {}, key: null };
function fakeElement(type, props = {}) {
return { $$typeof: REACT_ELEMENT_TYPE, type, props, key: null };
}
function isBlockMedia(child) {
return isValidElement(child) && child.props?.["data-block-media"] != null;
}
function classifyChildren(childArray) {
const imageChildren = childArray.filter(
(child) => isValidElement(child) && typeof child.type !== "string",
);
const imageChildren = childArray.filter(isBlockMedia);
const nonImageChildren = childArray.filter(
(child) =>
!(isValidElement(child) && typeof child.type !== "string") &&
!isBlockMedia(child) &&
!(typeof child === "string" && child.trim() === "") &&
!(isValidElement(child) && child.type === "br"),
);
@@ -153,14 +155,21 @@ test("shallowArrayEqual: empty arrays return true", () => {
// ── classifyChildren ──────────────────────────────────────────────────
test("classifyChildren: React component elements are image children", () => {
const ImgComponent = () => null;
const children = [fakeElement(ImgComponent)];
test("classifyChildren: elements with data-block-media are image children", () => {
const children = [fakeElement("span", { "data-block-media": "" })];
const { imageChildren, nonImageChildren } = classifyChildren(children);
assert.equal(imageChildren.length, 1);
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", () => {
const children = [fakeElement("span")];
const { imageChildren, nonImageChildren } = classifyChildren(children);
@@ -189,26 +198,24 @@ test("classifyChildren: <br> elements are excluded from non-image", () => {
assert.equal(nonImageChildren.length, 0);
});
test("classifyChildren: mixed images, text, and br", () => {
const Img = () => null;
test("classifyChildren: mixed media, text, and br", () => {
const children = [
fakeElement(Img),
fakeElement("span", { "data-block-media": "" }),
"some text",
fakeElement("br"),
fakeElement(Img),
fakeElement("span", { "data-block-media": "" }),
];
const { imageChildren, nonImageChildren } = classifyChildren(children);
assert.equal(imageChildren.length, 2);
assert.equal(nonImageChildren.length, 1); // "some text"
});
test("classifyChildren: images with only whitespace and br between them", () => {
const Img = () => null;
test("classifyChildren: media with only whitespace and br between them", () => {
const children = [
fakeElement(Img),
fakeElement("span", { "data-block-media": "" }),
" ",
fakeElement("br"),
fakeElement(Img),
fakeElement("span", { "data-block-media": "" }),
];
const { imageChildren, nonImageChildren } = classifyChildren(children);
assert.equal(imageChildren.length, 2);
@@ -217,21 +224,28 @@ test("classifyChildren: images with only whitespace and br between them", () =>
// ── isImageOnlyParagraph ──────────────────────────────────────────────
test("isImageOnlyParagraph: two images with br returns true", () => {
const Img = () => null;
const children = [fakeElement(Img), fakeElement("br"), fakeElement(Img)];
test("isImageOnlyParagraph: two media with br returns true", () => {
const media = { "data-block-media": "" };
const children = [
fakeElement("span", media),
fakeElement("br"),
fakeElement("span", media),
];
assert.equal(isImageOnlyParagraph(children), true);
});
test("isImageOnlyParagraph: single image returns false (needs 2+)", () => {
const Img = () => null;
const children = [fakeElement(Img)];
test("isImageOnlyParagraph: single media returns false (needs 2+)", () => {
const children = [fakeElement("span", { "data-block-media": "" })];
assert.equal(isImageOnlyParagraph(children), false);
});
test("isImageOnlyParagraph: images with text returns false", () => {
const Img = () => null;
const children = [fakeElement(Img), "caption text", fakeElement(Img)];
test("isImageOnlyParagraph: media with text returns false", () => {
const media = { "data-block-media": "" };
const children = [
fakeElement("span", media),
"caption text",
fakeElement("span", media),
];
assert.equal(isImageOnlyParagraph(children), false);
});
@@ -239,40 +253,56 @@ test("isImageOnlyParagraph: no children returns false", () => {
assert.equal(isImageOnlyParagraph([]), false);
});
test("isImageOnlyParagraph: three images returns true", () => {
const Img = () => null;
const children = [fakeElement(Img), fakeElement(Img), fakeElement(Img)];
test("isImageOnlyParagraph: three media returns true", () => {
const media = { "data-block-media": "" };
const children = [
fakeElement("span", media),
fakeElement("span", media),
fakeElement("span", media),
];
assert.equal(isImageOnlyParagraph(children), true);
});
test("isImageOnlyParagraph: plain HTML img tags are non-image (string type)", () => {
// <img> has type "img" (a string) — classified as non-image
test("isImageOnlyParagraph: plain HTML img tags without data-block-media are non-image", () => {
const children = [fakeElement("img"), fakeElement("img")];
assert.equal(isImageOnlyParagraph(children), false);
});
test("isImageOnlyParagraph: mention span + images is not image-only", () => {
const Img = () => null;
const children = [fakeElement("span"), fakeElement(Img), fakeElement(Img)];
test("isImageOnlyParagraph: non-media component + media is not image-only", () => {
const LinkComponent = () => null;
const media = { "data-block-media": "" };
const children = [
fakeElement(LinkComponent),
fakeElement("span", media),
fakeElement("span", media),
];
assert.equal(isImageOnlyParagraph(children), false);
});
// ── hasBlockMedia ─────────────────────────────────────────────────────
test("hasBlockMedia: single image component 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;
test("hasBlockMedia: single media element returns true", () => {
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,
);
});
@@ -285,15 +315,22 @@ test("hasBlockMedia: text only returns false", () => {
assert.equal(hasBlockMedia(["hello"]), false);
});
test("hasBlockMedia: image with text returns false", () => {
const Img = () => null;
assert.equal(hasBlockMedia([fakeElement(Img), "caption"]), false);
test("hasBlockMedia: media with text returns false", () => {
assert.equal(
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);
});
test("hasBlockMedia: React component without data-block-media returns false", () => {
const LinkComponent = () => null;
assert.equal(hasBlockMedia([fakeElement(LinkComponent)]), false);
});
// ── rehypeImageGallery (HAST-level grouping) ──────────────────────────
function hastImg(src) {
+66 -62
View File
@@ -314,76 +314,80 @@ function createMarkdownComponents(
? rewriteRelayUrl(posterUrl)
: undefined;
return (
<VideoPlayer
key={resolvedSrc}
src={resolvedSrc}
poster={resolvedPoster}
/>
<span data-block-media="">
<VideoPlayer
key={resolvedSrc}
src={resolvedSrc}
poster={resolvedPoster}
/>
</span>
);
}
return (
<ImageContextMenu src={src}>
<DialogPrimitive.Root>
<DialogPrimitive.Trigger asChild>
<div className="mt-1 max-w-sm cursor-pointer transition-opacity hover:opacity-90">
<img
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}>
<span data-block-media="">
<ImageContextMenu src={src}>
<DialogPrimitive.Root>
<DialogPrimitive.Trigger asChild>
<div className="mt-1 max-w-sm cursor-pointer transition-opacity hover:opacity-90">
<img
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}
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>
</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
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>,
+20 -9
View File
@@ -1,12 +1,25 @@
import * as React from "react";
/**
* Classifies an array of React children into image vs non-image buckets.
* Used by both the `p` component and `ImageGalleryGrouper` to detect
* image-only paragraphs for gallery rendering.
* Returns true when a React element is a block-level media wrapper (image or
* video). The `img` component in `createMarkdownComponents` marks its output
* 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
* (i.e. a React component like DialogPrimitive.Root wrapping an img).
* "Image children" = elements marked with `data-block-media` (images/videos).
* "Non-image children" = everything else, excluding whitespace-only strings
* and `<br>` elements (injected by remarkBreaks between images).
*/
@@ -14,12 +27,10 @@ export function classifyChildren(childArray: React.ReactNode[]): {
imageChildren: React.ReactNode[];
nonImageChildren: React.ReactNode[];
} {
const imageChildren = childArray.filter(
(child) => React.isValidElement(child) && typeof child.type !== "string",
);
const imageChildren = childArray.filter(isBlockMedia);
const nonImageChildren = childArray.filter(
(child) =>
!(React.isValidElement(child) && typeof child.type !== "string") &&
!isBlockMedia(child) &&
!(typeof child === "string" && child.trim() === "") &&
!(React.isValidElement(child) && child.type === "br"),
);