From e46815d8ea83b0866ece010289c49b77cbc4eb0d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 12 Feb 2026 20:23:49 -0800 Subject: [PATCH] feat: named image references and annotate command (#147) * feat: named image references and annotate command (#67, #109) Add human-readable names to image attachments throughout the annotation pipeline, and add a new `plannotator annotate ` command for annotating arbitrary markdown files. Image names: ImageAttachment type replaces plain string paths, upload endpoints return originalName, editable name inputs under thumbnails, [name] path format in exported feedback, backward-compatible sharing. Annotate command: new server module reusing plan editor HTML with mode:"annotate", CLI subcommand, slash commands for Claude Code and OpenCode, annotate mode UI (hides Approve, shows Send Annotations). Co-Authored-By: Claude Opus 4.6 * refactor: move image name input to ImageAnnotator screen The name input now appears on the full-screen annotator modal that opens immediately when uploading/pasting an image, pre-populated from the filename. Removes the disruptive inline name editing from thumbnails. Co-Authored-By: Claude Opus 4.6 * fix: stale closure in paste handler, update CLAUDE.md for new features Fix race condition where globalAttachments was captured as empty array in the paste event listener (missing dependency). Also update CLAUDE.md to document ImageAttachment type, annotate server/flow, updated sharing format with image support, and new slash commands. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- CLAUDE.md | 65 ++++- apps/hook/commands/plannotator-annotate.md | 12 + apps/hook/server/index.ts | 58 +++- .../commands/plannotator-annotate.md | 6 + apps/opencode-plugin/index.ts | 79 ++++++ packages/editor/App.tsx | 83 ++++-- packages/server/annotate.ts | 250 ++++++++++++++++++ packages/server/index.ts | 2 +- packages/server/package.json | 1 + packages/server/review.ts | 2 +- packages/ui/components/AnnotationPanel.tsx | 18 +- packages/ui/components/AnnotationToolbar.tsx | 24 +- packages/ui/components/AttachmentsButton.tsx | 140 +++++++--- .../ui/components/ImageAnnotator/index.tsx | 47 +++- packages/ui/components/Viewer.tsx | 20 +- packages/ui/hooks/useSharing.ts | 42 ++- packages/ui/types.ts | 7 +- packages/ui/utils/parser.ts | 14 +- packages/ui/utils/sharing.ts | 56 ++-- 19 files changed, 779 insertions(+), 147 deletions(-) create mode 100644 apps/hook/commands/plannotator-annotate.md create mode 100644 apps/opencode-plugin/commands/plannotator-annotate.md create mode 100644 packages/server/annotate.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4751986d..81fa3992 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Plannotator -A plan review UI for Claude Code that intercepts `ExitPlanMode` via hooks, letting users approve or request changes with annotated feedback. Also provides code review for git diffs. +A plan review UI for Claude Code that intercepts `ExitPlanMode` via hooks, letting users approve or request changes with annotated feedback. Also provides code review for git diffs and annotation of arbitrary markdown files. ## Project Structure @@ -9,13 +9,13 @@ plannotator/ ├── apps/ │ ├── hook/ # Claude Code plugin │ │ ├── .claude-plugin/plugin.json -│ │ ├── commands/ # Slash commands (plannotator-review.md) +│ │ ├── commands/ # Slash commands (plannotator-review.md, plannotator-annotate.md) │ │ ├── hooks/hooks.json # PermissionRequest hook config -│ │ ├── server/index.ts # Entry point (plan + review subcommand) +│ │ ├── server/index.ts # Entry point (plan + review + annotate subcommands) │ │ └── dist/ # Built single-file apps (index.html, review.html) │ ├── opencode-plugin/ # OpenCode plugin -│ │ ├── commands/ # Slash commands (plannotator-review.md) -│ │ ├── index.ts # Plugin entry with submit_plan tool + review event handler +│ │ ├── commands/ # Slash commands (plannotator-review.md, plannotator-annotate.md) +│ │ ├── index.ts # Plugin entry with submit_plan tool + review/annotate event handlers │ │ ├── plannotator.html # Built plan review app │ │ └── review-editor.html # Built code review app │ └── review/ # Standalone review server (for development) @@ -26,6 +26,7 @@ plannotator/ │ ├── server/ # Shared server implementation │ │ ├── index.ts # startPlannotatorServer(), handleServerReady() │ │ ├── review.ts # startReviewServer(), handleReviewServerReady() +│ │ ├── annotate.ts # startAnnotateServer(), handleAnnotateServerReady() │ │ ├── storage.ts # Plan saving to disk (getPlanDir, savePlan, etc.) │ │ ├── remote.ts # isRemoteSession(), getServerPort() │ │ ├── browser.ts # openBrowser() @@ -112,6 +113,23 @@ Send Feedback → feedback sent to agent session Approve → "LGTM" sent to agent session ``` +## Annotate Flow + +``` +User runs /plannotator-annotate command + ↓ +Claude Code: plannotator annotate subcommand runs +OpenCode: event handler intercepts command + ↓ +Markdown file read from disk + ↓ +Annotate server starts (reuses plan editor HTML with mode:"annotate") + ↓ +User annotates markdown, provides feedback + ↓ +Send Annotations → feedback sent to agent session +``` + ## Server API ### Plan Server (`packages/server/index.ts`) @@ -122,7 +140,7 @@ Approve → "LGTM" sent to agent session | `/api/approve` | POST | Approve plan (body: planSave, agentSwitch, obsidian, bear, feedback) | | `/api/deny` | POST | Deny plan (body: feedback, planSave) | | `/api/image` | GET | Serve image by path query param | -| `/api/upload` | POST | Upload image, returns temp path | +| `/api/upload` | POST | Upload image, returns `{ path, originalName }` | | `/api/obsidian/vaults`| GET | Detect available Obsidian vaults | ### Review Server (`packages/server/review.ts`) @@ -132,9 +150,18 @@ Approve → "LGTM" sent to agent session | `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin }` | | `/api/feedback` | POST | Submit review (body: feedback, annotations, agentSwitch) | | `/api/image` | GET | Serve image by path query param | -| `/api/upload` | POST | Upload image, returns temp path | +| `/api/upload` | POST | Upload image, returns `{ path, originalName }` | -Both servers use random ports locally or fixed port (`19432`) in remote mode. +### Annotate Server (`packages/server/annotate.ts`) + +| Endpoint | Method | Purpose | +| --------------------- | ------ | ------------------------------------------ | +| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath }` | +| `/api/feedback` | POST | Submit annotations (body: feedback, annotations) | +| `/api/image` | GET | Serve image by path query param | +| `/api/upload` | POST | Upload image, returns `{ path, originalName }` | + +All servers use random ports locally or fixed port (`19432`) in remote mode. ## Data Types @@ -149,6 +176,11 @@ enum AnnotationType { GLOBAL_COMMENT = "GLOBAL_COMMENT", } +interface ImageAttachment { + path: string; // temp file path + name: string; // human-readable label (e.g., "login-mockup") +} + interface Annotation { id: string; blockId: string; @@ -159,6 +191,7 @@ interface Annotation { originalText: string; // The selected text createdA: number; // Timestamp author?: string; // Tater identity + images?: ImageAttachment[]; // Attached images with names startMeta?: { parentTagName; parentIndex; textOffset }; endMeta?: { parentTagName; parentIndex; textOffset }; } @@ -187,7 +220,7 @@ interface Block { - Horizontal rules (`---`) - Paragraphs (default) -`exportDiff(blocks, annotations)` generates human-readable feedback for Claude. +`exportDiff(blocks, annotations, globalAttachments)` generates human-readable feedback for Claude. Images are referenced by name: `[image-name] /tmp/path...`. ## Annotation System @@ -205,17 +238,21 @@ Shares full plan + annotations via URL hash using deflate compression. **Payload format:** ```typescript +// Image in shareable format: plain string (old) or [path, name] tuple (new) +type ShareableImage = string | [string, string]; + interface SharePayload { p: string; // Plan markdown a: ShareableAnnotation[]; // Compact annotations + g?: ShareableImage[]; // Global attachments } type ShareableAnnotation = - | ["D", string, string | null] // [type, original, author] - | ["R", string, string, string | null] // [type, original, replacement, author] - | ["C", string, string, string | null] // [type, original, comment, author] - | ["I", string, string, string | null] // [type, context, newText, author] - | ["G", string, string | null]; // [type, comment, author] - global comment + | ["D", string, string | null, ShareableImage[]?] // [type, original, author, images?] + | ["R", string, string, string | null, ShareableImage[]?] // [type, original, replacement, author, images?] + | ["C", string, string, string | null, ShareableImage[]?] // [type, original, comment, author, images?] + | ["I", string, string, string | null, ShareableImage[]?] // [type, context, newText, author, images?] + | ["G", string, string | null, ShareableImage[]?]; // [type, comment, author, images?] ``` **Compression pipeline:** diff --git a/apps/hook/commands/plannotator-annotate.md b/apps/hook/commands/plannotator-annotate.md new file mode 100644 index 00000000..017a4354 --- /dev/null +++ b/apps/hook/commands/plannotator-annotate.md @@ -0,0 +1,12 @@ +--- +description: Open interactive annotation UI for a markdown file +allowed-tools: Bash(plannotator:*) +--- + +## Markdown Annotations + +!`plannotator annotate $ARGUMENTS` + +## Your task + +Address the annotation feedback above. The user has reviewed the markdown file and provided specific annotations and comments. diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 1c5a9cea..1b8c553e 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1,7 +1,7 @@ /** * Plannotator CLI for Claude Code * - * Supports two modes: + * Supports three modes: * * 1. Plan Review (default, no args): * - Spawned by ExitPlanMode hook @@ -13,6 +13,11 @@ * - Runs git diff, opens review UI * - Outputs feedback to stdout (captured by slash command) * + * 3. Annotate (`plannotator annotate `): + * - Triggered by /plannotator-annotate slash command + * - Opens any markdown file in the annotation UI + * - Outputs structured feedback to stdout + * * Environment variables: * PLANNOTATOR_REMOTE - Set to "1" or "true" for remote mode (preferred) * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) @@ -26,6 +31,10 @@ import { startReviewServer, handleReviewServerReady, } from "@plannotator/server/review"; +import { + startAnnotateServer, + handleAnnotateServerReady, +} from "@plannotator/server/annotate"; import { getGitContext, runGitDiff } from "@plannotator/server/git"; // Embed the built HTML at compile time @@ -86,6 +95,53 @@ if (args[0] === "review") { console.log(result.feedback || "No feedback provided."); process.exit(0); +} else if (args[0] === "annotate") { + // ============================================ + // ANNOTATE MODE + // ============================================ + + const filePath = args[1]; + if (!filePath) { + console.error("Usage: plannotator annotate "); + process.exit(1); + } + + // Resolve to absolute path + const path = await import("path"); + const absolutePath = path.resolve(filePath); + + // Read the markdown file + const file = Bun.file(absolutePath); + if (!(await file.exists())) { + console.error(`File not found: ${absolutePath}`); + process.exit(1); + } + const markdown = await file.text(); + + // Start the annotate server (reuses plan editor HTML) + const server = await startAnnotateServer({ + markdown, + filePath: absolutePath, + origin: "claude-code", + sharingEnabled, + shareBaseUrl, + htmlContent: planHtmlContent, + onReady: handleAnnotateServerReady, + }); + + // Wait for user feedback + const result = await server.waitForDecision(); + + // Give browser time to receive response and update UI + await Bun.sleep(1500); + + // Cleanup + server.stop(); + + // Output feedback (captured by slash command) + console.log(result.feedback || "No feedback provided."); + process.exit(0); + } else { // ============================================ // PLAN REVIEW MODE (default) diff --git a/apps/opencode-plugin/commands/plannotator-annotate.md b/apps/opencode-plugin/commands/plannotator-annotate.md new file mode 100644 index 00000000..a8b03084 --- /dev/null +++ b/apps/opencode-plugin/commands/plannotator-annotate.md @@ -0,0 +1,6 @@ +--- +description: Open interactive annotation UI for a markdown file +--- + +The Plannotator Annotate UI has been triggered. Opening the annotation UI... +Acknowledge "Opening annotation UI..." and wait for the user's feedback. diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index f6ee2a7a..adc557b0 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -21,6 +21,10 @@ import { startReviewServer, handleReviewServerReady, } from "@plannotator/server/review"; +import { + startAnnotateServer, + handleAnnotateServerReady, +} from "@plannotator/server/annotate"; import { getGitContext, runGitDiff } from "@plannotator/server/git"; // @ts-ignore - Bun import attribute for text @@ -209,6 +213,81 @@ Do NOT proceed with implementation until your plan is approved. } } } + + // Handle /plannotator-annotate command + const isAnnotateCommand = commandName === "plannotator-annotate"; + + if (isCommandEvent && isAnnotateCommand) { + // @ts-ignore - Event properties contain arguments + const filePath = event.properties?.arguments || event.arguments || ""; + + if (!filePath) { + ctx.client.app.log({ + level: "error", + message: "Usage: /plannotator-annotate ", + }); + return; + } + + ctx.client.app.log({ + level: "info", + message: `Opening annotation UI for ${filePath}...`, + }); + + // Resolve to absolute path + const path = await import("path"); + const absolutePath = path.resolve(filePath); + + // Read the markdown file + const file = Bun.file(absolutePath); + if (!(await file.exists())) { + ctx.client.app.log({ + level: "error", + message: `File not found: ${absolutePath}`, + }); + return; + } + const markdown = await file.text(); + + // Start annotate server (reuses plan editor HTML) + const server = await startAnnotateServer({ + markdown, + filePath: absolutePath, + origin: "opencode", + sharingEnabled: await getSharingEnabled(), + shareBaseUrl: getShareBaseUrl(), + htmlContent: htmlContent, + onReady: handleAnnotateServerReady, + }); + + const result = await server.waitForDecision(); + await Bun.sleep(1500); + server.stop(); + + // Send feedback back to the session if provided + if (result.feedback) { + // @ts-ignore - Event properties contain sessionID for command.executed events + const sessionId = event.properties?.sessionID; + + if (sessionId) { + try { + await ctx.client.session.prompt({ + path: { id: sessionId }, + body: { + parts: [ + { + type: "text", + text: `# Markdown Annotations\n\nFile: ${absolutePath}\n\n${result.feedback}\n\nPlease address the annotation feedback above.`, + }, + ], + }, + }); + } catch { + // Session may not be available + } + } + } + } }, tool: { diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index fe458fe3..772e1e7a 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -5,7 +5,7 @@ import { AnnotationPanel } from '@plannotator/ui/components/AnnotationPanel'; import { ExportModal } from '@plannotator/ui/components/ExportModal'; import { ImportModal } from '@plannotator/ui/components/ImportModal'; import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; -import { Annotation, Block, EditorMode } from '@plannotator/ui/types'; +import { Annotation, Block, EditorMode, type ImageAttachment } from '@plannotator/ui/types'; import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider'; import { ModeToggle } from '@plannotator/ui/components/ModeToggle'; import { ModeSwitcher } from '@plannotator/ui/components/ModeSwitcher'; @@ -35,6 +35,7 @@ import { import { PermissionModeSetup } from '@plannotator/ui/components/PermissionModeSetup'; import { UIFeaturesSetup } from '@plannotator/ui/components/UIFeaturesSetup'; import { ImageAnnotator } from '@plannotator/ui/components/ImageAnnotator'; +import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton'; const PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration @@ -350,11 +351,12 @@ const App: React.FC = () => { const [uiPrefs, setUiPrefs] = useState(() => getUIPreferences()); const [isApiMode, setIsApiMode] = useState(false); const [origin, setOrigin] = useState<'claude-code' | 'opencode' | null>(null); - const [globalAttachments, setGlobalAttachments] = useState([]); + const [globalAttachments, setGlobalAttachments] = useState([]); + const [annotateMode, setAnnotateMode] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null); - const [pendingPasteImage, setPendingPasteImage] = useState<{ file: File; blobUrl: string } | null>(null); + const [pendingPasteImage, setPendingPasteImage] = useState<{ file: File; blobUrl: string; initialName: string } | null>(null); const [showPermissionModeSetup, setShowPermissionModeSetup] = useState(false); const [showUIFeaturesSetup, setShowUIFeaturesSetup] = useState(false); const [permissionMode, setPermissionMode] = useState('bypassPermissions'); @@ -441,9 +443,12 @@ 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'; sharingEnabled?: boolean; shareBaseUrl?: string; repoInfo?: { display: string; branch?: string } }) => { + .then((data: { plan: string; origin?: 'claude-code' | 'opencode'; mode?: 'annotate'; sharingEnabled?: boolean; shareBaseUrl?: string; repoInfo?: { display: string; branch?: string } }) => { setMarkdown(data.plan); setIsApiMode(true); + if (data.mode === 'annotate') { + setAnnotateMode(true); + } if (data.sharingEnabled !== undefined) { setSharingEnabled(data.sharingEnabled); } @@ -489,9 +494,10 @@ const App: React.FC = () => { e.preventDefault(); const file = item.getAsFile(); if (file) { - // Show annotator instead of direct upload + // Derive name before showing annotator so user sees it immediately + const initialName = deriveImageName(file.name, globalAttachments.map(g => g.name)); const blobUrl = URL.createObjectURL(file); - setPendingPasteImage({ file, blobUrl }); + setPendingPasteImage({ file, blobUrl, initialName }); } break; } @@ -500,10 +506,10 @@ const App: React.FC = () => { document.addEventListener('paste', handlePaste); return () => document.removeEventListener('paste', handlePaste); - }, []); + }, [globalAttachments]); - // Handle paste annotator accept - const handlePasteAnnotatorAccept = async (blob: Blob, hasDrawings: boolean) => { + // Handle paste annotator accept — name comes from ImageAnnotator + const handlePasteAnnotatorAccept = async (blob: Blob, hasDrawings: boolean, name: string) => { if (!pendingPasteImage) return; try { @@ -515,8 +521,8 @@ const App: React.FC = () => { const res = await fetch('/api/upload', { method: 'POST', body: formData }); if (res.ok) { - const { path } = await res.json(); - setGlobalAttachments(prev => [...prev, path]); + const data = await res.json(); + setGlobalAttachments(prev => [...prev, { path: data.path, name }]); } } catch { // Upload failed silently @@ -618,6 +624,27 @@ const App: React.FC = () => { } }; + // Annotate mode handler — sends feedback via /api/feedback + const handleAnnotateFeedback = async () => { + setIsSubmitting(true); + try { + await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + feedback: diffOutput, + annotations, + }), + }); + if (getAutoClose()) { + window.close(); + } + setSubmitted('denied'); // reuse 'denied' state for "feedback sent" overlay + } catch { + setIsSubmitting(false); + } + }; + // Global keyboard shortcuts (Cmd/Ctrl+Enter to submit) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -640,6 +667,16 @@ const App: React.FC = () => { e.preventDefault(); + // Annotate mode: always send feedback + if (annotateMode) { + if (annotations.length === 0) { + setShowFeedbackPrompt(true); + } else { + handleAnnotateFeedback(); + } + return; + } + // No annotations → Approve, otherwise → Send Feedback if (annotations.length === 0) { // Check if agent exists for OpenCode users @@ -662,7 +699,7 @@ const App: React.FC = () => { }, [ showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning, showPermissionModeSetup, showUIFeaturesSetup, pendingPasteImage, - submitted, isSubmitting, isApiMode, annotations.length, + submitted, isSubmitting, isApiMode, annotations.length, annotateMode, origin, getAgentWarning, ]); @@ -690,14 +727,15 @@ const App: React.FC = () => { )); }; - const handleAddGlobalAttachment = (path: string) => { - setGlobalAttachments(prev => [...prev, path]); + const handleAddGlobalAttachment = (image: ImageAttachment) => { + setGlobalAttachments(prev => [...prev, image]); }; const handleRemoveGlobalAttachment = (path: string) => { - setGlobalAttachments(prev => prev.filter(p => p !== path)); + setGlobalAttachments(prev => prev.filter(p => p.path !== path)); }; + const handleTocNavigate = (blockId: string) => { // Navigation handled by TableOfContents component // This is just a placeholder for future custom logic @@ -853,6 +891,8 @@ const App: React.FC = () => { onClick={() => { if (annotations.length === 0) { setShowFeedbackPrompt(true); + } else if (annotateMode) { + handleAnnotateFeedback(); } else { handleDeny(); } @@ -868,10 +908,10 @@ const App: React.FC = () => { - {isSubmitting ? 'Sending...' : 'Send Feedback'} + {isSubmitting ? 'Sending...' : annotateMode ? 'Send Annotations' : 'Send Feedback'} -
+ {!annotateMode &&
)} -
+ }
@@ -1211,12 +1251,14 @@ const App: React.FC = () => {

- {submitted === 'approved' ? 'Plan Approved' : 'Feedback Sent'} + {submitted === 'approved' ? 'Plan Approved' : annotateMode ? 'Annotations Sent' : 'Feedback Sent'}

{submitted === 'approved' ? `${agentName} will proceed with the implementation.` - : `${agentName} will revise the plan based on your annotations.`} + : annotateMode + ? `${agentName} will address your annotations on the file.` + : `${agentName} will revise the plan based on your annotations.`}

@@ -1239,6 +1281,7 @@ const App: React.FC = () => { diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts new file mode 100644 index 00000000..3cfb3f14 --- /dev/null +++ b/packages/server/annotate.ts @@ -0,0 +1,250 @@ +/** + * Annotate Server + * + * Provides a server for annotating arbitrary markdown files. + * Follows the same patterns as the review server but serves + * markdown content via /api/plan so the plan editor UI can + * render it without modifications. + * + * Environment variables: + * PLANNOTATOR_REMOTE - Set to "1" or "true" for remote/devcontainer mode + * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) + */ + +import { mkdirSync } from "fs"; +import { isRemoteSession, getServerPort } from "./remote"; +import { openBrowser } from "./browser"; +import { getRepoInfo } from "./repo"; + +// Re-export utilities +export { isRemoteSession, getServerPort } from "./remote"; +export { openBrowser } from "./browser"; + +// --- Types --- + +export interface AnnotateServerOptions { + /** Markdown content of the file to annotate */ + markdown: string; + /** Original file path (for display purposes) */ + filePath: string; + /** HTML content to serve for the UI */ + htmlContent: string; + /** Origin identifier for UI customization */ + origin?: "opencode" | "claude-code"; + /** Whether URL sharing is enabled (default: true) */ + sharingEnabled?: boolean; + /** Custom base URL for share links */ + shareBaseUrl?: string; + /** Called when server starts with the URL, remote status, and port */ + onReady?: (url: string, isRemote: boolean, port: number) => void; +} + +export interface AnnotateServerResult { + /** The port the server is running on */ + port: number; + /** The full URL to access the server */ + url: string; + /** Whether running in remote mode */ + isRemote: boolean; + /** Wait for user feedback submission */ + waitForDecision: () => Promise<{ + feedback: string; + annotations: unknown[]; + }>; + /** Stop the server */ + stop: () => void; +} + +// --- Server Implementation --- + +const MAX_RETRIES = 5; +const RETRY_DELAY_MS = 500; + +/** + * Start the Annotate server + * + * Handles: + * - Remote detection and port configuration + * - API routes (/api/plan with mode:"annotate", /api/feedback) + * - Port conflict retries + */ +export async function startAnnotateServer( + options: AnnotateServerOptions +): Promise { + const { + markdown, + filePath, + htmlContent, + origin, + sharingEnabled = true, + shareBaseUrl, + onReady, + } = options; + + const isRemote = isRemoteSession(); + const configuredPort = getServerPort(); + + // Detect repo info (cached for this session) + const repoInfo = await getRepoInfo(); + + // Decision promise + let resolveDecision: (result: { + feedback: string; + annotations: unknown[]; + }) => void; + const decisionPromise = new Promise<{ + feedback: string; + annotations: unknown[]; + }>((resolve) => { + resolveDecision = resolve; + }); + + // Start server with retry logic + let server: ReturnType | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + server = Bun.serve({ + port: configuredPort, + + async fetch(req) { + const url = new URL(req.url); + + // API: Get plan content (reuse /api/plan so the plan editor UI works) + if (url.pathname === "/api/plan" && req.method === "GET") { + return Response.json({ + plan: markdown, + origin, + mode: "annotate", + filePath, + sharingEnabled, + shareBaseUrl, + repoInfo, + }); + } + + // API: Serve images (local paths or temp uploads) + if (url.pathname === "/api/image") { + const imagePath = url.searchParams.get("path"); + if (!imagePath) { + return new Response("Missing path parameter", { status: 400 }); + } + try { + const file = Bun.file(imagePath); + if (!(await file.exists())) { + return new Response("File not found", { status: 404 }); + } + return new Response(file); + } catch { + return new Response("Failed to read file", { status: 500 }); + } + } + + // API: Upload image -> save to temp -> return path + if (url.pathname === "/api/upload" && req.method === "POST") { + try { + const formData = await req.formData(); + const file = formData.get("file") as File; + if (!file) { + return new Response("No file provided", { status: 400 }); + } + + const ext = file.name.split(".").pop() || "png"; + const tempDir = "/tmp/plannotator"; + mkdirSync(tempDir, { recursive: true }); + const tempPath = `${tempDir}/${crypto.randomUUID()}.${ext}`; + + await Bun.write(tempPath, file); + return Response.json({ path: tempPath, originalName: file.name }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Upload failed"; + return Response.json({ error: message }, { status: 500 }); + } + } + + // API: Submit annotation feedback + if (url.pathname === "/api/feedback" && req.method === "POST") { + try { + const body = (await req.json()) as { + feedback: string; + annotations: unknown[]; + }; + + resolveDecision({ + feedback: body.feedback || "", + annotations: body.annotations || [], + }); + + return Response.json({ ok: true }); + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Failed to process feedback"; + return Response.json({ error: message }, { status: 500 }); + } + } + + // Serve embedded HTML for all other routes (SPA) + return new Response(htmlContent, { + headers: { "Content-Type": "text/html" }, + }); + }, + }); + + break; // Success, exit retry loop + } catch (err: unknown) { + const isAddressInUse = + err instanceof Error && err.message.includes("EADDRINUSE"); + + if (isAddressInUse && attempt < MAX_RETRIES) { + await Bun.sleep(RETRY_DELAY_MS); + continue; + } + + if (isAddressInUse) { + const hint = isRemote + ? " (set PLANNOTATOR_PORT to use different port)" + : ""; + throw new Error( + `Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}` + ); + } + + throw err; + } + } + + if (!server) { + throw new Error("Failed to start server"); + } + + const serverUrl = `http://localhost:${server.port}`; + + // Notify caller that server is ready + if (onReady) { + onReady(serverUrl, isRemote, server.port); + } + + return { + port: server.port, + url: serverUrl, + isRemote, + waitForDecision: () => decisionPromise, + stop: () => server.stop(), + }; +} + +/** + * Default behavior: open browser for local sessions + */ +export async function handleAnnotateServerReady( + url: string, + isRemote: boolean, + _port: number +): Promise { + if (!isRemote) { + await openBrowser(url); + } +} diff --git a/packages/server/index.ts b/packages/server/index.ts index 1a66c0e9..3642e1e9 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -172,7 +172,7 @@ export async function startPlannotatorServer( const tempPath = `${tempDir}/${crypto.randomUUID()}.${ext}`; await Bun.write(tempPath, file); - return Response.json({ path: tempPath }); + return Response.json({ path: tempPath, originalName: file.name }); } catch (err) { const message = err instanceof Error ? err.message : "Upload failed"; diff --git a/packages/server/package.json b/packages/server/package.json index fd4f304e..01db5e90 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -8,6 +8,7 @@ "exports": { ".": "./index.ts", "./review": "./review.ts", + "./annotate": "./annotate.ts", "./remote": "./remote.ts", "./browser": "./browser.ts", "./storage": "./storage.ts", diff --git a/packages/server/review.ts b/packages/server/review.ts index 786416a3..47448639 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -200,7 +200,7 @@ export async function startReviewServer( const tempPath = `${tempDir}/${crypto.randomUUID()}.${ext}`; await Bun.write(tempPath, file); - return Response.json({ path: tempPath }); + return Response.json({ path: tempPath, originalName: file.name }); } catch (err) { const message = err instanceof Error ? err.message : "Upload failed"; diff --git a/packages/ui/components/AnnotationPanel.tsx b/packages/ui/components/AnnotationPanel.tsx index 24afd7c8..902618b6 100644 --- a/packages/ui/components/AnnotationPanel.tsx +++ b/packages/ui/components/AnnotationPanel.tsx @@ -394,15 +394,17 @@ const AnnotationCard: React.FC<{ )} {/* Attached Images */} - {annotation.imagePaths && annotation.imagePaths.length > 0 && ( + {annotation.images && annotation.images.length > 0 && (
- {annotation.imagePaths.map((path, idx) => ( - + {annotation.images.map((img, idx) => ( +
+ +
{img.name}
+
))}
)} diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index ecbaa5f9..654bcf80 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from "react"; -import { AnnotationType } from "../types"; +import { AnnotationType, type ImageAttachment } from "../types"; import { createPortal } from "react-dom"; import { AttachmentsButton } from "./AttachmentsButton"; @@ -8,7 +8,7 @@ type PositionMode = 'center-above' | 'top-right'; interface AnnotationToolbarProps { element: HTMLElement; positionMode: PositionMode; - onAnnotate: (type: AnnotationType, text?: string, imagePaths?: string[]) => void; + onAnnotate: (type: AnnotationType, text?: string, images?: ImageAttachment[]) => void; onClose: () => void; /** Text to copy (for text selection, pass source.text) */ copyText?: string; @@ -43,7 +43,7 @@ export const AnnotationToolbar: React.FC = ({ const [step, setStep] = useState<"menu" | "input">(initialStep); const [activeType, setActiveType] = useState(initialType ?? null); const [inputValue, setInputValue] = useState(""); - const [imagePaths, setImagePaths] = useState([]); + const [images, setImages] = useState([]); const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null); const [copied, setCopied] = useState(false); const inputRef = useRef(null); @@ -81,7 +81,7 @@ export const AnnotationToolbar: React.FC = ({ setStep(initialStep); setActiveType(initialType ?? null); setInputValue(""); - setImagePaths([]); + setImages([]); setCopied(false); }, [element, initialStep, initialType]); @@ -159,8 +159,8 @@ export const AnnotationToolbar: React.FC = ({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (activeType && (inputValue.trim() || imagePaths.length > 0)) { - onAnnotate(activeType, inputValue || undefined, imagePaths.length > 0 ? imagePaths : undefined); + if (activeType && (inputValue.trim() || images.length > 0)) { + onAnnotate(activeType, inputValue || undefined, images.length > 0 ? images : undefined); } }; @@ -238,21 +238,21 @@ export const AnnotationToolbar: React.FC = ({ if (e.key === "Escape") setStep("menu"); if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - if (inputValue.trim() || imagePaths.length > 0) { - onAnnotate(activeType!, inputValue || undefined, imagePaths.length > 0 ? imagePaths : undefined); + if (inputValue.trim() || images.length > 0) { + onAnnotate(activeType!, inputValue || undefined, images.length > 0 ? images : undefined); } } }} /> setImagePaths((prev) => [...prev, path])} - onRemove={(path) => setImagePaths((prev) => prev.filter((p) => p !== path))} + images={images} + onAdd={(img) => setImages((prev) => [...prev, img])} + onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))} variant="inline" /> @@ -207,9 +255,9 @@ export const AttachmentsButton: React.FC = ({ {/* Header */}
Attachments
- {paths.length > 0 && ( + {images.length > 0 && ( - {paths.length} image{paths.length !== 1 ? 's' : ''} + {images.length} image{images.length !== 1 ? 's' : ''} )}
@@ -277,22 +325,29 @@ export const AttachmentsButton: React.FC = ({
{/* Grid of current attachments */} - {paths.length > 0 && ( + {images.length > 0 && (
Current
- {paths.map((path) => ( - { - setEditingPath(path); - setIsOpen(false); - }} - onRemove={() => onRemove(path)} - showRemove - /> + {images.map((img) => ( +
+ { + setEditingImage({ path: img.path, name: img.name }); + setIsOpen(false); + }} + onRemove={() => onRemove(img.path)} + showRemove + /> +
+ {img.name} +
+
))}
@@ -304,10 +359,11 @@ export const AttachmentsButton: React.FC = ({ )} {/* Image Annotator Dialog - portaled to body for correct positioning */} - {(!!annotatorImage || !!editingPath) && createPortal( + {annotatorOpen && createPortal( , diff --git a/packages/ui/components/ImageAnnotator/index.tsx b/packages/ui/components/ImageAnnotator/index.tsx index bd26ac74..987c2ddc 100644 --- a/packages/ui/components/ImageAnnotator/index.tsx +++ b/packages/ui/components/ImageAnnotator/index.tsx @@ -8,8 +8,10 @@ import { DEFAULT_STATE } from './types'; interface ImageAnnotatorProps { imageSrc: string; isOpen: boolean; - onAccept: (blob: Blob, hasDrawings: boolean) => Promise; + onAccept: (blob: Blob, hasDrawings: boolean, name: string) => Promise; onClose: () => void; + /** Pre-populated image name (derived from filename) */ + initialName?: string; } export const ImageAnnotator: React.FC = ({ @@ -17,23 +19,38 @@ export const ImageAnnotator: React.FC = ({ isOpen, onAccept, onClose, + initialName = '', }) => { const [state, setState] = useState(DEFAULT_STATE); const [saving, setSaving] = useState(false); + const [name, setName] = useState(initialName); const imageRef = useRef(null); + const nameInputRef = useRef(null); // Reset state when dialog opens useEffect(() => { if (isOpen) { setState(DEFAULT_STATE); + setName(initialName); } - }, [isOpen]); + }, [isOpen, initialName]); // Keyboard shortcuts useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { + // Don't intercept when typing in the name input + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT') { + if (e.key === 'Escape') { + // Blur and let the next Escape close + target.blur(); + e.preventDefault(); + } + return; + } + // Escape or Enter to accept if (e.key === 'Escape' || e.key === 'Enter') { e.preventDefault(); @@ -129,12 +146,13 @@ export const ImageAnnotator: React.FC = ({ } const hasDrawings = state.strokes.length > 0; + const finalName = name.trim() || initialName || 'image'; // If no drawings, just pass through original image if (!hasDrawings) { const response = await fetch(imageSrc); const blob = await response.blob(); - await onAccept(blob, false); + await onAccept(blob, false, finalName); onClose(); return; } @@ -160,7 +178,7 @@ export const ImageAnnotator: React.FC = ({ // Convert to blob canvas.toBlob(async (blob) => { if (blob) { - await onAccept(blob, true); + await onAccept(blob, true, finalName); } onClose(); }, 'image/png'); @@ -213,6 +231,27 @@ export const ImageAnnotator: React.FC = ({ onImageLoad={handleImageLoad} /> + {/* Image name input */} + {initialName && ( +
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAccept(); + } + }} + className="flex-1 px-2 py-1 text-xs bg-muted/50 border border-border rounded-md text-foreground focus:outline-none focus:ring-1 focus:ring-primary" + placeholder="Image name..." + /> +
+ )} + {/* Accept hint */}
Press Esc or Enter or click outside to accept diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index d7dd04a6..a598397e 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -2,7 +2,7 @@ import React, { useRef, useState, useEffect, forwardRef, useImperativeHandle, us import Highlighter from '@plannotator/web-highlighter'; import hljs from 'highlight.js'; import 'highlight.js/styles/github-dark.css'; -import { Block, Annotation, AnnotationType, EditorMode } from '../types'; +import { Block, Annotation, AnnotationType, EditorMode, type ImageAttachment } from '../types'; import { Frontmatter } from '../utils/parser'; import { AnnotationToolbar } from './AnnotationToolbar'; import { TaterSpriteSitting } from './TaterSpriteSitting'; @@ -20,8 +20,8 @@ interface ViewerProps { selectedAnnotationId: string | null; mode: EditorMode; taterMode: boolean; - globalAttachments?: string[]; - onAddGlobalAttachment?: (path: string) => void; + globalAttachments?: ImageAttachment[]; + onAddGlobalAttachment?: (image: ImageAttachment) => void; onRemoveGlobalAttachment?: (path: string) => void; repoInfo?: { display: string; branch?: string } | null; stickyActions?: boolean; @@ -194,7 +194,7 @@ export const Viewer = forwardRef(({ source: any, type: AnnotationType, text?: string, - imagePaths?: string[] + images?: ImageAttachment[] ) => { const doms = highlighter.getDoms(source.id); let blockId = ''; @@ -226,7 +226,7 @@ export const Viewer = forwardRef(({ author: getIdentity(), startMeta: source.startMeta, endMeta: source.endMeta, - imagePaths, + images, }; if (type === AnnotationType.DELETION) { @@ -548,11 +548,11 @@ export const Viewer = forwardRef(({ }); }, [annotations]); - const handleAnnotate = (type: AnnotationType, text?: string, imagePaths?: string[]) => { + const handleAnnotate = (type: AnnotationType, text?: string, images?: ImageAttachment[]) => { const highlighter = highlighterRef.current; if (!toolbarState || !highlighter) return; - createAnnotationFromSource(highlighter, toolbarState.source, type, text, imagePaths); + createAnnotationFromSource(highlighter, toolbarState.source, type, text, images); pendingSourceRef.current = null; setToolbarState(null); window.getSelection()?.removeAllRanges(); @@ -567,7 +567,7 @@ export const Viewer = forwardRef(({ window.getSelection()?.removeAllRanges(); }; - const handleCodeBlockAnnotate = (type: AnnotationType, text?: string, imagePaths?: string[]) => { + const handleCodeBlockAnnotate = (type: AnnotationType, text?: string, images?: ImageAttachment[]) => { const highlighter = highlighterRef.current; if (!hoveredCodeBlock || !highlighter) return; @@ -609,7 +609,7 @@ export const Viewer = forwardRef(({ originalText: codeText, createdA: Date.now(), author: getIdentity(), - imagePaths, + images, }; onAddAnnotationRef.current(newAnnotation); @@ -657,7 +657,7 @@ export const Viewer = forwardRef(({ {/* Attachments button */} {onAddGlobalAttachment && onRemoveGlobalAttachment && ( void; @@ -53,13 +53,30 @@ interface UseSharingResult { importFromShareUrl: (url: string) => Promise; } +/** + * Parse SharePayload.g (which can be old string[] or new [path,name][] format) into ImageAttachment[] + */ +function parseGlobalAttachments(g: unknown[] | undefined): ImageAttachment[] { + if (!g?.length) return []; + return g.map((item, idx) => { + if (typeof item === 'string') { + const name = item.split('/').pop()?.replace(/\.[^.]+$/, '') || `image-${idx + 1}`; + return { path: item, name }; + } + if (Array.isArray(item) && item.length >= 2) { + return { path: item[0] as string, name: item[1] as string }; + } + return { path: String(item), name: `image-${idx + 1}` }; + }); +} + export function useSharing( markdown: string, annotations: Annotation[], - globalAttachments: string[], + globalAttachments: ImageAttachment[], setMarkdown: (m: string) => void, setAnnotations: (a: Annotation[]) => void, - setGlobalAttachments: (g: string[]) => void, + setGlobalAttachments: (g: ImageAttachment[]) => void, onSharedLoad?: () => void, shareBaseUrl?: string ): UseSharingResult { @@ -68,7 +85,7 @@ export function useSharing( const [shareUrl, setShareUrl] = useState(''); const [shareUrlSize, setShareUrlSize] = useState(''); const [pendingSharedAnnotations, setPendingSharedAnnotations] = useState(null); - const [sharedGlobalAttachments, setSharedGlobalAttachments] = useState(null); + const [sharedGlobalAttachments, setSharedGlobalAttachments] = useState(null); const clearPendingSharedAnnotations = useCallback(() => { setPendingSharedAnnotations(null); @@ -90,8 +107,9 @@ export function useSharing( // Restore global attachments if present if (payload.g?.length) { - setGlobalAttachments(payload.g); - setSharedGlobalAttachments(payload.g); + const parsed = parseGlobalAttachments(payload.g); + setGlobalAttachments(parsed); + setSharedGlobalAttachments(parsed); } // Store for later application to DOM @@ -200,11 +218,13 @@ export function useSharing( // Handle global attachments (deduplicate by path) if (payload.g?.length) { - const newPaths = payload.g.filter(p => !globalAttachments.includes(p)); - if (newPaths.length > 0) { - setGlobalAttachments([...globalAttachments, ...newPaths]); + const parsed = parseGlobalAttachments(payload.g); + const existingPaths = new Set(globalAttachments.map(g => g.path)); + const newAttachments = parsed.filter(p => !existingPaths.has(p.path)); + if (newAttachments.length > 0) { + setGlobalAttachments([...globalAttachments, ...newAttachments]); } - setSharedGlobalAttachments(payload.g); + setSharedGlobalAttachments(parsed); } } diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 5671ecce..28759223 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -8,6 +8,11 @@ export enum AnnotationType { export type EditorMode = 'selection' | 'comment' | 'redline'; +export interface ImageAttachment { + path: string; + name: string; +} + export interface Annotation { id: string; blockId: string; // Legacy - not used with web-highlighter @@ -18,7 +23,7 @@ export interface Annotation { originalText: string; // The text that was selected createdA: number; author?: string; // Tater identity for collaborative sharing - imagePaths?: string[]; // Attached images (local paths or URLs) + images?: ImageAttachment[]; // Attached images with human-readable names // web-highlighter metadata for cross-element selections startMeta?: { parentTagName: string; diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index 7f7ca478..7e0b454a 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -1,4 +1,4 @@ -import { Block } from '../types'; +import { Block, type ImageAttachment } from '../types'; /** * Parsed YAML frontmatter as key-value pairs. @@ -243,7 +243,7 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => { return blocks; }; -export const exportDiff = (blocks: Block[], annotations: any[], globalAttachments: string[] = []): string => { +export const exportDiff = (blocks: Block[], annotations: any[], globalAttachments: ImageAttachment[] = []): string => { if (annotations.length === 0 && globalAttachments.length === 0) { return 'No changes detected.'; } @@ -262,8 +262,8 @@ export const exportDiff = (blocks: Block[], annotations: any[], globalAttachment if (globalAttachments.length > 0) { output += `## Reference Images\n`; output += `Please review these reference images (use the Read tool to view):\n`; - globalAttachments.forEach((path, idx) => { - output += `${idx + 1}. \`${path}\`\n`; + globalAttachments.forEach((img, idx) => { + output += `${idx + 1}. [${img.name}] \`${img.path}\`\n`; }); output += `\n`; } @@ -307,10 +307,10 @@ export const exportDiff = (blocks: Block[], annotations: any[], globalAttachment } // Add attached images for this annotation - if (ann.imagePaths && ann.imagePaths.length > 0) { + if (ann.images && ann.images.length > 0) { output += `**Attached images:**\n`; - ann.imagePaths.forEach((path: string) => { - output += `- \`${path}\`\n`; + ann.images.forEach((img: ImageAttachment) => { + output += `- [${img.name}] \`${img.path}\`\n`; }); } diff --git a/packages/ui/utils/sharing.ts b/packages/ui/utils/sharing.ts index 8762cbc3..28f8479b 100644 --- a/packages/ui/utils/sharing.ts +++ b/packages/ui/utils/sharing.ts @@ -8,20 +8,46 @@ * Inspired by textarea.my's approach. */ -import { Annotation, AnnotationType } from '../types'; +import { Annotation, AnnotationType, type ImageAttachment } from '../types'; -// Minimal shareable annotation format: [type, originalText, text?, author?, imagePaths?] +// Image in shareable format: plain string (old) or [path, name] tuple (new) +type ShareableImage = string | [string, string]; + +// Minimal shareable annotation format: [type, originalText, text?, author?, images?] export type ShareableAnnotation = - | ['D', string, string | null, string[]?] // Deletion: type, original, author, images - | ['R', string, string, string | null, string[]?] // Replacement: type, original, replacement, author, images - | ['C', string, string, string | null, string[]?] // Comment: type, original, comment, author, images - | ['I', string, string, string | null, string[]?] // Insertion: type, context, new text, author, images - | ['G', string, string | null, string[]?]; // Global Comment: type, comment, author, images + | ['D', string, string | null, ShareableImage[]?] // Deletion: type, original, author, images + | ['R', string, string, string | null, ShareableImage[]?] // Replacement: type, original, replacement, author, images + | ['C', string, string, string | null, ShareableImage[]?] // Comment: type, original, comment, author, images + | ['I', string, string, string | null, ShareableImage[]?] // Insertion: type, context, new text, author, images + | ['G', string, string | null, ShareableImage[]?]; // Global Comment: type, comment, author, images export interface SharePayload { p: string; // plan markdown a: ShareableAnnotation[]; - g?: string[]; // global attachments (image paths) + g?: ShareableImage[]; // global attachments (path strings or [path, name] tuples) +} + +/** + * Convert ShareableImage[] to ImageAttachment[] (handles old plain-string format) + */ +function parseShareableImages(raw: ShareableImage[] | undefined): ImageAttachment[] | undefined { + if (!raw?.length) return undefined; + return raw.map(img => { + if (typeof img === 'string') { + // Old format: plain path string — derive name from filename + const name = img.split('/').pop()?.replace(/\.[^.]+$/, '') || 'image'; + return { path: img, name }; + } + return { path: img[0], name: img[1] }; + }); +} + +/** + * Convert ImageAttachment[] to ShareableImage[] for compact serialization + */ +function toShareableImages(images: ImageAttachment[] | undefined): ShareableImage[] | undefined { + if (!images?.length) return undefined; + return images.map(img => [img.path, img.name]); } /** @@ -76,7 +102,7 @@ export async function decompress(b64: string): Promise { export function toShareable(annotations: Annotation[]): ShareableAnnotation[] { return annotations.map(ann => { const author = ann.author || null; - const images = ann.imagePaths?.length ? ann.imagePaths : undefined; + const images = toShareableImages(ann.images); // Handle GLOBAL_COMMENT specially - it starts with 'G' (from GLOBAL_COMMENT) if (ann.type === AnnotationType.GLOBAL_COMMENT) { @@ -115,7 +141,7 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] { if (type === 'G') { const text = item[1] as string; const author = item[2] as string | null; - const imagePaths = item[3] as string[] | undefined; + const rawImages = item[3] as ShareableImage[] | undefined; return { id: `shared-${index}-${Date.now()}`, @@ -127,7 +153,7 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] { originalText: '', createdA: Date.now() + index, author: author || undefined, - imagePaths: imagePaths?.length ? imagePaths : undefined, + images: parseShareableImages(rawImages), }; } @@ -136,7 +162,7 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] { // For others: [type, original, text, author, images?] const text = type === 'D' ? undefined : item[2] as string; const author = type === 'D' ? item[2] as string | null : item[3] as string | null; - const imagePaths = type === 'D' ? item[3] as string[] | undefined : item[4] as string[] | undefined; + const rawImages = type === 'D' ? item[3] as ShareableImage[] | undefined : item[4] as ShareableImage[] | undefined; return { id: `shared-${index}-${Date.now()}`, @@ -148,7 +174,7 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] { originalText, createdA: Date.now() + index, // Preserve order author: author || undefined, - imagePaths: imagePaths?.length ? imagePaths : undefined, + images: parseShareableImages(rawImages), // startMeta/endMeta will be set by web-highlighter }; }); @@ -160,13 +186,13 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] { export async function generateShareUrl( markdown: string, annotations: Annotation[], - globalAttachments?: string[], + globalAttachments?: ImageAttachment[], baseUrl: string = 'https://share.plannotator.ai' ): Promise { const payload: SharePayload = { p: markdown, a: toShareable(annotations), - g: globalAttachments?.length ? globalAttachments : undefined, + g: globalAttachments?.length ? toShareableImages(globalAttachments) : undefined, }; const hash = await compress(payload);