mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
feat(markdown): add image/video preview, lightbox, and multi-image gallery (#316)
This commit is contained in:
101
desktop/src/shared/lib/rehypeImageGallery.ts
Normal file
101
desktop/src/shared/lib/rehypeImageGallery.ts
Normal 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 `\n` 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;
|
||||
};
|
||||
}
|
||||
392
desktop/src/shared/ui/markdown.test.mjs
Normal file
392
desktop/src/shared/ui/markdown.test.mjs
Normal 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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
53
desktop/src/shared/ui/markdownUtils.ts
Normal file
53
desktop/src/shared/ui/markdownUtils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user