@@ -32,8 +32,13 @@ import {
3232 Quote ,
3333 Heading2 ,
3434 Smile ,
35+ X ,
36+ ZoomIn ,
37+ ZoomOut ,
38+ RotateCcw ,
3539} from "lucide-react" ;
3640import { Tooltip , TooltipContent , TooltipTrigger } from "./tooltip" ;
41+ import { Dialog , DialogContent , DialogTitle } from "./dialog" ;
3742
3843interface MarkdownProps {
3944 children : string ;
@@ -50,6 +55,202 @@ interface MarkdownProps {
5055// Pattern to match @mentions (GitHub-style: @username)
5156const MENTION_REGEX = / @ ( [ a - z A - Z 0 - 9 ] (?: [ a - z A - Z 0 - 9 ] | - (? = [ a - z A - Z 0 - 9 ] ) ) { 0 , 38 } ) / g;
5257
58+ // ============================================================================
59+ // Image Preview Context & Modal
60+ // ============================================================================
61+
62+ interface ImagePreviewContextValue {
63+ openPreview : ( src : string , alt ?: string ) => void ;
64+ }
65+
66+ const ImagePreviewContext = createContext < ImagePreviewContextValue | null > (
67+ null
68+ ) ;
69+
70+ function useImagePreview ( ) {
71+ return useContext ( ImagePreviewContext ) ;
72+ }
73+
74+ function ImagePreviewProvider ( { children } : { children : ReactNode } ) {
75+ const [ previewImage , setPreviewImage ] = useState < {
76+ src : string ;
77+ alt ?: string ;
78+ } | null > ( null ) ;
79+ const [ zoom , setZoom ] = useState ( 1 ) ;
80+ const containerRef = useRef < HTMLDivElement > ( null ) ;
81+ const [ isDragging , setIsDragging ] = useState ( false ) ;
82+ const [ dragStart , setDragStart ] = useState ( { x : 0 , y : 0 } ) ;
83+ const [ scrollStart , setScrollStart ] = useState ( { x : 0 , y : 0 } ) ;
84+
85+ const openPreview = useCallback ( ( src : string , alt ?: string ) => {
86+ setPreviewImage ( { src, alt } ) ;
87+ setZoom ( 1 ) ;
88+ } , [ ] ) ;
89+
90+ const closePreview = useCallback ( ( ) => {
91+ setPreviewImage ( null ) ;
92+ setZoom ( 1 ) ;
93+ } , [ ] ) ;
94+
95+ // Pan/drag handlers
96+ const handleMouseDown = useCallback (
97+ ( e : React . MouseEvent ) => {
98+ if ( zoom <= 1 || ! containerRef . current ) return ;
99+ e . preventDefault ( ) ;
100+ setIsDragging ( true ) ;
101+ setDragStart ( { x : e . clientX , y : e . clientY } ) ;
102+ setScrollStart ( {
103+ x : containerRef . current . scrollLeft ,
104+ y : containerRef . current . scrollTop ,
105+ } ) ;
106+ } ,
107+ [ zoom ]
108+ ) ;
109+
110+ const handleMouseMove = useCallback (
111+ ( e : React . MouseEvent ) => {
112+ if ( ! isDragging || ! containerRef . current ) return ;
113+ const dx = e . clientX - dragStart . x ;
114+ const dy = e . clientY - dragStart . y ;
115+ containerRef . current . scrollLeft = scrollStart . x - dx ;
116+ containerRef . current . scrollTop = scrollStart . y - dy ;
117+ } ,
118+ [ isDragging , dragStart , scrollStart ]
119+ ) ;
120+
121+ const handleMouseUp = useCallback ( ( ) => {
122+ setIsDragging ( false ) ;
123+ } , [ ] ) ;
124+
125+ const handleZoomIn = useCallback ( ( ) => {
126+ setZoom ( ( z ) => Math . min ( z + 0.25 , 4 ) ) ;
127+ } , [ ] ) ;
128+
129+ const handleZoomOut = useCallback ( ( ) => {
130+ setZoom ( ( z ) => Math . max ( z - 0.25 , 0.25 ) ) ;
131+ } , [ ] ) ;
132+
133+ const handleResetZoom = useCallback ( ( ) => {
134+ setZoom ( 1 ) ;
135+ } , [ ] ) ;
136+
137+ // Handle keyboard shortcuts
138+ useEffect ( ( ) => {
139+ if ( ! previewImage ) return ;
140+
141+ const handleKeyDown = ( e : KeyboardEvent ) => {
142+ if ( e . key === "Escape" ) {
143+ closePreview ( ) ;
144+ } else if ( e . key === "+" || e . key === "=" ) {
145+ handleZoomIn ( ) ;
146+ } else if ( e . key === "-" ) {
147+ handleZoomOut ( ) ;
148+ } else if ( e . key === "0" ) {
149+ handleResetZoom ( ) ;
150+ }
151+ } ;
152+
153+ window . addEventListener ( "keydown" , handleKeyDown ) ;
154+ return ( ) => window . removeEventListener ( "keydown" , handleKeyDown ) ;
155+ } , [
156+ previewImage ,
157+ closePreview ,
158+ handleZoomIn ,
159+ handleZoomOut ,
160+ handleResetZoom ,
161+ ] ) ;
162+
163+ const contextValue = useMemo ( ( ) => ( { openPreview } ) , [ openPreview ] ) ;
164+
165+ return (
166+ < ImagePreviewContext . Provider value = { contextValue } >
167+ { children }
168+ < Dialog open = { ! ! previewImage } onOpenChange = { ( ) => closePreview ( ) } >
169+ < DialogContent
170+ className = "!max-w-[90vw] max-h-[90vh] w-auto h-auto p-0 bg-black/95 border-border/50 overflow-hidden flex flex-col gap-0 sm:!max-w-[90vw]"
171+ showCloseButton = { false }
172+ >
173+ < DialogTitle className = "sr-only" >
174+ { previewImage ?. alt || "Image preview" }
175+ </ DialogTitle >
176+ { previewImage && (
177+ < >
178+ { /* Toolbar */ }
179+ < div className = "flex items-center justify-between px-3 py-2 bg-black/50 border-b border-white/10" >
180+ < div className = "flex items-center gap-1" >
181+ < button
182+ onClick = { handleZoomOut }
183+ className = "p-1.5 rounded bg-white/10 hover:bg-white/20 text-white/80 hover:text-white transition-colors"
184+ title = "Zoom out (-)"
185+ >
186+ < ZoomOut className = "w-4 h-4" />
187+ </ button >
188+ < span className = "text-xs text-white/70 font-mono min-w-[4ch] text-center px-1" >
189+ { Math . round ( zoom * 100 ) } %
190+ </ span >
191+ < button
192+ onClick = { handleZoomIn }
193+ className = "p-1.5 rounded bg-white/10 hover:bg-white/20 text-white/80 hover:text-white transition-colors"
194+ title = "Zoom in (+)"
195+ >
196+ < ZoomIn className = "w-4 h-4" />
197+ </ button >
198+ < button
199+ onClick = { handleResetZoom }
200+ className = "p-1.5 rounded bg-white/10 hover:bg-white/20 text-white/80 hover:text-white transition-colors ml-1"
201+ title = "Reset zoom (0)"
202+ >
203+ < RotateCcw className = "w-4 h-4" />
204+ </ button >
205+ </ div >
206+ { previewImage . alt && (
207+ < span className = "text-xs text-white/60 truncate max-w-[40%] px-2" >
208+ { previewImage . alt }
209+ </ span >
210+ ) }
211+ < button
212+ onClick = { closePreview }
213+ className = "p-1.5 rounded bg-white/10 hover:bg-white/20 text-white/80 hover:text-white transition-colors"
214+ title = "Close (Esc)"
215+ >
216+ < X className = "w-4 h-4" />
217+ </ button >
218+ </ div >
219+
220+ { /* Image container */ }
221+ < div
222+ ref = { containerRef }
223+ className = { cn (
224+ "overflow-auto flex-1 flex items-center justify-center p-4 min-h-0" ,
225+ zoom > 1 && ( isDragging ? "cursor-grabbing" : "cursor-grab" )
226+ ) }
227+ onMouseDown = { handleMouseDown }
228+ onMouseMove = { handleMouseMove }
229+ onMouseUp = { handleMouseUp }
230+ onMouseLeave = { handleMouseUp }
231+ >
232+ < img
233+ src = { previewImage . src }
234+ alt = { previewImage . alt || "Preview" }
235+ className = { cn (
236+ "max-w-[85vw] max-h-[80vh] object-contain transition-transform duration-150 select-none" ,
237+ zoom > 1 && "pointer-events-none"
238+ ) }
239+ style = { {
240+ transform : `scale(${ zoom } )` ,
241+ transformOrigin : "center center" ,
242+ } }
243+ draggable = { false }
244+ />
245+ </ div >
246+ </ >
247+ ) }
248+ </ DialogContent >
249+ </ Dialog >
250+ </ ImagePreviewContext . Provider >
251+ ) ;
252+ }
253+
53254// ============================================================================
54255// HTML with Mentions Component
55256// ============================================================================
@@ -143,14 +344,28 @@ function parseNode(node: Node): HtmlNode | null {
143344
144345function HtmlWithMentions ( { html } : { html : string } ) {
145346 const nodes = useMemo ( ( ) => parseHtmlToNodes ( html ) , [ html ] ) ;
146- return < > { renderNodes ( nodes ) } </ > ;
347+ const imagePreview = useImagePreview ( ) ;
348+
349+ const rendered = useMemo (
350+ ( ) => renderNodes ( nodes , imagePreview ?. openPreview ) ,
351+ [ nodes , imagePreview ]
352+ ) ;
353+
354+ return < > { rendered } </ > ;
147355}
148356
149- function renderNodes ( nodes : HtmlNode [ ] ) : React . ReactNode {
150- return nodes . map ( ( node , index ) => renderNode ( node , index ) ) ;
357+ function renderNodes (
358+ nodes : HtmlNode [ ] ,
359+ openPreview ?: ( src : string , alt ?: string ) => void
360+ ) : React . ReactNode {
361+ return nodes . map ( ( node , index ) => renderNode ( node , index , openPreview ) ) ;
151362}
152363
153- function renderNode ( node : HtmlNode , key : number ) : React . ReactNode {
364+ function renderNode (
365+ node : HtmlNode ,
366+ key : number ,
367+ openPreview ?: ( src : string , alt ?: string ) => void
368+ ) : React . ReactNode {
154369 if ( node . type === "text" ) {
155370 return node . content ;
156371 }
@@ -215,11 +430,36 @@ function renderNode(node: HtmlNode, key: number): React.ReactNode {
215430 "wbr" ,
216431 ] ) ;
217432
433+ // Special handling for images - make them clickable for preview
434+ if ( node . tag === "img" && openPreview ) {
435+ const src = node . attributes ?. src ;
436+ const alt = node . attributes ?. alt ;
437+ if ( src ) {
438+ return (
439+ < img
440+ key = { key }
441+ { ...( safeAttributes as React . ImgHTMLAttributes < HTMLImageElement > ) }
442+ className = { cn (
443+ safeAttributes . className as string ,
444+ "cursor-pointer hover:opacity-90 transition-opacity"
445+ ) }
446+ onClick = { ( e ) => {
447+ e . preventDefault ( ) ;
448+ e . stopPropagation ( ) ;
449+ openPreview ( src , alt ) ;
450+ } }
451+ />
452+ ) ;
453+ }
454+ }
455+
218456 if ( voidElements . has ( node . tag ) ) {
219457 return createElement ( node . tag , { key, ...safeAttributes } ) ;
220458 }
221459
222- const children = node . children ? renderNodes ( node . children ) : null ;
460+ const children = node . children
461+ ? renderNodes ( node . children , openPreview )
462+ : null ;
223463 return createElement ( node . tag , { key, ...safeAttributes } , children ) ;
224464 }
225465
@@ -247,15 +487,15 @@ export const Markdown = memo(function Markdown({
247487 // If pre-rendered HTML is provided (from GitHub's API with signed attachment URLs), use it
248488 if ( html ) {
249489 return (
250- < >
490+ < ImagePreviewProvider >
251491 { isEmpty && emptyState }
252492 < div
253493 ref = { containerRef }
254494 className = { cn ( "markdown-body" , className , isEmpty && "hidden" ) }
255495 >
256496 < HtmlWithMentions html = { html } />
257497 </ div >
258- </ >
498+ </ ImagePreviewProvider >
259499 ) ;
260500 }
261501 // Parse the content to find @mentions and wrap them
0 commit comments