Skip to content

Commit 7863d47

Browse files
jaffrepaulcursoragentgetsantry[bot]sergical
authored
Update image handling (#14564)
## DESCRIBE YOUR PR This PR implements a modern image lightbox for all documentation images, enhancing the user experience. Previously, clicking an image would open the raw image file in a new browser tab. This change introduces an in-page modal overlay for images, leveraging Radix UI Dialog for accessibility and a smooth user experience. Key features: - Images now open in a responsive lightbox overlay on click. - Preserves existing functionality: right-click or Ctrl/Cmd+click still opens the image in a new tab. - Includes accessibility features (keyboard navigation, ARIA labels). - Adds subtle CSS animations for opening/closing the lightbox. ## IS YOUR CHANGE URGENT? Help us prioritize incoming PRs by letting us know when the change needs to go live. - [ ] Urgent deadline (GA date, etc.): - [ ] Other deadline: - [x] None: Not urgent, can wait up to 1 week+ ## SLA - Teamwork makes the dream work, so please add a reviewer to your PRs. - Please give the docs team up to 1 week to review your PR unless you've added an urgent due date to it. Thanks in advance for your help! ## PRE-MERGE CHECKLIST *Make sure you've checked the following before merging your changes:* - [ ] Checked Vercel preview for correctness, including links - [ ] PR was reviewed and approved by any necessary SMEs (subject matter experts) - [ ] PR was reviewed and approved by a member of the [Sentry docs team](https://github.com/orgs/getsentry/teams/docs) ## LEGAL BOILERPLATE Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms. ## EXTRA RESOURCES - [Sentry Doc --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> Co-authored-by: Sergiy Dybskiy <[email protected]>
1 parent 05c4eba commit 7863d47

File tree

9 files changed

+458
-39
lines changed

9 files changed

+458
-39
lines changed

app/globals.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,6 @@ body {
177177

178178
.onboarding-step .step-heading::before,
179179
.onboarding-step h2::before {
180-
content: "Step " counter(onboarding-step) ": ";
180+
content: 'Step ' counter(onboarding-step) ': ';
181181
font-weight: inherit;
182182
}

next.config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {codecovNextJSWebpackPlugin} from '@codecov/nextjs-webpack-plugin';
22
import {withSentryConfig} from '@sentry/nextjs';
33

44
import {redirects} from './redirects.js';
5+
import {REMOTE_IMAGE_PATTERNS} from './src/config/images';
56

67
const outputFileTracingExcludes = process.env.NEXT_PUBLIC_DEVELOPER_DOCS
78
? {
@@ -54,6 +55,10 @@ const nextConfig = {
5455
trailingSlash: true,
5556
serverExternalPackages: ['rehype-preset-minify'],
5657
outputFileTracingExcludes,
58+
images: {
59+
contentDispositionType: 'inline', // "open image in new tab" instead of downloading
60+
remotePatterns: REMOTE_IMAGE_PATTERNS,
61+
},
5762
webpack: (config, options) => {
5863
config.plugins.push(
5964
codecovNextJSWebpackPlugin({
@@ -71,7 +76,7 @@ const nextConfig = {
7176
DEVELOPER_DOCS_: process.env.NEXT_PUBLIC_DEVELOPER_DOCS,
7277
},
7378
redirects,
74-
rewrites: async () => [
79+
rewrites: () => [
7580
{
7681
source: '/:path*.md',
7782
destination: '/md-exports/:path*.md',

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"@prettier/plugin-xml": "^3.3.1",
4848
"@radix-ui/colors": "^3.0.0",
4949
"@radix-ui/react-collapsible": "^1.1.1",
50+
"@radix-ui/react-dialog": "^1.1.14",
5051
"@radix-ui/react-dropdown-menu": "^2.1.2",
5152
"@radix-ui/react-icons": "^1.3.2",
5253
"@radix-ui/react-tabs": "^1.1.1",

src/components/docImage.tsx

Lines changed: 95 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,68 @@
11
import path from 'path';
22

3-
import Image from 'next/image';
4-
3+
import {isExternalImage} from 'sentry-docs/config/images';
54
import {serverContext} from 'sentry-docs/serverContext';
65

6+
import {ImageLightbox} from './imageLightbox';
7+
8+
// Helper function to safely parse dimension values
9+
const parseDimension = (value: string | number | undefined): number | undefined => {
10+
if (typeof value === 'number' && value > 0 && value <= 10000) return value;
11+
if (typeof value === 'string') {
12+
const parsed = parseInt(value, 10);
13+
return parsed > 0 && parsed <= 10000 ? parsed : undefined;
14+
}
15+
return undefined;
16+
};
17+
18+
// Dimension pattern regex - used to identify dimension hashes like "800x600"
19+
const DIMENSION_PATTERN = /^(\d+)x(\d+)$/;
20+
21+
// Helper function to extract hash from URL string (works with both relative and absolute URLs)
22+
const extractHash = (url: string): string => {
23+
const hashIndex = url.indexOf('#');
24+
return hashIndex !== -1 ? url.slice(hashIndex + 1) : '';
25+
};
26+
27+
// Helper function to check if a hash contains dimension information
28+
const isDimensionHash = (hash: string): boolean => {
29+
return DIMENSION_PATTERN.test(hash);
30+
};
31+
32+
// Helper function to parse dimensions from URL hash
33+
const parseDimensionsFromHash = (url: string): number[] => {
34+
const hash = extractHash(url);
35+
const match = hash.match(DIMENSION_PATTERN);
36+
37+
if (match) {
38+
const width = parseInt(match[1], 10);
39+
const height = parseInt(match[2], 10);
40+
return width > 0 && width <= 10000 && height > 0 && height <= 10000
41+
? [width, height]
42+
: [];
43+
}
44+
45+
return [];
46+
};
47+
48+
// Helper function to remove dimension hash from URL while preserving fragment identifiers
49+
const cleanUrl = (url: string): string => {
50+
const hash = extractHash(url);
51+
52+
// If no hash or hash is not a dimension pattern, return original URL
53+
if (!hash || !isDimensionHash(hash)) {
54+
return url;
55+
}
56+
57+
// Remove dimension hash
58+
const hashIndex = url.indexOf('#');
59+
return url.slice(0, hashIndex);
60+
};
61+
762
export default function DocImage({
863
src,
64+
width: propsWidth,
65+
height: propsHeight,
966
...props
1067
}: Omit<React.HTMLProps<HTMLImageElement>, 'ref' | 'placeholder'>) {
1168
const {path: pagePath} = serverContext();
@@ -14,44 +71,46 @@ export default function DocImage({
1471
return null;
1572
}
1673

17-
// Next.js Image component only supports images from the public folder
18-
// or from a remote server with properly configured domain
19-
if (src.startsWith('http')) {
20-
// eslint-disable-next-line @next/next/no-img-element
21-
return <img src={src} {...props} />;
22-
}
74+
const isExternal = isExternalImage(src);
75+
let finalSrc = src;
76+
let imgPath = src;
2377

24-
// If the image src is not an absolute URL, we assume it's a relative path
25-
// and we prepend /mdx-images/ to it.
26-
if (src.startsWith('./')) {
27-
src = path.join('/mdx-images', src);
28-
}
29-
// account for the old way of doing things where the public folder structure mirrored the docs folder
30-
else if (!src?.startsWith('/') && !src?.includes('://')) {
31-
src = `/${pagePath.join('/')}/${src}`;
78+
// For internal images, process the path
79+
if (!isExternal) {
80+
if (src.startsWith('./')) {
81+
finalSrc = path.join('/mdx-images', src);
82+
} else if (!src?.startsWith('/') && !src?.includes('://')) {
83+
finalSrc = `/${pagePath.join('/')}/${src}`;
84+
}
85+
86+
// For internal images, imgPath should be the pathname only
87+
try {
88+
const srcURL = new URL(finalSrc, 'https://example.com');
89+
imgPath = srcURL.pathname;
90+
} catch (_error) {
91+
imgPath = finalSrc;
92+
}
93+
} else {
94+
// For external images, clean URL by removing only dimension hashes, preserving fragment identifiers
95+
finalSrc = cleanUrl(src);
96+
imgPath = finalSrc;
3297
}
3398

34-
// parse the size from the URL hash (set by remark-image-size.js)
35-
const srcURL = new URL(src, 'https://example.com');
36-
const imgPath = srcURL.pathname;
37-
const [width, height] = srcURL.hash // #wxh
38-
.slice(1)
39-
.split('x')
40-
.map(s => parseInt(s, 10));
99+
// Parse dimensions from URL hash (works for both internal and external)
100+
const hashDimensions = parseDimensionsFromHash(src);
101+
102+
// Use hash dimensions first, fallback to props
103+
const width = hashDimensions[0] > 0 ? hashDimensions[0] : parseDimension(propsWidth);
104+
const height = hashDimensions[1] > 0 ? hashDimensions[1] : parseDimension(propsHeight);
41105

42106
return (
43-
<a href={imgPath} target="_blank" rel="noreferrer">
44-
<Image
45-
{...props}
46-
src={src}
47-
width={width}
48-
height={height}
49-
style={{
50-
width: '100%',
51-
height: 'auto',
52-
}}
53-
alt={props.alt ?? ''}
54-
/>
55-
</a>
107+
<ImageLightbox
108+
src={finalSrc}
109+
imgPath={imgPath}
110+
width={width}
111+
height={height}
112+
alt={props.alt ?? ''}
113+
{...props}
114+
/>
56115
);
57116
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
'use client';
2+
3+
import {useState} from 'react';
4+
import Image from 'next/image';
5+
6+
import {Lightbox} from 'sentry-docs/components/lightbox';
7+
import {isAllowedRemoteImage, isExternalImage} from 'sentry-docs/config/images';
8+
9+
interface ImageLightboxProps
10+
extends Omit<
11+
React.HTMLProps<HTMLImageElement>,
12+
'ref' | 'src' | 'width' | 'height' | 'alt'
13+
> {
14+
alt: string;
15+
imgPath: string;
16+
src: string;
17+
height?: number;
18+
width?: number;
19+
}
20+
21+
const getImageUrl = (src: string, imgPath: string): string => {
22+
if (isExternalImage(src)) {
23+
// Normalize protocol-relative URLs to use https:
24+
return src.startsWith('//') ? `https:${src}` : src;
25+
}
26+
return imgPath;
27+
};
28+
29+
type ValidDimensions = {
30+
height: number;
31+
width: number;
32+
};
33+
34+
const getValidDimensions = (width?: number, height?: number): ValidDimensions | null => {
35+
if (
36+
width != null &&
37+
height != null &&
38+
!isNaN(width) &&
39+
!isNaN(height) &&
40+
width > 0 &&
41+
height > 0
42+
) {
43+
return {width, height};
44+
}
45+
return null;
46+
};
47+
48+
export function ImageLightbox({
49+
src,
50+
alt,
51+
width,
52+
height,
53+
imgPath,
54+
style,
55+
className,
56+
...props
57+
}: ImageLightboxProps) {
58+
const [open, setOpen] = useState(false);
59+
60+
const dimensions = getValidDimensions(width, height);
61+
const shouldUseNextImage =
62+
!!dimensions && (!isExternalImage(src) || isAllowedRemoteImage(src));
63+
64+
const openInNewTab = () => {
65+
window.open(getImageUrl(src, imgPath), '_blank', 'noopener,noreferrer');
66+
};
67+
68+
const handleClick = (e: React.MouseEvent) => {
69+
// Middle-click or Ctrl/Cmd+click opens in new tab
70+
if (e.button === 1 || e.ctrlKey || e.metaKey) {
71+
e.preventDefault();
72+
e.stopPropagation();
73+
openInNewTab();
74+
}
75+
// Regular click is handled by Dialog.Trigger
76+
};
77+
78+
const handleKeyDown = (e: React.KeyboardEvent) => {
79+
// Ctrl/Cmd+Enter/Space opens in new tab
80+
// Regular Enter/Space is handled by Dialog.Trigger
81+
if ((e.key === 'Enter' || e.key === ' ') && (e.ctrlKey || e.metaKey)) {
82+
e.preventDefault();
83+
e.stopPropagation();
84+
openInNewTab();
85+
}
86+
};
87+
88+
// Filter out props that are incompatible with Next.js Image component
89+
// Next.js Image has stricter typing for certain props like 'placeholder'
90+
const {placeholder: _placeholder, ...imageCompatibleProps} = props;
91+
92+
const renderImage = (isInline: boolean = true) => {
93+
const renderedSrc = getImageUrl(src, imgPath);
94+
const imageClassName = isInline
95+
? className
96+
: 'max-h-[90vh] max-w-[90vw] object-contain';
97+
const imageStyle = isInline
98+
? {width: '100%', height: 'auto', ...style}
99+
: {width: 'auto', height: 'auto'};
100+
101+
if (shouldUseNextImage && dimensions) {
102+
return (
103+
<Image
104+
src={renderedSrc}
105+
width={dimensions.width}
106+
height={dimensions.height}
107+
style={imageStyle}
108+
className={imageClassName}
109+
alt={alt}
110+
priority={!isInline}
111+
{...imageCompatibleProps}
112+
/>
113+
);
114+
}
115+
return (
116+
/* eslint-disable-next-line @next/next/no-img-element */
117+
<img
118+
src={renderedSrc}
119+
alt={alt}
120+
loading={isInline ? 'lazy' : 'eager'}
121+
decoding="async"
122+
style={imageStyle}
123+
className={imageClassName}
124+
{...imageCompatibleProps}
125+
/>
126+
);
127+
};
128+
129+
return (
130+
<Lightbox.Root open={open} onOpenChange={setOpen} content={renderImage(false)}>
131+
<Lightbox.Trigger
132+
onClick={handleClick}
133+
onAuxClick={handleClick}
134+
onKeyDown={handleKeyDown}
135+
className="cursor-pointer border-none bg-transparent p-0 block w-full no-underline"
136+
aria-label={`View image: ${alt}`}
137+
>
138+
{renderImage()}
139+
</Lightbox.Trigger>
140+
</Lightbox.Root>
141+
);
142+
}

0 commit comments

Comments
 (0)