Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ const App: React.FC = () => {
const [origin, setOrigin] = useState<'claude-code' | 'opencode' | 'pi' | null>(null);
const [globalAttachments, setGlobalAttachments] = useState<ImageAttachment[]>([]);
const [annotateMode, setAnnotateMode] = useState(false);
const [imageBaseDir, setImageBaseDir] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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}
/>
)}
</div>
Expand Down
19 changes: 16 additions & 3 deletions packages/server/shared-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,23 @@ export async function handleImage(req: Request): Promise<Response> {
}
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 });
}
Expand Down
8 changes: 6 additions & 2 deletions packages/ui/components/ImageThumbnail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
85 changes: 72 additions & 13 deletions packages/ui/components/Viewer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -127,8 +130,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
maxWidth,
onOpenLinkedDoc,
linkedDocInfo,
imageBaseDir,
}, ref) => {
const [copied, setCopied] = useState(false);
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
const globalCommentButtonRef = useRef<HTMLButtonElement>(null);

const handleCopyPlan = async () => {
Expand Down Expand Up @@ -972,7 +977,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
group.type === 'list-group' ? (
<div key={group.key} data-pinpoint-group="list" className="py-1 -mx-2 px-2">
{group.blocks.map(block => (
<BlockRenderer key={block.id} block={block} onOpenLinkedDoc={onOpenLinkedDoc} />
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={block.id} block={block} onOpenLinkedDoc={onOpenLinkedDoc} />
))}
</div>
) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? (
Expand Down Expand Up @@ -1010,7 +1015,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
isHovered={inputMethod !== 'pinpoint' && hoveredCodeBlock?.block.id === group.block.id}
/>
) : (
<BlockRenderer key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} />
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} />
)
)}

Expand Down Expand Up @@ -1078,14 +1083,48 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
/>
)}
</article>

{/* Image lightbox */}
{lightbox && createPortal(
<ImageLightbox src={lightbox.src} alt={lightbox.alt} onClose={() => setLightbox(null)} />,
document.body
)}
</div>
);
});

/** 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 (
<div
className="fixed inset-0 z-[200] flex flex-col items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out"
onClick={onClose}
>
<img
src={src}
alt={alt}
className="max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
{alt && (
<div className="mt-3 text-sm text-white/70 max-w-[90vw] text-center truncate">{alt}</div>
)}
</div>
);
};

/**
* 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;
Expand All @@ -1094,15 +1133,15 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
// Bold: **text**
let match = remaining.match(/^\*\*(.+?)\*\*/);
if (match) {
parts.push(<strong key={key++} className="font-semibold"><InlineMarkdown text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} /></strong>);
parts.push(<strong key={key++} className="font-semibold"><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} /></strong>);
remaining = remaining.slice(match[0].length);
continue;
}

// Italic: *text*
match = remaining.match(/^\*(.+?)\*/);
if (match) {
parts.push(<em key={key++}><InlineMarkdown text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} /></em>);
parts.push(<em key={key++}><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} /></em>);
remaining = remaining.slice(match[0].length);
continue;
}
Expand Down Expand Up @@ -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(
<img
key={key++}
src={imgSrc}
alt={alt}
className="max-w-full rounded my-2 cursor-zoom-in"
loading="lazy"
onClick={(e) => { e.stopPropagation(); onImageClick?.(imgSrc, alt); }}
/>
);
remaining = remaining.slice(match[0].length);
continue;
}

// Links: [text](url)
match = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (match) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -1283,15 +1342,15 @@ 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 <Tag className={styles} data-block-id={block.id} data-block-type="heading"><InlineMarkdown text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} /></Tag>;
return <Tag className={styles} data-block-id={block.id} data-block-type="heading"><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} /></Tag>;

case 'blockquote':
return (
<blockquote
className="border-l-2 border-primary/50 pl-4 my-4 text-muted-foreground italic"
data-block-id={block.id}
>
<InlineMarkdown text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} />
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} />
</blockquote>
);

Expand Down Expand Up @@ -1322,7 +1381,7 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) =
)}
</span>
<span className={`text-sm leading-relaxed ${isCheckbox && block.checked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`}>
<InlineMarkdown text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} />
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} />
</span>
</div>
);
Expand All @@ -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"
>
<InlineMarkdown text={header} onOpenLinkedDoc={onOpenLinkedDoc} />
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={header} onOpenLinkedDoc={onOpenLinkedDoc} />
</th>
))}
</tr>
Expand All @@ -1353,7 +1412,7 @@ const BlockRenderer: React.FC<{ block: Block; onOpenLinkedDoc?: (path: string) =
<tr key={rowIdx} className="border-b border-border/50 hover:bg-muted/20">
{row.map((cell, cellIdx) => (
<td key={cellIdx} className="px-3 py-2 text-foreground/80">
<InlineMarkdown text={cell} onOpenLinkedDoc={onOpenLinkedDoc} />
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={cell} onOpenLinkedDoc={onOpenLinkedDoc} />
</td>
))}
</tr>
Expand All @@ -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}
>
<InlineMarkdown text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} />
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} />
</p>
);
}
Expand Down
Loading