mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user