Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -510,8 +510,12 @@ export function MessageThreadPanel({
tabIndex={-1}
ref={threadBodyRef}
>
{/* The gallery is intentionally DOM-scoped: only media currently rendered
in this open thread participates. Collapsed or unloaded descendants
join only after the thread UI renders them. */}
<div
className={cn(hasConstrainedColumn && THREAD_PANEL_COLUMN_CLASS)}
data-image-gallery-scope="thread"
ref={threadContentRef}
style={
hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined
Expand Down
62 changes: 31 additions & 31 deletions desktop/src/shared/ui/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
markdownPropsAreEqual,
} from "./markdownUtils";
import { ImageMosaic } from "./markdown/ImageMosaic";
import { ImageGalleryStatus } from "./markdown/ImageGalleryStatus";
import { ImageLightboxZoomControls } from "./markdown/ImageLightboxZoomControls";
import {
CODE_BLOCK_CLASS,
Expand Down Expand Up @@ -145,12 +146,30 @@ type WebKitGestureLikeEvent = Event & {
scale?: number;
};

function copyImageToClipboard(src: string | undefined) {
if (!src) return;
invokeTauri("copy_image_to_clipboard", { url: src })
.then(() => {
toast.success("Copied to clipboard");
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "Copy failed";
toast.error(msg);
});
}

function downloadImage(src: string | undefined) {
if (!src) return;
invokeTauri("download_image", { url: src }).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "Download failed";
toast.error(msg);
});
}

function ImageZoomOverlay({
alt,
galleryIndex = 0,
galleryItems,
onCopy,
onDownload,
onClose,
resolvedSrc,
sourceBox,
Expand All @@ -161,8 +180,6 @@ function ImageZoomOverlay({
alt: string | undefined;
galleryIndex?: number;
galleryItems?: ImageGalleryItem[];
onCopy: (src: string | undefined) => void;
onDownload: (src: string | undefined) => void;
onClose: () => void;
resolvedSrc: string;
sourceBox: ImageLightboxBox;
Expand Down Expand Up @@ -718,13 +735,13 @@ function ImageZoomOverlay({
const handleMenuCopy = React.useCallback(() => {
setMenu(null);
markControlGesture();
onCopy(currentItem.src);
}, [currentItem.src, markControlGesture, onCopy]);
copyImageToClipboard(currentItem.src);
}, [currentItem.src, markControlGesture]);
const handleMenuDownload = React.useCallback(() => {
setMenu(null);
markControlGesture();
onDownload(currentItem.src);
}, [currentItem.src, markControlGesture, onDownload]);
downloadImage(currentItem.src);
}, [currentItem.src, markControlGesture]);

return createPortal(
<div
Expand Down Expand Up @@ -859,7 +876,7 @@ function ImageZoomOverlay({
custom={galleryDirection}
exit="exit"
initial="enter"
key={currentItem.resolvedSrc}
key={`${currentIndex}:${currentItem.resolvedSrc}`}
src={currentItem.resolvedSrc}
transition={{
duration: prefersReducedMotion
Expand Down Expand Up @@ -936,7 +953,7 @@ function ImageZoomOverlay({
type="button"
onClick={(event) => {
event.stopPropagation();
onDownload(currentItem.src);
downloadImage(currentItem.src);
}}
>
<Download className="h-4 w-4" />
Expand All @@ -952,6 +969,7 @@ function ImageZoomOverlay({
updateZoom={updateZoom}
zoom={zoom}
/>
<ImageGalleryStatus {...{ currentIndex, itemCount: items.length }} />
</div>
</div>
{menu && canActOnCurrentImage ? (
Expand Down Expand Up @@ -1000,7 +1018,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) {
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
useSmoothCorners(inlineImageRef);
useSmoothCorners(thumbnailImageRef);

const [spoilerMediaSize, setSpoilerMediaSize] = React.useState<{
height: number;
src: string;
Expand Down Expand Up @@ -1078,7 +1095,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) {

return () => observer.disconnect();
}, []);

const closeMenu = React.useCallback(() => setMenu(null), []);
useDismissMediaContextMenu(Boolean(menu), closeMenu);

Expand All @@ -1089,7 +1105,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) {
e.nativeEvent.stopImmediatePropagation();
setMenu({ x: e.clientX, y: e.clientY });
};

const openLightbox = React.useCallback(
(image: HTMLImageElement) => {
if (!resolvedSrc || isInsideHiddenSpoiler(image)) {
Expand All @@ -1113,6 +1128,7 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) {
{
alt,
dim,
trigger: triggerRef.current,
resolvedSrc,
src,
thumbnailBox: sourceBox,
Expand Down Expand Up @@ -1140,27 +1156,13 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) {

const handleCopyImage = React.useCallback((copySrc: string | undefined) => {
setMenu(null);
if (!copySrc) return;
invokeTauri("copy_image_to_clipboard", { url: copySrc })
.then(() => {
toast.success("Copied to clipboard");
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "Copy failed";
toast.error(msg);
});
copyImageToClipboard(copySrc);
}, []);

const handleDownload = React.useCallback(
(downloadSrc: string | undefined) => {
setMenu(null);
if (!downloadSrc) return;
invokeTauri("download_image", { url: downloadSrc }).catch(
(err: unknown) => {
const msg = err instanceof Error ? err.message : "Download failed";
toast.error(msg);
},
);
downloadImage(downloadSrc);
},
[],
);
Expand Down Expand Up @@ -1215,8 +1217,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) {
alt={alt}
galleryIndex={lightboxState.galleryIndex}
galleryItems={lightboxState.galleryItems}
onCopy={handleCopyImage}
onDownload={handleDownload}
onClose={() => setLightboxState(null)}
resolvedSrc={resolvedSrc}
sourceBox={lightboxState.sourceBox}
Expand Down
31 changes: 31 additions & 0 deletions desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
type ImageGalleryStatusProps = {
currentIndex: number;
itemCount: number;
};

export function ImageGalleryStatus({
currentIndex,
itemCount,
}: ImageGalleryStatusProps) {
if (itemCount <= 1) {
return null;
}

const position = currentIndex + 1;
return (
<>
<div
aria-hidden="true"
className="h-5 w-px shrink-0 bg-muted-foreground/15"
/>
<span
aria-label={`Image ${position} of ${itemCount}`}
aria-live="polite"
className="min-w-9 text-center text-xs font-medium tabular-nums text-muted-foreground"
role="status"
>
{position} / {itemCount}
</span>
</>
);
}
10 changes: 3 additions & 7 deletions desktop/src/shared/ui/markdown/LinkPreviewImageLightbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ImageLightboxCornerRadii,
imageLightboxBoxFromRect,
imageLightboxCornerRadiiFromElement,
imageLightboxSourceScopeForTrigger,
visibleImageGalleryForTrigger,
} from "./imageLightbox";

Expand All @@ -18,17 +19,13 @@ type ImageZoomOverlayProps = {
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> {
Expand All @@ -52,7 +49,7 @@ export function createLinkPreviewImageLightbox(

const sourceBox = imageLightboxBoxFromRect(rect);
const sourceCornerRadii = imageLightboxCornerRadiiFromElement(image);
const sourceScope = trigger.closest("[data-link-preview-list]");
const sourceScope = imageLightboxSourceScopeForTrigger(trigger);
const dim =
image.naturalWidth > 0 && image.naturalHeight > 0
? `${image.naturalWidth}x${image.naturalHeight}`
Expand All @@ -62,6 +59,7 @@ export function createLinkPreviewImageLightbox(
{
alt,
dim,
trigger,
resolvedSrc: src,
src: undefined,
thumbnailBox: sourceBox,
Expand Down Expand Up @@ -103,8 +101,6 @@ export function createLinkPreviewImageLightbox(
galleryIndex={lightboxState.galleryIndex}
galleryItems={lightboxState.galleryItems}
onClose={() => setLightboxState(null)}
onCopy={ignoreUnavailableImageAction}
onDownload={ignoreUnavailableImageAction}
resolvedSrc={src}
sourceBox={lightboxState.sourceBox}
sourceCornerRadii={lightboxState.sourceCornerRadii}
Expand Down
12 changes: 9 additions & 3 deletions desktop/src/shared/ui/markdown/imageLightbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type ImageGalleryDirection = "forward" | "backward";
export type ImageGalleryItem = {
alt: string | undefined;
dim?: string;
trigger?: HTMLElement;
resolvedSrc: string;
src: string | undefined;
thumbnailBox?: ImageLightboxBox;
Expand Down Expand Up @@ -339,12 +340,15 @@ function imageLightboxThumbnailTargetForItem(
sourceScope: Element | null | undefined,
): ImageLightboxThumbnailTarget | null {
const root = sourceScope?.isConnected ? sourceScope : document.body;
const triggers = Array.from(
root.querySelectorAll<HTMLElement>("[data-image-lightbox-trigger]"),
);
const triggers = item.trigger
? [item.trigger]
: Array.from(
root.querySelectorAll<HTMLElement>("[data-image-lightbox-trigger]"),
);

for (const trigger of triggers) {
const isCurrentItem =
item.trigger != null ||
trigger.dataset.imageLightboxResolvedSrc === item.resolvedSrc ||
(item.src != null && trigger.dataset.imageLightboxSrc === item.src);
if (!isCurrentItem) {
Expand Down Expand Up @@ -386,6 +390,7 @@ export function imageLightboxSourceScopeForTrigger(
trigger: HTMLElement,
): Element | null {
return (
trigger.closest("[data-image-gallery-scope]") ??
trigger.closest(IMAGE_LIGHTBOX_MARKDOWN_SCOPE_SELECTOR) ??
trigger.closest("[data-testid='message-row']")
);
Expand All @@ -409,6 +414,7 @@ function imageGalleryItemFromTrigger(
return {
alt: trigger.dataset.imageLightboxAlt || undefined,
dim: trigger.dataset.imageLightboxDim || inferredDim,
trigger,
resolvedSrc,
src: trigger.dataset.imageLightboxSrc || undefined,
thumbnailBox: thumbnail?.box,
Expand Down
Loading