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) {
|
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) {
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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"),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user