feat(markdown): add image/video preview, lightbox, and multi-image gallery (#316)

This commit is contained in:
Taylor Ho
2026-04-14 13:57:14 -10:00
committed by GitHub
parent fe673254e6
commit 0724f19382
4 changed files with 653 additions and 27 deletions

View File

@@ -0,0 +1,101 @@
/**
* Rehype plugin that groups consecutive image-only paragraphs into a single
* merged `<p>` containing all the images. The custom `p` component in
* markdown.tsx detects 2+ images and renders them as a grid gallery.
*
* This runs at the HAST (HTML AST) level, before React rendering, so
* consecutive `![](a)\n![](b)` paragraphs get merged and the `p` component
* receives all images together.
*
* A paragraph is "image-only" when it contains only `<img>` elements
* (plus optional whitespace text nodes and `<br>` from remark processing).
*/
// Minimal HAST types — avoids adding @types/hast as a dependency.
interface HastText {
type: "text";
value: string;
}
interface HastElement {
type: "element";
tagName: string;
properties: Record<string, unknown>;
children: HastNode[];
}
type HastNode = HastElement | HastText | { type: string };
interface HastRoot {
type: "root";
children: HastNode[];
}
function isElement(node: HastNode): node is HastElement {
return node.type === "element";
}
function isText(node: HastNode): node is HastText {
return node.type === "text";
}
function isImageOnlyParagraph(node: HastNode): node is HastElement {
if (!isElement(node) || node.tagName !== "p") {
return false;
}
const meaningful = node.children.filter(
(child) =>
!(isText(child) && child.value.trim() === "") &&
!(isElement(child) && child.tagName === "br"),
);
return (
meaningful.length >= 1 &&
meaningful.every((child) => isElement(child) && child.tagName === "img")
);
}
export default function rehypeImageGallery() {
return (tree: HastRoot) => {
const newChildren: HastNode[] = [];
let imageRun: HastElement[] = [];
function flushRun() {
if (imageRun.length <= 1) {
newChildren.push(...imageRun);
} else {
// Merge consecutive single-image paragraphs into one paragraph
// containing all the images. The `p` component in markdown.tsx
// will detect 2+ images and render the grid gallery.
const allImages: HastNode[] = [];
for (const p of imageRun) {
for (const child of p.children) {
if (isElement(child) && child.tagName === "img") {
allImages.push(child);
}
}
}
newChildren.push({
type: "element",
tagName: "p",
properties: {},
children: allImages,
});
}
imageRun = [];
}
for (const child of tree.children) {
if (isImageOnlyParagraph(child)) {
imageRun.push(child);
continue;
}
flushRun();
newChildren.push(child);
}
flushRun();
tree.children = newChildren;
};
}

View File

@@ -0,0 +1,392 @@
import assert from "node:assert/strict";
import test from "node:test";
// ── Inlined pure functions from markdownUtils.ts ──────────────────────
// These are copied here to avoid importing from .ts files that depend on
// React (which isn't resolvable outside the bundler). Same pattern as
// useMediaUpload.test.mjs inlining shortHash.
function shallowArrayEqual(a, b) {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
// Minimal React.isValidElement check — real React checks $$typeof
const REACT_ELEMENT_TYPE =
Symbol.for("react.transitional.element") ?? Symbol.for("react.element");
function isValidElement(obj) {
return (
typeof obj === "object" &&
obj !== null &&
obj.$$typeof === REACT_ELEMENT_TYPE
);
}
function fakeElement(type) {
return { $$typeof: REACT_ELEMENT_TYPE, type, props: {}, key: null };
}
function classifyChildren(childArray) {
const imageChildren = childArray.filter(
(child) => isValidElement(child) && typeof child.type !== "string",
);
const nonImageChildren = childArray.filter(
(child) =>
!(isValidElement(child) && typeof child.type !== "string") &&
!(typeof child === "string" && child.trim() === "") &&
!(isValidElement(child) && child.type === "br"),
);
return { imageChildren, nonImageChildren };
}
function isImageOnlyParagraph(childArray) {
const { imageChildren, nonImageChildren } = classifyChildren(childArray);
return imageChildren.length >= 2 && nonImageChildren.length === 0;
}
function hasBlockMedia(childArray) {
const { imageChildren, nonImageChildren } = classifyChildren(childArray);
return imageChildren.length >= 1 && nonImageChildren.length === 0;
}
// ── Inlined rehypeImageGallery (HAST-level) ───────────────────────────
function isHastElement(node) {
return node && node.type === "element";
}
function isHastText(node) {
return node && node.type === "text";
}
function isHastImageOnlyParagraph(node) {
if (!isHastElement(node) || node.tagName !== "p") return false;
const meaningful = node.children.filter(
(child) =>
!(isHastText(child) && child.value.trim() === "") &&
!(isHastElement(child) && child.tagName === "br"),
);
return (
meaningful.length >= 1 &&
meaningful.every((child) => isHastElement(child) && child.tagName === "img")
);
}
function rehypeImageGallery() {
return (tree) => {
const newChildren = [];
let imageRun = [];
function flushRun() {
if (imageRun.length <= 1) {
newChildren.push(...imageRun);
} else {
const allImages = [];
for (const p of imageRun) {
for (const child of p.children) {
if (isHastElement(child) && child.tagName === "img") {
allImages.push(child);
}
}
}
newChildren.push({
type: "element",
tagName: "p",
properties: {},
children: allImages,
});
}
imageRun = [];
}
for (const child of tree.children) {
if (isHastImageOnlyParagraph(child)) {
imageRun.push(child);
continue;
}
flushRun();
newChildren.push(child);
}
flushRun();
tree.children = newChildren;
};
}
// ── shallowArrayEqual ─────────────────────────────────────────────────
test("shallowArrayEqual: identical references return true", () => {
const arr = ["a", "b"];
assert.equal(shallowArrayEqual(arr, arr), true);
});
test("shallowArrayEqual: equal arrays return true", () => {
assert.equal(shallowArrayEqual(["a", "b"], ["a", "b"]), true);
});
test("shallowArrayEqual: different values return false", () => {
assert.equal(shallowArrayEqual(["a", "b"], ["a", "c"]), false);
});
test("shallowArrayEqual: different lengths return false", () => {
assert.equal(shallowArrayEqual(["a"], ["a", "b"]), false);
});
test("shallowArrayEqual: both undefined return true", () => {
assert.equal(shallowArrayEqual(undefined, undefined), true);
});
test("shallowArrayEqual: one undefined returns false", () => {
assert.equal(shallowArrayEqual(["a"], undefined), false);
assert.equal(shallowArrayEqual(undefined, ["a"]), false);
});
test("shallowArrayEqual: empty arrays return true", () => {
assert.equal(shallowArrayEqual([], []), true);
});
// ── classifyChildren ──────────────────────────────────────────────────
test("classifyChildren: React component elements are image children", () => {
const ImgComponent = () => null;
const children = [fakeElement(ImgComponent)];
const { imageChildren, nonImageChildren } = classifyChildren(children);
assert.equal(imageChildren.length, 1);
assert.equal(nonImageChildren.length, 0);
});
test("classifyChildren: plain HTML elements are non-image children", () => {
const children = [fakeElement("span")];
const { imageChildren, nonImageChildren } = classifyChildren(children);
assert.equal(imageChildren.length, 0);
assert.equal(nonImageChildren.length, 1);
});
test("classifyChildren: text strings are non-image children", () => {
const children = ["hello world"];
const { imageChildren, nonImageChildren } = classifyChildren(children);
assert.equal(imageChildren.length, 0);
assert.equal(nonImageChildren.length, 1);
});
test("classifyChildren: whitespace-only strings are excluded from both", () => {
const children = [" ", "\n"];
const { imageChildren, nonImageChildren } = classifyChildren(children);
assert.equal(imageChildren.length, 0);
assert.equal(nonImageChildren.length, 0);
});
test("classifyChildren: <br> elements are excluded from non-image", () => {
const children = [fakeElement("br")];
const { imageChildren, nonImageChildren } = classifyChildren(children);
assert.equal(imageChildren.length, 0);
assert.equal(nonImageChildren.length, 0);
});
test("classifyChildren: mixed images, text, and br", () => {
const Img = () => null;
const children = [
fakeElement(Img),
"some text",
fakeElement("br"),
fakeElement(Img),
];
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;
const children = [
fakeElement(Img),
" ",
fakeElement("br"),
fakeElement(Img),
];
const { imageChildren, nonImageChildren } = classifyChildren(children);
assert.equal(imageChildren.length, 2);
assert.equal(nonImageChildren.length, 0);
});
// ── isImageOnlyParagraph ──────────────────────────────────────────────
test("isImageOnlyParagraph: two images with br returns true", () => {
const Img = () => null;
const children = [fakeElement(Img), fakeElement("br"), fakeElement(Img)];
assert.equal(isImageOnlyParagraph(children), true);
});
test("isImageOnlyParagraph: single image returns false (needs 2+)", () => {
const Img = () => null;
const children = [fakeElement(Img)];
assert.equal(isImageOnlyParagraph(children), false);
});
test("isImageOnlyParagraph: images with text returns false", () => {
const Img = () => null;
const children = [fakeElement(Img), "caption text", fakeElement(Img)];
assert.equal(isImageOnlyParagraph(children), false);
});
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)];
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
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)];
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;
assert.equal(
hasBlockMedia([fakeElement(Img), " ", fakeElement("br")]),
true,
);
});
test("hasBlockMedia: no children returns false", () => {
assert.equal(hasBlockMedia([]), false);
});
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: plain HTML img (string type) returns false", () => {
assert.equal(hasBlockMedia([fakeElement("img")]), false);
});
// ── rehypeImageGallery (HAST-level grouping) ──────────────────────────
function hastImg(src) {
return { type: "element", tagName: "img", properties: { src }, children: [] };
}
function hastP(...children) {
return { type: "element", tagName: "p", properties: {}, children };
}
function hastText(value) {
return { type: "text", value };
}
test("rehypeImageGallery: merges two consecutive single-image paragraphs", () => {
const tree = {
type: "root",
children: [hastP(hastImg("a.png")), hastP(hastImg("b.png"))],
};
rehypeImageGallery()(tree);
assert.equal(tree.children.length, 1);
assert.equal(tree.children[0].tagName, "p");
assert.equal(tree.children[0].children.length, 2);
assert.equal(tree.children[0].children[0].properties.src, "a.png");
assert.equal(tree.children[0].children[1].properties.src, "b.png");
});
test("rehypeImageGallery: three consecutive images merge into one paragraph", () => {
const tree = {
type: "root",
children: [
hastP(hastImg("a.png")),
hastP(hastImg("b.png")),
hastP(hastImg("c.png")),
],
};
rehypeImageGallery()(tree);
assert.equal(tree.children.length, 1);
assert.equal(tree.children[0].children.length, 3);
});
test("rehypeImageGallery: single image paragraph is not grouped", () => {
const tree = {
type: "root",
children: [hastP(hastImg("a.png"))],
};
rehypeImageGallery()(tree);
assert.equal(tree.children.length, 1);
// Still the original single-image paragraph
assert.equal(tree.children[0].children.length, 1);
});
test("rehypeImageGallery: text paragraph breaks image run", () => {
const tree = {
type: "root",
children: [
hastP(hastImg("a.png")),
hastP(hastText("hello")),
hastP(hastImg("b.png")),
],
};
rehypeImageGallery()(tree);
assert.equal(tree.children.length, 3);
// Each stays separate — text paragraph broke the run
assert.equal(tree.children[0].children[0].properties.src, "a.png");
assert.equal(tree.children[1].children[0].value, "hello");
assert.equal(tree.children[2].children[0].properties.src, "b.png");
});
test("rehypeImageGallery: ignores whitespace and br in image paragraphs", () => {
const br = { type: "element", tagName: "br", properties: {}, children: [] };
const tree = {
type: "root",
children: [
hastP(hastImg("a.png"), hastText(" "), br),
hastP(hastImg("b.png")),
],
};
rehypeImageGallery()(tree);
assert.equal(tree.children.length, 1);
assert.equal(tree.children[0].children.length, 2);
});
test("rehypeImageGallery: mixed content paragraph is not image-only", () => {
const tree = {
type: "root",
children: [
hastP(hastImg("a.png")),
hastP(hastText("Look: "), hastImg("b.png")),
hastP(hastImg("c.png")),
],
};
rehypeImageGallery()(tree);
// Middle paragraph has text, so it breaks the run
assert.equal(tree.children.length, 3);
});

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
@@ -8,9 +9,17 @@ import type { Channel } from "@/shared/api/types";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import rehypeImageGallery from "@/shared/lib/rehypeImageGallery";
import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
import remarkMentions from "@/shared/lib/remarkMentions";
import {
classifyChildren,
hasBlockMedia,
isImageOnlyParagraph,
shallowArrayEqual,
} from "./markdownUtils";
type ImetaLookup = Map<string, { image?: string; thumb?: string }>;
type MarkdownProps = {
@@ -120,29 +129,103 @@ function createMarkdownComponents(
? rewriteRelayUrl(posterUrl)
: undefined;
return (
// biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available
<video
controls
preload="metadata"
poster={resolvedPoster}
className="max-h-96 rounded-2xl border border-border/70"
src={resolvedSrc}
/>
<div className="mt-3 flex max-w-sm items-center justify-center overflow-hidden rounded-2xl border border-border/70 bg-muted/40">
{/* biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available */}
<video
controls
preload="metadata"
poster={resolvedPoster}
className="max-h-64 max-w-full object-contain"
src={resolvedSrc}
/>
</div>
);
}
return (
<img
alt={alt}
className="max-h-96 rounded-2xl border border-border/70 object-cover"
src={resolvedSrc}
/>
<DialogPrimitive.Root>
<DialogPrimitive.Trigger asChild>
<div className="mt-3 flex max-w-sm cursor-pointer items-center justify-center overflow-hidden rounded-2xl border border-border/70 bg-muted/40 transition-opacity hover:opacity-90">
<img
alt={alt}
className="max-h-64 max-w-full object-contain"
src={resolvedSrc}
/>
</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"
/>
<img
alt={alt}
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
src={resolvedSrc}
/>
<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-none 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>
);
},
li: ({ children }) => <li className={listItemClassName}>{children}</li>,
ol: ({ children }) => (
<ol className={cn("list-decimal", listClassName)}>{children}</ol>
),
p: ({ children }) => <p className={paragraphClassName}>{children}</p>,
p: ({ children }) => {
// Detect image-only paragraphs (images + <br> from remarkBreaks).
// Multi-image: render as a 2-column grid gallery.
// Single media: render as a plain <div> to avoid invalid <p><div> nesting
// (the img component returns block-level wrappers for lightbox/video).
const childArray = React.Children.toArray(children);
const { imageChildren } = classifyChildren(childArray);
if (isImageOnlyParagraph(childArray)) {
return (
<div className="mt-3 grid max-w-lg grid-cols-2 gap-1.5 [&_br]:hidden [&_div]:mt-0 [&_div]:max-w-none">
{imageChildren}
</div>
);
}
if (hasBlockMedia(childArray)) {
return <div>{children}</div>;
}
return <p className={paragraphClassName}>{children}</p>;
},
pre: ({ children }) => (
<pre className="overflow-x-auto rounded-2xl border border-border/70 bg-muted/60 px-4 py-3 shadow-sm">
{children}
@@ -172,7 +255,7 @@ function createMarkdownComponents(
<ul className={cn("list-disc", listClassName)}>{children}</ul>
),
mention: ({ children }: { children?: React.ReactNode }) => (
<span className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-medium text-primary">
<span className="rounded-md bg-primary/15 px-1 py-0.5 text-sm text-primary">
{children}
</span>
),
@@ -201,7 +284,7 @@ function createMarkdownComponents(
}
return (
<span className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-medium text-primary">
<span className="rounded-md bg-primary/15 px-1 py-0.5 text-sm text-primary">
{children}
</span>
);
@@ -209,16 +292,6 @@ function createMarkdownComponents(
} as Components;
}
function shallowArrayEqual(a?: string[], b?: string[]): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
function MarkdownInner({
channelNames,
className,
@@ -260,6 +333,9 @@ function MarkdownInner({
[mentionNames, channelNames],
);
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
const rehypePlugins = React.useMemo<any[]>(() => [rehypeImageGallery], []);
let processedContent = content;
if (/^(?:\s{2}\n)+/.test(content)) {
@@ -281,7 +357,11 @@ function MarkdownInner({
className,
)}
>
<ReactMarkdown components={components} remarkPlugins={remarkPlugins}>
<ReactMarkdown
components={components}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
>
{processedContent}
</ReactMarkdown>
</div>

View File

@@ -0,0 +1,53 @@
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.
*
* "Image children" = any React element whose type is not a plain HTML string
* (i.e. a React component like DialogPrimitive.Root wrapping an img).
* "Non-image children" = everything else, excluding whitespace-only strings
* and `<br>` elements (injected by remarkBreaks between images).
*/
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 nonImageChildren = childArray.filter(
(child) =>
!(React.isValidElement(child) && typeof child.type !== "string") &&
!(typeof child === "string" && child.trim() === "") &&
!(React.isValidElement(child) && child.type === "br"),
);
return { imageChildren, nonImageChildren };
}
/** Returns true when a paragraph contains 2+ images and no other content. */
export function isImageOnlyParagraph(childArray: React.ReactNode[]): boolean {
const { imageChildren, nonImageChildren } = classifyChildren(childArray);
return imageChildren.length >= 2 && nonImageChildren.length === 0;
}
/**
* Returns true when a paragraph contains block-level media (1+ image/video
* component) and no meaningful text content. These paragraphs must render as
* `<div>` instead of `<p>` to avoid invalid `<p><div>` nesting.
*/
export function hasBlockMedia(childArray: React.ReactNode[]): boolean {
const { imageChildren, nonImageChildren } = classifyChildren(childArray);
return imageChildren.length >= 1 && nonImageChildren.length === 0;
}
export function shallowArrayEqual(a?: string[], b?: string[]): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}