11import React , { useRef , useState , useEffect , forwardRef , useImperativeHandle , useCallback } from 'react' ;
2+ import { createPortal } from 'react-dom' ;
23import Highlighter from '@plannotator/web-highlighter' ;
34import hljs from 'highlight.js' ;
45import 'highlight.js/styles/github-dark.css' ;
@@ -28,6 +29,7 @@ import { TaterSpriteSitting } from './TaterSpriteSitting';
2829import { AttachmentsButton } from './AttachmentsButton' ;
2930import { GraphvizBlock } from './GraphvizBlock' ;
3031import { MermaidBlock } from './MermaidBlock' ;
32+ import { getImageSrc } from './ImageThumbnail' ;
3133import { isGraphvizLanguage , isMermaidLanguage } from './diagramLanguages' ;
3234import { getIdentity } from '../utils/identity' ;
3335import { type QuickLabel } from '../utils/quickLabels' ;
@@ -52,6 +54,7 @@ interface ViewerProps {
5254 repoInfo ?: { display : string ; branch ?: string } | null ;
5355 stickyActions ?: boolean ;
5456 onOpenLinkedDoc ?: ( path : string ) => void ;
57+ imageBaseDir ?: string ;
5558 linkedDocInfo ?: { filepath : string ; onBack : ( ) => void ; label ?: string } | null ;
5659 // Plan diff props
5760 planDiffStats ?: { additions : number ; deletions : number ; modifications : number } | null ;
@@ -127,8 +130,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
127130 maxWidth,
128131 onOpenLinkedDoc,
129132 linkedDocInfo,
133+ imageBaseDir,
130134} , ref ) => {
131135 const [ copied , setCopied ] = useState ( false ) ;
136+ const [ lightbox , setLightbox ] = useState < { src : string ; alt : string } | null > ( null ) ;
132137 const globalCommentButtonRef = useRef < HTMLButtonElement > ( null ) ;
133138
134139 const handleCopyPlan = async ( ) => {
@@ -972,7 +977,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
972977 group . type === 'list-group' ? (
973978 < div key = { group . key } data-pinpoint-group = "list" className = "py-1 -mx-2 px-2" >
974979 { group . blocks . map ( block => (
975- < BlockRenderer key = { block . id } block = { block } onOpenLinkedDoc = { onOpenLinkedDoc } />
980+ < BlockRenderer imageBaseDir = { imageBaseDir } onImageClick = { ( src , alt ) => setLightbox ( { src , alt } ) } key = { block . id } block = { block } onOpenLinkedDoc = { onOpenLinkedDoc } />
976981 ) ) }
977982 </ div >
978983 ) : group . block . type === 'code' && isMermaidLanguage ( group . block . language ) ? (
@@ -1010,7 +1015,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
10101015 isHovered = { inputMethod !== 'pinpoint' && hoveredCodeBlock ?. block . id === group . block . id }
10111016 />
10121017 ) : (
1013- < BlockRenderer key = { group . block . id } block = { group . block } onOpenLinkedDoc = { onOpenLinkedDoc } />
1018+ < BlockRenderer imageBaseDir = { imageBaseDir } onImageClick = { ( src , alt ) => setLightbox ( { src , alt } ) } key = { group . block . id } block = { group . block } onOpenLinkedDoc = { onOpenLinkedDoc } />
10141019 )
10151020 ) }
10161021
@@ -1078,14 +1083,48 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
10781083 />
10791084 ) }
10801085 </ article >
1086+
1087+ { /* Image lightbox */ }
1088+ { lightbox && createPortal (
1089+ < ImageLightbox src = { lightbox . src } alt = { lightbox . alt } onClose = { ( ) => setLightbox ( null ) } /> ,
1090+ document . body
1091+ ) }
10811092 </ div >
10821093 ) ;
10831094} ) ;
10841095
1096+ /** Simple lightbox overlay for enlarged image viewing. */
1097+ const ImageLightbox : React . FC < { src : string ; alt : string ; onClose : ( ) => void } > = ( { src, alt, onClose } ) => {
1098+ useEffect ( ( ) => {
1099+ const handleKeyDown = ( e : KeyboardEvent ) => {
1100+ if ( e . key === 'Escape' ) onClose ( ) ;
1101+ } ;
1102+ window . addEventListener ( 'keydown' , handleKeyDown ) ;
1103+ return ( ) => window . removeEventListener ( 'keydown' , handleKeyDown ) ;
1104+ } , [ onClose ] ) ;
1105+
1106+ return (
1107+ < div
1108+ className = "fixed inset-0 z-[200] flex flex-col items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out"
1109+ onClick = { onClose }
1110+ >
1111+ < img
1112+ src = { src }
1113+ alt = { alt }
1114+ className = "max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl"
1115+ onClick = { ( e ) => e . stopPropagation ( ) }
1116+ />
1117+ { alt && (
1118+ < div className = "mt-3 text-sm text-white/70 max-w-[90vw] text-center truncate" > { alt } </ div >
1119+ ) }
1120+ </ div >
1121+ ) ;
1122+ } ;
1123+
10851124/**
10861125 * Renders inline markdown: **bold**, *italic*, `code`, [links](url)
10871126 */
1088- const InlineMarkdown : React . FC < { text : string ; onOpenLinkedDoc ?: ( path : string ) => void } > = ( { text, onOpenLinkedDoc } ) => {
1127+ const InlineMarkdown : React . FC < { text : string ; onOpenLinkedDoc ?: ( path : string ) => void ; imageBaseDir ?: string ; onImageClick ?: ( src : string , alt : string ) => void } > = ( { text, onOpenLinkedDoc, imageBaseDir , onImageClick } ) => {
10891128 const parts : React . ReactNode [ ] = [ ] ;
10901129 let remaining = text ;
10911130 let key = 0 ;
@@ -1094,15 +1133,15 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
10941133 // Bold: **text**
10951134 let match = remaining . match ( / ^ \* \* ( .+ ?) \* \* / ) ;
10961135 if ( match ) {
1097- parts . push ( < strong key = { key ++ } className = "font-semibold" > < InlineMarkdown text = { match [ 1 ] } onOpenLinkedDoc = { onOpenLinkedDoc } /> </ strong > ) ;
1136+ parts . push ( < strong key = { key ++ } className = "font-semibold" > < InlineMarkdown imageBaseDir = { imageBaseDir } onImageClick = { onImageClick } text = { match [ 1 ] } onOpenLinkedDoc = { onOpenLinkedDoc } /> </ strong > ) ;
10981137 remaining = remaining . slice ( match [ 0 ] . length ) ;
10991138 continue ;
11001139 }
11011140
11021141 // Italic: *text*
11031142 match = remaining . match ( / ^ \* ( .+ ?) \* / ) ;
11041143 if ( match ) {
1105- parts . push ( < em key = { key ++ } > < InlineMarkdown text = { match [ 1 ] } onOpenLinkedDoc = { onOpenLinkedDoc } /> </ em > ) ;
1144+ parts . push ( < em key = { key ++ } > < InlineMarkdown imageBaseDir = { imageBaseDir } onImageClick = { onImageClick } text = { match [ 1 ] } onOpenLinkedDoc = { onOpenLinkedDoc } /> </ em > ) ;
11061145 remaining = remaining . slice ( match [ 0 ] . length ) ;
11071146 continue ;
11081147 }
@@ -1153,6 +1192,26 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
11531192 continue ;
11541193 }
11551194
1195+ // Images: 
1196+ match = remaining . match ( / ^ ! \[ ( [ ^ \] ] * ) \] \( ( [ ^ ) ] + ) \) / ) ;
1197+ if ( match ) {
1198+ const alt = match [ 1 ] ;
1199+ const src = match [ 2 ] ;
1200+ const imgSrc = / ^ h t t p s ? : \/ \/ / . test ( src ) ? src : getImageSrc ( src , imageBaseDir ) ;
1201+ parts . push (
1202+ < img
1203+ key = { key ++ }
1204+ src = { imgSrc }
1205+ alt = { alt }
1206+ className = "max-w-full rounded my-2 cursor-zoom-in"
1207+ loading = "lazy"
1208+ onClick = { ( e ) => { e . stopPropagation ( ) ; onImageClick ?.( imgSrc , alt ) ; } }
1209+ />
1210+ ) ;
1211+ remaining = remaining . slice ( match [ 0 ] . length ) ;
1212+ continue ;
1213+ }
1214+
11561215 // Links: [text](url)
11571216 match = remaining . match ( / ^ \[ ( [ ^ \] ] + ) \] \( ( [ ^ ) ] + ) \) / ) ;
11581217 if ( match ) {
@@ -1209,7 +1268,7 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
12091268 }
12101269
12111270 // Find next special character or consume one regular character
1212- const nextSpecial = remaining . slice ( 1 ) . search ( / [ \* ` \[ ] / ) ;
1271+ const nextSpecial = remaining . slice ( 1 ) . search ( / [ \* ` \[ ! ] / ) ;
12131272 if ( nextSpecial === - 1 ) {
12141273 parts . push ( remaining ) ;
12151274 break ;
@@ -1273,7 +1332,7 @@ function groupBlocks(blocks: Block[]): RenderGroup[] {
12731332 return groups ;
12741333}
12751334
1276- const BlockRenderer : React . FC < { block : Block ; onOpenLinkedDoc ?: ( path : string ) => void } > = ( { block, onOpenLinkedDoc } ) => {
1335+ const BlockRenderer : React . FC < { block : Block ; onOpenLinkedDoc ?: ( path : string ) => void ; imageBaseDir ?: string ; onImageClick ?: ( src : string , alt : string ) => void } > = ( { block, onOpenLinkedDoc, imageBaseDir , onImageClick } ) => {
12771336 switch ( block . type ) {
12781337 case 'heading' :
12791338 const Tag = `h${ block . level || 1 } ` as keyof JSX . IntrinsicElements ;
@@ -1283,15 +1342,15 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) =
12831342 3 : 'text-base font-semibold mb-2 mt-6 text-foreground/80' ,
12841343 } [ block . level || 1 ] || 'text-base font-semibold mb-2 mt-4' ;
12851344
1286- return < Tag className = { styles } data-block-id = { block . id } data-block-type = "heading" > < InlineMarkdown text = { block . content } onOpenLinkedDoc = { onOpenLinkedDoc } /> </ Tag > ;
1345+ return < Tag className = { styles } data-block-id = { block . id } data-block-type = "heading" > < InlineMarkdown imageBaseDir = { imageBaseDir } onImageClick = { onImageClick } text = { block . content } onOpenLinkedDoc = { onOpenLinkedDoc } /> </ Tag > ;
12871346
12881347 case 'blockquote' :
12891348 return (
12901349 < blockquote
12911350 className = "border-l-2 border-primary/50 pl-4 my-4 text-muted-foreground italic"
12921351 data-block-id = { block . id }
12931352 >
1294- < InlineMarkdown text = { block . content } onOpenLinkedDoc = { onOpenLinkedDoc } />
1353+ < InlineMarkdown imageBaseDir = { imageBaseDir } onImageClick = { onImageClick } text = { block . content } onOpenLinkedDoc = { onOpenLinkedDoc } />
12951354 </ blockquote >
12961355 ) ;
12971356
@@ -1322,7 +1381,7 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) =
13221381 ) }
13231382 </ span >
13241383 < span className = { `text-sm leading-relaxed ${ isCheckbox && block . checked ? 'text-muted-foreground line-through' : 'text-foreground/90' } ` } >
1325- < InlineMarkdown text = { block . content } onOpenLinkedDoc = { onOpenLinkedDoc } />
1384+ < InlineMarkdown imageBaseDir = { imageBaseDir } onImageClick = { onImageClick } text = { block . content } onOpenLinkedDoc = { onOpenLinkedDoc } />
13261385 </ span >
13271386 </ div >
13281387 ) ;
@@ -1343,7 +1402,7 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) =
13431402 key = { i }
13441403 className = "px-3 py-2 text-left font-semibold text-foreground/90 bg-muted/30"
13451404 >
1346- < InlineMarkdown text = { header } onOpenLinkedDoc = { onOpenLinkedDoc } />
1405+ < InlineMarkdown imageBaseDir = { imageBaseDir } onImageClick = { onImageClick } text = { header } onOpenLinkedDoc = { onOpenLinkedDoc } />
13471406 </ th >
13481407 ) ) }
13491408 </ tr >
@@ -1353,7 +1412,7 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) =
13531412 < tr key = { rowIdx } className = "border-b border-border/50 hover:bg-muted/20" >
13541413 { row . map ( ( cell , cellIdx ) => (
13551414 < td key = { cellIdx } className = "px-3 py-2 text-foreground/80" >
1356- < InlineMarkdown text = { cell } onOpenLinkedDoc = { onOpenLinkedDoc } />
1415+ < InlineMarkdown imageBaseDir = { imageBaseDir } onImageClick = { onImageClick } text = { cell } onOpenLinkedDoc = { onOpenLinkedDoc } />
13571416 </ td >
13581417 ) ) }
13591418 </ tr >
@@ -1373,7 +1432,7 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) =
13731432 className = "mb-4 leading-relaxed text-foreground/90 text-[15px]"
13741433 data-block-id = { block . id }
13751434 >
1376- < InlineMarkdown text = { block . content } onOpenLinkedDoc = { onOpenLinkedDoc } />
1435+ < InlineMarkdown imageBaseDir = { imageBaseDir } onImageClick = { onImageClick } text = { block . content } onOpenLinkedDoc = { onOpenLinkedDoc } />
13771436 </ p >
13781437 ) ;
13791438 }
0 commit comments