diff --git a/src/components/common/ConversationTimeline.tsx b/src/components/common/ConversationTimeline.tsx index e009f219..991de5f1 100644 --- a/src/components/common/ConversationTimeline.tsx +++ b/src/components/common/ConversationTimeline.tsx @@ -15,9 +15,41 @@ import rehypeRaw from 'rehype-raw'; import { formatDate } from '../../utils/format'; import { getGithubAvatarSrc } from '../../utils/ExplorerUtils'; import { STATUS_COLORS, scrollbarSx } from '../../theme'; +import { isImageOnlyLinkChild, MarkdownImage } from './MarkdownImage'; import 'github-markdown-css/github-markdown-dark.css'; +const conversationMarkdownComponents = { + img: (props: React.ImgHTMLAttributes) => ( + + ), + a: ({ + href, + children, + ...props + }: React.AnchorHTMLAttributes) => { + if (isImageOnlyLinkChild(children)) { + const nodes = React.Children.toArray(children); + const only = nodes[0]; + if (React.isValidElement(only) && only.type === MarkdownImage) { + return <>{children}; + } + if (React.isValidElement(only)) { + const imgProps = + only.props as React.ImgHTMLAttributes; + if (imgProps.src) { + return ; + } + } + } + return ( + + {children} + + ); + }, +}; + /** A comment or the body rendered in the conversation timeline. */ export type ConversationItem = { id: string; @@ -337,8 +369,11 @@ const ConversationTimeline: React.FC = ({ backgroundColor: colors.border.default, border: 0, }, - '& img': { + '& .markdown-body img': { maxWidth: '100%', + width: 'auto', + height: 'auto', + objectFit: 'contain', borderRadius: '6px', backgroundColor: 'transparent', }, @@ -348,6 +383,7 @@ const ConversationTimeline: React.FC = ({ {item.body} diff --git a/src/components/common/ImageLightbox.tsx b/src/components/common/ImageLightbox.tsx new file mode 100644 index 00000000..1aeddfb1 --- /dev/null +++ b/src/components/common/ImageLightbox.tsx @@ -0,0 +1,166 @@ +import React, { useCallback, useEffect } from 'react'; +import { Box, Fade, IconButton, Modal, Typography } from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; + +export interface ImageLightboxProps { + open: boolean; + src: string; + alt?: string; + onClose: () => void; +} + +/** + * Full-screen image preview — opened when a markdown/issue screenshot is clicked. + */ +export const ImageLightbox: React.FC = ({ + open, + src, + alt, + onClose, +}) => { + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }, + [onClose], + ); + + useEffect(() => { + if (!open) { + return undefined; + } + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [open, handleKeyDown]); + + return ( + theme.zIndex.modal + 2, + }} + > + + + event.stopPropagation()} + > + + + + + + + + + event.stopPropagation()} + sx={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + maxWidth: '100%', + maxHeight: '100%', + }} + > + + {alt ? ( + + {alt} + + ) : ( + + Press Esc or click outside to close + + )} + + + + + ); +}; diff --git a/src/components/common/MarkdownImage.tsx b/src/components/common/MarkdownImage.tsx new file mode 100644 index 00000000..e75707c3 --- /dev/null +++ b/src/components/common/MarkdownImage.tsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react'; +import { Box, Typography, alpha } from '@mui/material'; +import { ImageLightbox } from './ImageLightbox'; + +type MarkdownImageProps = React.ImgHTMLAttributes; + +const openLightboxFromEvent = ( + event: React.MouseEvent | React.KeyboardEvent, + open: () => void, +) => { + event.preventDefault(); + event.stopPropagation(); + open(); +}; + +/** + * Renders markdown/HTML images with preserved aspect ratio and a click-to-zoom lightbox. + */ +export const MarkdownImage: React.FC = ({ + src, + alt, + style: _inlineStyle, + width: _width, + height: _height, + onClick: _onClick, + ...rest +}) => { + const [lightboxOpen, setLightboxOpen] = useState(false); + + if (!src) { + return null; + } + + const openLightbox = () => setLightboxOpen(true); + + return ( + <> + + openLightboxFromEvent(event, openLightbox)} + aria-label={alt ? `Enlarge image: ${alt}` : 'Enlarge image'} + sx={{ + display: 'block', + p: 0, + m: 0, + border: 'none', + background: 'none', + cursor: 'zoom-in', + maxWidth: '100%', + borderRadius: '6px', + overflow: 'hidden', + '&:focus-visible': { + outline: '2px solid', + outlineColor: 'primary.main', + outlineOffset: 2, + }, + }} + > + + + ({ + display: 'block', + mt: 0.5, + color: alpha(theme.palette.text.secondary, 0.85), + fontSize: '0.7rem', + letterSpacing: '0.02em', + })} + > + Click to enlarge + + + + setLightboxOpen(false)} + /> + + ); +}; + +/** GitHub often wraps screenshots in `` — unwrap so the lightbox receives clicks. */ +export const isImageOnlyLinkChild = (children: React.ReactNode): boolean => { + const nodes = React.Children.toArray(children); + if (nodes.length !== 1 || !React.isValidElement(nodes[0])) { + return false; + } + const child = nodes[0]; + if (child.type === MarkdownImage) { + return true; + } + if (child.type === 'img' || (child.props as { src?: string }).src) { + return true; + } + return false; +}; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 81a26298..26e0aafd 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -5,3 +5,5 @@ export * from './WatchlistButton'; export * from './DataTable'; export { default as ConversationTimeline } from './ConversationTimeline'; export * from './ConversationTimeline'; +export { MarkdownImage } from './MarkdownImage'; +export { ImageLightbox } from './ImageLightbox';