From 76137dd051eeecf08cd28dec92fe87628d4490b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 5 Aug 2025 22:48:46 +0000 Subject: [PATCH 01/37] Add image lightbox with Radix UI Dialog and improved image handling Co-authored-by: paul.jaffre --- app/globals.css | 57 ++++++++++++++++++++++++++ package.json | 1 + src/components/docImage.tsx | 25 +++++------- src/components/docImageClient.tsx | 66 +++++++++++++++++++++++++++++++ src/components/imageLightbox.tsx | 64 ++++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 15 deletions(-) create mode 100644 src/components/docImageClient.tsx create mode 100644 src/components/imageLightbox.tsx diff --git a/app/globals.css b/app/globals.css index de7c493bb4107..53ad06d03d4c1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -180,3 +180,60 @@ body { content: "Step " counter(onboarding-step) ": "; font-weight: inherit; } + +/* Lightbox animations */ +@keyframes dialog-content-show { + from { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +@keyframes dialog-content-hide { + from { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + to { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } +} + +@keyframes dialog-overlay-show { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes dialog-overlay-hide { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +[data-state="open"] { + animation: dialog-content-show 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +[data-state="closed"] { + animation: dialog-content-hide 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.dialog-overlay[data-state="open"] { + animation: dialog-overlay-show 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.dialog-overlay[data-state="closed"] { + animation: dialog-overlay-hide 200ms cubic-bezier(0.16, 1, 0.3, 1); +} diff --git a/package.json b/package.json index e5063ea4b06a6..ab6b5e372d613 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@prettier/plugin-xml": "^3.3.1", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-collapsible": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-tabs": "^1.1.1", diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index cef75042244ce..40eec39bd7acf 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -1,8 +1,7 @@ import path from 'path'; -import Image from 'next/image'; - import {serverContext} from 'sentry-docs/serverContext'; +import {DocImageClient} from './docImageClient'; export default function DocImage({ src, @@ -40,18 +39,14 @@ export default function DocImage({ .map(s => parseInt(s, 10)); return ( - - {props.alt - + ); } diff --git a/src/components/docImageClient.tsx b/src/components/docImageClient.tsx new file mode 100644 index 0000000000000..d970912b7a40e --- /dev/null +++ b/src/components/docImageClient.tsx @@ -0,0 +1,66 @@ +'use client'; + +import Image from 'next/image'; +import {ImageLightbox} from './imageLightbox'; + +interface DocImageClientProps { + src: string; + imgPath: string; + width: number; + height: number; + alt: string; + style?: React.CSSProperties; + className?: string; +} + +export function DocImageClient({ + src, + imgPath, + width, + height, + alt, + style, + className, +}: DocImageClientProps) { + const handleContextMenu = (e: React.MouseEvent) => { + // Allow right-click to open in new tab + const link = document.createElement('a'); + link.href = imgPath; + link.target = '_blank'; + link.rel = 'noreferrer'; + link.click(); + }; + + const handleClick = (e: React.MouseEvent) => { + // If Ctrl/Cmd+click, open in new tab instead of lightbox + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + window.open(imgPath, '_blank', 'noreferrer'); + } + }; + + return ( +
+ + {alt} + +
+ ); +} \ No newline at end of file diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox.tsx new file mode 100644 index 0000000000000..b60580bf22257 --- /dev/null +++ b/src/components/imageLightbox.tsx @@ -0,0 +1,64 @@ +'use client'; + +import {useState} from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import {X} from 'react-feather'; +import Image from 'next/image'; + +interface ImageLightboxProps { + src: string; + alt: string; + width: number; + height: number; + children: React.ReactNode; +} + +export function ImageLightbox({src, alt, width, height, children}: ImageLightboxProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + + + + {/* Close button */} + + + Close + + + {/* Image container */} +
+ {alt} +
+ + {/* Image caption */} + {alt && ( +
+

{alt}

+
+ )} +
+
+
+ ); +} \ No newline at end of file From 222fbb26be22fc2144485a0f713246381d50ca8e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 5 Aug 2025 23:01:28 +0000 Subject: [PATCH 02/37] Refactor DocImage components and improve type ordering Co-authored-by: paul.jaffre --- src/components/docImage.tsx | 1 + src/components/docImageClient.tsx | 11 ++++++----- src/components/imageLightbox.tsx | 8 ++++---- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index 40eec39bd7acf..c5e386de28380 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -1,6 +1,7 @@ import path from 'path'; import {serverContext} from 'sentry-docs/serverContext'; + import {DocImageClient} from './docImageClient'; export default function DocImage({ diff --git a/src/components/docImageClient.tsx b/src/components/docImageClient.tsx index d970912b7a40e..61eb324f9fcec 100644 --- a/src/components/docImageClient.tsx +++ b/src/components/docImageClient.tsx @@ -1,16 +1,17 @@ 'use client'; import Image from 'next/image'; + import {ImageLightbox} from './imageLightbox'; interface DocImageClientProps { - src: string; + alt: string; + height: number; imgPath: string; + src: string; width: number; - height: number; - alt: string; - style?: React.CSSProperties; className?: string; + style?: React.CSSProperties; } export function DocImageClient({ @@ -22,7 +23,7 @@ export function DocImageClient({ style, className, }: DocImageClientProps) { - const handleContextMenu = (e: React.MouseEvent) => { + const handleContextMenu = (_e: React.MouseEvent) => { // Allow right-click to open in new tab const link = document.createElement('a'); link.href = imgPath; diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox.tsx index b60580bf22257..c3210f3e76e6f 100644 --- a/src/components/imageLightbox.tsx +++ b/src/components/imageLightbox.tsx @@ -1,16 +1,16 @@ 'use client'; import {useState} from 'react'; -import * as Dialog from '@radix-ui/react-dialog'; import {X} from 'react-feather'; +import * as Dialog from '@radix-ui/react-dialog'; import Image from 'next/image'; interface ImageLightboxProps { - src: string; alt: string; - width: number; - height: number; children: React.ReactNode; + height: number; + src: string; + width: number; } export function ImageLightbox({src, alt, width, height, children}: ImageLightboxProps) { From 1d4c77088afca4f51b88b15fff4228daf4101c4b Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 23:02:12 +0000 Subject: [PATCH 03/37] [getsentry/action-github-commit] Auto commit --- src/components/docImageClient.tsx | 9 ++------- src/components/imageLightbox.tsx | 7 +++---- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/components/docImageClient.tsx b/src/components/docImageClient.tsx index 61eb324f9fcec..80962523d5a6c 100644 --- a/src/components/docImageClient.tsx +++ b/src/components/docImageClient.tsx @@ -43,12 +43,7 @@ export function DocImageClient({ return (
- +
); -} \ No newline at end of file +} diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox.tsx index c3210f3e76e6f..24117fa41ebf8 100644 --- a/src/components/imageLightbox.tsx +++ b/src/components/imageLightbox.tsx @@ -23,12 +23,11 @@ export function ImageLightbox({src, alt, width, height, children}: ImageLightbox {children} - + - + - {/* Close button */} @@ -61,4 +60,4 @@ export function ImageLightbox({src, alt, width, height, children}: ImageLightbox ); -} \ No newline at end of file +} From 970049b49c0692843aeaee8c882dd67ae4ba1f9b Mon Sep 17 00:00:00 2001 From: paulj Date: Wed, 6 Aug 2025 11:55:16 -0400 Subject: [PATCH 04/37] remove alt text overlay --- src/components/imageLightbox.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox.tsx index 24117fa41ebf8..128dea32056d6 100644 --- a/src/components/imageLightbox.tsx +++ b/src/components/imageLightbox.tsx @@ -49,13 +49,6 @@ export function ImageLightbox({src, alt, width, height, children}: ImageLightbox priority /> - - {/* Image caption */} - {alt && ( -
-

{alt}

-
- )} From 2e98bdc47c3d46550aa13e1a3e83731b9385f4ea Mon Sep 17 00:00:00 2001 From: paulj Date: Wed, 6 Aug 2025 13:23:47 -0400 Subject: [PATCH 05/37] fix default browser behavior --- src/components/docImageClient.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/docImageClient.tsx b/src/components/docImageClient.tsx index 80962523d5a6c..073325b20ef27 100644 --- a/src/components/docImageClient.tsx +++ b/src/components/docImageClient.tsx @@ -23,7 +23,8 @@ export function DocImageClient({ style, className, }: DocImageClientProps) { - const handleContextMenu = (_e: React.MouseEvent) => { + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); // Prevent default context menu // Allow right-click to open in new tab const link = document.createElement('a'); link.href = imgPath; From 041b40d3eeee895cce0130f92b3590a8e28f5075 Mon Sep 17 00:00:00 2001 From: paulj Date: Wed, 6 Aug 2025 13:44:47 -0400 Subject: [PATCH 06/37] more specific classes --- app/globals.css | 10 ++++++---- src/components/imageLightbox.tsx | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/globals.css b/app/globals.css index 53ad06d03d4c1..26daa00163c51 100644 --- a/app/globals.css +++ b/app/globals.css @@ -222,18 +222,20 @@ body { } } -[data-state="open"] { +/* Target only image lightbox dialog content */ +.image-lightbox-content[data-state="open"] { animation: dialog-content-show 200ms cubic-bezier(0.16, 1, 0.3, 1); } -[data-state="closed"] { +.image-lightbox-content[data-state="closed"] { animation: dialog-content-hide 200ms cubic-bezier(0.16, 1, 0.3, 1); } -.dialog-overlay[data-state="open"] { +/* Target only image lightbox dialog overlay */ +.image-lightbox-overlay[data-state="open"] { animation: dialog-overlay-show 200ms cubic-bezier(0.16, 1, 0.3, 1); } -.dialog-overlay[data-state="closed"] { +.image-lightbox-overlay[data-state="closed"] { animation: dialog-overlay-hide 200ms cubic-bezier(0.16, 1, 0.3, 1); } diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox.tsx index 128dea32056d6..a7e94417dfc1a 100644 --- a/src/components/imageLightbox.tsx +++ b/src/components/imageLightbox.tsx @@ -25,9 +25,9 @@ export function ImageLightbox({src, alt, width, height, children}: ImageLightbox - + - + {/* Close button */} From b5470a6e741751c489f9efcd8cf34df5ff38b77f Mon Sep 17 00:00:00 2001 From: paulj Date: Wed, 6 Aug 2025 13:33:30 -0400 Subject: [PATCH 07/37] fix external image bug, update to pass all props, & ensure fallback size is applied --- src/components/docImage.tsx | 16 +++++++++++---- src/components/docImageClient.tsx | 34 +++++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index c5e386de28380..5f980b2b293f8 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -6,6 +6,8 @@ import {DocImageClient} from './docImageClient'; export default function DocImage({ src, + width: propsWidth, + height: propsHeight, ...props }: Omit, 'ref' | 'placeholder'>) { const {path: pagePath} = serverContext(); @@ -34,10 +36,18 @@ export default function DocImage({ // parse the size from the URL hash (set by remark-image-size.js) const srcURL = new URL(src, 'https://example.com'); const imgPath = srcURL.pathname; - const [width, height] = srcURL.hash // #wxh + const dimensions = srcURL.hash // #wxh .slice(1) .split('x') .map(s => parseInt(s, 10)); + + // Use parsed dimensions, fallback to props, then default to 800 + const width = !isNaN(dimensions[0]) ? dimensions[0] : + (typeof propsWidth === 'number' ? propsWidth : + typeof propsWidth === 'string' ? parseInt(propsWidth, 10) || 800 : 800); + const height = !isNaN(dimensions[1]) ? dimensions[1] : + (typeof propsHeight === 'number' ? propsHeight : + typeof propsHeight === 'string' ? parseInt(propsHeight, 10) || 800 : 800); return ( ); } diff --git a/src/components/docImageClient.tsx b/src/components/docImageClient.tsx index 073325b20ef27..5ed35b9ab00d6 100644 --- a/src/components/docImageClient.tsx +++ b/src/components/docImageClient.tsx @@ -4,14 +4,11 @@ import Image from 'next/image'; import {ImageLightbox} from './imageLightbox'; -interface DocImageClientProps { - alt: string; +interface DocImageClientProps extends Omit, 'ref' | 'placeholder' | 'src' | 'width' | 'height'> { height: number; imgPath: string; src: string; width: number; - className?: string; - style?: React.CSSProperties; } export function DocImageClient({ @@ -22,6 +19,7 @@ export function DocImageClient({ alt, style, className, + ...props }: DocImageClientProps) { const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); // Prevent default context menu @@ -42,9 +40,32 @@ export function DocImageClient({ } }; + // Check if dimensions are valid (not NaN) for Next.js Image + const isValidDimensions = !isNaN(width) && !isNaN(height) && width > 0 && height > 0; + + // For external images or invalid dimensions, fall back to regular img tag + if (src.startsWith('http') || !isValidDimensions) { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {alt +
+ ); + } + return (
- + {alt}
From 12ca95cfff3586fb552aa95346f2191171ba9904 Mon Sep 17 00:00:00 2001 From: paulj Date: Wed, 6 Aug 2025 14:27:53 -0400 Subject: [PATCH 08/37] refactor to fix event handling & propagation issues --- src/components/docImageClient.tsx | 55 ++++++++++--------------------- src/components/imageLightbox.tsx | 29 ++++++++++++---- 2 files changed, 40 insertions(+), 44 deletions(-) diff --git a/src/components/docImageClient.tsx b/src/components/docImageClient.tsx index 5ed35b9ab00d6..e27786c96c4a2 100644 --- a/src/components/docImageClient.tsx +++ b/src/components/docImageClient.tsx @@ -21,32 +21,13 @@ export function DocImageClient({ className, ...props }: DocImageClientProps) { - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); // Prevent default context menu - // Allow right-click to open in new tab - const link = document.createElement('a'); - link.href = imgPath; - link.target = '_blank'; - link.rel = 'noreferrer'; - link.click(); - }; - - const handleClick = (e: React.MouseEvent) => { - // If Ctrl/Cmd+click, open in new tab instead of lightbox - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - e.stopPropagation(); - window.open(imgPath, '_blank', 'noreferrer'); - } - }; - // Check if dimensions are valid (not NaN) for Next.js Image const isValidDimensions = !isNaN(width) && !isNaN(height) && width > 0 && height > 0; // For external images or invalid dimensions, fall back to regular img tag if (src.startsWith('http') || !isValidDimensions) { return ( - + ); } return ( -
- - {alt - -
+ + {alt + ); } diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox.tsx index a7e94417dfc1a..ab4032816f2d2 100644 --- a/src/components/imageLightbox.tsx +++ b/src/components/imageLightbox.tsx @@ -9,20 +9,37 @@ interface ImageLightboxProps { alt: string; children: React.ReactNode; height: number; + imgPath: string; src: string; width: number; } -export function ImageLightbox({src, alt, width, height, children}: ImageLightboxProps) { +export function ImageLightbox({src, alt, width, height, imgPath, children}: ImageLightboxProps) { const [open, setOpen] = useState(false); + const handleClick = (e: React.MouseEvent) => { + // If Ctrl/Cmd+click, let the link handle it naturally (opens in new tab) + if (e.ctrlKey || e.metaKey) { + // Allow default link behavior + return; + } + // Normal click - prevent link navigation and open lightbox + e.preventDefault(); + setOpen(true); + }; + return ( - - - + {/* Custom trigger that handles modifier keys properly */} + + {children} + From a636625a36a49c6ab293755dc7d8fba3e8c13854 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Wed, 6 Aug 2025 18:28:52 +0000 Subject: [PATCH 09/37] [getsentry/action-github-commit] Auto commit --- src/components/docImage.tsx | 22 +++++++++++++++------- src/components/docImageClient.tsx | 16 +++++++++++++--- src/components/imageLightbox.tsx | 9 ++++++++- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index 5f980b2b293f8..fff2812f695f4 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -40,14 +40,22 @@ export default function DocImage({ .slice(1) .split('x') .map(s => parseInt(s, 10)); - + // Use parsed dimensions, fallback to props, then default to 800 - const width = !isNaN(dimensions[0]) ? dimensions[0] : - (typeof propsWidth === 'number' ? propsWidth : - typeof propsWidth === 'string' ? parseInt(propsWidth, 10) || 800 : 800); - const height = !isNaN(dimensions[1]) ? dimensions[1] : - (typeof propsHeight === 'number' ? propsHeight : - typeof propsHeight === 'string' ? parseInt(propsHeight, 10) || 800 : 800); + const width = !isNaN(dimensions[0]) + ? dimensions[0] + : typeof propsWidth === 'number' + ? propsWidth + : typeof propsWidth === 'string' + ? parseInt(propsWidth, 10) || 800 + : 800; + const height = !isNaN(dimensions[1]) + ? dimensions[1] + : typeof propsHeight === 'number' + ? propsHeight + : typeof propsHeight === 'string' + ? parseInt(propsHeight, 10) || 800 + : 800; return ( , 'ref' | 'placeholder' | 'src' | 'width' | 'height'> { +interface DocImageClientProps + extends Omit< + React.HTMLProps, + 'ref' | 'placeholder' | 'src' | 'width' | 'height' + > { height: number; imgPath: string; src: string; @@ -23,7 +27,7 @@ export function DocImageClient({ }: DocImageClientProps) { // Check if dimensions are valid (not NaN) for Next.js Image const isValidDimensions = !isNaN(width) && !isNaN(height) && width > 0 && height > 0; - + // For external images or invalid dimensions, fall back to regular img tag if (src.startsWith('http') || !isValidDimensions) { return ( @@ -45,7 +49,13 @@ export function DocImageClient({ } return ( - + { From 11227e4333e8c5bb73a2ab3acd0728ee9b0d8d1a Mon Sep 17 00:00:00 2001 From: paulj Date: Wed, 6 Aug 2025 16:51:09 -0400 Subject: [PATCH 10/37] remove anchors --- src/components/docImageClient.tsx | 12 ++++++++++-- src/components/imageLightbox.tsx | 14 +++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/components/docImageClient.tsx b/src/components/docImageClient.tsx index 60d8f016d6f5f..8a3dac372397b 100644 --- a/src/components/docImageClient.tsx +++ b/src/components/docImageClient.tsx @@ -30,8 +30,16 @@ export function DocImageClient({ // For external images or invalid dimensions, fall back to regular img tag if (src.startsWith('http') || !isValidDimensions) { + const handleClick = (e: React.MouseEvent) => { + // If Ctrl/Cmd+click, open image in new tab + if (e.ctrlKey || e.metaKey) { + window.open(imgPath, '_blank', 'noreferrer'); + return; + } + }; + return ( - + ); } diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox.tsx index f18137b118c5d..ba73b5b5a6a7d 100644 --- a/src/components/imageLightbox.tsx +++ b/src/components/imageLightbox.tsx @@ -25,28 +25,24 @@ export function ImageLightbox({ const [open, setOpen] = useState(false); const handleClick = (e: React.MouseEvent) => { - // If Ctrl/Cmd+click, let the link handle it naturally (opens in new tab) + // If Ctrl/Cmd+click, open image in new tab if (e.ctrlKey || e.metaKey) { - // Allow default link behavior + window.open(imgPath, '_blank', 'noreferrer'); return; } - // Normal click - prevent link navigation and open lightbox - e.preventDefault(); + // Normal click - open lightbox setOpen(true); }; return ( {/* Custom trigger that handles modifier keys properly */} - {children} - + From 528b31a3e7f977229acacef1cbdeefe27f49a6f9 Mon Sep 17 00:00:00 2001 From: paulj Date: Wed, 6 Aug 2025 17:28:42 -0400 Subject: [PATCH 11/37] prevent browser context menu from downloading images instead of opening in tab --- next.config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/next.config.ts b/next.config.ts index 3ab0fb515b680..862be3a78a71f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -54,6 +54,9 @@ const nextConfig = { trailingSlash: true, serverExternalPackages: ['rehype-preset-minify'], outputFileTracingExcludes, + images: { + contentDispositionType: 'inline', // "open image in new tab" instead of downloading + }, webpack: (config, options) => { config.plugins.push( codecovNextJSWebpackPlugin({ @@ -71,7 +74,7 @@ const nextConfig = { DEVELOPER_DOCS_: process.env.NEXT_PUBLIC_DEVELOPER_DOCS, }, redirects, - rewrites: async () => [ + rewrites: () => [ { source: '/:path*.md', destination: '/md-exports/:path*.md', From 0bd0bae3cba9c5cc9b0c44ac824fe372937cf670 Mon Sep 17 00:00:00 2001 From: paulj Date: Thu, 7 Aug 2025 12:37:24 -0400 Subject: [PATCH 12/37] fix window parameter & click behavior cursor bug --- src/components/docImage.tsx | 26 ++++++++++++++++++++++---- src/components/docImageClient.tsx | 11 ++++++----- src/components/imageLightbox.tsx | 6 +++++- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index fff2812f695f4..ef7fc73b18ff7 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -16,11 +16,29 @@ export default function DocImage({ return null; } - // Next.js Image component only supports images from the public folder - // or from a remote server with properly configured domain + // Handle external images early - pass through without processing if (src.startsWith('http')) { - // eslint-disable-next-line @next/next/no-img-element - return ; + // Use provided props or defaults for external images + const width = typeof propsWidth === 'number' + ? propsWidth + : typeof propsWidth === 'string' + ? parseInt(propsWidth, 10) || 800 + : 800; + const height = typeof propsHeight === 'number' + ? propsHeight + : typeof propsHeight === 'string' + ? parseInt(propsHeight, 10) || 600 + : 600; + + return ( + + ); } // If the image src is not an absolute URL, we assume it's a relative path diff --git a/src/components/docImageClient.tsx b/src/components/docImageClient.tsx index 8a3dac372397b..81f52d0cfd323 100644 --- a/src/components/docImageClient.tsx +++ b/src/components/docImageClient.tsx @@ -30,11 +30,12 @@ export function DocImageClient({ // For external images or invalid dimensions, fall back to regular img tag if (src.startsWith('http') || !isValidDimensions) { - const handleClick = (e: React.MouseEvent) => { - // If Ctrl/Cmd+click, open image in new tab - if (e.ctrlKey || e.metaKey) { - window.open(imgPath, '_blank', 'noreferrer'); - return; + const handleClick = () => { + // Always open image in new tab + const url = src.startsWith('http') ? src : imgPath; + const newWindow = window.open(url, '_blank'); + if (newWindow) { + newWindow.opener = null; // Security: prevent opener access } }; diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox.tsx index ba73b5b5a6a7d..e3f02d77162a1 100644 --- a/src/components/imageLightbox.tsx +++ b/src/components/imageLightbox.tsx @@ -27,7 +27,11 @@ export function ImageLightbox({ const handleClick = (e: React.MouseEvent) => { // If Ctrl/Cmd+click, open image in new tab if (e.ctrlKey || e.metaKey) { - window.open(imgPath, '_blank', 'noreferrer'); + const url = src.startsWith('http') ? src : imgPath; + const newWindow = window.open(url, '_blank'); + if (newWindow) { + newWindow.opener = null; // Security: prevent opener access + } return; } // Normal click - open lightbox From 37fd8ce4755f3faff3f44b8714c46946a0aea921 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 17:13:20 +0000 Subject: [PATCH 13/37] [getsentry/action-github-commit] Auto commit --- src/components/docImage.tsx | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index ef7fc73b18ff7..714f82efff213 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -19,17 +19,19 @@ export default function DocImage({ // Handle external images early - pass through without processing if (src.startsWith('http')) { // Use provided props or defaults for external images - const width = typeof propsWidth === 'number' - ? propsWidth - : typeof propsWidth === 'string' - ? parseInt(propsWidth, 10) || 800 - : 800; - const height = typeof propsHeight === 'number' - ? propsHeight - : typeof propsHeight === 'string' - ? parseInt(propsHeight, 10) || 600 - : 600; - + const width = + typeof propsWidth === 'number' + ? propsWidth + : typeof propsWidth === 'string' + ? parseInt(propsWidth, 10) || 800 + : 800; + const height = + typeof propsHeight === 'number' + ? propsHeight + : typeof propsHeight === 'string' + ? parseInt(propsHeight, 10) || 600 + : 600; + return ( Date: Thu, 7 Aug 2025 13:24:50 -0400 Subject: [PATCH 14/37] cleanup --- src/components/docImage.tsx | 24 +++++++++++------------- src/components/docImageClient.tsx | 4 ++-- src/components/imageLightbox.tsx | 2 +- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index 714f82efff213..783c948274a11 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -19,19 +19,17 @@ export default function DocImage({ // Handle external images early - pass through without processing if (src.startsWith('http')) { // Use provided props or defaults for external images - const width = - typeof propsWidth === 'number' - ? propsWidth - : typeof propsWidth === 'string' - ? parseInt(propsWidth, 10) || 800 - : 800; - const height = - typeof propsHeight === 'number' - ? propsHeight - : typeof propsHeight === 'string' - ? parseInt(propsHeight, 10) || 600 - : 600; - + const width = typeof propsWidth === 'number' + ? propsWidth + : typeof propsWidth === 'string' + ? parseInt(propsWidth, 10) || 800 + : 800; + const height = typeof propsHeight === 'number' + ? propsHeight + : typeof propsHeight === 'string' + ? parseInt(propsHeight, 10) || 800 + : 800; + return ( 0 && height > 0; - // For external images or invalid dimensions, fall back to regular img tag - if (src.startsWith('http') || !isValidDimensions) { + // For images with invalid dimensions, fall back to regular img tag + if (!isValidDimensions) { const handleClick = () => { // Always open image in new tab const url = src.startsWith('http') ? src : imgPath; diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox.tsx index e3f02d77162a1..86a8a586a2f9e 100644 --- a/src/components/imageLightbox.tsx +++ b/src/components/imageLightbox.tsx @@ -53,7 +53,7 @@ export function ImageLightbox({ {/* Close button */} - + Close From 7b781f462424826ae94e9b994706773c582b2f79 Mon Sep 17 00:00:00 2001 From: paulj Date: Thu, 7 Aug 2025 13:41:15 -0400 Subject: [PATCH 15/37] add basic a11y --- src/components/imageLightbox.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox.tsx index 86a8a586a2f9e..eed80d0343fc0 100644 --- a/src/components/imageLightbox.tsx +++ b/src/components/imageLightbox.tsx @@ -38,12 +38,34 @@ export function ImageLightbox({ setOpen(true); }; + const handleKeyDown = (e: React.KeyboardEvent) => { + // Handle Enter and Space keys + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + // If Ctrl/Cmd+key, open image in new tab + if (e.ctrlKey || e.metaKey) { + const url = src.startsWith('http') ? src : imgPath; + const newWindow = window.open(url, '_blank'); + if (newWindow) { + newWindow.opener = null; // Security: prevent opener access + } + return; + } + // Normal key press - open lightbox + setOpen(true); + } + }; + return ( {/* Custom trigger that handles modifier keys properly */}
{children}
From 106d776ac896358ac4c48ea4a812c485c5aba9f4 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 17:45:18 +0000 Subject: [PATCH 16/37] [getsentry/action-github-commit] Auto commit --- src/components/docImage.tsx | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index 783c948274a11..9cbcefcb184a7 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -19,17 +19,19 @@ export default function DocImage({ // Handle external images early - pass through without processing if (src.startsWith('http')) { // Use provided props or defaults for external images - const width = typeof propsWidth === 'number' - ? propsWidth - : typeof propsWidth === 'string' - ? parseInt(propsWidth, 10) || 800 - : 800; - const height = typeof propsHeight === 'number' - ? propsHeight - : typeof propsHeight === 'string' - ? parseInt(propsHeight, 10) || 800 - : 800; - + const width = + typeof propsWidth === 'number' + ? propsWidth + : typeof propsWidth === 'string' + ? parseInt(propsWidth, 10) || 800 + : 800; + const height = + typeof propsHeight === 'number' + ? propsHeight + : typeof propsHeight === 'string' + ? parseInt(propsHeight, 10) || 800 + : 800; + return ( Date: Thu, 7 Aug 2025 14:52:28 -0400 Subject: [PATCH 17/37] update button styles --- src/components/imageLightbox.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox.tsx index eed80d0343fc0..030b645ad68c3 100644 --- a/src/components/imageLightbox.tsx +++ b/src/components/imageLightbox.tsx @@ -75,8 +75,8 @@ export function ImageLightbox({ {/* Close button */} - - + + Close From 6c6b8a1a58511d58b237bb4f6177b1e2cd8fa026 Mon Sep 17 00:00:00 2001 From: paulj Date: Thu, 7 Aug 2025 16:00:10 -0400 Subject: [PATCH 18/37] refactor to simplify logic --- src/components/docImage.tsx | 54 +++++++++++++-------- src/components/docImageClient.tsx | 53 ++------------------ src/components/imageLightbox.tsx | 81 +++++++++++++++++++++++++------ 3 files changed, 104 insertions(+), 84 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index 9cbcefcb184a7..dce565f311a2c 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -54,28 +54,42 @@ export default function DocImage({ } // parse the size from the URL hash (set by remark-image-size.js) - const srcURL = new URL(src, 'https://example.com'); - const imgPath = srcURL.pathname; - const dimensions = srcURL.hash // #wxh - .slice(1) - .split('x') - .map(s => parseInt(s, 10)); + let srcURL: URL; + let imgPath: string; + let dimensions: number[] = []; + + try { + srcURL = new URL(src, 'https://example.com'); + imgPath = srcURL.pathname; + dimensions = srcURL.hash // #wxh + .slice(1) + .split('x') + .map(s => { + const parsed = parseInt(s, 10); + return isNaN(parsed) ? 0 : parsed; + }); + } catch (_error) { + // Failed to parse URL, fallback to using src directly + imgPath = src; + dimensions = []; + } + + // Helper function to safely parse dimension values + const parseDimension = ( + value: string | number | undefined, + fallback: number = 800 + ): number => { + if (typeof value === 'number' && value > 0) return value; + if (typeof value === 'string') { + const parsed = parseInt(value, 10); + return parsed > 0 ? parsed : fallback; + } + return fallback; + }; // Use parsed dimensions, fallback to props, then default to 800 - const width = !isNaN(dimensions[0]) - ? dimensions[0] - : typeof propsWidth === 'number' - ? propsWidth - : typeof propsWidth === 'string' - ? parseInt(propsWidth, 10) || 800 - : 800; - const height = !isNaN(dimensions[1]) - ? dimensions[1] - : typeof propsHeight === 'number' - ? propsHeight - : typeof propsHeight === 'string' - ? parseInt(propsHeight, 10) || 800 - : 800; + const width = dimensions[0] > 0 ? dimensions[0] : parseDimension(propsWidth); + const height = dimensions[1] > 0 ? dimensions[1] : parseDimension(propsHeight); return ( 0 && height > 0; - - // For images with invalid dimensions, fall back to regular img tag - if (!isValidDimensions) { - const handleClick = () => { - // Always open image in new tab - const url = src.startsWith('http') ? src : imgPath; - const newWindow = window.open(url, '_blank'); - if (newWindow) { - newWindow.opener = null; // Security: prevent opener access - } - }; - - return ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {alt -
- ); - } - return ( - {alt - + style={style} + className={className} + {...props} + /> ); } diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox.tsx index 030b645ad68c3..45544685060f8 100644 --- a/src/components/imageLightbox.tsx +++ b/src/components/imageLightbox.tsx @@ -7,11 +7,12 @@ import Image from 'next/image'; interface ImageLightboxProps { alt: string; - children: React.ReactNode; height: number; imgPath: string; src: string; width: number; + className?: string; + style?: React.CSSProperties; } export function ImageLightbox({ @@ -20,10 +21,14 @@ export function ImageLightbox({ width, height, imgPath, - children, + style, + className, }: ImageLightboxProps) { const [open, setOpen] = useState(false); + // Check if dimensions are valid (not NaN) for Next.js Image + const isValidDimensions = !isNaN(width) && !isNaN(height) && width > 0 && height > 0; + const handleClick = (e: React.MouseEvent) => { // If Ctrl/Cmd+click, open image in new tab if (e.ctrlKey || e.metaKey) { @@ -56,6 +61,39 @@ export function ImageLightbox({ } }; + // Render the appropriate image component based on dimension validity + const renderImage = () => { + if (isValidDimensions) { + return ( + {alt} + ); + } + return ( + /* eslint-disable-next-line @next/next/no-img-element */ + {alt} + ); + }; + return ( {/* Custom trigger that handles modifier keys properly */} @@ -67,7 +105,7 @@ export function ImageLightbox({ onKeyDown={handleKeyDown} aria-label={`View image: ${alt}`} > - {children} + {renderImage()} @@ -82,18 +120,31 @@ export function ImageLightbox({ {/* Image container */}
- {alt} + {isValidDimensions ? ( + {alt} + ) : ( + /* eslint-disable-next-line @next/next/no-img-element */ + {alt} + )}
From 19e598e6511deba969eca3252c9cd042992c2220 Mon Sep 17 00:00:00 2001 From: paulj Date: Thu, 7 Aug 2025 16:11:48 -0400 Subject: [PATCH 19/37] prop and types improvements --- src/components/imageLightbox.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox.tsx index 45544685060f8..4a7201937767d 100644 --- a/src/components/imageLightbox.tsx +++ b/src/components/imageLightbox.tsx @@ -5,14 +5,16 @@ import {X} from 'react-feather'; import * as Dialog from '@radix-ui/react-dialog'; import Image from 'next/image'; -interface ImageLightboxProps { +interface ImageLightboxProps + extends Omit< + React.HTMLProps, + 'ref' | 'src' | 'width' | 'height' | 'alt' + > { alt: string; height: number; imgPath: string; src: string; width: number; - className?: string; - style?: React.CSSProperties; } export function ImageLightbox({ @@ -23,6 +25,7 @@ export function ImageLightbox({ imgPath, style, className, + ...props }: ImageLightboxProps) { const [open, setOpen] = useState(false); @@ -61,6 +64,13 @@ export function ImageLightbox({ } }; + // Filter out props that are incompatible with Next.js Image component + // Next.js Image has stricter typing for certain props like 'placeholder' + const { + placeholder: _placeholder, + ...imageCompatibleProps + } = props; + // Render the appropriate image component based on dimension validity const renderImage = () => { if (isValidDimensions) { @@ -76,6 +86,7 @@ export function ImageLightbox({ }} className={className} alt={alt} + {...imageCompatibleProps} /> ); } @@ -90,6 +101,7 @@ export function ImageLightbox({ ...style, }} className={className} + {...props} /> ); }; @@ -132,6 +144,7 @@ export function ImageLightbox({ height: 'auto', }} priority + {...imageCompatibleProps} /> ) : ( /* eslint-disable-next-line @next/next/no-img-element */ @@ -143,6 +156,7 @@ export function ImageLightbox({ width: 'auto', height: 'auto', }} + {...props} /> )} From c74e56385b9720f3e6d8564f149aac84ba18d63b Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 20:12:55 +0000 Subject: [PATCH 20/37] [getsentry/action-github-commit] Auto commit --- src/components/imageLightbox.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox.tsx index 4a7201937767d..d357a5087974b 100644 --- a/src/components/imageLightbox.tsx +++ b/src/components/imageLightbox.tsx @@ -66,10 +66,7 @@ export function ImageLightbox({ // Filter out props that are incompatible with Next.js Image component // Next.js Image has stricter typing for certain props like 'placeholder' - const { - placeholder: _placeholder, - ...imageCompatibleProps - } = props; + const {placeholder: _placeholder, ...imageCompatibleProps} = props; // Render the appropriate image component based on dimension validity const renderImage = () => { From 9bffc3c1a8c0d1643cb24b83b63c43ebe5c6dab9 Mon Sep 17 00:00:00 2001 From: paulj Date: Fri, 8 Aug 2025 11:36:59 -0400 Subject: [PATCH 21/37] remove DocImageClient to simplify and scope styles --- app/globals.css | 61 +-------------- src/components/docImage.tsx | 8 +- src/components/docImageClient.tsx | 38 ---------- .../imageLightbox/imageLightbox.module.scss | 76 +++++++++++++++++++ .../index.tsx} | 6 +- 5 files changed, 86 insertions(+), 103 deletions(-) delete mode 100644 src/components/docImageClient.tsx create mode 100644 src/components/imageLightbox/imageLightbox.module.scss rename src/components/{imageLightbox.tsx => imageLightbox/index.tsx} (94%) diff --git a/app/globals.css b/app/globals.css index 26daa00163c51..f338ee8f47e6d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -177,65 +177,6 @@ body { .onboarding-step .step-heading::before, .onboarding-step h2::before { - content: "Step " counter(onboarding-step) ": "; + content: 'Step ' counter(onboarding-step) ': '; font-weight: inherit; } - -/* Lightbox animations */ -@keyframes dialog-content-show { - from { - opacity: 0; - transform: translate(-50%, -48%) scale(0.96); - } - to { - opacity: 1; - transform: translate(-50%, -50%) scale(1); - } -} - -@keyframes dialog-content-hide { - from { - opacity: 1; - transform: translate(-50%, -50%) scale(1); - } - to { - opacity: 0; - transform: translate(-50%, -48%) scale(0.96); - } -} - -@keyframes dialog-overlay-show { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes dialog-overlay-hide { - from { - opacity: 1; - } - to { - opacity: 0; - } -} - -/* Target only image lightbox dialog content */ -.image-lightbox-content[data-state="open"] { - animation: dialog-content-show 200ms cubic-bezier(0.16, 1, 0.3, 1); -} - -.image-lightbox-content[data-state="closed"] { - animation: dialog-content-hide 200ms cubic-bezier(0.16, 1, 0.3, 1); -} - -/* Target only image lightbox dialog overlay */ -.image-lightbox-overlay[data-state="open"] { - animation: dialog-overlay-show 200ms cubic-bezier(0.16, 1, 0.3, 1); -} - -.image-lightbox-overlay[data-state="closed"] { - animation: dialog-overlay-hide 200ms cubic-bezier(0.16, 1, 0.3, 1); -} diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index dce565f311a2c..f1df9290c47c1 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -2,7 +2,7 @@ import path from 'path'; import {serverContext} from 'sentry-docs/serverContext'; -import {DocImageClient} from './docImageClient'; +import {ImageLightbox} from './imageLightbox'; export default function DocImage({ src, @@ -33,11 +33,12 @@ export default function DocImage({ : 800; return ( - ); @@ -92,11 +93,12 @@ export default function DocImage({ const height = dimensions[1] > 0 ? dimensions[1] : parseDimension(propsHeight); return ( - ); diff --git a/src/components/docImageClient.tsx b/src/components/docImageClient.tsx deleted file mode 100644 index 97df606e1adf4..0000000000000 --- a/src/components/docImageClient.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client'; - -import {ImageLightbox} from './imageLightbox'; - -interface DocImageClientProps - extends Omit< - React.HTMLProps, - 'ref' | 'placeholder' | 'src' | 'width' | 'height' - > { - height: number; - imgPath: string; - src: string; - width: number; -} - -export function DocImageClient({ - src, - imgPath, - width, - height, - alt, - style, - className, - ...props -}: DocImageClientProps) { - return ( - - ); -} diff --git a/src/components/imageLightbox/imageLightbox.module.scss b/src/components/imageLightbox/imageLightbox.module.scss new file mode 100644 index 0000000000000..7d8f355233bc4 --- /dev/null +++ b/src/components/imageLightbox/imageLightbox.module.scss @@ -0,0 +1,76 @@ +/* Lightbox animations */ +@keyframes dialogContentShow { + from { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +@keyframes dialogContentHide { + from { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + to { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } +} + +@keyframes dialogOverlayShow { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes dialogOverlayHide { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +/* Lightbox content styles */ +.lightboxContent { + position: fixed; + left: 50%; + top: 50%; + z-index: 50; + max-height: 90vh; + max-width: 90vw; + transform: translate(-50%, -50%); +} + +.lightboxContent[data-state='open'] { + animation: dialogContentShow 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.lightboxContent[data-state='closed'] { + animation: dialogContentHide 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Lightbox overlay styles */ +.lightboxOverlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); + z-index: 50; +} + +.lightboxOverlay[data-state='open'] { + animation: dialogOverlayShow 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.lightboxOverlay[data-state='closed'] { + animation: dialogOverlayHide 200ms cubic-bezier(0.16, 1, 0.3, 1); +} diff --git a/src/components/imageLightbox.tsx b/src/components/imageLightbox/index.tsx similarity index 94% rename from src/components/imageLightbox.tsx rename to src/components/imageLightbox/index.tsx index d357a5087974b..0249d2d8cd1b1 100644 --- a/src/components/imageLightbox.tsx +++ b/src/components/imageLightbox/index.tsx @@ -5,6 +5,8 @@ import {X} from 'react-feather'; import * as Dialog from '@radix-ui/react-dialog'; import Image from 'next/image'; +import styles from './imageLightbox.module.scss'; + interface ImageLightboxProps extends Omit< React.HTMLProps, @@ -118,9 +120,9 @@ export function ImageLightbox({ - + - + {/* Close button */} From 4065b0ce090517099e218c658170caeb0fe66c0b Mon Sep 17 00:00:00 2001 From: paulj Date: Fri, 8 Aug 2025 11:49:39 -0400 Subject: [PATCH 22/37] port image logic to imageLightbox --- src/components/docImage.tsx | 11 ++++----- src/components/imageLightbox/index.tsx | 31 +++++++++++++++++--------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index f1df9290c47c1..9c60d40f10355 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -18,19 +18,20 @@ export default function DocImage({ // Handle external images early - pass through without processing if (src.startsWith('http')) { - // Use provided props or defaults for external images + // For external images, let ImageLightbox decide whether to use Next.js Image or regular img + // Parse dimensions if provided const width = typeof propsWidth === 'number' ? propsWidth : typeof propsWidth === 'string' - ? parseInt(propsWidth, 10) || 800 - : 800; + ? parseInt(propsWidth, 10) || undefined + : undefined; const height = typeof propsHeight === 'number' ? propsHeight : typeof propsHeight === 'string' - ? parseInt(propsHeight, 10) || 800 - : 800; + ? parseInt(propsHeight, 10) || undefined + : undefined; return ( { alt: string; - height: number; imgPath: string; src: string; - width: number; + height?: number; + width?: number; } export function ImageLightbox({ @@ -31,8 +31,17 @@ export function ImageLightbox({ }: ImageLightboxProps) { const [open, setOpen] = useState(false); - // Check if dimensions are valid (not NaN) for Next.js Image - const isValidDimensions = !isNaN(width) && !isNaN(height) && width > 0 && height > 0; + // Check if we should use Next.js Image or regular img + // Use Next.js Image for internal images with valid dimensions + // Use regular img for external images or when dimensions are invalid/missing + const shouldUseNextImage = + !src.startsWith('http') && // Internal image + width != null && + height != null && // Dimensions provided + !isNaN(width) && + !isNaN(height) && // Valid numbers + width > 0 && + height > 0; // Positive values const handleClick = (e: React.MouseEvent) => { // If Ctrl/Cmd+click, open image in new tab @@ -70,14 +79,14 @@ export function ImageLightbox({ // Next.js Image has stricter typing for certain props like 'placeholder' const {placeholder: _placeholder, ...imageCompatibleProps} = props; - // Render the appropriate image component based on dimension validity + // Render the appropriate image component const renderImage = () => { - if (isValidDimensions) { + if (shouldUseNextImage) { return ( - {isValidDimensions ? ( + {shouldUseNextImage ? ( {alt} Date: Fri, 8 Aug 2025 11:54:29 -0400 Subject: [PATCH 23/37] dry things up --- src/components/docImage.tsx | 13 ++++------ src/components/imageLightbox/index.tsx | 36 ++++++++++++++++---------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index 9c60d40f10355..13b8fe7b56888 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -76,20 +76,17 @@ export default function DocImage({ dimensions = []; } - // Helper function to safely parse dimension values - const parseDimension = ( - value: string | number | undefined, - fallback: number = 800 - ): number => { + // Helper function to safely parse dimension values - only return valid numbers or undefined + const parseDimension = (value: string | number | undefined): number | undefined => { if (typeof value === 'number' && value > 0) return value; if (typeof value === 'string') { const parsed = parseInt(value, 10); - return parsed > 0 ? parsed : fallback; + return parsed > 0 ? parsed : undefined; } - return fallback; + return undefined; }; - // Use parsed dimensions, fallback to props, then default to 800 + // Use parsed dimensions, fallback to props - let ImageLightbox decide on defaults const width = dimensions[0] > 0 ? dimensions[0] : parseDimension(propsWidth); const height = dimensions[1] > 0 ? dimensions[1] : parseDimension(propsHeight); diff --git a/src/components/imageLightbox/index.tsx b/src/components/imageLightbox/index.tsx index dda60e25915c3..67bf248101021 100644 --- a/src/components/imageLightbox/index.tsx +++ b/src/components/imageLightbox/index.tsx @@ -19,6 +19,20 @@ interface ImageLightboxProps width?: number; } +// Helper functions +const isExternalImage = (src: string): boolean => src.startsWith('http'); + +const getImageUrl = (src: string, imgPath: string): string => + isExternalImage(src) ? src : imgPath; + +const hasValidDimensions = (width?: number, height?: number): width is number => + width != null && + height != null && + !isNaN(width) && + !isNaN(height) && + width > 0 && + height > 0; + export function ImageLightbox({ src, alt, @@ -34,19 +48,12 @@ export function ImageLightbox({ // Check if we should use Next.js Image or regular img // Use Next.js Image for internal images with valid dimensions // Use regular img for external images or when dimensions are invalid/missing - const shouldUseNextImage = - !src.startsWith('http') && // Internal image - width != null && - height != null && // Dimensions provided - !isNaN(width) && - !isNaN(height) && // Valid numbers - width > 0 && - height > 0; // Positive values + const shouldUseNextImage = !isExternalImage(src) && hasValidDimensions(width, height); const handleClick = (e: React.MouseEvent) => { // If Ctrl/Cmd+click, open image in new tab if (e.ctrlKey || e.metaKey) { - const url = src.startsWith('http') ? src : imgPath; + const url = getImageUrl(src, imgPath); const newWindow = window.open(url, '_blank'); if (newWindow) { newWindow.opener = null; // Security: prevent opener access @@ -63,7 +70,7 @@ export function ImageLightbox({ e.preventDefault(); // If Ctrl/Cmd+key, open image in new tab if (e.ctrlKey || e.metaKey) { - const url = src.startsWith('http') ? src : imgPath; + const url = getImageUrl(src, imgPath); const newWindow = window.open(url, '_blank'); if (newWindow) { newWindow.opener = null; // Security: prevent opener access @@ -82,11 +89,12 @@ export function ImageLightbox({ // Render the appropriate image component const renderImage = () => { if (shouldUseNextImage) { + // Type assertion is safe here because hasValidDimensions guarantees these are valid numbers return ( {alt} Date: Fri, 8 Aug 2025 11:56:37 -0400 Subject: [PATCH 24/37] fix alt prop bug --- src/components/docImage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index 13b8fe7b56888..fd8739315b0f4 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -39,7 +39,7 @@ export default function DocImage({ imgPath={src} // For external images, imgPath should be the same as src width={width} height={height} - alt="" + alt={props.alt ?? ""} {...props} /> ); @@ -96,7 +96,7 @@ export default function DocImage({ imgPath={imgPath} width={width} height={height} - alt="" + alt={props.alt ?? ""} {...props} /> ); From 5a7c1be714c539843a22f584cd8afd7ba7a77e42 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:57:22 +0000 Subject: [PATCH 25/37] [getsentry/action-github-commit] Auto commit --- src/components/docImage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index fd8739315b0f4..90eaf802d4fad 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -39,7 +39,7 @@ export default function DocImage({ imgPath={src} // For external images, imgPath should be the same as src width={width} height={height} - alt={props.alt ?? ""} + alt={props.alt ?? ''} {...props} /> ); @@ -96,7 +96,7 @@ export default function DocImage({ imgPath={imgPath} width={width} height={height} - alt={props.alt ?? ""} + alt={props.alt ?? ''} {...props} /> ); From 7ff34e894cad5c90a20ebdc8f9b62fc9f8b10222 Mon Sep 17 00:00:00 2001 From: paulj Date: Fri, 8 Aug 2025 12:21:02 -0400 Subject: [PATCH 26/37] fix type narrowing issue --- src/components/imageLightbox/index.tsx | 44 +++++++++++++++++--------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/src/components/imageLightbox/index.tsx b/src/components/imageLightbox/index.tsx index 67bf248101021..b50a810f53f57 100644 --- a/src/components/imageLightbox/index.tsx +++ b/src/components/imageLightbox/index.tsx @@ -25,13 +25,27 @@ const isExternalImage = (src: string): boolean => src.startsWith('http'); const getImageUrl = (src: string, imgPath: string): string => isExternalImage(src) ? src : imgPath; -const hasValidDimensions = (width?: number, height?: number): width is number => - width != null && - height != null && - !isNaN(width) && - !isNaN(height) && - width > 0 && - height > 0; +type ValidDimensions = { + height: number; + width: number; +}; + +const getValidDimensions = ( + width?: number, + height?: number +): ValidDimensions | null => { + if ( + width != null && + height != null && + !isNaN(width) && + !isNaN(height) && + width > 0 && + height > 0 + ) { + return {width, height}; + } + return null; +}; export function ImageLightbox({ src, @@ -48,7 +62,7 @@ export function ImageLightbox({ // Check if we should use Next.js Image or regular img // Use Next.js Image for internal images with valid dimensions // Use regular img for external images or when dimensions are invalid/missing - const shouldUseNextImage = !isExternalImage(src) && hasValidDimensions(width, height); + const validDimensions = !isExternalImage(src) ? getValidDimensions(width, height) : null; const handleClick = (e: React.MouseEvent) => { // If Ctrl/Cmd+click, open image in new tab @@ -88,13 +102,13 @@ export function ImageLightbox({ // Render the appropriate image component const renderImage = () => { - if (shouldUseNextImage) { - // Type assertion is safe here because hasValidDimensions guarantees these are valid numbers + if (validDimensions) { + // TypeScript knows validDimensions.width and validDimensions.height are both numbers return ( - {shouldUseNextImage ? ( + {validDimensions ? ( {alt} Date: Fri, 8 Aug 2025 12:29:24 -0400 Subject: [PATCH 27/37] extend next config for external images --- next.config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/next.config.ts b/next.config.ts index 862be3a78a71f..850d3cb19c65a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -56,6 +56,16 @@ const nextConfig = { outputFileTracingExcludes, images: { contentDispositionType: 'inline', // "open image in new tab" instead of downloading + remotePatterns: [ + { + protocol: 'https', + hostname: 'user-images.githubusercontent.com', + }, + { + protocol: 'https', + hostname: 'sentry-brand.storage.googleapis.com', + }, + ], }, webpack: (config, options) => { config.plugins.push( From 02a849e1a64b57a7e9f88d27dd0b321348d4fd0b Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:42:26 +0000 Subject: [PATCH 28/37] [getsentry/action-github-commit] Auto commit --- src/components/imageLightbox/index.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/imageLightbox/index.tsx b/src/components/imageLightbox/index.tsx index b50a810f53f57..f4b0fe08695d2 100644 --- a/src/components/imageLightbox/index.tsx +++ b/src/components/imageLightbox/index.tsx @@ -30,10 +30,7 @@ type ValidDimensions = { width: number; }; -const getValidDimensions = ( - width?: number, - height?: number -): ValidDimensions | null => { +const getValidDimensions = (width?: number, height?: number): ValidDimensions | null => { if ( width != null && height != null && @@ -62,7 +59,9 @@ export function ImageLightbox({ // Check if we should use Next.js Image or regular img // Use Next.js Image for internal images with valid dimensions // Use regular img for external images or when dimensions are invalid/missing - const validDimensions = !isExternalImage(src) ? getValidDimensions(width, height) : null; + const validDimensions = !isExternalImage(src) + ? getValidDimensions(width, height) + : null; const handleClick = (e: React.MouseEvent) => { // If Ctrl/Cmd+click, open image in new tab From ca1f744b3db71268d1c7b7b8fbad32a7946a1836 Mon Sep 17 00:00:00 2001 From: paulj Date: Fri, 8 Aug 2025 12:55:08 -0400 Subject: [PATCH 29/37] =?UTF-8?q?gpt5=20update=20=F0=9F=92=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 12 ++------- src/components/imageLightbox/index.tsx | 35 ++++++++++++++++---------- src/config/images.ts | 22 ++++++++++++++++ 3 files changed, 46 insertions(+), 23 deletions(-) create mode 100644 src/config/images.ts diff --git a/next.config.ts b/next.config.ts index 850d3cb19c65a..104aa5d27992d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,7 @@ import {codecovNextJSWebpackPlugin} from '@codecov/nextjs-webpack-plugin'; import {withSentryConfig} from '@sentry/nextjs'; import {redirects} from './redirects.js'; +import {REMOTE_IMAGE_PATTERNS} from './src/config/images'; const outputFileTracingExcludes = process.env.NEXT_PUBLIC_DEVELOPER_DOCS ? { @@ -56,16 +57,7 @@ const nextConfig = { outputFileTracingExcludes, images: { contentDispositionType: 'inline', // "open image in new tab" instead of downloading - remotePatterns: [ - { - protocol: 'https', - hostname: 'user-images.githubusercontent.com', - }, - { - protocol: 'https', - hostname: 'sentry-brand.storage.googleapis.com', - }, - ], + remotePatterns: REMOTE_IMAGE_PATTERNS, }, webpack: (config, options) => { config.plugins.push( diff --git a/src/components/imageLightbox/index.tsx b/src/components/imageLightbox/index.tsx index f4b0fe08695d2..ae7e78583e047 100644 --- a/src/components/imageLightbox/index.tsx +++ b/src/components/imageLightbox/index.tsx @@ -5,6 +5,8 @@ import {X} from 'react-feather'; import * as Dialog from '@radix-ui/react-dialog'; import Image from 'next/image'; +import {isAllowedRemoteImage} from 'sentry-docs/config/images'; + import styles from './imageLightbox.module.scss'; interface ImageLightboxProps @@ -25,6 +27,8 @@ const isExternalImage = (src: string): boolean => src.startsWith('http'); const getImageUrl = (src: string, imgPath: string): string => isExternalImage(src) ? src : imgPath; +// Using shared allowlist logic from src/config/images + type ValidDimensions = { height: number; width: number; @@ -59,9 +63,9 @@ export function ImageLightbox({ // Check if we should use Next.js Image or regular img // Use Next.js Image for internal images with valid dimensions // Use regular img for external images or when dimensions are invalid/missing - const validDimensions = !isExternalImage(src) - ? getValidDimensions(width, height) - : null; + const dimensions = getValidDimensions(width, height); + const shouldUseNextImage = + !!dimensions && (!isExternalImage(src) || isAllowedRemoteImage(src)); const handleClick = (e: React.MouseEvent) => { // If Ctrl/Cmd+click, open image in new tab @@ -101,13 +105,14 @@ export function ImageLightbox({ // Render the appropriate image component const renderImage = () => { - if (validDimensions) { + const renderedSrc = getImageUrl(src, imgPath); + if (shouldUseNextImage && dimensions) { // TypeScript knows validDimensions.width and validDimensions.height are both numbers return ( {alt} - {validDimensions ? ( + {shouldUseNextImage && dimensions ? ( {alt} ({ + protocol: 'https' as const, + hostname, +})); + +export function isAllowedRemoteImage(src: string): boolean { + try { + const url = new URL(src); + return ( + url.protocol === 'https:' && + (REMOTE_IMAGE_HOSTNAMES as readonly string[]).includes(url.hostname) + ); + } catch (_error) { + return false; + } +} + From d6081b0c3dbcbafdd29744e6799798f8520407ce Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 17:07:13 +0000 Subject: [PATCH 30/37] [getsentry/action-github-commit] Auto commit --- src/config/images.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/config/images.ts b/src/config/images.ts index cd385ec20daf7..882984e5aad92 100644 --- a/src/config/images.ts +++ b/src/config/images.ts @@ -19,4 +19,3 @@ export function isAllowedRemoteImage(src: string): boolean { return false; } } - From 896228ed61099ae93ca34ba3e0ba5d6f44628e29 Mon Sep 17 00:00:00 2001 From: paulj Date: Fri, 8 Aug 2025 13:55:59 -0400 Subject: [PATCH 31/37] fix image edgecase bug --- src/components/docImage.tsx | 144 +++++++++++++------------ src/components/imageLightbox/index.tsx | 3 +- 2 files changed, 77 insertions(+), 70 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index 90eaf802d4fad..e81c853208158 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -4,6 +4,54 @@ import {serverContext} from 'sentry-docs/serverContext'; import {ImageLightbox} from './imageLightbox'; +// Helper function to safely parse dimension values +const parseDimension = (value: string | number | undefined): number | undefined => { + if (typeof value === 'number' && value > 0 && value <= 10000) return value; + if (typeof value === 'string') { + const parsed = parseInt(value, 10); + return parsed > 0 && parsed <= 10000 ? parsed : undefined; + } + return undefined; +}; + +// Helper function to parse dimensions from URL hash +const parseDimensionsFromHash = (url: string): number[] => { + try { + const urlObj = new URL(url, 'https://example.com'); + const hash = urlObj.hash.slice(1); + + // Only parse hash if it looks like dimensions (e.g., "800x600", "100x200") + // Must be exactly two positive integers separated by 'x' + const dimensionPattern = /^(\d+)x(\d+)$/; + const match = hash.match(dimensionPattern); + + if (match) { + const width = parseInt(match[1], 10); + const height = parseInt(match[2], 10); + return width > 0 && width <= 10000 && height > 0 && height <= 10000 + ? [width, height] + : []; + } + + return []; + } catch (_error) { + return []; + } +}; + +// Helper function to clean URL by removing hash +const cleanUrl = (url: string): string => { + try { + const urlObj = new URL(url); + // For external URLs, reconstruct without hash + urlObj.hash = ''; + return urlObj.toString(); + } catch (_error) { + // If URL parsing fails, just remove hash manually + return url.split('#')[0]; + } +}; + export default function DocImage({ src, width: propsWidth, @@ -16,83 +64,41 @@ export default function DocImage({ return null; } - // Handle external images early - pass through without processing - if (src.startsWith('http')) { - // For external images, let ImageLightbox decide whether to use Next.js Image or regular img - // Parse dimensions if provided - const width = - typeof propsWidth === 'number' - ? propsWidth - : typeof propsWidth === 'string' - ? parseInt(propsWidth, 10) || undefined - : undefined; - const height = - typeof propsHeight === 'number' - ? propsHeight - : typeof propsHeight === 'string' - ? parseInt(propsHeight, 10) || undefined - : undefined; - - return ( - - ); - } - - // If the image src is not an absolute URL, we assume it's a relative path - // and we prepend /mdx-images/ to it. - if (src.startsWith('./')) { - src = path.join('/mdx-images', src); - } - // account for the old way of doing things where the public folder structure mirrored the docs folder - else if (!src?.startsWith('/') && !src?.includes('://')) { - src = `/${pagePath.join('/')}/${src}`; - } + const isExternalImage = src.startsWith('http') || src.startsWith('//'); + let finalSrc = src; + let imgPath = src; - // parse the size from the URL hash (set by remark-image-size.js) - let srcURL: URL; - let imgPath: string; - let dimensions: number[] = []; + // For internal images, process the path + if (!isExternalImage) { + if (src.startsWith('./')) { + finalSrc = path.join('/mdx-images', src); + } else if (!src?.startsWith('/') && !src?.includes('://')) { + finalSrc = `/${pagePath.join('/')}/${src}`; + } - try { - srcURL = new URL(src, 'https://example.com'); - imgPath = srcURL.pathname; - dimensions = srcURL.hash // #wxh - .slice(1) - .split('x') - .map(s => { - const parsed = parseInt(s, 10); - return isNaN(parsed) ? 0 : parsed; - }); - } catch (_error) { - // Failed to parse URL, fallback to using src directly - imgPath = src; - dimensions = []; + // For internal images, imgPath should be the pathname only + try { + const srcURL = new URL(finalSrc, 'https://example.com'); + imgPath = srcURL.pathname; + } catch (_error) { + imgPath = finalSrc; + } + } else { + // For external images, strip hash from both src and imgPath + finalSrc = cleanUrl(src); + imgPath = finalSrc; } - // Helper function to safely parse dimension values - only return valid numbers or undefined - const parseDimension = (value: string | number | undefined): number | undefined => { - if (typeof value === 'number' && value > 0) return value; - if (typeof value === 'string') { - const parsed = parseInt(value, 10); - return parsed > 0 ? parsed : undefined; - } - return undefined; - }; + // Parse dimensions from URL hash (works for both internal and external) + const hashDimensions = parseDimensionsFromHash(src); - // Use parsed dimensions, fallback to props - let ImageLightbox decide on defaults - const width = dimensions[0] > 0 ? dimensions[0] : parseDimension(propsWidth); - const height = dimensions[1] > 0 ? dimensions[1] : parseDimension(propsHeight); + // Use hash dimensions first, fallback to props + const width = hashDimensions[0] > 0 ? hashDimensions[0] : parseDimension(propsWidth); + const height = hashDimensions[1] > 0 ? hashDimensions[1] : parseDimension(propsHeight); return ( src.startsWith('http'); +const isExternalImage = (src: string): boolean => + src.startsWith('http') || src.startsWith('//'); const getImageUrl = (src: string, imgPath: string): string => isExternalImage(src) ? src : imgPath; From 052325c9f4641d98fcf2b08f308584c7cabc56a4 Mon Sep 17 00:00:00 2001 From: paulj Date: Fri, 8 Aug 2025 14:40:48 -0400 Subject: [PATCH 32/37] add PR feedback --- src/components/imageLightbox/index.tsx | 135 +++++----------- src/components/lightbox/index.tsx | 145 ++++++++++++++++++ .../lightbox.module.scss} | 2 +- 3 files changed, 187 insertions(+), 95 deletions(-) create mode 100644 src/components/lightbox/index.tsx rename src/components/{imageLightbox/imageLightbox.module.scss => lightbox/lightbox.module.scss} (99%) diff --git a/src/components/imageLightbox/index.tsx b/src/components/imageLightbox/index.tsx index d8e635e44b4cb..4c6bf63f22f96 100644 --- a/src/components/imageLightbox/index.tsx +++ b/src/components/imageLightbox/index.tsx @@ -1,14 +1,12 @@ 'use client'; import {useState} from 'react'; -import {X} from 'react-feather'; import * as Dialog from '@radix-ui/react-dialog'; import Image from 'next/image'; +import {Lightbox} from 'sentry-docs/components/lightbox'; import {isAllowedRemoteImage} from 'sentry-docs/config/images'; -import styles from './imageLightbox.module.scss'; - interface ImageLightboxProps extends Omit< React.HTMLProps, @@ -68,36 +66,32 @@ export function ImageLightbox({ const shouldUseNextImage = !!dimensions && (!isExternalImage(src) || isAllowedRemoteImage(src)); - const handleClick = (e: React.MouseEvent) => { - // If Ctrl/Cmd+click, open image in new tab + const handleModifierClick = (e: React.MouseEvent) => { + // If Ctrl/Cmd+click, open image in new tab instead of lightbox if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); const url = getImageUrl(src, imgPath); const newWindow = window.open(url, '_blank'); if (newWindow) { newWindow.opener = null; // Security: prevent opener access } - return; } - // Normal click - open lightbox - setOpen(true); + // Normal click will be handled by Dialog.Trigger }; - const handleKeyDown = (e: React.KeyboardEvent) => { - // Handle Enter and Space keys - if (e.key === 'Enter' || e.key === ' ') { + const handleModifierKeyDown = (e: React.KeyboardEvent) => { + // Handle Ctrl/Cmd+Enter or Ctrl/Cmd+Space to open in new tab + if ((e.key === 'Enter' || e.key === ' ') && (e.ctrlKey || e.metaKey)) { e.preventDefault(); - // If Ctrl/Cmd+key, open image in new tab - if (e.ctrlKey || e.metaKey) { - const url = getImageUrl(src, imgPath); - const newWindow = window.open(url, '_blank'); - if (newWindow) { - newWindow.opener = null; // Security: prevent opener access - } - return; + e.stopPropagation(); + const url = getImageUrl(src, imgPath); + const newWindow = window.open(url, '_blank'); + if (newWindow) { + newWindow.opener = null; // Security: prevent opener access } - // Normal key press - open lightbox - setOpen(true); } + // Normal key presses will be handled by Dialog.Trigger }; // Filter out props that are incompatible with Next.js Image component @@ -105,22 +99,25 @@ export function ImageLightbox({ const {placeholder: _placeholder, ...imageCompatibleProps} = props; // Render the appropriate image component - const renderImage = () => { + const renderImage = (isInline: boolean = true) => { const renderedSrc = getImageUrl(src, imgPath); + const imageClassName = isInline + ? className + : 'max-h-[90vh] max-w-[90vw] object-contain'; + const imageStyle = isInline + ? {width: '100%', height: 'auto', ...style} + : {width: 'auto', height: 'auto'}; + if (shouldUseNextImage && dimensions) { - // TypeScript knows validDimensions.width and validDimensions.height are both numbers return ( {alt} ); @@ -130,77 +127,27 @@ export function ImageLightbox({ {alt} ); }; return ( - - {/* Custom trigger that handles modifier keys properly */} -
- {renderImage()} -
- - - - - - {/* Close button */} - - - Close - - - {/* Image container */} -
- {shouldUseNextImage && dimensions ? ( - {alt} - ) : ( - /* eslint-disable-next-line @next/next/no-img-element */ - {alt} - )} -
-
-
-
+ + +
+ {renderImage()} +
+
+
); } diff --git a/src/components/lightbox/index.tsx b/src/components/lightbox/index.tsx new file mode 100644 index 0000000000000..3bfd134418ad1 --- /dev/null +++ b/src/components/lightbox/index.tsx @@ -0,0 +1,145 @@ +/** + * Reusable Lightbox component built on top of Radix UI Dialog + * + * @example + * // Simple usage with children as trigger + * }> + * Click to expand + * + * + * @example + * // Advanced usage with custom trigger and controlled state + * const [open, setOpen] = useState(false); + * + * } + * trigger={ + * + * + * + * } + * /> + */ + +'use client'; + +import {useState} from 'react'; +import {X} from 'react-feather'; +import * as Dialog from '@radix-ui/react-dialog'; + +import styles from './lightbox.module.scss'; + +interface LightboxProps { + children?: React.ReactNode; + content: React.ReactNode; + trigger?: React.ReactNode; + onOpenChange?: (open: boolean) => void; + open?: boolean; + closeButton?: boolean; +} + +interface LightboxTriggerProps { + children: React.ReactNode; + onClick?: (e: React.MouseEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + className?: string; + 'aria-label'?: string; +} + +interface LightboxContentProps { + children: React.ReactNode; + className?: string; +} + +interface LightboxCloseProps { + children?: React.ReactNode; + className?: string; +} + +// Root component +function LightboxRoot({ + children, + content, + trigger, + onOpenChange, + open: controlledOpen, + closeButton = true, +}: LightboxProps) { + const [internalOpen, setInternalOpen] = useState(false); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen; + + return ( + + {trigger || (children && {children})} + + + + + {closeButton && ( + + + Close + + )} +
{content}
+
+
+
+ ); +} + +// Trigger component for custom triggers +function LightboxTrigger({ + children, + onClick, + onKeyDown, + className = '', + 'aria-label': ariaLabel, +}: LightboxTriggerProps) { + return ( +
+ {children} +
+ ); +} + +// Content wrapper (optional, for additional styling) +function LightboxContent({children, className = ''}: LightboxContentProps) { + return
{children}
; +} + +// Close button component +function LightboxClose({children, className = ''}: LightboxCloseProps) { + return ( + + {children || ( + <> + + Close + + )} + + ); +} + +// Compound component exports +export const Lightbox = { + Root: LightboxRoot, + Trigger: LightboxTrigger, + Content: LightboxContent, + Close: LightboxClose, +}; + +// For backward compatibility, export the root as default +export default LightboxRoot; diff --git a/src/components/imageLightbox/imageLightbox.module.scss b/src/components/lightbox/lightbox.module.scss similarity index 99% rename from src/components/imageLightbox/imageLightbox.module.scss rename to src/components/lightbox/lightbox.module.scss index 7d8f355233bc4..aecf60e0f8fb7 100644 --- a/src/components/imageLightbox/imageLightbox.module.scss +++ b/src/components/lightbox/lightbox.module.scss @@ -73,4 +73,4 @@ .lightboxOverlay[data-state='closed'] { animation: dialogOverlayHide 200ms cubic-bezier(0.16, 1, 0.3, 1); -} +} \ No newline at end of file From 07702289f3fcfe830cf8cbcd291d941a125071da Mon Sep 17 00:00:00 2001 From: paulj Date: Fri, 8 Aug 2025 15:14:30 -0400 Subject: [PATCH 33/37] better abstraction refactor & cleanup --- src/components/docImage.tsx | 5 +- src/components/imageLightbox/index.tsx | 60 +++++++++------------- src/components/lightbox/index.tsx | 70 +++++++------------------- src/config/images.ts | 8 ++- src/remark-image-size.js | 2 +- 5 files changed, 51 insertions(+), 94 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index e81c853208158..5823c5ac8f055 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -1,5 +1,6 @@ import path from 'path'; +import {isExternalImage} from 'sentry-docs/config/images'; import {serverContext} from 'sentry-docs/serverContext'; import {ImageLightbox} from './imageLightbox'; @@ -64,12 +65,12 @@ export default function DocImage({ return null; } - const isExternalImage = src.startsWith('http') || src.startsWith('//'); + const isExternal = isExternalImage(src); let finalSrc = src; let imgPath = src; // For internal images, process the path - if (!isExternalImage) { + if (!isExternal) { if (src.startsWith('./')) { finalSrc = path.join('/mdx-images', src); } else if (!src?.startsWith('/') && !src?.includes('://')) { diff --git a/src/components/imageLightbox/index.tsx b/src/components/imageLightbox/index.tsx index 4c6bf63f22f96..d21fc9765801e 100644 --- a/src/components/imageLightbox/index.tsx +++ b/src/components/imageLightbox/index.tsx @@ -1,11 +1,10 @@ 'use client'; import {useState} from 'react'; -import * as Dialog from '@radix-ui/react-dialog'; import Image from 'next/image'; import {Lightbox} from 'sentry-docs/components/lightbox'; -import {isAllowedRemoteImage} from 'sentry-docs/config/images'; +import {isAllowedRemoteImage, isExternalImage} from 'sentry-docs/config/images'; interface ImageLightboxProps extends Omit< @@ -19,15 +18,9 @@ interface ImageLightboxProps width?: number; } -// Helper functions -const isExternalImage = (src: string): boolean => - src.startsWith('http') || src.startsWith('//'); - const getImageUrl = (src: string, imgPath: string): string => isExternalImage(src) ? src : imgPath; -// Using shared allowlist logic from src/config/images - type ValidDimensions = { height: number; width: number; @@ -59,46 +52,36 @@ export function ImageLightbox({ }: ImageLightboxProps) { const [open, setOpen] = useState(false); - // Check if we should use Next.js Image or regular img - // Use Next.js Image for internal images with valid dimensions - // Use regular img for external images or when dimensions are invalid/missing const dimensions = getValidDimensions(width, height); const shouldUseNextImage = !!dimensions && (!isExternalImage(src) || isAllowedRemoteImage(src)); - const handleModifierClick = (e: React.MouseEvent) => { - // If Ctrl/Cmd+click, open image in new tab instead of lightbox - if (e.ctrlKey || e.metaKey) { + const openInNewTab = () => { + window.open(getImageUrl(src, imgPath), '_blank'); + }; + + const handleClick = (e: React.MouseEvent) => { + // Middle-click or Ctrl/Cmd+click opens in new tab + if (e.button === 1 || e.ctrlKey || e.metaKey) { e.preventDefault(); - e.stopPropagation(); - const url = getImageUrl(src, imgPath); - const newWindow = window.open(url, '_blank'); - if (newWindow) { - newWindow.opener = null; // Security: prevent opener access - } + openInNewTab(); + return; } - // Normal click will be handled by Dialog.Trigger + // Regular click falls through to Dialog.Trigger }; - const handleModifierKeyDown = (e: React.KeyboardEvent) => { - // Handle Ctrl/Cmd+Enter or Ctrl/Cmd+Space to open in new tab + const handleKeyDown = (e: React.KeyboardEvent) => { if ((e.key === 'Enter' || e.key === ' ') && (e.ctrlKey || e.metaKey)) { e.preventDefault(); - e.stopPropagation(); - const url = getImageUrl(src, imgPath); - const newWindow = window.open(url, '_blank'); - if (newWindow) { - newWindow.opener = null; // Security: prevent opener access - } + openInNewTab(); } - // Normal key presses will be handled by Dialog.Trigger + // Regular Enter/Space falls through to Dialog.Trigger }; // Filter out props that are incompatible with Next.js Image component // Next.js Image has stricter typing for certain props like 'placeholder' const {placeholder: _placeholder, ...imageCompatibleProps} = props; - // Render the appropriate image component const renderImage = (isInline: boolean = true) => { const renderedSrc = getImageUrl(src, imgPath); const imageClassName = isInline @@ -127,27 +110,30 @@ export function ImageLightbox({ {alt} ); }; return ( - +
{renderImage()}
-
+
); } diff --git a/src/components/lightbox/index.tsx b/src/components/lightbox/index.tsx index 3bfd134418ad1..bc23f8218d045 100644 --- a/src/components/lightbox/index.tsx +++ b/src/components/lightbox/index.tsx @@ -8,49 +8,38 @@ *
* * @example - * // Advanced usage with custom trigger and controlled state + * // Advanced usage with Lightbox.Trigger and controlled state * const [open, setOpen] = useState(false); * - * } - * trigger={ - * + * }> + * + * + * + * */ 'use client'; -import {useState} from 'react'; +import {Fragment, useState} from 'react'; import {X} from 'react-feather'; import * as Dialog from '@radix-ui/react-dialog'; import styles from './lightbox.module.scss'; interface LightboxProps { - children?: React.ReactNode; content: React.ReactNode; - trigger?: React.ReactNode; + children?: React.ReactNode; + closeButton?: boolean; onOpenChange?: (open: boolean) => void; open?: boolean; - closeButton?: boolean; + trigger?: React.ReactNode; } interface LightboxTriggerProps { children: React.ReactNode; - onClick?: (e: React.MouseEvent) => void; - onKeyDown?: (e: React.KeyboardEvent) => void; - className?: string; - 'aria-label'?: string; -} - -interface LightboxContentProps { - children: React.ReactNode; - className?: string; + asChild?: boolean; } interface LightboxCloseProps { @@ -92,31 +81,9 @@ function LightboxRoot({ ); } -// Trigger component for custom triggers -function LightboxTrigger({ - children, - onClick, - onKeyDown, - className = '', - 'aria-label': ariaLabel, -}: LightboxTriggerProps) { - return ( -
- {children} -
- ); -} - -// Content wrapper (optional, for additional styling) -function LightboxContent({children, className = ''}: LightboxContentProps) { - return
{children}
; +// Trigger component +function LightboxTrigger({children, asChild = false}: LightboxTriggerProps) { + return {children}; } // Close button component @@ -124,22 +91,19 @@ function LightboxClose({children, className = ''}: LightboxCloseProps) { return ( {children || ( - <> + Close - + )} ); } -// Compound component exports export const Lightbox = { Root: LightboxRoot, Trigger: LightboxTrigger, - Content: LightboxContent, Close: LightboxClose, }; -// For backward compatibility, export the root as default export default LightboxRoot; diff --git a/src/config/images.ts b/src/config/images.ts index 882984e5aad92..3177674d448d8 100644 --- a/src/config/images.ts +++ b/src/config/images.ts @@ -8,9 +8,15 @@ export const REMOTE_IMAGE_PATTERNS = REMOTE_IMAGE_HOSTNAMES.map(hostname => ({ hostname, })); +export function isExternalImage(src: string): boolean { + return src.startsWith('http') || src.startsWith('//'); +} + export function isAllowedRemoteImage(src: string): boolean { try { - const url = new URL(src); + // Handle protocol-relative URLs by adding https: protocol + const normalizedSrc = src.startsWith('//') ? `https:${src}` : src; + const url = new URL(normalizedSrc); return ( url.protocol === 'https:' && (REMOTE_IMAGE_HOSTNAMES as readonly string[]).includes(url.hostname) diff --git a/src/remark-image-size.js b/src/remark-image-size.js index b934168a09c6f..197ae3c5b7ec9 100644 --- a/src/remark-image-size.js +++ b/src/remark-image-size.js @@ -13,7 +13,7 @@ export default function remarkImageSize(options) { return tree => visit(tree, 'image', node => { // don't process external images - if (node.url.startsWith('http')) { + if (node.url.startsWith('http') || node.url.startsWith('//')) { return; } const fullImagePath = path.join( From 4be0b7edd942b112f7f09c49198ab475b60abe50 Mon Sep 17 00:00:00 2001 From: Paul Jaffre Date: Fri, 8 Aug 2025 14:36:27 -0400 Subject: [PATCH 34/37] Update package.json Co-authored-by: Sergiy Dybskiy --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ab6b5e372d613..a48b7410e46d1 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@prettier/plugin-xml": "^3.3.1", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-collapsible": "^1.1.1", - "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-tabs": "^1.1.1", From 689e57f4b089820abaa1a3af8d968cc0543198f8 Mon Sep 17 00:00:00 2001 From: paulj Date: Fri, 8 Aug 2025 16:10:57 -0400 Subject: [PATCH 35/37] bugbot smash --- src/components/imageLightbox/index.tsx | 26 +++++++++++++++++++------- src/components/lightbox/index.tsx | 15 ++++++++------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/components/imageLightbox/index.tsx b/src/components/imageLightbox/index.tsx index d21fc9765801e..323deb911a5fd 100644 --- a/src/components/imageLightbox/index.tsx +++ b/src/components/imageLightbox/index.tsx @@ -18,8 +18,13 @@ interface ImageLightboxProps width?: number; } -const getImageUrl = (src: string, imgPath: string): string => - isExternalImage(src) ? src : imgPath; +const getImageUrl = (src: string, imgPath: string): string => { + if (isExternalImage(src)) { + // Normalize protocol-relative URLs to use https: + return src.startsWith('//') ? `https:${src}` : src; + } + return imgPath; +}; type ValidDimensions = { height: number; @@ -57,25 +62,32 @@ export function ImageLightbox({ !!dimensions && (!isExternalImage(src) || isAllowedRemoteImage(src)); const openInNewTab = () => { - window.open(getImageUrl(src, imgPath), '_blank'); + window.open(getImageUrl(src, imgPath), '_blank', 'noopener,noreferrer'); }; const handleClick = (e: React.MouseEvent) => { // Middle-click or Ctrl/Cmd+click opens in new tab if (e.button === 1 || e.ctrlKey || e.metaKey) { e.preventDefault(); + e.stopPropagation(); openInNewTab(); return; } - // Regular click falls through to Dialog.Trigger + // Regular click opens lightbox - let it bubble to Dialog.Trigger }; const handleKeyDown = (e: React.KeyboardEvent) => { - if ((e.key === 'Enter' || e.key === ' ') && (e.ctrlKey || e.metaKey)) { + if (e.key === 'Enter' || e.key === ' ') { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + openInNewTab(); + return; + } + // Regular Enter/Space should open lightbox e.preventDefault(); - openInNewTab(); + setOpen(true); } - // Regular Enter/Space falls through to Dialog.Trigger }; // Filter out props that are incompatible with Next.js Image component diff --git a/src/components/lightbox/index.tsx b/src/components/lightbox/index.tsx index bc23f8218d045..4c7aa941715ec 100644 --- a/src/components/lightbox/index.tsx +++ b/src/components/lightbox/index.tsx @@ -1,14 +1,17 @@ /** - * Reusable Lightbox component built on top of Radix UI Dialog + * Reusable Lightbox component built on top of Radix UI Dialog. + * Provides a modal overlay for displaying images or other content. * * @example - * // Simple usage with children as trigger + * // Basic usage - you must provide Lightbox.Trigger as children * }> - * Click to expand + * + * Click to expand + * * * * @example - * // Advanced usage with Lightbox.Trigger and controlled state + * // Controlled state with custom trigger * const [open, setOpen] = useState(false); * * }> @@ -34,7 +37,6 @@ interface LightboxProps { closeButton?: boolean; onOpenChange?: (open: boolean) => void; open?: boolean; - trigger?: React.ReactNode; } interface LightboxTriggerProps { @@ -51,7 +53,6 @@ interface LightboxCloseProps { function LightboxRoot({ children, content, - trigger, onOpenChange, open: controlledOpen, closeButton = true, @@ -63,7 +64,7 @@ function LightboxRoot({ return ( - {trigger || (children && {children})} + {children} From 5dc9bcd23e620ca07fbb89af4817e06245602c3f Mon Sep 17 00:00:00 2001 From: paulj Date: Fri, 8 Aug 2025 16:40:50 -0400 Subject: [PATCH 36/37] delegate basic events to radix, keep modifier logic --- src/components/imageLightbox/index.tsx | 37 ++++++++++---------------- src/components/lightbox/index.tsx | 7 +++-- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/src/components/imageLightbox/index.tsx b/src/components/imageLightbox/index.tsx index 323deb911a5fd..63173f41cdb37 100644 --- a/src/components/imageLightbox/index.tsx +++ b/src/components/imageLightbox/index.tsx @@ -71,22 +71,17 @@ export function ImageLightbox({ e.preventDefault(); e.stopPropagation(); openInNewTab(); - return; } - // Regular click opens lightbox - let it bubble to Dialog.Trigger + // Regular click is handled by Dialog.Trigger }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - e.stopPropagation(); - openInNewTab(); - return; - } - // Regular Enter/Space should open lightbox + // Ctrl/Cmd+Enter/Space opens in new tab + // Regular Enter/Space is handled by Dialog.Trigger + if ((e.key === 'Enter' || e.key === ' ') && (e.ctrlKey || e.metaKey)) { e.preventDefault(); - setOpen(true); + e.stopPropagation(); + openInNewTab(); } }; @@ -133,18 +128,14 @@ export function ImageLightbox({ return ( - -
- {renderImage()} -
+ + {renderImage()}
); diff --git a/src/components/lightbox/index.tsx b/src/components/lightbox/index.tsx index 4c7aa941715ec..f3ed95a4fb275 100644 --- a/src/components/lightbox/index.tsx +++ b/src/components/lightbox/index.tsx @@ -39,9 +39,8 @@ interface LightboxProps { open?: boolean; } -interface LightboxTriggerProps { +interface LightboxTriggerProps extends React.ComponentProps { children: React.ReactNode; - asChild?: boolean; } interface LightboxCloseProps { @@ -83,8 +82,8 @@ function LightboxRoot({ } // Trigger component -function LightboxTrigger({children, asChild = false}: LightboxTriggerProps) { - return {children}; +function LightboxTrigger({children, ...props}: LightboxTriggerProps) { + return {children}; } // Close button component From b2fecbf04d3c12c7abb4a8f7b429d1c3784017c9 Mon Sep 17 00:00:00 2001 From: paulj Date: Fri, 8 Aug 2025 21:41:34 -0400 Subject: [PATCH 37/37] bugbot fix --- src/components/docImage.tsx | 66 ++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index 5823c5ac8f055..dd20790831411 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -15,42 +15,48 @@ const parseDimension = (value: string | number | undefined): number | undefined return undefined; }; +// Dimension pattern regex - used to identify dimension hashes like "800x600" +const DIMENSION_PATTERN = /^(\d+)x(\d+)$/; + +// Helper function to extract hash from URL string (works with both relative and absolute URLs) +const extractHash = (url: string): string => { + const hashIndex = url.indexOf('#'); + return hashIndex !== -1 ? url.slice(hashIndex + 1) : ''; +}; + +// Helper function to check if a hash contains dimension information +const isDimensionHash = (hash: string): boolean => { + return DIMENSION_PATTERN.test(hash); +}; + // Helper function to parse dimensions from URL hash const parseDimensionsFromHash = (url: string): number[] => { - try { - const urlObj = new URL(url, 'https://example.com'); - const hash = urlObj.hash.slice(1); - - // Only parse hash if it looks like dimensions (e.g., "800x600", "100x200") - // Must be exactly two positive integers separated by 'x' - const dimensionPattern = /^(\d+)x(\d+)$/; - const match = hash.match(dimensionPattern); - - if (match) { - const width = parseInt(match[1], 10); - const height = parseInt(match[2], 10); - return width > 0 && width <= 10000 && height > 0 && height <= 10000 - ? [width, height] - : []; - } - - return []; - } catch (_error) { - return []; + const hash = extractHash(url); + const match = hash.match(DIMENSION_PATTERN); + + if (match) { + const width = parseInt(match[1], 10); + const height = parseInt(match[2], 10); + return width > 0 && width <= 10000 && height > 0 && height <= 10000 + ? [width, height] + : []; } + + return []; }; -// Helper function to clean URL by removing hash +// Helper function to remove dimension hash from URL while preserving fragment identifiers const cleanUrl = (url: string): string => { - try { - const urlObj = new URL(url); - // For external URLs, reconstruct without hash - urlObj.hash = ''; - return urlObj.toString(); - } catch (_error) { - // If URL parsing fails, just remove hash manually - return url.split('#')[0]; + const hash = extractHash(url); + + // If no hash or hash is not a dimension pattern, return original URL + if (!hash || !isDimensionHash(hash)) { + return url; } + + // Remove dimension hash + const hashIndex = url.indexOf('#'); + return url.slice(0, hashIndex); }; export default function DocImage({ @@ -85,7 +91,7 @@ export default function DocImage({ imgPath = finalSrc; } } else { - // For external images, strip hash from both src and imgPath + // For external images, clean URL by removing only dimension hashes, preserving fragment identifiers finalSrc = cleanUrl(src); imgPath = finalSrc; }