mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix: prevent inline links rendering in 2-column grid layout (#767)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user