@@ -143,13 +143,29 @@ type MarkdownProps = {
143143
144144type 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