mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
parent
5dc70bd0b8
commit
2c41e9e6b8
@@ -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 `\n` 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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) => ``),
|
||||
].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",
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
].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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user