diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx index bb8fe352b..c125f5898 100644 --- a/desktop/src/shared/ui/link-preview-attachment.tsx +++ b/desktop/src/shared/ui/link-preview-attachment.tsx @@ -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 ( + + ); + } return ( - + ); } diff --git a/desktop/src/shared/ui/link-preview-list.tsx b/desktop/src/shared/ui/link-preview-list.tsx index dd95a49a7..2c36e7596 100644 --- a/desktop/src/shared/ui/link-preview-list.tsx +++ b/desktop/src/shared/ui/link-preview-list.tsx @@ -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; 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 ( <> {previews.map((preview, index) => ( setDialogOpen(true) : undefined } diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 4a2aa802e..1a3718b8c 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -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( - [ - "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({ ) : null} 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, +): ComponentType { + 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(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 ( + <> + + {lightboxState ? ( + setLightboxState(null)} + onCopy={ignoreUnavailableImageAction} + onDownload={ignoreUnavailableImageAction} + resolvedSrc={src} + sourceBox={lightboxState.sourceBox} + sourceCornerRadii={lightboxState.sourceCornerRadii} + sourceScope={lightboxState.sourceScope} + src={undefined} + /> + ) : null} + + ); + }; +} diff --git a/desktop/src/shared/ui/markdown/imageLightbox.ts b/desktop/src/shared/ui/markdown/imageLightbox.ts index 1c57cb9e5..4c0c01f93 100644 --- a/desktop/src/shared/ui/markdown/imageLightbox.ts +++ b/desktop/src/shared/ui/markdown/imageLightbox.ts @@ -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( + [ + "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, + ); +} diff --git a/desktop/src/shared/ui/rich-link-preview-attachment.tsx b/desktop/src/shared/ui/rich-link-preview-attachment.tsx index f2728cfa9..c906e1d4e 100644 --- a/desktop/src/shared/ui/rich-link-preview-attachment.tsx +++ b/desktop/src/shared/ui/rich-link-preview-attachment.tsx @@ -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; 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 ( - <> - - - + {alt} + + ); } @@ -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({ ) : 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 ( @@ -271,6 +278,7 @@ export function RichLinkPreviewAttachment({ ) : null}