feat(desktop): integrate rich previews with the markdown lightbox

- Route rich link preview images through the markdown gallery lightbox so preview images support consistent zoom transitions and gallery navigation.
- Extract link preview lightbox behavior and focusable-element discovery into focused markdown modules to keep the main renderer within its size limit.
- Infer image dimensions from loaded thumbnails when explicit lightbox metadata is unavailable.
- Pass the shared lightbox component through the link preview list and attachment layers.
- Render compact preview cards inline with wrapping and place the remove action on the final compact card.
- Preserve rich preview stacking and existing remove behavior for rich layouts.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-07-30 23:21:07 -07:00
parent 56f3771446
commit 2cabbbcc50
6 changed files with 221 additions and 55 deletions
@@ -1,22 +1,39 @@
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import { useLinkPreviewStyle } from "@/shared/lib/linkPreviewStylePreference";
import { CompactLinkPreviewAttachment } from "@/shared/ui/compact-link-preview-attachment";
import { RichLinkPreviewAttachment } from "@/shared/ui/rich-link-preview-attachment";
import {
type LinkPreviewImageLightboxComponent,
RichLinkPreviewAttachment,
} from "@/shared/ui/rich-link-preview-attachment";
export function LinkPreviewAttachment({
className,
ImageLightbox,
onRemove,
preview,
}: {
className?: string;
ImageLightbox: LinkPreviewImageLightboxComponent;
onRemove?: () => void;
preview: ResolvedLinkPreview;
}) {
const style = useLinkPreviewStyle();
const Preview =
style === "rich" ? RichLinkPreviewAttachment : CompactLinkPreviewAttachment;
if (style === "rich") {
return (
<RichLinkPreviewAttachment
className={className}
ImageLightbox={ImageLightbox}
onRemove={onRemove}
preview={preview}
/>
);
}
return (
<Preview className={className} onRemove={onRemove} preview={preview} />
<CompactLinkPreviewAttachment
className={className}
onRemove={onRemove}
preview={preview}
/>
);
}
+13 -2
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useLinkPreviewStyle } from "@/shared/lib/linkPreviewStylePreference";
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import {
AlertDialog,
@@ -14,30 +15,40 @@ import {
import { AttachmentGroup } from "@/shared/ui/attachment";
import { Button } from "@/shared/ui/button";
import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment";
import type { LinkPreviewImageLightboxComponent } from "@/shared/ui/rich-link-preview-attachment";
export function LinkPreviewList({
ImageLightbox,
onRemoveForEveryone,
previews,
}: {
ImageLightbox: LinkPreviewImageLightboxComponent;
onRemoveForEveryone?: () => Promise<void>;
previews: ResolvedLinkPreview[];
}) {
const [dialogOpen, setDialogOpen] = useState(false);
const [removed, setRemoved] = useState(false);
const style = useLinkPreviewStyle();
if (removed || previews.length === 0) return null;
const previewNoun = previews.length === 1 ? "preview" : "previews";
const removeButtonIndex = style === "compact" ? previews.length - 1 : 0;
return (
<>
<AttachmentGroup
className="max-w-full flex-col items-start overflow-visible pb-0"
className={
style === "compact"
? "max-w-full flex-row flex-wrap items-start overflow-visible pb-0"
: "max-w-full flex-col items-start overflow-visible pb-0"
}
data-link-preview-list=""
>
{previews.map((preview, index) => (
<LinkPreviewAttachment
key={preview.href}
ImageLightbox={ImageLightbox}
onRemove={
onRemoveForEveryone && index === 0
onRemoveForEveryone && index === removeButtonIndex
? () => setDialogOpen(true)
: undefined
}
+6 -22
View File
@@ -61,6 +61,7 @@ import {
} from "./markdown/CodeBlock";
import { FileCard } from "./markdown/FileCard";
import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover";
import { createLinkPreviewImageLightbox } from "./markdown/LinkPreviewImageLightbox";
import { MarkdownInput } from "./markdown/MarkdownInput";
import {
MediaContextMenu,
@@ -93,6 +94,7 @@ import {
IMAGE_LIGHTBOX_WHEEL_ZOOM_SPEED,
IMAGE_LIGHTBOX_ZOOM_STEP,
IMAGE_LIGHTBOX_ZOOM_TRANSITION_MS,
getImageLightboxFocusableElements,
imageLightboxBasisBoxForItem,
imageLightboxBoxFromRect,
imageLightboxCornerRadiiFromElement,
@@ -145,28 +147,6 @@ type WebKitGestureLikeEvent = Event & {
scale?: number;
};
function getImageLightboxFocusableElements(
container: HTMLElement,
): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
[
"a[href]",
"button:not(:disabled)",
"input:not(:disabled)",
"select:not(:disabled)",
"textarea:not(:disabled)",
"[tabindex]:not([tabindex='-1'])",
].join(","),
),
).filter(
(element) =>
!element.hasAttribute("disabled") &&
element.getAttribute("aria-hidden") !== "true" &&
element.getClientRects().length > 0,
);
}
function ImageZoomOverlay({
alt,
galleryIndex = 0,
@@ -995,6 +975,9 @@ function ImageZoomOverlay({
);
}
const LinkPreviewImageLightbox =
createLinkPreviewImageLightbox(ImageZoomOverlay);
/**
* Inline image embed with click-to-zoom lightbox and right-click download.
*
@@ -1973,6 +1956,7 @@ function MarkdownInner({
</AttachmentGroup>
) : null}
<LinkPreviewList
ImageLightbox={LinkPreviewImageLightbox}
key={messageId}
onRemoveForEveryone={onRemoveLinkPreviewsForEveryone}
previews={resolvedLinkPreviews}
@@ -0,0 +1,118 @@
import type { ComponentType } from "react";
import { useRef, useState } from "react";
import { cn } from "@/shared/lib/cn";
import type { LinkPreviewImageLightboxProps } from "@/shared/ui/rich-link-preview-attachment";
import {
type ImageGalleryItem,
type ImageLightboxBox,
type ImageLightboxCornerRadii,
imageLightboxBoxFromRect,
imageLightboxCornerRadiiFromElement,
visibleImageGalleryForTrigger,
} from "./imageLightbox";
type ImageZoomOverlayProps = {
alt: string | undefined;
galleryIndex?: number;
galleryItems?: ImageGalleryItem[];
onClose: () => void;
onCopy: (src: string | undefined) => void;
onDownload: (src: string | undefined) => void;
resolvedSrc: string;
sourceBox: ImageLightboxBox;
sourceCornerRadii: ImageLightboxCornerRadii;
sourceScope?: Element | null;
src: string | undefined;
};
const ignoreUnavailableImageAction = () => undefined;
export function createLinkPreviewImageLightbox(
ImageZoomOverlay: ComponentType<ImageZoomOverlayProps>,
): ComponentType<LinkPreviewImageLightboxProps> {
return function LinkPreviewImageLightbox({ alt, children, className, src }) {
const [lightboxState, setLightboxState] = useState<{
galleryIndex: number;
galleryItems?: ImageGalleryItem[];
sourceBox: ImageLightboxBox;
sourceCornerRadii: ImageLightboxCornerRadii;
sourceScope: Element | null;
} | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const openLightbox = () => {
const trigger = triggerRef.current;
const image = trigger?.querySelector("img");
if (!trigger || !image) return;
const rect = image.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const sourceBox = imageLightboxBoxFromRect(rect);
const sourceCornerRadii = imageLightboxCornerRadiiFromElement(image);
const sourceScope = trigger.closest("[data-link-preview-list]");
const dim =
image.naturalWidth > 0 && image.naturalHeight > 0
? `${image.naturalWidth}x${image.naturalHeight}`
: undefined;
const gallery = visibleImageGalleryForTrigger(
trigger,
{
alt,
dim,
resolvedSrc: src,
src: undefined,
thumbnailBox: sourceBox,
thumbnailCornerRadii: sourceCornerRadii,
},
sourceScope,
);
setLightboxState({
galleryIndex: gallery.galleryIndex,
galleryItems: gallery.galleryItems,
sourceBox,
sourceCornerRadii,
sourceScope,
});
};
return (
<>
<button
aria-label={`Zoom image: ${alt}`}
className={cn(
"cursor-zoom-in border-0 p-0 text-left focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/50",
lightboxState && "opacity-0",
className,
)}
data-image-lightbox-alt={alt}
data-image-lightbox-resolved-src={src}
data-image-lightbox-trigger=""
onClick={openLightbox}
ref={triggerRef}
type="button"
>
{children}
</button>
{lightboxState ? (
<ImageZoomOverlay
alt={alt}
galleryIndex={lightboxState.galleryIndex}
galleryItems={lightboxState.galleryItems}
onClose={() => setLightboxState(null)}
onCopy={ignoreUnavailableImageAction}
onDownload={ignoreUnavailableImageAction}
resolvedSrc={src}
sourceBox={lightboxState.sourceBox}
sourceCornerRadii={lightboxState.sourceCornerRadii}
sourceScope={lightboxState.sourceScope}
src={undefined}
/>
) : null}
</>
);
};
}
@@ -316,9 +316,15 @@ function imageGalleryItemFromTrigger(
return null;
}
const image = trigger.querySelector("img");
const inferredDim =
image && image.naturalWidth > 0 && image.naturalHeight > 0
? `${image.naturalWidth}x${image.naturalHeight}`
: undefined;
return {
alt: trigger.dataset.imageLightboxAlt || undefined,
dim: trigger.dataset.imageLightboxDim || undefined,
dim: trigger.dataset.imageLightboxDim || inferredDim,
resolvedSrc,
src: trigger.dataset.imageLightboxSrc || undefined,
thumbnailBox: thumbnail?.box,
@@ -403,3 +409,25 @@ export function visibleImageGalleryForTrigger(
galleryItems: galleryItems.length > 1 ? galleryItems : undefined,
};
}
export function getImageLightboxFocusableElements(
container: HTMLElement,
): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
[
"a[href]",
"button:not(:disabled)",
"input:not(:disabled)",
"select:not(:disabled)",
"textarea:not(:disabled)",
"[tabindex]:not([tabindex='-1'])",
].join(","),
),
).filter(
(element) =>
!element.hasAttribute("disabled") &&
element.getAttribute("aria-hidden") !== "true" &&
element.getClientRects().length > 0,
);
}
@@ -1,10 +1,20 @@
import { ChevronDown, ChevronUp, X } from "lucide-react";
import type { ComponentType, ReactNode } from "react";
import { useState } from "react";
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { SimpleImageLightbox } from "@/shared/ui/SimpleImageLightbox";
export type LinkPreviewImageLightboxProps = {
alt: string;
children: ReactNode;
className?: string;
src: string;
};
export type LinkPreviewImageLightboxComponent =
ComponentType<LinkPreviewImageLightboxProps>;
function LinkPreviewIdentity({ preview }: { preview: ResolvedLinkPreview }) {
if (preview.faviconDataUrl) {
@@ -44,13 +54,14 @@ function isTweetPreview(preview: ResolvedLinkPreview): boolean {
function LinkPreviewImage({
aspectClassName,
className,
ImageLightbox,
preview,
}: {
aspectClassName: string;
className?: string;
ImageLightbox: LinkPreviewImageLightboxComponent;
preview: ResolvedLinkPreview;
}) {
const [lightboxOpen, setLightboxOpen] = useState(false);
const imageSrc =
preview.imageState === "image" ? preview.imageDataUrl : undefined;
const alt = `Preview from ${preview.imageDomain}`;
@@ -73,32 +84,22 @@ function LinkPreviewImage({
}
return (
<>
<button
aria-label={`Open ${alt.toLowerCase()} in lightbox`}
className={cn(
"block cursor-zoom-in overflow-hidden rounded-xl bg-muted",
className,
)}
<ImageLightbox
alt={alt}
className={cn("block overflow-hidden rounded-xl bg-muted", className)}
src={imageSrc}
>
<div
className={cn("w-full", aspectClassName)}
data-link-preview-thumbnail=""
onClick={() => setLightboxOpen(true)}
type="button"
>
<div className={cn("w-full", aspectClassName)}>
<img
alt={alt}
className="h-full w-full object-cover"
src={imageSrc}
/>
</div>
</button>
<SimpleImageLightbox
alt={alt}
onOpenChange={setLightboxOpen}
open={lightboxOpen}
src={imageSrc}
/>
</>
<img
alt={alt}
className="h-full w-full rounded-xl object-cover"
src={imageSrc}
/>
</div>
</ImageLightbox>
);
}
@@ -125,10 +126,12 @@ function LinkPreviewDescription({
function TweetPreview({
className,
ImageLightbox,
onRemove,
preview,
}: {
className?: string;
ImageLightbox: LinkPreviewImageLightboxComponent;
onRemove?: () => void;
preview: ResolvedLinkPreview;
}) {
@@ -171,6 +174,7 @@ function TweetPreview({
<LinkPreviewImage
aspectClassName="aspect-video"
className="mt-2 w-full max-w-75"
ImageLightbox={ImageLightbox}
preview={preview}
/>
) : null}
@@ -208,10 +212,12 @@ function TweetPreview({
export function RichLinkPreviewAttachment({
className,
ImageLightbox,
onRemove,
preview,
}: {
className?: string;
ImageLightbox: LinkPreviewImageLightboxComponent;
onRemove?: () => void;
preview: ResolvedLinkPreview;
}) {
@@ -221,6 +227,7 @@ export function RichLinkPreviewAttachment({
return (
<TweetPreview
className={className}
ImageLightbox={ImageLightbox}
onRemove={onRemove}
preview={preview}
/>
@@ -271,6 +278,7 @@ export function RichLinkPreviewAttachment({
<LinkPreviewImage
aspectClassName="aspect-[1.91/1]"
className="mt-2 w-full max-w-75"
ImageLightbox={ImageLightbox}
preview={preview}
/>
) : null}