Skip to content

Commit b911663

Browse files
committed
Add nice image preview to markdown rendering
1 parent a280025 commit b911663

File tree

1 file changed

+247
-7
lines changed

1 file changed

+247
-7
lines changed

src/browser/ui/markdown.tsx

Lines changed: 247 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,13 @@ import {
3232
Quote,
3333
Heading2,
3434
Smile,
35+
X,
36+
ZoomIn,
37+
ZoomOut,
38+
RotateCcw,
3539
} from "lucide-react";
3640
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
41+
import { Dialog, DialogContent, DialogTitle } from "./dialog";
3742

3843
interface MarkdownProps {
3944
children: string;
@@ -50,6 +55,202 @@ interface MarkdownProps {
5055
// Pattern to match @mentions (GitHub-style: @username)
5156
const MENTION_REGEX = /@([a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-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

144345
function 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

Comments
 (0)