fix(desktop): restore multi-image mosaic galleries (#1769)

Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Fizz <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
klopez4212
2026-07-12 09:14:59 -07:00
committed by GitHub
co-authored by npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
parent 5dc70bd0b8
commit 2c41e9e6b8
6 changed files with 1002 additions and 353 deletions
+54 -6
View File
@@ -1,7 +1,7 @@
/**
* 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.
* markdown.tsx detects 2+ images and renders them as an adaptive mosaic.
*
* This runs at the HAST (HTML AST) level, before React rendering, so
* consecutive `![](a)\n![](b)` paragraphs get merged and the `p` component
@@ -39,15 +39,20 @@ function isText(node: HastNode): node is HastText {
return node.type === "text";
}
function isIgnorableImageSeparator(node: HastNode): boolean {
return (
(isText(node) && node.value.trim() === "") ||
(isElement(node) && node.tagName === "br")
);
}
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"),
(child) => !isIgnorableImageSeparator(child),
);
return (
@@ -56,8 +61,51 @@ function isImageOnlyParagraph(node: HastNode): node is HastElement {
);
}
/**
* Composer attachments are appended with soft line breaks, so a post with text
* and multiple images initially arrives as one mixed paragraph:
* `text<br><img><br><img>`. Split that trailing image run into its own paragraph
* so it can use the same gallery path as image-only Markdown.
*
* Only a trailing run of 2+ images is split. A lone inline image and images
* separated by meaningful content retain their original Markdown flow.
*/
function splitTrailingImageRun(node: HastNode): HastNode[] {
if (!isElement(node) || node.tagName !== "p") return [node];
let cursor = node.children.length - 1;
const trailingImages: HastElement[] = [];
while (cursor >= 0) {
const child = node.children[cursor];
if (isElement(child) && child.tagName === "img") {
trailingImages.unshift(child);
cursor -= 1;
continue;
}
if (isIgnorableImageSeparator(child)) {
cursor -= 1;
continue;
}
break;
}
if (trailingImages.length < 2 || cursor < 0) return [node];
return [
{ ...node, children: node.children.slice(0, cursor + 1) },
{
type: "element",
tagName: "p",
properties: {},
children: trailingImages,
},
];
}
export default function rehypeImageGallery() {
return (tree: HastRoot) => {
const normalizedChildren = tree.children.flatMap(splitTrailingImageRun);
const newChildren: HastNode[] = [];
let imageRun: HastElement[] = [];
@@ -67,7 +115,7 @@ export default function rehypeImageGallery() {
} 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.
// will detect 2+ images and render the mosaic gallery.
const allImages: HastNode[] = [];
for (const p of imageRun) {
for (const child of p.children) {
@@ -86,7 +134,7 @@ export default function rehypeImageGallery() {
imageRun = [];
}
for (const child of tree.children) {
for (const child of normalizedChildren) {
if (isImageOnlyParagraph(child)) {
imageRun.push(child);
continue;
+109 -5
View File
@@ -41,7 +41,10 @@ function classifyChildren(childArray) {
(child) =>
!isBlockMedia(child) &&
!(typeof child === "string" && child.trim() === "") &&
!(isValidElement(child) && child.type === "br"),
!(
isValidElement(child) &&
(child.type === "br" || child.props?.node?.tagName === "br")
),
);
return { imageChildren, nonImageChildren };
}
@@ -67,9 +70,7 @@ function isHastText(node) {
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"),
(child) => !isIgnorableImageSeparator(child),
);
return (
meaningful.length >= 1 &&
@@ -77,8 +78,47 @@ function isHastImageOnlyParagraph(node) {
);
}
function isIgnorableImageSeparator(node) {
return (
(isHastText(node) && node.value.trim() === "") ||
(isHastElement(node) && node.tagName === "br")
);
}
function splitTrailingImageRun(node) {
if (!isHastElement(node) || node.tagName !== "p") return [node];
let cursor = node.children.length - 1;
const trailingImages = [];
while (cursor >= 0) {
const child = node.children[cursor];
if (isHastElement(child) && child.tagName === "img") {
trailingImages.unshift(child);
cursor -= 1;
continue;
}
if (isIgnorableImageSeparator(child)) {
cursor -= 1;
continue;
}
break;
}
if (trailingImages.length < 2 || cursor < 0) return [node];
return [
{ ...node, children: node.children.slice(0, cursor + 1) },
{
type: "element",
tagName: "p",
properties: {},
children: trailingImages,
},
];
}
function rehypeImageGallery() {
return (tree) => {
const normalizedChildren = tree.children.flatMap(splitTrailingImageRun);
const newChildren = [];
let imageRun = [];
@@ -104,7 +144,7 @@ function rehypeImageGallery() {
imageRun = [];
}
for (const child of tree.children) {
for (const child of normalizedChildren) {
if (isHastImageOnlyParagraph(child)) {
imageRun.push(child);
continue;
@@ -191,6 +231,32 @@ test("classifyChildren: <br> elements are excluded from non-image", () => {
assert.equal(nonImageChildren.length, 0);
});
test("classifyChildren: react-markdown break components are excluded", () => {
const BreakComponent = () => null;
const children = [
fakeElement(BreakComponent, { node: { type: "element", tagName: "br" } }),
];
const { imageChildren, nonImageChildren } = classifyChildren(children);
assert.equal(imageChildren.length, 0);
assert.equal(nonImageChildren.length, 0);
});
test("isImageOnlyParagraph: react-markdown breaks preserve image mosaics", () => {
const BreakComponent = () => null;
const media = { "data-block-media": "" };
const customBreak = fakeElement(BreakComponent, {
node: { type: "element", tagName: "br" },
});
const children = [
fakeElement("span", media),
customBreak,
fakeElement("span", media),
customBreak,
fakeElement("span", media),
];
assert.equal(isImageOnlyParagraph(children), true);
});
test("classifyChildren: mixed media, text, and br", () => {
const children = [
fakeElement("span", { "data-block-media": "" }),
@@ -415,6 +481,44 @@ test("rehypeImageGallery: mixed content paragraph is not image-only", () => {
assert.equal(tree.children.length, 3);
});
test("rehypeImageGallery: splits composer text from trailing image bundle", () => {
const br = { type: "element", tagName: "br", properties: {}, children: [] };
const tree = {
type: "root",
children: [
hastP(
hastText("gallery bundle"),
br,
hastImg("a.png"),
br,
hastImg("b.png"),
br,
hastImg("c.png"),
),
],
};
rehypeImageGallery()(tree);
assert.equal(tree.children.length, 2);
assert.equal(tree.children[0].children[0].value, "gallery bundle");
assert.deepEqual(
tree.children[1].children.map((child) => child.properties.src),
["a.png", "b.png", "c.png"],
);
});
test("rehypeImageGallery: leaves a single trailing image in the text flow", () => {
const br = { type: "element", tagName: "br", properties: {}, children: [] };
const paragraph = hastP(hastText("caption"), br, hastImg("a.png"));
const tree = { type: "root", children: [paragraph] };
rehypeImageGallery()(tree);
assert.equal(tree.children.length, 1);
assert.equal(tree.children[0], paragraph);
});
// Regression test: react-markdown's `defaultUrlTransform` strips unknown
// schemes (returns `""`) before our `a` component override can see them,
// which would break copy → paste → click for `buzz://message?…` links
+154 -320
View File
@@ -64,6 +64,45 @@ import {
import { FileCard } from "./markdown/FileCard";
import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover";
import { MarkdownInput } from "./markdown/MarkdownInput";
import {
clampImageLightboxZoom,
type ImageGalleryDirection,
type ImageGalleryItem,
type ImageLightboxBox,
type ImageLightboxCornerRadii,
IMAGE_LIGHTBOX_CONTROL_SUPPRESS_CLOSE_MS,
IMAGE_LIGHTBOX_EASE_IN_OUT,
IMAGE_LIGHTBOX_EASE_OUT,
IMAGE_LIGHTBOX_ENTER_MS,
IMAGE_LIGHTBOX_EXIT_MS,
IMAGE_LIGHTBOX_FADE_ENTER_MS,
IMAGE_LIGHTBOX_FADE_EXIT_MS,
IMAGE_LIGHTBOX_GALLERY_BLUR_PX,
IMAGE_LIGHTBOX_GALLERY_EASE,
IMAGE_LIGHTBOX_GALLERY_SLIDE_DISTANCE_PX,
IMAGE_LIGHTBOX_GALLERY_SLIDE_MS,
IMAGE_LIGHTBOX_MAX_ZOOM,
IMAGE_LIGHTBOX_MIN_ZOOM,
IMAGE_LIGHTBOX_REDUCED_MOTION_MS,
IMAGE_LIGHTBOX_TRACKPAD_ZOOM_IDLE_MS,
IMAGE_LIGHTBOX_WHEEL_ZOOM_MAX_DELTA,
IMAGE_LIGHTBOX_WHEEL_ZOOM_SPEED,
IMAGE_LIGHTBOX_ZOOM_STEP,
IMAGE_LIGHTBOX_ZOOM_TRANSITION_MS,
imageLightboxBasisBoxForItem,
imageLightboxBoxFromRect,
imageLightboxCornerRadiiFromElement,
imageLightboxCornerRadiiStyle,
imageLightboxExpandedCornerRadii,
imageLightboxReturnTargetForItem,
imageLightboxSourceScopeForTrigger,
imageLightboxStyle,
imageLightboxTargetBox,
imageLightboxTransform,
imageLightboxZoomBox,
normalizedWheelDeltaY,
visibleImageGalleryForTrigger,
} from "./markdown/imageLightbox";
import { MarkdownTable } from "./markdown/MarkdownTable";
import { MaskedLinkTooltip } from "./markdown/MaskedLinkTooltip";
import { MessageLinkPill } from "./markdown/MessageLinkPill";
@@ -76,8 +115,6 @@ import { resolveFileCard } from "./markdownFileCard";
import type { MarkdownProps, MarkdownRuntime } from "./markdown/types";
import { SpoilerInline } from "./markdown/SpoilerInline";
import {
dimensionsFromDim,
getDecodedImageDimensions,
imageReserveStyle,
isInsideHiddenSpoiler,
getReactNodeText,
@@ -90,23 +127,6 @@ import {
VideoReviewMarkdownContext,
} from "./markdown/MarkdownVideoPlayer";
type ImageLightboxBox = {
height: number;
left: number;
top: number;
width: number;
};
type ImageGalleryDirection = "forward" | "backward";
type ImageGalleryItem = {
alt: string | undefined;
dim?: string;
resolvedSrc: string;
src: string | undefined;
thumbnailBox?: ImageLightboxBox;
};
type ImageBlockProps = {
alt: string | undefined;
dim?: string;
@@ -114,285 +134,6 @@ type ImageBlockProps = {
src: string | undefined;
};
const IMAGE_LIGHTBOX_ENTER_MS = 260;
const IMAGE_LIGHTBOX_EXIT_MS = 170;
const IMAGE_LIGHTBOX_FADE_ENTER_MS = 180;
const IMAGE_LIGHTBOX_FADE_EXIT_MS = 90;
const IMAGE_LIGHTBOX_GALLERY_SLIDE_MS = 280;
const IMAGE_LIGHTBOX_GALLERY_SLIDE_DISTANCE_PX = 48;
const IMAGE_LIGHTBOX_GALLERY_BLUR_PX = 4;
const IMAGE_LIGHTBOX_REDUCED_MOTION_MS = 100;
const IMAGE_LIGHTBOX_ZOOM_TRANSITION_MS = 80;
const IMAGE_LIGHTBOX_BASE_VIEWPORT_RATIO = 0.8;
const IMAGE_LIGHTBOX_CONTROL_SUPPRESS_CLOSE_MS = 450;
const IMAGE_LIGHTBOX_TRACKPAD_ZOOM_IDLE_MS = 120;
const IMAGE_LIGHTBOX_WHEEL_ZOOM_SPEED = 0.002;
const IMAGE_LIGHTBOX_WHEEL_ZOOM_MAX_DELTA = 0.2;
const IMAGE_LIGHTBOX_MIN_ZOOM = 1;
const IMAGE_LIGHTBOX_MAX_ZOOM = 3;
const IMAGE_LIGHTBOX_ZOOM_STEP = 0.05;
const IMAGE_LIGHTBOX_EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
const IMAGE_LIGHTBOX_EASE_IN_OUT = "cubic-bezier(0.77, 0, 0.175, 1)";
const IMAGE_LIGHTBOX_GALLERY_EASE: [number, number, number, number] = [
0.22, 1, 0.36, 1,
];
const IMAGE_LIGHTBOX_MARKDOWN_SCOPE_SELECTOR = `.${MESSAGE_MARKDOWN_CLASS}`;
function imageLightboxBoxFromRect(rect: DOMRect): ImageLightboxBox {
return {
height: rect.height,
left: rect.left,
top: rect.top,
width: rect.width,
};
}
function imageLightboxTargetBox(sourceBox: ImageLightboxBox): ImageLightboxBox {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const horizontalPadding = Math.min(80, Math.max(16, viewportWidth * 0.0625));
const verticalPadding = Math.min(24, Math.max(16, viewportHeight * 0.033));
const maxWidth = Math.max(
1,
Math.min(
viewportWidth - horizontalPadding * 2,
viewportWidth * IMAGE_LIGHTBOX_BASE_VIEWPORT_RATIO,
),
);
const maxHeight = Math.max(
1,
Math.min(
viewportHeight - verticalPadding * 2,
viewportHeight * IMAGE_LIGHTBOX_BASE_VIEWPORT_RATIO,
),
);
const scale = Math.min(
maxWidth / sourceBox.width,
maxHeight / sourceBox.height,
);
const width = Math.max(1, sourceBox.width * scale);
const height = Math.max(1, sourceBox.height * scale);
return {
height,
left: (viewportWidth - width) / 2,
top: (viewportHeight - height) / 2,
width,
};
}
function imageLightboxStyle(box: ImageLightboxBox): React.CSSProperties {
return {
height: `${box.height}px`,
left: `${box.left}px`,
top: `${box.top}px`,
width: `${box.width}px`,
};
}
function clampImageLightboxZoom(value: number): number {
return Math.min(
IMAGE_LIGHTBOX_MAX_ZOOM,
Math.max(IMAGE_LIGHTBOX_MIN_ZOOM, value),
);
}
function normalizedWheelDeltaY(event: WheelEvent): number {
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
return event.deltaY * 16;
}
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
return event.deltaY * window.innerHeight;
}
return event.deltaY;
}
function imageLightboxTransform(
sourceBox: ImageLightboxBox,
targetBox: ImageLightboxBox,
): string {
const scaleX = targetBox.width / Math.max(1, sourceBox.width);
const scaleY = targetBox.height / Math.max(1, sourceBox.height);
const translateX = targetBox.left - sourceBox.left;
const translateY = targetBox.top - sourceBox.top;
return `translate3d(${translateX}px, ${translateY}px, 0) scale(${scaleX}, ${scaleY})`;
}
function imageLightboxZoomBox(
targetBox: ImageLightboxBox,
zoom: number,
): ImageLightboxBox {
const width = targetBox.width * zoom;
const height = targetBox.height * zoom;
return {
height,
left: targetBox.left + (targetBox.width - width) / 2,
top: targetBox.top + (targetBox.height - height) / 2,
width,
};
}
function imageLightboxBasisBoxForItem(
item: ImageGalleryItem,
fallbackBox: ImageLightboxBox,
): ImageLightboxBox {
const dimensions =
dimensionsFromDim(item.dim) ?? getDecodedImageDimensions(item.resolvedSrc);
if (!dimensions) {
return item.thumbnailBox ?? fallbackBox;
}
return {
...fallbackBox,
height: dimensions.height,
width: dimensions.width,
};
}
function imageLightboxThumbnailBoxForItem(
item: ImageGalleryItem,
sourceScope: Element | null | undefined,
): ImageLightboxBox | null {
const root = sourceScope?.isConnected ? sourceScope : document.body;
const triggers = Array.from(
root.querySelectorAll<HTMLElement>("[data-image-lightbox-trigger]"),
);
for (const trigger of triggers) {
const isCurrentItem =
trigger.dataset.imageLightboxResolvedSrc === item.resolvedSrc ||
(item.src != null && trigger.dataset.imageLightboxSrc === item.src);
if (!isCurrentItem) {
continue;
}
const image = trigger.querySelector("img");
const rect = (image ?? trigger).getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
return imageLightboxBoxFromRect(rect);
}
}
return null;
}
function imageLightboxReturnBoxForItem(
item: ImageGalleryItem,
fallbackBox: ImageLightboxBox,
sourceScope: Element | null | undefined,
): ImageLightboxBox {
return (
imageLightboxThumbnailBoxForItem(item, sourceScope) ??
item.thumbnailBox ??
fallbackBox
);
}
function imageLightboxSourceScopeForTrigger(
trigger: HTMLElement,
): Element | null {
return (
trigger.closest(IMAGE_LIGHTBOX_MARKDOWN_SCOPE_SELECTOR) ??
trigger.closest("[data-testid='message-row']")
);
}
function imageGalleryItemFromTrigger(
trigger: HTMLElement,
thumbnailBox?: ImageLightboxBox,
): ImageGalleryItem | null {
const resolvedSrc = trigger.dataset.imageLightboxResolvedSrc;
if (!resolvedSrc) {
return null;
}
return {
alt: trigger.dataset.imageLightboxAlt || undefined,
dim: trigger.dataset.imageLightboxDim || undefined,
resolvedSrc,
src: trigger.dataset.imageLightboxSrc || undefined,
thumbnailBox,
};
}
function isVisibleImageLightboxTrigger(trigger: HTMLElement): boolean {
if (isInsideHiddenSpoiler(trigger)) {
return false;
}
const image = trigger.querySelector("img");
for (const element of [trigger, image]) {
if (!element) {
continue;
}
const style = window.getComputedStyle(element);
if (
style.display === "none" ||
style.visibility === "hidden" ||
Number(style.opacity) === 0
) {
return false;
}
}
return true;
}
function visibleImageGalleryForTrigger(
trigger: HTMLElement,
fallbackItem: ImageGalleryItem,
sourceScope: Element | null | undefined,
): { galleryIndex: number; galleryItems?: ImageGalleryItem[] } {
const root = sourceScope?.isConnected ? sourceScope : null;
const triggers = root
? Array.from(
root.querySelectorAll<HTMLElement>("[data-image-lightbox-trigger]"),
)
: [trigger];
const galleryItems: ImageGalleryItem[] = [];
let galleryIndex = 0;
let foundCurrentTrigger = false;
for (const candidate of triggers) {
if (!isVisibleImageLightboxTrigger(candidate)) {
continue;
}
const image = candidate.querySelector("img");
const rect = (image ?? candidate).getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
const thumbnailBox = imageLightboxBoxFromRect(rect);
const item = imageGalleryItemFromTrigger(candidate, thumbnailBox);
if (!item) {
continue;
}
if (candidate === trigger) {
galleryIndex = galleryItems.length;
foundCurrentTrigger = true;
}
galleryItems.push(item);
}
if (!foundCurrentTrigger) {
galleryItems.unshift(fallbackItem);
galleryIndex = 0;
}
return {
galleryIndex,
galleryItems: galleryItems.length > 1 ? galleryItems : undefined,
};
}
type WebKitGestureLikeEvent = Event & {
scale?: number;
};
@@ -455,21 +196,24 @@ function useDismissImageContextMenu(isOpen: boolean, onDismiss: () => void) {
function ImageContextMenu({
onCopy,
onDownload,
portalContainer,
position,
}: {
onCopy: () => void;
onDownload: () => void;
portalContainer?: Element;
position: ImageContextMenuPosition;
}) {
const itemClass =
"flex min-h-9 w-full cursor-default select-none items-center rounded-lg py-2 pl-2 pr-4 text-sm outline-hidden hover:bg-muted/50 hover:text-foreground";
return (
return createPortal(
<div
className={cn(
"fixed z-[100] min-w-60 origin-top-left rounded-xl p-1 slide-in-from-top-1",
POPOVER_CUSTOM_ENTER_MOTION_CLASS,
POPOVER_SURFACE_CLASS,
)}
data-image-context-menu=""
data-image-lightbox-controls=""
style={{ ...POPOVER_SHADOW_STYLE, left: position.x, top: position.y }}
>
@@ -479,7 +223,8 @@ function ImageContextMenu({
<button type="button" className={itemClass} onClick={onDownload}>
Download image
</button>
</div>
</div>,
portalContainer ?? document.body,
);
}
@@ -492,6 +237,7 @@ function ImageZoomOverlay({
onClose,
resolvedSrc,
sourceBox,
sourceCornerRadii,
sourceScope,
src,
}: {
@@ -503,14 +249,23 @@ function ImageZoomOverlay({
onClose: () => void;
resolvedSrc: string;
sourceBox: ImageLightboxBox;
sourceCornerRadii: ImageLightboxCornerRadii;
sourceScope?: Element | null;
src: string | undefined;
}) {
const shouldReduceMotion = useReducedMotion();
const prefersReducedMotion = shouldReduceMotion === true;
const fallbackGalleryItems = React.useMemo<ImageGalleryItem[]>(
() => [{ alt, resolvedSrc, src, thumbnailBox: sourceBox }],
[alt, resolvedSrc, sourceBox, src],
() => [
{
alt,
resolvedSrc,
src,
thumbnailBox: sourceBox,
thumbnailCornerRadii: sourceCornerRadii,
},
],
[alt, resolvedSrc, sourceBox, sourceCornerRadii, src],
);
const items =
galleryItems && galleryItems.length > 0
@@ -524,6 +279,7 @@ function ImageZoomOverlay({
const [phase, setPhase] = React.useState<
"opening" | "open" | "closing" | "fading"
>(() => (prefersReducedMotion ? "open" : "opening"));
const isReturning = phase === "closing" || phase === "fading";
const [hasEntered, setHasEntered] = React.useState(prefersReducedMotion);
const [isAdjustingZoom, setIsAdjustingZoom] = React.useState(false);
const [isGalleryNavigating, setIsGalleryNavigating] = React.useState(false);
@@ -537,6 +293,8 @@ function ImageZoomOverlay({
imageLightboxTargetBox(basisBox),
);
const [returnBox, setReturnBox] = React.useState(sourceBox);
const [returnCornerRadii, setReturnCornerRadii] =
React.useState(sourceCornerRadii);
const [zoom, setZoom] = React.useState(IMAGE_LIGHTBOX_MIN_ZOOM);
const controlPointerDownRef = React.useRef(false);
const fadeTimerRef = React.useRef<number | null>(null);
@@ -615,9 +373,14 @@ function ImageZoomOverlay({
galleryTransitionTimerRef.current = null;
}
setIsGalleryNavigating(false);
setReturnBox(
imageLightboxReturnBoxForItem(currentItem, sourceBox, sourceScope),
const returnTarget = imageLightboxReturnTargetForItem(
currentItem,
sourceBox,
sourceCornerRadii,
sourceScope,
);
setReturnBox(returnTarget.box);
setReturnCornerRadii(returnTarget.cornerRadii);
if (prefersReducedMotion) {
setPhase("fading");
@@ -634,7 +397,14 @@ function ImageZoomOverlay({
closeTimerRef.current = window.setTimeout(() => {
onClose();
}, IMAGE_LIGHTBOX_EXIT_MS + IMAGE_LIGHTBOX_FADE_EXIT_MS);
}, [currentItem, onClose, prefersReducedMotion, sourceBox, sourceScope]);
}, [
currentItem,
onClose,
prefersReducedMotion,
sourceBox,
sourceCornerRadii,
sourceScope,
]);
const navigateGallery = React.useCallback(
(nextIndex: number) => {
@@ -945,9 +715,11 @@ function ImageZoomOverlay({
const isClosing = phase === "closing";
const isOpen = phase === "open";
const isFading = phase === "fading";
const isReturning = isClosing || isFading;
const displayBox = imageLightboxZoomBox(targetBox, zoom);
const frameBox = isReturning ? returnBox : targetBox;
const frameCornerRadii = isReturning
? returnCornerRadii
: imageLightboxExpandedCornerRadii();
// Once fully settled at 1x, drop the transform to `none` so the wrapper
// leaves the GPU-composited path and the <img> repaints through WebKit's
// high-quality paint rasterizer — matching inline-image sharpness. An
@@ -970,7 +742,7 @@ function ImageZoomOverlay({
const imageTransitionProperty = prefersReducedMotion
? "opacity"
: isReturning
? "height, left, opacity, top, transform, width"
? "border-radius, height, left, opacity, top, transform, width"
: atRest
? "opacity"
: "opacity, transform";
@@ -1089,6 +861,7 @@ function ImageZoomOverlay({
)}
style={{
...imageLightboxStyle(frameBox),
...imageLightboxCornerRadiiStyle(frameCornerRadii),
opacity: prefersReducedMotion && isReturning ? 0 : 1,
transform,
transitionDuration: `${imageTransitionDuration}ms`,
@@ -1101,10 +874,28 @@ function ImageZoomOverlay({
: IMAGE_LIGHTBOX_EASE_OUT,
}}
>
<div className="relative h-full w-full rounded-2xl shadow-2xl">
<div
className="relative h-full w-full shadow-2xl"
style={{
...imageLightboxCornerRadiiStyle(frameCornerRadii),
transitionDuration: `${imageTransitionDuration}ms`,
transitionProperty: isReturning ? "border-radius" : "none",
transitionTimingFunction: isClosing
? IMAGE_LIGHTBOX_EASE_IN_OUT
: IMAGE_LIGHTBOX_EASE_OUT,
}}
>
<div
ref={imageFrameSurfaceRef}
className="relative h-full w-full overflow-hidden rounded-2xl"
className="relative h-full w-full overflow-hidden"
style={{
...imageLightboxCornerRadiiStyle(frameCornerRadii),
transitionDuration: `${imageTransitionDuration}ms`,
transitionProperty: isReturning ? "border-radius" : "none",
transitionTimingFunction: isClosing
? IMAGE_LIGHTBOX_EASE_IN_OUT
: IMAGE_LIGHTBOX_EASE_OUT,
}}
>
<AnimatePresence
custom={galleryDirection}
@@ -1114,7 +905,15 @@ function ImageZoomOverlay({
<motion.img
alt={currentItem.alt}
animate="center"
className="absolute inset-0 h-full w-full object-contain"
className={cn(
"absolute inset-0 h-full w-full",
// The expanded frame matches the image aspect ratio, so
// switching to cover at close starts without a visual jump.
// As the frame morphs to the mosaic tile's aspect ratio, the
// image is progressively cropped into the same fill geometry
// as its thumbnail instead of snapping after it lands.
isReturning ? "object-cover" : "object-contain",
)}
custom={galleryDirection}
exit="exit"
initial="enter"
@@ -1242,6 +1041,7 @@ function ImageZoomOverlay({
<ImageContextMenu
onCopy={handleMenuCopy}
onDownload={handleMenuDownload}
portalContainer={dialogRef.current ?? undefined}
position={menu}
/>
) : null}
@@ -1264,6 +1064,7 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
galleryIndex: number;
galleryItems?: ImageGalleryItem[];
sourceBox: ImageLightboxBox;
sourceCornerRadii: ImageLightboxCornerRadii;
sourceScope: Element | null;
} | null>(null);
const [isHiddenInSpoiler, setIsHiddenInSpoiler] = React.useState(false);
@@ -1382,13 +1183,21 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
setMenu(null);
const sourceBox = imageLightboxBoxFromRect(rect);
const sourceCornerRadii = imageLightboxCornerRadiiFromElement(image);
const sourceScope = triggerRef.current
? imageLightboxSourceScopeForTrigger(triggerRef.current)
: null;
const gallery = triggerRef.current
? visibleImageGalleryForTrigger(
triggerRef.current,
{ alt, dim, resolvedSrc, src, thumbnailBox: sourceBox },
{
alt,
dim,
resolvedSrc,
src,
thumbnailBox: sourceBox,
thumbnailCornerRadii: sourceCornerRadii,
},
sourceScope,
)
: { galleryIndex: 0, galleryItems: undefined };
@@ -1396,6 +1205,7 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
galleryIndex: gallery.galleryIndex,
galleryItems: gallery.galleryItems,
sourceBox,
sourceCornerRadii,
sourceScope,
});
},
@@ -1441,7 +1251,7 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
aria-hidden={isHiddenInSpoiler ? true : undefined}
aria-label={alt?.trim() ? `Zoom image: ${alt}` : "Zoom image"}
className={cn(
"mt-1 inline-block min-w-0 max-w-full cursor-zoom-in rounded-2xl border-0 bg-transparent p-0 text-left align-top focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/50",
"mt-1 inline-block min-w-0 max-w-full cursor-zoom-in overflow-hidden rounded-2xl border-0 bg-transparent p-0 text-left align-top focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/50",
lightboxState && "opacity-0",
)}
data-image-lightbox-resolved-src={resolvedSrc}
@@ -1487,6 +1297,7 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
onClose={() => setLightboxState(null)}
resolvedSrc={resolvedSrc}
sourceBox={lightboxState.sourceBox}
sourceCornerRadii={lightboxState.sourceCornerRadii}
sourceScope={lightboxState.sourceScope}
src={src}
/>
@@ -1495,6 +1306,30 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
);
}
function ImageMosaic({ children }: { children: React.ReactNode[] }) {
const mosaicRef = React.useRef<HTMLDivElement | null>(null);
const isTriptych = children.length === 3;
const hasOddTail = children.length > 3 && children.length % 2 === 1;
useSmoothCorners(mosaicRef);
return (
<div
className={cn(
"mt-1 grid w-full min-w-0 max-w-lg grid-cols-2 gap-1.5 overflow-hidden rounded-2xl [&_br]:hidden [&_[data-block-media]]:min-h-0 [&_[data-block-media]]:max-w-none [&_[data-block-media]]:overflow-hidden [&_[data-block-media]>button]:m-0 [&_[data-block-media]>button]:h-full [&_[data-block-media]>button]:w-full [&_[data-block-media]>button]:max-w-none [&_[data-block-media]>button]:rounded-none [&_[data-block-media]_img]:!h-full [&_[data-block-media]_img]:!max-h-none [&_[data-block-media]_img]:!w-full [&_[data-block-media]_img]:!max-w-none [&_[data-block-media]_img]:rounded-none [&_[data-block-media]_img]:object-cover",
isTriptych
? "h-80 grid-rows-2 [&_[data-block-media]]:h-auto [&_[data-block-media]:first-child]:row-span-2"
: "[&_[data-block-media]]:h-48",
hasOddTail && "[&_[data-block-media]:last-child]:col-span-2",
)}
data-image-mosaic=""
data-image-mosaic-count={children.length}
ref={mosaicRef}
>
{children}
</div>
);
}
function createMarkdownComponents(
interactive = true,
mediaInset = false,
@@ -1733,19 +1568,17 @@ function createMarkdownComponents(
<ol className={cn("list-decimal", listClassName)}>{children}</ol>
),
p: ({ children }) => {
// Detect image-only paragraphs (images + <br> from remarkBreaks).
// Multi-image: render as a 2-column grid gallery.
// Detect media-only paragraphs (images + <br> from remarkBreaks).
// Multi-image: render as a compact, count-aware mosaic. Two images split
// a row, three form a hero-and-stack triptych, and larger odd counts let
// the final image span both columns.
// 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-1 grid w-full min-w-0 max-w-lg grid-cols-2 gap-1.5 [&_br]:hidden [&_[data-block-media]]:mt-0 [&_[data-block-media]]:max-w-none [&_img]:mt-0 [&_img]:w-full [&_img]:max-w-full">
{imageChildren}
</div>
);
return <ImageMosaic>{imageChildren}</ImageMosaic>;
}
if (hasBlockMedia(childArray)) {
@@ -1921,6 +1754,7 @@ function createMarkdownComponents(
* four instances ever exist. Module-stable maps mean cached markdown element
* trees (see ./markdown/nodeCache.ts) never embed per-mount closures.
*/
const MARKDOWN_COMPONENT_SCHEMA_VERSION = "4";
const markdownComponentsByVariant = new Map<string, MarkdownComponentSet>();
type MarkdownComponentSet = { components: Components; variant: string };
@@ -1936,7 +1770,7 @@ function getMarkdownComponents(
interactive: boolean,
mediaInset: boolean,
): MarkdownComponentSet {
const variant = `${interactive ? "i" : ""}${mediaInset ? "m" : ""}`;
const variant = `${MARKDOWN_COMPONENT_SCHEMA_VERSION}:${interactive ? "i" : ""}${mediaInset ? "m" : ""}`;
let entry = markdownComponentsByVariant.get(variant);
if (!entry) {
entry = {
@@ -0,0 +1,403 @@
import type { CSSProperties } from "react";
import { MESSAGE_MARKDOWN_CLASS } from "@/shared/ui/mentionChip";
import {
dimensionsFromDim,
getDecodedImageDimensions,
isInsideHiddenSpoiler,
} from "./utils";
export type ImageLightboxBox = {
height: number;
left: number;
top: number;
width: number;
};
export type ImageLightboxCornerRadii = {
bottomLeft: string;
bottomRight: string;
topLeft: string;
topRight: string;
};
type ImageLightboxThumbnailTarget = {
box: ImageLightboxBox;
cornerRadii: ImageLightboxCornerRadii;
};
export type ImageGalleryDirection = "forward" | "backward";
export type ImageGalleryItem = {
alt: string | undefined;
dim?: string;
resolvedSrc: string;
src: string | undefined;
thumbnailBox?: ImageLightboxBox;
thumbnailCornerRadii?: ImageLightboxCornerRadii;
};
export const IMAGE_LIGHTBOX_ENTER_MS = 260;
export const IMAGE_LIGHTBOX_EXIT_MS = 170;
export const IMAGE_LIGHTBOX_FADE_ENTER_MS = 180;
export const IMAGE_LIGHTBOX_FADE_EXIT_MS = 90;
export const IMAGE_LIGHTBOX_GALLERY_SLIDE_MS = 280;
export const IMAGE_LIGHTBOX_GALLERY_SLIDE_DISTANCE_PX = 48;
export const IMAGE_LIGHTBOX_GALLERY_BLUR_PX = 4;
export const IMAGE_LIGHTBOX_REDUCED_MOTION_MS = 100;
export const IMAGE_LIGHTBOX_ZOOM_TRANSITION_MS = 80;
export const IMAGE_LIGHTBOX_BASE_VIEWPORT_RATIO = 0.8;
export const IMAGE_LIGHTBOX_CONTROL_SUPPRESS_CLOSE_MS = 450;
export const IMAGE_LIGHTBOX_TRACKPAD_ZOOM_IDLE_MS = 120;
export const IMAGE_LIGHTBOX_WHEEL_ZOOM_SPEED = 0.002;
export const IMAGE_LIGHTBOX_WHEEL_ZOOM_MAX_DELTA = 0.2;
export const IMAGE_LIGHTBOX_MIN_ZOOM = 1;
export const IMAGE_LIGHTBOX_MAX_ZOOM = 3;
export const IMAGE_LIGHTBOX_ZOOM_STEP = 0.05;
export const IMAGE_LIGHTBOX_EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
export const IMAGE_LIGHTBOX_EASE_IN_OUT = "cubic-bezier(0.77, 0, 0.175, 1)";
export const IMAGE_LIGHTBOX_EXPANDED_CORNER_RADIUS = "1rem";
export const IMAGE_LIGHTBOX_GALLERY_EASE: [number, number, number, number] = [
0.22, 1, 0.36, 1,
];
export const IMAGE_LIGHTBOX_MARKDOWN_SCOPE_SELECTOR = `.${MESSAGE_MARKDOWN_CLASS}`;
export function imageLightboxBoxFromRect(rect: DOMRect): ImageLightboxBox {
return {
height: rect.height,
left: rect.left,
top: rect.top,
width: rect.width,
};
}
export function imageLightboxTargetBox(
sourceBox: ImageLightboxBox,
): ImageLightboxBox {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const horizontalPadding = Math.min(80, Math.max(16, viewportWidth * 0.0625));
const verticalPadding = Math.min(24, Math.max(16, viewportHeight * 0.033));
const maxWidth = Math.max(
1,
Math.min(
viewportWidth - horizontalPadding * 2,
viewportWidth * IMAGE_LIGHTBOX_BASE_VIEWPORT_RATIO,
),
);
const maxHeight = Math.max(
1,
Math.min(
viewportHeight - verticalPadding * 2,
viewportHeight * IMAGE_LIGHTBOX_BASE_VIEWPORT_RATIO,
),
);
const scale = Math.min(
maxWidth / sourceBox.width,
maxHeight / sourceBox.height,
);
const width = Math.max(1, sourceBox.width * scale);
const height = Math.max(1, sourceBox.height * scale);
return {
height,
left: (viewportWidth - width) / 2,
top: (viewportHeight - height) / 2,
width,
};
}
export function imageLightboxStyle(box: ImageLightboxBox): CSSProperties {
return {
height: `${box.height}px`,
left: `${box.left}px`,
top: `${box.top}px`,
width: `${box.width}px`,
};
}
export function clampImageLightboxZoom(value: number): number {
return Math.min(
IMAGE_LIGHTBOX_MAX_ZOOM,
Math.max(IMAGE_LIGHTBOX_MIN_ZOOM, value),
);
}
export function normalizedWheelDeltaY(event: WheelEvent): number {
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
return event.deltaY * 16;
}
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
return event.deltaY * window.innerHeight;
}
return event.deltaY;
}
export function imageLightboxTransform(
sourceBox: ImageLightboxBox,
targetBox: ImageLightboxBox,
): string {
const scaleX = targetBox.width / Math.max(1, sourceBox.width);
const scaleY = targetBox.height / Math.max(1, sourceBox.height);
const translateX = targetBox.left - sourceBox.left;
const translateY = targetBox.top - sourceBox.top;
return `translate3d(${translateX}px, ${translateY}px, 0) scale(${scaleX}, ${scaleY})`;
}
export function imageLightboxZoomBox(
targetBox: ImageLightboxBox,
zoom: number,
): ImageLightboxBox {
const width = targetBox.width * zoom;
const height = targetBox.height * zoom;
return {
height,
left: targetBox.left + (targetBox.width - width) / 2,
top: targetBox.top + (targetBox.height - height) / 2,
width,
};
}
export function imageLightboxBasisBoxForItem(
item: ImageGalleryItem,
fallbackBox: ImageLightboxBox,
): ImageLightboxBox {
const dimensions =
dimensionsFromDim(item.dim) ?? getDecodedImageDimensions(item.resolvedSrc);
if (!dimensions) {
return item.thumbnailBox ?? fallbackBox;
}
return {
...fallbackBox,
height: dimensions.height,
width: dimensions.width,
};
}
export function imageLightboxCornerRadiiFromElement(
element: Element,
): ImageLightboxCornerRadii {
const style = window.getComputedStyle(element);
const cornerRadii = {
bottomLeft: style.borderBottomLeftRadius,
bottomRight: style.borderBottomRightRadius,
topLeft: style.borderTopLeftRadius,
topRight: style.borderTopRightRadius,
};
const mosaic = element.closest<HTMLElement>("[data-image-mosaic]");
if (!mosaic || mosaic === element) {
return cornerRadii;
}
// Mosaic tiles themselves are square. Their visible outer corners come from
// the gallery container's clip, so preserve only the container corners that
// this tile actually touches when the overlay returns.
const elementRect = element.getBoundingClientRect();
const mosaicRect = mosaic.getBoundingClientRect();
const mosaicStyle = window.getComputedStyle(mosaic);
const touches = (first: number, second: number) =>
Math.abs(first - second) < 1;
const touchesTop = touches(elementRect.top, mosaicRect.top);
const touchesRight = touches(elementRect.right, mosaicRect.right);
const touchesBottom = touches(elementRect.bottom, mosaicRect.bottom);
const touchesLeft = touches(elementRect.left, mosaicRect.left);
return {
bottomLeft:
touchesBottom && touchesLeft
? mosaicStyle.borderBottomLeftRadius
: cornerRadii.bottomLeft,
bottomRight:
touchesBottom && touchesRight
? mosaicStyle.borderBottomRightRadius
: cornerRadii.bottomRight,
topLeft:
touchesTop && touchesLeft
? mosaicStyle.borderTopLeftRadius
: cornerRadii.topLeft,
topRight:
touchesTop && touchesRight
? mosaicStyle.borderTopRightRadius
: cornerRadii.topRight,
};
}
export function imageLightboxCornerRadiiStyle(
cornerRadii: ImageLightboxCornerRadii,
): CSSProperties {
return {
borderBottomLeftRadius: cornerRadii.bottomLeft,
borderBottomRightRadius: cornerRadii.bottomRight,
borderTopLeftRadius: cornerRadii.topLeft,
borderTopRightRadius: cornerRadii.topRight,
};
}
export function imageLightboxExpandedCornerRadii(): ImageLightboxCornerRadii {
return {
bottomLeft: IMAGE_LIGHTBOX_EXPANDED_CORNER_RADIUS,
bottomRight: IMAGE_LIGHTBOX_EXPANDED_CORNER_RADIUS,
topLeft: IMAGE_LIGHTBOX_EXPANDED_CORNER_RADIUS,
topRight: IMAGE_LIGHTBOX_EXPANDED_CORNER_RADIUS,
};
}
function imageLightboxThumbnailTargetForItem(
item: ImageGalleryItem,
sourceScope: Element | null | undefined,
): ImageLightboxThumbnailTarget | null {
const root = sourceScope?.isConnected ? sourceScope : document.body;
const triggers = Array.from(
root.querySelectorAll<HTMLElement>("[data-image-lightbox-trigger]"),
);
for (const trigger of triggers) {
const isCurrentItem =
trigger.dataset.imageLightboxResolvedSrc === item.resolvedSrc ||
(item.src != null && trigger.dataset.imageLightboxSrc === item.src);
if (!isCurrentItem) {
continue;
}
const image = trigger.querySelector("img");
const target = image ?? trigger;
const rect = target.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
return {
box: imageLightboxBoxFromRect(rect),
cornerRadii: imageLightboxCornerRadiiFromElement(target),
};
}
}
return null;
}
export function imageLightboxReturnTargetForItem(
item: ImageGalleryItem,
fallbackBox: ImageLightboxBox,
fallbackCornerRadii: ImageLightboxCornerRadii,
sourceScope: Element | null | undefined,
): ImageLightboxThumbnailTarget {
const currentTarget = imageLightboxThumbnailTargetForItem(item, sourceScope);
if (currentTarget) {
return currentTarget;
}
return {
box: item.thumbnailBox ?? fallbackBox,
cornerRadii: item.thumbnailCornerRadii ?? fallbackCornerRadii,
};
}
export function imageLightboxSourceScopeForTrigger(
trigger: HTMLElement,
): Element | null {
return (
trigger.closest(IMAGE_LIGHTBOX_MARKDOWN_SCOPE_SELECTOR) ??
trigger.closest("[data-testid='message-row']")
);
}
function imageGalleryItemFromTrigger(
trigger: HTMLElement,
thumbnail?: ImageLightboxThumbnailTarget,
): ImageGalleryItem | null {
const resolvedSrc = trigger.dataset.imageLightboxResolvedSrc;
if (!resolvedSrc) {
return null;
}
return {
alt: trigger.dataset.imageLightboxAlt || undefined,
dim: trigger.dataset.imageLightboxDim || undefined,
resolvedSrc,
src: trigger.dataset.imageLightboxSrc || undefined,
thumbnailBox: thumbnail?.box,
thumbnailCornerRadii: thumbnail?.cornerRadii,
};
}
function isVisibleImageLightboxTrigger(trigger: HTMLElement): boolean {
if (isInsideHiddenSpoiler(trigger)) {
return false;
}
const image = trigger.querySelector("img");
for (const element of [trigger, image]) {
if (!element) {
continue;
}
const style = window.getComputedStyle(element);
if (
style.display === "none" ||
style.visibility === "hidden" ||
Number(style.opacity) === 0
) {
return false;
}
}
return true;
}
export function visibleImageGalleryForTrigger(
trigger: HTMLElement,
fallbackItem: ImageGalleryItem,
sourceScope: Element | null | undefined,
): { galleryIndex: number; galleryItems?: ImageGalleryItem[] } {
const root = sourceScope?.isConnected ? sourceScope : null;
const triggers = root
? Array.from(
root.querySelectorAll<HTMLElement>("[data-image-lightbox-trigger]"),
)
: [trigger];
const galleryItems: ImageGalleryItem[] = [];
let galleryIndex = 0;
let foundCurrentTrigger = false;
for (const candidate of triggers) {
if (!isVisibleImageLightboxTrigger(candidate)) {
continue;
}
const image = candidate.querySelector("img");
const target = image ?? candidate;
const rect = target.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
const thumbnail = {
box: imageLightboxBoxFromRect(rect),
cornerRadii: imageLightboxCornerRadiiFromElement(target),
};
const item = imageGalleryItemFromTrigger(candidate, thumbnail);
if (!item) {
continue;
}
if (candidate === trigger) {
galleryIndex = galleryItems.length;
foundCurrentTrigger = true;
}
galleryItems.push(item);
}
if (!foundCurrentTrigger) {
galleryItems.unshift(fallbackItem);
galleryIndex = 0;
}
return {
galleryIndex,
galleryItems: galleryItems.length > 1 ? galleryItems : undefined,
};
}
+6 -1
View File
@@ -42,7 +42,12 @@ export function classifyChildren(childArray: React.ReactNode[]): {
(child) =>
!isBlockMedia(child) &&
!(typeof child === "string" && child.trim() === "") &&
!(React.isValidElement(child) && child.type === "br"),
!(
React.isValidElement(child) &&
((typeof child.type === "string" && child.type === "br") ||
(child.props as { node?: { tagName?: unknown } })?.node?.tagName ===
"br")
),
);
return { imageChildren, nonImageChildren };
}
@@ -1,6 +1,7 @@
import { expect, type Page, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
const IMAGE_SHAS = ["a".repeat(64), "b".repeat(64), "c".repeat(64)];
@@ -10,6 +11,7 @@ const SPOILER_VISIBLE_URL = `http://localhost:3000/media/${SPOILER_VISIBLE_SHA}.
const SPOILER_HIDDEN_URL = `http://localhost:3000/media/${SPOILER_HIDDEN_SHA}.png`;
const NO_DIM_WIDE_URL = "https://example.com/e2e/gallery-wide.png";
const NO_DIM_PORTRAIT_URL = "https://example.com/e2e/gallery-portrait.png";
const NO_DIM_SECOND_URL = "https://example.com/e2e/gallery-second.png";
async function waitForMockLiveSubscription(page: Page, channelName: string) {
await expect
@@ -55,10 +57,12 @@ function imageImetaTag({
async function installNoDimImageRoutes(page: Page) {
await page.route("https://example.com/e2e/gallery-*.png", (route) => {
const isPortrait = route.request().url().includes("portrait");
const requestedUrl = route.request().url();
const isPortrait = requestedUrl.includes("portrait");
const isSecond = requestedUrl.includes("second");
const width = isPortrait ? 120 : 320;
const height = isPortrait ? 320 : 120;
const fill = isPortrait ? "#f4b860" : "#4aa3df";
const fill = isSecond ? "#a78bfa" : isPortrait ? "#f4b860" : "#4aa3df";
route.fulfill({
body: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="100%" height="100%" fill="${fill}"/></svg>`,
contentType: "image/svg+xml",
@@ -126,9 +130,36 @@ test("image bundle lightbox navigates as a gallery", async ({ page }) => {
const triggers = row.getByTestId("message-image-lightbox-trigger");
await expect(triggers).toHaveCount(3);
await expectCornerRadiusPx(triggers.first(), 16);
await expectCornerRadiusPx(triggers.first().locator("img"), 16);
await expectSmoothCorners(triggers.first().locator("img"));
const mosaic = row.locator("[data-image-mosaic]");
await expect(mosaic).toHaveAttribute("data-image-mosaic-count", "3");
await expectSmoothCorners(mosaic);
const mosaicCornerRadius = await mosaic.evaluate(
(element) => window.getComputedStyle(element).borderTopLeftRadius,
);
const mosaicBox = await mosaic.boundingBox();
const firstBox = await triggers.first().boundingBox();
const secondBox = await triggers.nth(1).boundingBox();
const thirdBox = await triggers.nth(2).boundingBox();
if (!mosaicBox || !firstBox || !secondBox || !thirdBox) {
throw new Error("Expected image mosaic tiles to have layout boxes");
}
expect(mosaicBox.width).toBeCloseTo(512, 0);
expect(firstBox.height).toBeGreaterThan(secondBox.height * 1.8);
expect(firstBox.height).toBeCloseTo(
thirdBox.y + thirdBox.height - secondBox.y,
0,
);
expect(secondBox.x).toBeCloseTo(thirdBox.x, 0);
expect(secondBox.y).toBeLessThan(thirdBox.y);
await expectCornerRadiusPx(mosaic, 16);
await expectCornerRadiusPx(triggers.first(), 0);
await expect(triggers.first().locator("img")).toHaveCSS(
"object-fit",
"cover",
);
await expectCornerRadiusPx(triggers.first().locator("img"), 0);
await triggers.first().click();
const dialog = page.getByRole("dialog");
@@ -150,7 +181,10 @@ test("image bundle lightbox navigates as a gallery", async ({ page }) => {
).toBeVisible();
await page.keyboard.press("ArrowRight");
await expect(dialog.locator(`img[src*="${IMAGE_SHAS[2]}"]`)).toBeVisible();
const currentLightboxImage = dialog.locator(`img[src*="${IMAGE_SHAS[2]}"]`);
const lightboxFrame = page.locator("[data-image-lightbox-frame]");
await expect(currentLightboxImage).toBeVisible();
await expect(currentLightboxImage).toHaveCSS("object-fit", "contain");
await expect(page.getByRole("button", { name: "Next image" })).toHaveCount(0);
const currentThumbnailBox = await triggers
@@ -163,26 +197,57 @@ test("image bundle lightbox navigates as a gallery", async ({ page }) => {
await page.waitForTimeout(500);
await page.mouse.click(20, 20);
await page.waitForTimeout(200);
await expect(currentLightboxImage).toHaveCSS("object-fit", "cover");
const closingFrameStyle = await lightboxFrame.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Expected HTML lightbox frame");
}
return {
borderBottomLeftRadius: element.style.borderBottomLeftRadius,
borderBottomRightRadius: element.style.borderBottomRightRadius,
borderTopLeftRadius: element.style.borderTopLeftRadius,
borderTopRightRadius: element.style.borderTopRightRadius,
height: Number.parseFloat(element.style.height),
left: Number.parseFloat(element.style.left),
top: Number.parseFloat(element.style.top),
transitionProperty: element.style.transitionProperty,
width: Number.parseFloat(element.style.width),
};
});
expect(closingFrameStyle.borderTopLeftRadius).toBe("0px");
expect(closingFrameStyle.borderTopRightRadius).toBe("0px");
expect(closingFrameStyle.borderBottomLeftRadius).toBe("0px");
expect(closingFrameStyle.borderBottomRightRadius).toBe(mosaicCornerRadius);
expect(closingFrameStyle.transitionProperty).toContain("border-radius");
const closingFrameBox = await page
.locator("[data-image-lightbox-frame]")
.boundingBox();
if (!closingFrameBox) {
throw new Error("Expected lightbox frame to remain mounted while closing");
}
await expectCornerRadiusPx(
page.locator("[data-image-lightbox-frame] > div > div").first(),
16,
const lightboxSurfaceStyle = await page
.locator("[data-image-lightbox-frame] > div > div")
.first()
.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Expected HTML lightbox surface");
}
return {
borderBottomRightRadius: element.style.borderBottomRightRadius,
borderTopLeftRadius: element.style.borderTopLeftRadius,
transitionProperty: element.style.transitionProperty,
};
});
expect(lightboxSurfaceStyle.borderTopLeftRadius).toBe("0px");
expect(lightboxSurfaceStyle.borderBottomRightRadius).toBe(mosaicCornerRadius);
expect(lightboxSurfaceStyle.transitionProperty).toBe("border-radius");
expect(Math.abs(closingFrameStyle.left - currentThumbnailBox.x)).toBeLessThan(
2,
);
expect(Math.abs(closingFrameStyle.top - currentThumbnailBox.y)).toBeLessThan(
2,
);
expect(Math.abs(closingFrameBox.x - currentThumbnailBox.x)).toBeLessThan(2);
expect(Math.abs(closingFrameBox.y - currentThumbnailBox.y)).toBeLessThan(2);
expect(
Math.abs(closingFrameBox.width - currentThumbnailBox.width),
Math.abs(closingFrameStyle.width - currentThumbnailBox.width),
).toBeLessThan(2);
expect(
Math.abs(closingFrameBox.height - currentThumbnailBox.height),
Math.abs(closingFrameStyle.height - currentThumbnailBox.height),
).toBeLessThan(2);
});
@@ -400,6 +465,196 @@ test("forum markdown images use the markdown root as their gallery scope", async
).toBeVisible();
});
test("multi-image mosaics keep a fixed width and grow by rows", async ({
page,
}) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const urls = Array.from(
{ length: 5 },
(_, index) =>
`https://example.com/e2e/gallery-${index % 2 === 0 ? "wide" : "portrait"}.png?item=${index}`,
);
await page.evaluate((imageUrls) => {
const emit = (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
for (const count of [2, 4, 5]) {
emit?.({
channelName: "general",
content: [
`${count} image mosaic`,
...imageUrls.slice(0, count).map((url) => `![image](${url})`),
].join("\n"),
});
}
}, urls);
const mosaics: Array<{ count: number; height: number; width: number }> = [];
for (const count of [2, 4, 5]) {
const row = page
.getByTestId("message-row")
.filter({ hasText: `${count} image mosaic` })
.last();
const mosaic = row.locator("[data-image-mosaic]");
await expect(mosaic).toHaveAttribute(
"data-image-mosaic-count",
String(count),
);
const box = await mosaic.boundingBox();
if (!box) throw new Error(`Expected ${count}-image mosaic layout box`);
mosaics.push({ count, height: box.height, width: box.width });
}
expect(mosaics[0].width).toBeCloseTo(mosaics[1].width, 0);
expect(mosaics[1].width).toBeCloseTo(mosaics[2].width, 0);
expect(mosaics[1].height).toBeGreaterThan(mosaics[0].height);
expect(mosaics[2].height).toBeGreaterThan(mosaics[1].height);
expect(mosaics[2].height - mosaics[1].height).toBeCloseTo(
mosaics[0].height + 6,
0,
);
});
test("image mosaic screenshot", async ({ page }) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await page.evaluate(
({ portraitUrl, secondUrl, wideUrl }) => {
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: [
"Weekend photo dump",
`![Coastal overlook](${wideUrl})`,
`![Boardwalk detail](${portraitUrl})`,
`![Golden hour](${secondUrl})`,
].join("\n"),
});
},
{
portraitUrl: NO_DIM_PORTRAIT_URL,
secondUrl: NO_DIM_SECOND_URL,
wideUrl: NO_DIM_WIDE_URL,
},
);
const row = page
.getByTestId("message-row")
.filter({ hasText: "Weekend photo dump" })
.last();
await expect(row.locator("[data-image-mosaic] img")).toHaveCount(3);
await waitForAnimations(page);
await row.screenshot({
path: "test-results/image-mosaic/three-image-mosaic.png",
});
});
test("mosaic image context menu is portaled outside the clipped gallery", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill("mosaic context menu");
await page.getByRole("button", { name: "Attach image" }).click();
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
const row = page
.getByTestId("message-row")
.filter({ hasText: "mosaic context menu" })
.last();
const mosaic = row.locator("[data-image-mosaic]");
const trigger = row.getByTestId("message-image-lightbox-trigger").last();
await expect(mosaic).toBeVisible();
await trigger.click({ button: "right" });
const menu = page.locator("[data-image-context-menu]");
await expect(menu).toBeVisible();
await expect(page.getByRole("button", { name: "Copy image" })).toBeVisible();
await expect(
page.getByRole("button", { name: "Download image" }),
).toBeVisible();
await expect(mosaic.locator("[data-image-context-menu]")).toHaveCount(0);
expect(
await menu.evaluate((element) => element.parentElement === document.body),
).toBe(true);
const mosaicBox = await mosaic.boundingBox();
const menuBox = await menu.boundingBox();
if (!mosaicBox || !menuBox) {
throw new Error("Expected mosaic and image context menu layout boxes");
}
expect(menuBox.x + menuBox.width).toBeGreaterThan(
mosaicBox.x + mosaicBox.width,
);
});
test("lightbox image context menu stays inside the dialog focus scope", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill("lightbox context menu");
await page.getByRole("button", { name: "Attach image" }).click();
await page.getByTestId("send-message").click();
await expect(page.getByText("Sending")).toHaveCount(0);
const row = page
.getByTestId("message-row")
.filter({ hasText: "lightbox context menu" })
.last();
await row.getByTestId("message-image-lightbox-trigger").first().click();
const dialog = page.getByRole("dialog");
const lightboxImage = dialog.locator(`img[src*="${IMAGE_SHAS[0]}"]`);
await expect(lightboxImage).toBeVisible();
await lightboxImage.click({ button: "right" });
const menu = dialog.locator("[data-image-context-menu]");
const copyButton = menu.getByRole("button", { name: "Copy image" });
const downloadButton = menu.getByRole("button", { name: "Download image" });
await expect(menu).toBeVisible();
await expect(page.locator("body > [data-image-context-menu]")).toHaveCount(0);
await dialog.focus();
await page.keyboard.press("Shift+Tab");
await expect(downloadButton).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(copyButton).toBeFocused();
await page.keyboard.press("Tab");
await expect(downloadButton).toBeFocused();
await page.keyboard.press("Tab");
await expect(
dialog.getByRole("button", { name: "Next image" }),
).toBeFocused();
});
test("right-click image shows Copy image and invokes copy command", async ({
page,
}) => {