Skip to content

Commit 2d1016a

Browse files
dgrissen2claudebacknotprop
authored
feat: render markdown images with lightbox zoom (#271)
Adds full inline image rendering to the Viewer for both annotate and plan review modes. Previously, ![alt](path) syntax was treated as plain text. ## What changed ### Viewer.tsx - Added `![alt](path)` regex to InlineMarkdown before the link pattern. Images starting with `!` were silently falling through to plain text because `!` was not in the special-character scan set — added it. - Added `imageBaseDir` prop (flows Viewer → BlockRenderer → InlineMarkdown) so relative image paths in markdown resolve against the annotated file's directory rather than the server's cwd. - Added `onImageClick` callback (same prop chain) to open lightbox on click. - Added `ImageLightbox` component: full-screen dark backdrop, image scaled to 90vw/85vh with object-contain, alt text caption, closes on Escape or backdrop click. Portaled to document.body. - Images render with `cursor-zoom-in` to hint they're clickable. ### App.tsx - Captures `filePath` from `/api/plan` response (already sent by annotate server) and derives `imageBaseDir` (directory of the markdown file). - Passes `imageBaseDir` to `<Viewer>`. ### ImageThumbnail.tsx - `getImageSrc()` now accepts an optional `base` parameter. For relative local paths, appends `&base=<dir>` to the `/api/image` query string. ### shared-handlers.ts - `handleImage` falls back to resolving the path relative to a `base` query param when the primary path doesn't exist and the path is relative. Covers the common case of `![chart](./images/chart.png)` in a markdown file opened from an arbitrary directory. ## Behavior - Remote URLs (https://...) render directly, no proxy. - Absolute local paths route through /api/image as before. - Relative paths resolve against the markdown file's directory via ?base=. - Click any image → lightbox. Escape or click outside → dismiss. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Michael Ramos <mdramos8@gmail.com>
1 parent c007662 commit 2d1016a

4 files changed

Lines changed: 100 additions & 19 deletions

File tree

packages/editor/App.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@ const App: React.FC = () => {
415415
const [origin, setOrigin] = useState<'claude-code' | 'opencode' | 'pi' | null>(null);
416416
const [globalAttachments, setGlobalAttachments] = useState<ImageAttachment[]>([]);
417417
const [annotateMode, setAnnotateMode] = useState(false);
418+
const [imageBaseDir, setImageBaseDir] = useState<string | undefined>(undefined);
418419
const [isLoading, setIsLoading] = useState(true);
419420
const [isSubmitting, setIsSubmitting] = useState(false);
420421
const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null);
@@ -646,12 +647,15 @@ const App: React.FC = () => {
646647
if (!res.ok) throw new Error('Not in API mode');
647648
return res.json();
648649
})
649-
.then((data: { plan: string; origin?: 'claude-code' | 'opencode' | 'pi'; mode?: 'annotate'; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string } }) => {
650+
.then((data: { plan: string; origin?: 'claude-code' | 'opencode' | 'pi'; mode?: 'annotate'; filePath?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string } }) => {
650651
if (data.plan) setMarkdown(data.plan);
651652
setIsApiMode(true);
652653
if (data.mode === 'annotate') {
653654
setAnnotateMode(true);
654655
}
656+
if (data.filePath) {
657+
setImageBaseDir(data.filePath.replace(/\/[^/]+$/, ''));
658+
}
655659
if (data.sharingEnabled !== undefined) {
656660
setSharingEnabled(data.sharingEnabled);
657661
}
@@ -1530,6 +1534,7 @@ const App: React.FC = () => {
15301534
maxWidth={planMaxWidth}
15311535
onOpenLinkedDoc={handleOpenLinkedDoc}
15321536
linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: vaultBrowser.activeFile ? 'Vault File' : undefined } : null}
1537+
imageBaseDir={imageBaseDir}
15331538
/>
15341539
)}
15351540
</div>

packages/server/shared-handlers.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,23 @@ export async function handleImage(req: Request): Promise<Response> {
2424
}
2525
try {
2626
const file = Bun.file(validation.resolved);
27-
if (!(await file.exists())) {
28-
return new Response("File not found", { status: 404 });
27+
if (await file.exists()) {
28+
return new Response(file);
2929
}
30-
return new Response(file);
30+
// If not found and a base directory is provided, try resolving relative to it
31+
const base = url.searchParams.get("base");
32+
if (base && !imagePath.startsWith("/")) {
33+
const { resolve: resolvePath } = await import("path");
34+
const fromBase = resolvePath(base, imagePath);
35+
const baseValidation = validateImagePath(fromBase);
36+
if (baseValidation.valid) {
37+
const baseFile = Bun.file(baseValidation.resolved);
38+
if (await baseFile.exists()) {
39+
return new Response(baseFile);
40+
}
41+
}
42+
}
43+
return new Response("File not found", { status: 404 });
3144
} catch {
3245
return new Response("Failed to read file", { status: 500 });
3346
}

packages/ui/components/ImageThumbnail.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ import React, { useState } from 'react';
33
/**
44
* Get the display URL for an image path or URL
55
*/
6-
export const getImageSrc = (path: string): string => {
6+
export const getImageSrc = (path: string, base?: string): string => {
77
if (path.startsWith('http://') || path.startsWith('https://')) {
88
return path; // Remote URL, use directly
99
}
10-
return `/api/image?path=${encodeURIComponent(path)}`; // Local path, proxy through server
10+
let url = `/api/image?path=${encodeURIComponent(path)}`;
11+
if (base && !path.startsWith('/')) {
12+
url += `&base=${encodeURIComponent(base)}`;
13+
}
14+
return url;
1115
};
1216

1317
interface ImageThumbnailProps {

packages/ui/components/Viewer.tsx

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useRef, useState, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react';
2+
import { createPortal } from 'react-dom';
23
import Highlighter from '@plannotator/web-highlighter';
34
import hljs from 'highlight.js';
45
import 'highlight.js/styles/github-dark.css';
@@ -28,6 +29,7 @@ import { TaterSpriteSitting } from './TaterSpriteSitting';
2829
import { AttachmentsButton } from './AttachmentsButton';
2930
import { GraphvizBlock } from './GraphvizBlock';
3031
import { MermaidBlock } from './MermaidBlock';
32+
import { getImageSrc } from './ImageThumbnail';
3133
import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
3234
import { getIdentity } from '../utils/identity';
3335
import { 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: ![alt](path)
1196+
match = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/);
1197+
if (match) {
1198+
const alt = match[1];
1199+
const src = match[2];
1200+
const imgSrc = /^https?:\/\//.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

Comments
 (0)