mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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 <file.md>` 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <file.md> 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:**
|
||||
|
||||
@@ -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.
|
||||
@@ -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 <file.md>`):
|
||||
* - 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 <file.md>");
|
||||
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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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 <file.md>",
|
||||
});
|
||||
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: {
|
||||
|
||||
+63
-20
@@ -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<string[]>([]);
|
||||
const [globalAttachments, setGlobalAttachments] = useState<ImageAttachment[]>([]);
|
||||
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<PermissionMode>('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 = () => {
|
||||
<svg className="w-4 h-4 md:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<span className="hidden md:inline">{isSubmitting ? 'Sending...' : 'Send Feedback'}</span>
|
||||
<span className="hidden md:inline">{isSubmitting ? 'Sending...' : annotateMode ? 'Send Annotations' : 'Send Feedback'}</span>
|
||||
</button>
|
||||
|
||||
<div className="relative group/approve">
|
||||
{!annotateMode && <div className="relative group/approve">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Show warning for Claude Code users with annotations
|
||||
@@ -911,7 +951,7 @@ const App: React.FC = () => {
|
||||
{agentName} doesn't support feedback on approval. Your annotations won't be seen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
|
||||
</>
|
||||
@@ -1211,12 +1251,14 @@ const App: React.FC = () => {
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{submitted === 'approved' ? 'Plan Approved' : 'Feedback Sent'}
|
||||
{submitted === 'approved' ? 'Plan Approved' : annotateMode ? 'Annotations Sent' : 'Feedback Sent'}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{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.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1239,6 +1281,7 @@ const App: React.FC = () => {
|
||||
<ImageAnnotator
|
||||
isOpen={!!pendingPasteImage}
|
||||
imageSrc={pendingPasteImage?.blobUrl ?? ''}
|
||||
initialName={pendingPasteImage?.initialName}
|
||||
onAccept={handlePasteAnnotatorAccept}
|
||||
onClose={handlePasteAnnotatorClose}
|
||||
/>
|
||||
|
||||
@@ -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<AnnotateServerResult> {
|
||||
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<typeof Bun.serve> | 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<void> {
|
||||
if (!isRemote) {
|
||||
await openBrowser(url);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./review": "./review.ts",
|
||||
"./annotate": "./annotate.ts",
|
||||
"./remote": "./remote.ts",
|
||||
"./browser": "./browser.ts",
|
||||
"./storage": "./storage.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";
|
||||
|
||||
@@ -394,15 +394,17 @@ const AnnotationCard: React.FC<{
|
||||
)}
|
||||
|
||||
{/* Attached Images */}
|
||||
{annotation.imagePaths && annotation.imagePaths.length > 0 && (
|
||||
{annotation.images && annotation.images.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{annotation.imagePaths.map((path, idx) => (
|
||||
<ImageThumbnail
|
||||
key={idx}
|
||||
path={path}
|
||||
size="sm"
|
||||
showRemove={false}
|
||||
/>
|
||||
{annotation.images.map((img, idx) => (
|
||||
<div key={idx} className="text-center">
|
||||
<ImageThumbnail
|
||||
path={img.path}
|
||||
size="sm"
|
||||
showRemove={false}
|
||||
/>
|
||||
<div className="text-[9px] text-muted-foreground truncate max-w-[3rem]" title={img.name}>{img.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<AnnotationToolbarProps> = ({
|
||||
const [step, setStep] = useState<"menu" | "input">(initialStep);
|
||||
const [activeType, setActiveType] = useState<AnnotationType | null>(initialType ?? null);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [imagePaths, setImagePaths] = useState<string[]>([]);
|
||||
const [images, setImages] = useState<ImageAttachment[]>([]);
|
||||
const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -81,7 +81,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
||||
setStep(initialStep);
|
||||
setActiveType(initialType ?? null);
|
||||
setInputValue("");
|
||||
setImagePaths([]);
|
||||
setImages([]);
|
||||
setCopied(false);
|
||||
}, [element, initialStep, initialType]);
|
||||
|
||||
@@ -159,8 +159,8 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
||||
|
||||
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<AnnotationToolbarProps> = ({
|
||||
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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AttachmentsButton
|
||||
paths={imagePaths}
|
||||
onAdd={(path) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!inputValue.trim() && imagePaths.length === 0}
|
||||
disabled={!inputValue.trim() && images.length === 0}
|
||||
className="px-[15px] py-1 text-xs font-medium rounded bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-opacity self-stretch"
|
||||
>
|
||||
Save
|
||||
|
||||
@@ -2,16 +2,53 @@ import React, { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ImageThumbnail, getImageSrc } from './ImageThumbnail';
|
||||
import { ImageAnnotator } from './ImageAnnotator';
|
||||
import type { ImageAttachment } from '../types';
|
||||
|
||||
/**
|
||||
* Derive a clean, human-readable name from an original filename.
|
||||
* "Login Mockup.png" → "login-mockup"
|
||||
* "annotated.png" or generic names → "image-N"
|
||||
*/
|
||||
export function deriveImageName(originalName: string, existingNames: string[]): string {
|
||||
const base = originalName.replace(/\.[^.]+$/, '');
|
||||
const generic = ['annotated', 'image', 'screenshot', 'paste', 'clipboard', 'untitled'];
|
||||
|
||||
if (generic.includes(base.toLowerCase())) {
|
||||
let n = 1;
|
||||
while (existingNames.includes(`image-${n}`)) n++;
|
||||
return `image-${n}`;
|
||||
}
|
||||
|
||||
let name = base.toLowerCase()
|
||||
.replace(/[_\s]+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
if (!name) {
|
||||
let n = 1;
|
||||
while (existingNames.includes(`image-${n}`)) n++;
|
||||
return `image-${n}`;
|
||||
}
|
||||
|
||||
if (existingNames.includes(name)) {
|
||||
let n = 2;
|
||||
while (existingNames.includes(`${name}-${n}`)) n++;
|
||||
name = `${name}-${n}`;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
interface AttachmentsButtonProps {
|
||||
paths: string[];
|
||||
onAdd: (path: string) => void;
|
||||
images: ImageAttachment[];
|
||||
onAdd: (image: ImageAttachment) => void;
|
||||
onRemove: (path: string) => void;
|
||||
variant?: 'toolbar' | 'inline';
|
||||
}
|
||||
|
||||
export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
|
||||
paths,
|
||||
images,
|
||||
onAdd,
|
||||
onRemove,
|
||||
variant = 'toolbar',
|
||||
@@ -25,8 +62,8 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
// Annotator state
|
||||
const [annotatorImage, setAnnotatorImage] = useState<{ file: File; blobUrl: string } | null>(null);
|
||||
const [editingPath, setEditingPath] = useState<string | null>(null);
|
||||
const [annotatorImage, setAnnotatorImage] = useState<{ file: File; blobUrl: string; initialName: string } | null>(null);
|
||||
const [editingImage, setEditingImage] = useState<{ path: string; name: string } | null>(null);
|
||||
|
||||
// Update popover position when opened
|
||||
useEffect(() => {
|
||||
@@ -51,13 +88,14 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
|
||||
}, [isOpen]);
|
||||
|
||||
const handleFileSelect = (file: File) => {
|
||||
// Show annotator instead of uploading directly
|
||||
// Derive name before opening annotator so user sees it immediately
|
||||
const initialName = deriveImageName(file.name, images.map(i => i.name));
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
setAnnotatorImage({ file, blobUrl });
|
||||
setAnnotatorImage({ file, blobUrl, initialName });
|
||||
setIsOpen(false); // Close popover when annotator opens
|
||||
};
|
||||
|
||||
const handleAnnotatorAccept = async (blob: Blob, hasDrawings: boolean) => {
|
||||
const handleAnnotatorAccept = async (blob: Blob, hasDrawings: boolean, name: string) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -67,7 +105,7 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
|
||||
? new File([blob], 'annotated.png', { type: 'image/png' })
|
||||
: annotatorImage.file;
|
||||
formData.append('file', fileToUpload);
|
||||
} else if (editingPath) {
|
||||
} else if (editingImage) {
|
||||
// Re-editing: always upload the new blob
|
||||
formData.append('file', new File([blob], 'annotated.png', { type: 'image/png' }));
|
||||
}
|
||||
@@ -76,10 +114,11 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
|
||||
const data = await res.json();
|
||||
if (data.path) {
|
||||
// If re-editing, remove old path first
|
||||
if (editingPath) {
|
||||
onRemove(editingPath);
|
||||
if (editingImage) {
|
||||
onRemove(editingImage.path);
|
||||
}
|
||||
onAdd(data.path);
|
||||
// Use the name from the annotator (user may have edited it)
|
||||
onAdd({ path: data.path, name });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
@@ -90,7 +129,7 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
|
||||
URL.revokeObjectURL(annotatorImage.blobUrl);
|
||||
setAnnotatorImage(null);
|
||||
}
|
||||
setEditingPath(null);
|
||||
setEditingImage(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +138,7 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
|
||||
URL.revokeObjectURL(annotatorImage.blobUrl);
|
||||
setAnnotatorImage(null);
|
||||
}
|
||||
setEditingPath(null);
|
||||
setEditingImage(null);
|
||||
};
|
||||
|
||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -122,16 +161,25 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
|
||||
const handleManualAdd = () => {
|
||||
const trimmed = manualPath.trim();
|
||||
if (trimmed) {
|
||||
onAdd(trimmed);
|
||||
const name = deriveImageName(
|
||||
trimmed.split('/').pop() || 'image',
|
||||
images.map(i => i.name)
|
||||
);
|
||||
onAdd({ path: trimmed, name });
|
||||
setManualPath('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
paths.forEach(p => onRemove(p));
|
||||
images.forEach(img => onRemove(img.path));
|
||||
};
|
||||
|
||||
// Determine annotator props
|
||||
const annotatorOpen = !!annotatorImage || !!editingImage;
|
||||
const annotatorSrc = annotatorImage?.blobUrl ?? (editingImage ? getImageSrc(editingImage.path) : '');
|
||||
const annotatorInitialName = annotatorImage?.initialName ?? editingImage?.name ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Trigger Button */}
|
||||
@@ -142,29 +190,29 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
|
||||
className="group relative flex items-center gap-1.5 px-2 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{/* Show stacked thumbnails if we have images */}
|
||||
{paths.length > 0 ? (
|
||||
{images.length > 0 ? (
|
||||
<>
|
||||
<div className="relative flex items-center">
|
||||
{paths.slice(0, 3).map((path, idx) => (
|
||||
{images.slice(0, 3).map((img, idx) => (
|
||||
<div
|
||||
key={path}
|
||||
key={img.path}
|
||||
className="relative w-5 h-5 rounded border border-background"
|
||||
style={{ marginLeft: idx > 0 ? '-6px' : 0, zIndex: 3 - idx }}
|
||||
>
|
||||
<img
|
||||
src={getImageSrc(path)}
|
||||
alt={`Attachment ${idx + 1}`}
|
||||
src={getImageSrc(img.path)}
|
||||
alt={img.name}
|
||||
loading="lazy"
|
||||
className="w-5 h-5 rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{paths.length > 3 && (
|
||||
{images.length > 3 && (
|
||||
<div
|
||||
className="relative w-5 h-5 rounded bg-muted border border-background flex items-center justify-center text-[9px] font-medium"
|
||||
style={{ marginLeft: '-6px', zIndex: 0 }}
|
||||
>
|
||||
+{paths.length - 3}
|
||||
+{images.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -184,7 +232,7 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
|
||||
</svg>
|
||||
)}
|
||||
<span className={variant === 'inline' ? 'sr-only' : ''}>
|
||||
{paths.length > 0 ? `${paths.length}` : 'Images'}
|
||||
{images.length > 0 ? `${images.length}` : 'Images'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -207,9 +255,9 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Attachments</div>
|
||||
{paths.length > 0 && (
|
||||
{images.length > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{paths.length} image{paths.length !== 1 ? 's' : ''}
|
||||
{images.length} image{images.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -277,22 +325,29 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Grid of current attachments */}
|
||||
{paths.length > 0 && (
|
||||
{images.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">Current</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{paths.map((path) => (
|
||||
<ImageThumbnail
|
||||
key={path}
|
||||
path={path}
|
||||
size="md"
|
||||
onClick={() => {
|
||||
setEditingPath(path);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
onRemove={() => onRemove(path)}
|
||||
showRemove
|
||||
/>
|
||||
{images.map((img) => (
|
||||
<div key={img.path} className="text-center">
|
||||
<ImageThumbnail
|
||||
path={img.path}
|
||||
size="md"
|
||||
onClick={() => {
|
||||
setEditingImage({ path: img.path, name: img.name });
|
||||
setIsOpen(false);
|
||||
}}
|
||||
onRemove={() => onRemove(img.path)}
|
||||
showRemove
|
||||
/>
|
||||
<div
|
||||
className="text-[9px] text-muted-foreground truncate max-w-[3.5rem] mt-0.5 mx-auto"
|
||||
title={img.name}
|
||||
>
|
||||
{img.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -304,10 +359,11 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
|
||||
)}
|
||||
|
||||
{/* Image Annotator Dialog - portaled to body for correct positioning */}
|
||||
{(!!annotatorImage || !!editingPath) && createPortal(
|
||||
{annotatorOpen && createPortal(
|
||||
<ImageAnnotator
|
||||
isOpen={!!annotatorImage || !!editingPath}
|
||||
imageSrc={annotatorImage?.blobUrl ?? (editingPath ? getImageSrc(editingPath) : '')}
|
||||
isOpen={annotatorOpen}
|
||||
imageSrc={annotatorSrc}
|
||||
initialName={annotatorInitialName}
|
||||
onAccept={handleAnnotatorAccept}
|
||||
onClose={handleAnnotatorClose}
|
||||
/>,
|
||||
|
||||
@@ -8,8 +8,10 @@ import { DEFAULT_STATE } from './types';
|
||||
interface ImageAnnotatorProps {
|
||||
imageSrc: string;
|
||||
isOpen: boolean;
|
||||
onAccept: (blob: Blob, hasDrawings: boolean) => Promise<void>;
|
||||
onAccept: (blob: Blob, hasDrawings: boolean, name: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
/** Pre-populated image name (derived from filename) */
|
||||
initialName?: string;
|
||||
}
|
||||
|
||||
export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
|
||||
@@ -17,23 +19,38 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
|
||||
isOpen,
|
||||
onAccept,
|
||||
onClose,
|
||||
initialName = '',
|
||||
}) => {
|
||||
const [state, setState] = useState<AnnotatorState>(DEFAULT_STATE);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [name, setName] = useState(initialName);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const nameInputRef = useRef<HTMLInputElement>(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<ImageAnnotatorProps> = ({
|
||||
}
|
||||
|
||||
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<ImageAnnotatorProps> = ({
|
||||
// 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<ImageAnnotatorProps> = ({
|
||||
onImageLoad={handleImageLoad}
|
||||
/>
|
||||
|
||||
{/* Image name input */}
|
||||
{initialName && (
|
||||
<div className="flex items-center gap-2 w-full max-w-xs">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">Name</label>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => 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..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accept hint */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-foreground">Esc</kbd> or <kbd className="px-1.5 py-0.5 bg-muted rounded text-foreground">Enter</kbd> or click outside to accept
|
||||
|
||||
@@ -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<ViewerHandle, ViewerProps>(({
|
||||
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<ViewerHandle, ViewerProps>(({
|
||||
author: getIdentity(),
|
||||
startMeta: source.startMeta,
|
||||
endMeta: source.endMeta,
|
||||
imagePaths,
|
||||
images,
|
||||
};
|
||||
|
||||
if (type === AnnotationType.DELETION) {
|
||||
@@ -548,11 +548,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
||||
});
|
||||
}, [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<ViewerHandle, ViewerProps>(({
|
||||
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<ViewerHandle, ViewerProps>(({
|
||||
originalText: codeText,
|
||||
createdA: Date.now(),
|
||||
author: getIdentity(),
|
||||
imagePaths,
|
||||
images,
|
||||
};
|
||||
|
||||
onAddAnnotationRef.current(newAnnotation);
|
||||
@@ -657,7 +657,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
||||
{/* Attachments button */}
|
||||
{onAddGlobalAttachment && onRemoveGlobalAttachment && (
|
||||
<AttachmentsButton
|
||||
paths={globalAttachments}
|
||||
images={globalAttachments}
|
||||
onAdd={onAddGlobalAttachment}
|
||||
onRemove={onRemoveGlobalAttachment}
|
||||
variant="toolbar"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Annotation } from '../types';
|
||||
import { Annotation, type ImageAttachment } from '../types';
|
||||
import {
|
||||
parseShareHash,
|
||||
generateShareUrl,
|
||||
@@ -41,7 +41,7 @@ interface UseSharingResult {
|
||||
pendingSharedAnnotations: Annotation[] | null;
|
||||
|
||||
/** Global attachments loaded from share */
|
||||
sharedGlobalAttachments: string[] | null;
|
||||
sharedGlobalAttachments: ImageAttachment[] | null;
|
||||
|
||||
/** Call after applying shared annotations to clear the pending state */
|
||||
clearPendingSharedAnnotations: () => void;
|
||||
@@ -53,13 +53,30 @@ interface UseSharingResult {
|
||||
importFromShareUrl: (url: string) => Promise<ImportResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Annotation[] | null>(null);
|
||||
const [sharedGlobalAttachments, setSharedGlobalAttachments] = useState<string[] | null>(null);
|
||||
const [sharedGlobalAttachments, setSharedGlobalAttachments] = useState<ImageAttachment[] | null>(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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SharePayload> {
|
||||
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<string> {
|
||||
const payload: SharePayload = {
|
||||
p: markdown,
|
||||
a: toShareable(annotations),
|
||||
g: globalAttachments?.length ? globalAttachments : undefined,
|
||||
g: globalAttachments?.length ? toShareableImages(globalAttachments) : undefined,
|
||||
};
|
||||
|
||||
const hash = await compress(payload);
|
||||
|
||||
Reference in New Issue
Block a user