Skip to content

Commit f4ab218

Browse files
fix(desktop): eliminate image-hover layout jump in messages (block#813)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8332c5d commit f4ab218

1 file changed

Lines changed: 96 additions & 94 deletions

File tree

desktop/src/shared/ui/markdown.tsx

Lines changed: 96 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,29 @@ type MarkdownProps = {
143143

144144
type MarkdownVariant = "default" | "compact" | "tight";
145145

146-
function ImageContextMenu({
147-
children,
146+
/**
147+
* Inline image embed with click-to-zoom lightbox and right-click download.
148+
*
149+
* IMPORTANT: this component renders the inline `<img>` with NO wrapping div
150+
* and drives the Dialog via controlled `open` state — *not* via Radix's
151+
* `<Trigger asChild>` cloning onto a wrapper div. An earlier version used
152+
* the Trigger-asChild pattern around a `<div>` around the `<img>`, which
153+
* caused a 1-2px layout reflow in the surrounding message body on hover
154+
* (the gap between the username header and the body would visibly grow).
155+
* The nested wrapper divs + Radix attribute cloning were repainting the
156+
* surrounding inline flow on `:hover` state changes. Keeping the `<img>`
157+
* bare and managing the lightbox + context menu via React state avoids it.
158+
*/
159+
function ImageBlock({
160+
alt,
161+
resolvedSrc,
148162
src,
149163
}: {
150-
children: React.ReactNode;
164+
alt: string | undefined;
165+
resolvedSrc: string | undefined;
151166
src: string | undefined;
152167
}) {
168+
const [lightboxOpen, setLightboxOpen] = React.useState(false);
153169
const [menu, setMenu] = React.useState<{ x: number; y: number } | null>(null);
154170

155171
React.useEffect(() => {
@@ -179,41 +195,93 @@ function ImageContextMenu({
179195
};
180196
}, [menu]);
181197

198+
const handleContextMenu = (e: React.MouseEvent) => {
199+
e.preventDefault();
200+
e.stopPropagation();
201+
e.nativeEvent.stopImmediatePropagation();
202+
setMenu({ x: e.clientX, y: e.clientY });
203+
};
204+
205+
const handleDownload = () => {
206+
setMenu(null);
207+
if (!src) return;
208+
invokeTauri("download_image", { url: src }).catch((err: unknown) => {
209+
const msg = err instanceof Error ? err.message : "Download failed";
210+
toast.error(msg);
211+
});
212+
};
213+
182214
return (
183215
<>
184-
<div
185-
onContextMenuCapture={(e) => {
186-
e.preventDefault();
187-
e.stopPropagation();
188-
e.nativeEvent.stopImmediatePropagation();
189-
setMenu({ x: e.clientX, y: e.clientY });
190-
}}
191-
>
192-
{children}
193-
</div>
194-
{menu && src && (
216+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: image opens lightbox on click; keyboard equivalent handled by lightbox close button */}
217+
<img
218+
alt={alt}
219+
className="mt-1 block max-h-64 max-w-sm cursor-pointer rounded-xl object-contain"
220+
src={resolvedSrc}
221+
onClick={() => setLightboxOpen(true)}
222+
onContextMenuCapture={handleContextMenu}
223+
/>
224+
{menu && src ? (
195225
<div
196226
className="fixed z-[100] min-w-[160px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
197227
style={{ left: menu.x, top: menu.y }}
198228
>
199229
<button
200230
type="button"
201231
className="flex w-full cursor-default select-none items-center rounded-xs px-2 py-1.5 text-sm outline-hidden hover:bg-accent hover:text-accent-foreground"
202-
onClick={() => {
203-
setMenu(null);
204-
invokeTauri("download_image", { url: src }).catch(
205-
(err: unknown) => {
206-
const msg =
207-
err instanceof Error ? err.message : "Download failed";
208-
toast.error(msg);
209-
},
210-
);
211-
}}
232+
onClick={handleDownload}
212233
>
213234
Download image
214235
</button>
215236
</div>
216-
)}
237+
) : null}
238+
<DialogPrimitive.Root open={lightboxOpen} onOpenChange={setLightboxOpen}>
239+
<DialogPrimitive.Portal>
240+
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
241+
<DialogPrimitive.Content
242+
className="fixed inset-0 z-50 flex items-center justify-center p-8"
243+
onPointerDownOutside={(e) => e.preventDefault()}
244+
onInteractOutside={(e) => e.preventDefault()}
245+
>
246+
<DialogPrimitive.Title className="sr-only">
247+
{alt || "Image preview"}
248+
</DialogPrimitive.Title>
249+
<DialogPrimitive.Description className="sr-only">
250+
Full-size image preview. Press Escape or click outside the image
251+
to close.
252+
</DialogPrimitive.Description>
253+
{/* Clicking anywhere except the image closes the dialog. */}
254+
<DialogPrimitive.Close
255+
className="absolute inset-0 cursor-default"
256+
aria-label="Close lightbox"
257+
/>
258+
<img
259+
alt={alt}
260+
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
261+
src={resolvedSrc}
262+
onContextMenuCapture={handleContextMenu}
263+
/>
264+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-hidden focus:ring-2 focus:ring-white/30">
265+
<svg
266+
aria-hidden="true"
267+
xmlns="http://www.w3.org/2000/svg"
268+
width="20"
269+
height="20"
270+
viewBox="0 0 24 24"
271+
fill="none"
272+
stroke="currentColor"
273+
strokeWidth="2"
274+
strokeLinecap="round"
275+
strokeLinejoin="round"
276+
>
277+
<line x1="18" y1="6" x2="6" y2="18" />
278+
<line x1="6" y1="6" x2="18" y2="18" />
279+
</svg>
280+
<span className="sr-only">Close</span>
281+
</DialogPrimitive.Close>
282+
</DialogPrimitive.Content>
283+
</DialogPrimitive.Portal>
284+
</DialogPrimitive.Root>
217285
</>
218286
);
219287
}
@@ -669,74 +737,8 @@ function createMarkdownComponents(
669737
);
670738
}
671739
return (
672-
<span data-block-media="">
673-
<ImageContextMenu src={src}>
674-
<DialogPrimitive.Root>
675-
<DialogPrimitive.Trigger asChild>
676-
<div
677-
className="mt-1 max-w-sm cursor-pointer"
678-
onPointerDown={(e) => {
679-
if (e.button !== 0) e.preventDefault();
680-
}}
681-
>
682-
<img
683-
alt={alt}
684-
className="max-h-64 max-w-full rounded-xl object-contain"
685-
src={resolvedSrc}
686-
onContextMenu={(e) => e.preventDefault()}
687-
/>
688-
</div>
689-
</DialogPrimitive.Trigger>
690-
<DialogPrimitive.Portal>
691-
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
692-
<DialogPrimitive.Content
693-
className="fixed inset-0 z-50 flex items-center justify-center p-8"
694-
// Let clicks on the backdrop (the content container itself) close the lightbox
695-
onPointerDownOutside={(e) => e.preventDefault()}
696-
onInteractOutside={(e) => e.preventDefault()}
697-
>
698-
<DialogPrimitive.Title className="sr-only">
699-
{alt || "Image preview"}
700-
</DialogPrimitive.Title>
701-
<DialogPrimitive.Description className="sr-only">
702-
Full-size image preview. Press Escape or click outside the
703-
image to close.
704-
</DialogPrimitive.Description>
705-
{/* Close region: clicking anywhere except the image closes the dialog */}
706-
<DialogPrimitive.Close
707-
className="absolute inset-0 cursor-default"
708-
aria-label="Close lightbox"
709-
/>
710-
<ImageContextMenu src={src}>
711-
<img
712-
alt={alt}
713-
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
714-
src={resolvedSrc}
715-
onContextMenu={(e) => e.preventDefault()}
716-
/>
717-
</ImageContextMenu>
718-
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-hidden focus:ring-2 focus:ring-white/30">
719-
<svg
720-
aria-hidden="true"
721-
xmlns="http://www.w3.org/2000/svg"
722-
width="20"
723-
height="20"
724-
viewBox="0 0 24 24"
725-
fill="none"
726-
stroke="currentColor"
727-
strokeWidth="2"
728-
strokeLinecap="round"
729-
strokeLinejoin="round"
730-
>
731-
<line x1="18" y1="6" x2="6" y2="18" />
732-
<line x1="6" y1="6" x2="18" y2="18" />
733-
</svg>
734-
<span className="sr-only">Close</span>
735-
</DialogPrimitive.Close>
736-
</DialogPrimitive.Content>
737-
</DialogPrimitive.Portal>
738-
</DialogPrimitive.Root>
739-
</ImageContextMenu>
740+
<span data-block-media="" className="block">
741+
<ImageBlock alt={alt} resolvedSrc={resolvedSrc} src={src} />
740742
</span>
741743
);
742744
},
@@ -754,7 +756,7 @@ function createMarkdownComponents(
754756

755757
if (isImageOnlyParagraph(childArray)) {
756758
return (
757-
<div className="mt-1 grid max-w-lg grid-cols-2 gap-1.5 [&_br]:hidden [&_div]:mt-0 [&_div]:max-w-none">
759+
<div className="mt-1 grid 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">
758760
{imageChildren}
759761
</div>
760762
);

0 commit comments

Comments
 (0)