diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 889f6ce8e..7074ab832 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -415,6 +415,7 @@ const App: React.FC = () => { const [origin, setOrigin] = useState<'claude-code' | 'opencode' | 'pi' | null>(null); const [globalAttachments, setGlobalAttachments] = useState([]); const [annotateMode, setAnnotateMode] = useState(false); + const [imageBaseDir, setImageBaseDir] = useState(undefined); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null); @@ -646,12 +647,15 @@ const App: React.FC = () => { if (!res.ok) throw new Error('Not in API mode'); return res.json(); }) - .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 } }) => { + .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 } }) => { if (data.plan) setMarkdown(data.plan); setIsApiMode(true); if (data.mode === 'annotate') { setAnnotateMode(true); } + if (data.filePath) { + setImageBaseDir(data.filePath.replace(/\/[^/]+$/, '')); + } if (data.sharingEnabled !== undefined) { setSharingEnabled(data.sharingEnabled); } @@ -1530,6 +1534,7 @@ const App: React.FC = () => { maxWidth={planMaxWidth} onOpenLinkedDoc={handleOpenLinkedDoc} linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: vaultBrowser.activeFile ? 'Vault File' : undefined } : null} + imageBaseDir={imageBaseDir} /> )} diff --git a/packages/server/shared-handlers.ts b/packages/server/shared-handlers.ts index 1f4183adc..2a5a32da5 100644 --- a/packages/server/shared-handlers.ts +++ b/packages/server/shared-handlers.ts @@ -24,10 +24,23 @@ export async function handleImage(req: Request): Promise { } try { const file = Bun.file(validation.resolved); - if (!(await file.exists())) { - return new Response("File not found", { status: 404 }); + if (await file.exists()) { + return new Response(file); } - return new Response(file); + // If not found and a base directory is provided, try resolving relative to it + const base = url.searchParams.get("base"); + if (base && !imagePath.startsWith("/")) { + const { resolve: resolvePath } = await import("path"); + const fromBase = resolvePath(base, imagePath); + const baseValidation = validateImagePath(fromBase); + if (baseValidation.valid) { + const baseFile = Bun.file(baseValidation.resolved); + if (await baseFile.exists()) { + return new Response(baseFile); + } + } + } + return new Response("File not found", { status: 404 }); } catch { return new Response("Failed to read file", { status: 500 }); } diff --git a/packages/ui/components/ImageThumbnail.tsx b/packages/ui/components/ImageThumbnail.tsx index ba38c7eb8..605c714cb 100644 --- a/packages/ui/components/ImageThumbnail.tsx +++ b/packages/ui/components/ImageThumbnail.tsx @@ -3,11 +3,15 @@ import React, { useState } from 'react'; /** * Get the display URL for an image path or URL */ -export const getImageSrc = (path: string): string => { +export const getImageSrc = (path: string, base?: string): string => { if (path.startsWith('http://') || path.startsWith('https://')) { return path; // Remote URL, use directly } - return `/api/image?path=${encodeURIComponent(path)}`; // Local path, proxy through server + let url = `/api/image?path=${encodeURIComponent(path)}`; + if (base && !path.startsWith('/')) { + url += `&base=${encodeURIComponent(base)}`; + } + return url; }; interface ImageThumbnailProps { diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index a61401924..116302f1e 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -1,4 +1,5 @@ import React, { useRef, useState, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import Highlighter from '@plannotator/web-highlighter'; import hljs from 'highlight.js'; import 'highlight.js/styles/github-dark.css'; @@ -28,6 +29,7 @@ import { TaterSpriteSitting } from './TaterSpriteSitting'; import { AttachmentsButton } from './AttachmentsButton'; import { GraphvizBlock } from './GraphvizBlock'; import { MermaidBlock } from './MermaidBlock'; +import { getImageSrc } from './ImageThumbnail'; import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages'; import { getIdentity } from '../utils/identity'; import { type QuickLabel } from '../utils/quickLabels'; @@ -52,6 +54,7 @@ interface ViewerProps { repoInfo?: { display: string; branch?: string } | null; stickyActions?: boolean; onOpenLinkedDoc?: (path: string) => void; + imageBaseDir?: string; linkedDocInfo?: { filepath: string; onBack: () => void; label?: string } | null; // Plan diff props planDiffStats?: { additions: number; deletions: number; modifications: number } | null; @@ -127,8 +130,10 @@ export const Viewer = forwardRef(({ maxWidth, onOpenLinkedDoc, linkedDocInfo, + imageBaseDir, }, ref) => { const [copied, setCopied] = useState(false); + const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); const globalCommentButtonRef = useRef(null); const handleCopyPlan = async () => { @@ -972,7 +977,7 @@ export const Viewer = forwardRef(({ group.type === 'list-group' ? (
{group.blocks.map(block => ( - + setLightbox({ src, alt })} key={block.id} block={block} onOpenLinkedDoc={onOpenLinkedDoc} /> ))}
) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? ( @@ -1010,7 +1015,7 @@ export const Viewer = forwardRef(({ isHovered={inputMethod !== 'pinpoint' && hoveredCodeBlock?.block.id === group.block.id} /> ) : ( - + setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} /> ) )} @@ -1078,14 +1083,48 @@ export const Viewer = forwardRef(({ /> )} + + {/* Image lightbox */} + {lightbox && createPortal( + setLightbox(null)} />, + document.body + )} ); }); +/** Simple lightbox overlay for enlarged image viewing. */ +const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void }> = ({ src, alt, onClose }) => { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + return ( +
+ {alt} e.stopPropagation()} + /> + {alt && ( +
{alt}
+ )} +
+ ); +}; + /** * Renders inline markdown: **bold**, *italic*, `code`, [links](url) */ -const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string) => void }> = ({ text, onOpenLinkedDoc }) => { +const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string) => void; imageBaseDir?: string; onImageClick?: (src: string, alt: string) => void }> = ({ text, onOpenLinkedDoc, imageBaseDir, onImageClick }) => { const parts: React.ReactNode[] = []; let remaining = text; let key = 0; @@ -1094,7 +1133,7 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string) // Bold: **text** let match = remaining.match(/^\*\*(.+?)\*\*/); if (match) { - parts.push(); + parts.push(); remaining = remaining.slice(match[0].length); continue; } @@ -1102,7 +1141,7 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string) // Italic: *text* match = remaining.match(/^\*(.+?)\*/); if (match) { - parts.push(); + parts.push(); remaining = remaining.slice(match[0].length); continue; } @@ -1153,6 +1192,26 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string) continue; } + // Images: ![alt](path) + match = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/); + if (match) { + const alt = match[1]; + const src = match[2]; + const imgSrc = /^https?:\/\//.test(src) ? src : getImageSrc(src, imageBaseDir); + parts.push( + {alt} { e.stopPropagation(); onImageClick?.(imgSrc, alt); }} + /> + ); + remaining = remaining.slice(match[0].length); + continue; + } + // Links: [text](url) match = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/); if (match) { @@ -1209,7 +1268,7 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string) } // Find next special character or consume one regular character - const nextSpecial = remaining.slice(1).search(/[\*`\[]/); + const nextSpecial = remaining.slice(1).search(/[\*`\[!]/); if (nextSpecial === -1) { parts.push(remaining); break; @@ -1273,7 +1332,7 @@ function groupBlocks(blocks: Block[]): RenderGroup[] { return groups; } -const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) => void }> = ({ block, onOpenLinkedDoc }) => { +const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) => void; imageBaseDir?: string; onImageClick?: (src: string, alt: string) => void }> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick }) => { switch (block.type) { case 'heading': const Tag = `h${block.level || 1}` as keyof JSX.IntrinsicElements; @@ -1283,7 +1342,7 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) = 3: 'text-base font-semibold mb-2 mt-6 text-foreground/80', }[block.level || 1] || 'text-base font-semibold mb-2 mt-4'; - return ; + return ; case 'blockquote': return ( @@ -1291,7 +1350,7 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) = className="border-l-2 border-primary/50 pl-4 my-4 text-muted-foreground italic" data-block-id={block.id} > - + ); @@ -1322,7 +1381,7 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) = )} - + ); @@ -1343,7 +1402,7 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) = key={i} className="px-3 py-2 text-left font-semibold text-foreground/90 bg-muted/30" > - + ))} @@ -1353,7 +1412,7 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) = {row.map((cell, cellIdx) => ( - + ))} @@ -1373,7 +1432,7 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) = className="mb-4 leading-relaxed text-foreground/90 text-[15px]" data-block-id={block.id} > - +

); }