Skip to content
Open
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
38 changes: 37 additions & 1 deletion src/components/common/ConversationTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLImageElement>) => (
<MarkdownImage {...props} />
),
a: ({
href,
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
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<HTMLImageElement>;
if (imgProps.src) {
return <MarkdownImage src={imgProps.src} alt={imgProps.alt} />;
}
}
}
return (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
);
},
};

/** A comment or the body rendered in the conversation timeline. */
export type ConversationItem = {
id: string;
Expand Down Expand Up @@ -337,8 +369,11 @@ const ConversationTimeline: React.FC<ConversationTimelineProps> = ({
backgroundColor: colors.border.default,
border: 0,
},
'& img': {
'& .markdown-body img': {
maxWidth: '100%',
width: 'auto',
height: 'auto',
objectFit: 'contain',
borderRadius: '6px',
backgroundColor: 'transparent',
},
Expand All @@ -348,6 +383,7 @@ const ConversationTimeline: React.FC<ConversationTimelineProps> = ({
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={conversationMarkdownComponents}
>
{item.body}
</ReactMarkdown>
Expand Down
166 changes: 166 additions & 0 deletions src/components/common/ImageLightbox.tsx
Original file line number Diff line number Diff line change
@@ -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<ImageLightboxProps> = ({
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 (
<Modal
open={open}
onClose={onClose}
closeAfterTransition
aria-labelledby="image-lightbox-title"
BackdropProps={{
sx: {
backgroundColor: 'rgba(0, 0, 0, 0.92)',
backdropFilter: 'blur(4px)',
},
}}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: (theme) => theme.zIndex.modal + 2,
}}
>
<Fade in={open}>
<Box
role="dialog"
aria-modal="true"
onClick={onClose}
sx={{
position: 'fixed',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: { xs: 2, sm: 3 },
outline: 'none',
}}
>
<Box
sx={{
position: 'absolute',
top: { xs: 12, sm: 20 },
right: { xs: 12, sm: 20 },
display: 'flex',
gap: 1,
zIndex: 1,
}}
onClick={(event) => event.stopPropagation()}
>
<IconButton
component="a"
href={src}
target="_blank"
rel="noopener noreferrer"
aria-label="Open image in new tab"
sx={{
color: 'common.white',
backgroundColor: 'rgba(255, 255, 255, 0.12)',
'&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.22)' },
}}
>
<OpenInNewIcon />
</IconButton>
<IconButton
onClick={onClose}
aria-label="Close image preview"
sx={{
color: 'common.white',
backgroundColor: 'rgba(255, 255, 255, 0.12)',
'&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.22)' },
}}
>
<CloseIcon />
</IconButton>
</Box>

<Box
onClick={(event) => event.stopPropagation()}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
maxWidth: '100%',
maxHeight: '100%',
}}
>
<Box
component="img"
id="image-lightbox-title"
src={src}
alt={alt ?? 'Enlarged screenshot'}
sx={{
display: 'block',
maxWidth: 'min(96vw, 1600px)',
maxHeight: 'calc(92vh - 48px)',
width: 'auto',
height: 'auto',
objectFit: 'contain',
borderRadius: 1,
boxShadow: '0 24px 80px rgba(0, 0, 0, 0.55)',
}}
/>
{alt ? (
<Typography
variant="body2"
sx={{
mt: 1.5,
color: 'rgba(255, 255, 255, 0.72)',
textAlign: 'center',
maxWidth: 'min(96vw, 720px)',
}}
>
{alt}
</Typography>
) : (
<Typography
variant="caption"
sx={{
mt: 1.5,
color: 'rgba(255, 255, 255, 0.5)',
}}
>
Press Esc or click outside to close
</Typography>
)}
</Box>
</Box>
</Fade>
</Modal>
);
};
125 changes: 125 additions & 0 deletions src/components/common/MarkdownImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import React, { useState } from 'react';
import { Box, Typography, alpha } from '@mui/material';
import { ImageLightbox } from './ImageLightbox';

type MarkdownImageProps = React.ImgHTMLAttributes<HTMLImageElement>;

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<MarkdownImageProps> = ({
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 (
<>
<Box
sx={{
display: 'inline-block',
maxWidth: '100%',
my: 2,
}}
>
<Box
component="button"
type="button"
onClick={(event) => 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,
},
}}
>
<Box
component="img"
src={src}
alt={alt ?? ''}
loading="lazy"
decoding="async"
draggable={false}
sx={{
display: 'block',
maxWidth: '100%',
width: 'auto',
height: 'auto',
objectFit: 'contain',
borderRadius: '6px',
backgroundColor: 'transparent',
pointerEvents: 'none',
}}
{...rest}
/>
</Box>
<Typography
variant="caption"
sx={(theme) => ({
display: 'block',
mt: 0.5,
color: alpha(theme.palette.text.secondary, 0.85),
fontSize: '0.7rem',
letterSpacing: '0.02em',
})}
>
Click to enlarge
</Typography>
</Box>

<ImageLightbox
open={lightboxOpen}
src={src}
alt={alt}
onClose={() => setLightboxOpen(false)}
/>
</>
);
};

/** GitHub often wraps screenshots in `<a><img /></a>` — 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;
};
2 changes: 2 additions & 0 deletions src/components/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';