mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
Initial commit: Plannotator - Plan review UI for Claude Code
A React-based annotation tool that intercepts Claude Code's ExitPlanMode to provide visual plan review with collaborative features. Features: - Markdown plan rendering with syntax highlighting - Text selection annotations (delete, replace, insert, comment) - Redline mode for quick deletion marking - URL-based sharing via compressed hash (no server storage) - Tater identity system for anonymous collaboration - Export to diff format - Dark/light theme support - Tater sprite easter egg 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,573 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { parseMarkdownToBlocks, exportDiff } from './utils/parser';
|
||||
import { Viewer, ViewerHandle } from './components/Viewer';
|
||||
import { AnnotationPanel } from './components/AnnotationPanel';
|
||||
import { Landing } from './components/Landing';
|
||||
import { ExportModal } from './components/ExportModal';
|
||||
import { Annotation, Block, AnnotationType, EditorMode } from './types';
|
||||
import { ThemeProvider } from './components/ThemeProvider';
|
||||
import { ModeToggle } from './components/ModeToggle';
|
||||
import { ModeSwitcher } from './components/ModeSwitcher';
|
||||
import { TaterSpriteRunning } from './components/TaterSpriteRunning';
|
||||
import { TaterSpritePullup } from './components/TaterSpritePullup';
|
||||
import { Settings } from './components/Settings';
|
||||
import { useSharing } from './hooks/useSharing';
|
||||
|
||||
const PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration
|
||||
|
||||
## Overview
|
||||
Add real-time collaboration features to the editor using WebSocket connections and operational transforms.
|
||||
|
||||
## Phase 1: Infrastructure
|
||||
|
||||
### WebSocket Server
|
||||
Set up a WebSocket server to handle concurrent connections:
|
||||
|
||||
\`\`\`typescript
|
||||
const server = new WebSocketServer({ port: 8080 });
|
||||
|
||||
server.on('connection', (socket, request) => {
|
||||
const sessionId = generateSessionId();
|
||||
sessions.set(sessionId, socket);
|
||||
|
||||
socket.on('message', (data) => {
|
||||
broadcast(sessionId, data);
|
||||
});
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### Client Connection
|
||||
- Establish persistent connection on document load
|
||||
- Implement reconnection logic with exponential backoff
|
||||
- Handle offline state gracefully
|
||||
|
||||
### Database Schema
|
||||
|
||||
\`\`\`sql
|
||||
CREATE TABLE documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE collaborators (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
role VARCHAR(50) DEFAULT 'editor',
|
||||
cursor_position JSONB,
|
||||
last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_collaborators_document ON collaborators(document_id);
|
||||
\`\`\`
|
||||
|
||||
## Phase 2: Operational Transforms
|
||||
|
||||
> The key insight is that we need to transform operations against concurrent operations to maintain consistency.
|
||||
|
||||
Key requirements:
|
||||
- Transform insert against insert
|
||||
- Transform insert against delete
|
||||
- Transform delete against delete
|
||||
- Maintain cursor positions across transforms
|
||||
|
||||
### Transform Implementation
|
||||
|
||||
\`\`\`typescript
|
||||
interface Operation {
|
||||
type: 'insert' | 'delete';
|
||||
position: number;
|
||||
content?: string;
|
||||
length?: number;
|
||||
userId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
class OperationalTransform {
|
||||
private pendingOps: Operation[] = [];
|
||||
private history: Operation[] = [];
|
||||
|
||||
transform(op1: Operation, op2: Operation): [Operation, Operation] {
|
||||
if (op1.type === 'insert' && op2.type === 'insert') {
|
||||
if (op1.position <= op2.position) {
|
||||
return [op1, { ...op2, position: op2.position + (op1.content?.length || 0) }];
|
||||
} else {
|
||||
return [{ ...op1, position: op1.position + (op2.content?.length || 0) }, op2];
|
||||
}
|
||||
}
|
||||
|
||||
if (op1.type === 'delete' && op2.type === 'delete') {
|
||||
// Complex delete vs delete transformation
|
||||
const op1End = op1.position + (op1.length || 0);
|
||||
const op2End = op2.position + (op2.length || 0);
|
||||
|
||||
if (op1End <= op2.position) {
|
||||
return [op1, { ...op2, position: op2.position - (op1.length || 0) }];
|
||||
}
|
||||
// ... more cases
|
||||
}
|
||||
|
||||
return [op1, op2];
|
||||
}
|
||||
|
||||
apply(doc: string, op: Operation): string {
|
||||
if (op.type === 'insert') {
|
||||
return doc.slice(0, op.position) + op.content + doc.slice(op.position);
|
||||
} else {
|
||||
return doc.slice(0, op.position) + doc.slice(op.position + (op.length || 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Phase 3: UI Updates
|
||||
|
||||
1. Show collaborator cursors in real-time
|
||||
2. Display presence indicators
|
||||
3. Add conflict resolution UI
|
||||
4. Implement undo/redo stack per user
|
||||
|
||||
### React Component for Cursors
|
||||
|
||||
\`\`\`tsx
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useCollaboration } from '../hooks/useCollaboration';
|
||||
|
||||
interface CursorOverlayProps {
|
||||
documentId: string;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const CursorOverlay: React.FC<CursorOverlayProps> = ({
|
||||
documentId,
|
||||
containerRef
|
||||
}) => {
|
||||
const { collaborators, currentUser } = useCollaboration(documentId);
|
||||
const [positions, setPositions] = useState<Map<string, DOMRect>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const updatePositions = () => {
|
||||
const newPositions = new Map<string, DOMRect>();
|
||||
collaborators.forEach(collab => {
|
||||
if (collab.userId !== currentUser.id && collab.cursorPosition) {
|
||||
const rect = getCursorRect(containerRef.current, collab.cursorPosition);
|
||||
if (rect) newPositions.set(collab.userId, rect);
|
||||
}
|
||||
});
|
||||
setPositions(newPositions);
|
||||
};
|
||||
|
||||
const interval = setInterval(updatePositions, 50);
|
||||
return () => clearInterval(interval);
|
||||
}, [collaborators, currentUser, containerRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from(positions.entries()).map(([userId, rect]) => (
|
||||
<div
|
||||
key={userId}
|
||||
className="absolute pointer-events-none transition-all duration-75"
|
||||
style={{
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
height: rect.height,
|
||||
}}
|
||||
>
|
||||
<div className="w-0.5 h-full bg-blue-500 animate-pulse" />
|
||||
<div className="absolute -top-5 left-0 px-1.5 py-0.5 bg-blue-500
|
||||
text-white text-xs rounded whitespace-nowrap">
|
||||
{collaborators.find(c => c.userId === userId)?.userName}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
### Configuration
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"collaboration": {
|
||||
"enabled": true,
|
||||
"maxCollaborators": 10,
|
||||
"cursorColors": ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"],
|
||||
"syncInterval": 100,
|
||||
"reconnect": {
|
||||
"maxAttempts": 5,
|
||||
"backoffMultiplier": 1.5,
|
||||
"initialDelay": 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
**Target:** Ship MVP in next sprint
|
||||
`;
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [page, setPage] = useState<'landing' | 'editor'>(() => {
|
||||
const path = window.location.pathname;
|
||||
const hasShareHash = window.location.hash.length > 1;
|
||||
// Go directly to editor if path indicates or if we have a share hash
|
||||
return path === '/editor' || path === '/app' || hasShareHash ? 'editor' : 'landing';
|
||||
});
|
||||
const [markdown, setMarkdown] = useState(PLAN_CONTENT);
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
||||
const [blocks, setBlocks] = useState<Block[]>([]);
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(true);
|
||||
const [editorMode, setEditorMode] = useState<EditorMode>('selection');
|
||||
const [taterMode, setTaterMode] = useState(() => {
|
||||
const stored = localStorage.getItem('plannotator-tater-mode');
|
||||
return stored === 'true';
|
||||
});
|
||||
const [isApiMode, setIsApiMode] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null);
|
||||
const viewerRef = useRef<ViewerHandle>(null);
|
||||
|
||||
// URL-based sharing
|
||||
const {
|
||||
isSharedSession,
|
||||
isLoadingShared,
|
||||
shareUrl,
|
||||
shareUrlSize,
|
||||
pendingSharedAnnotations,
|
||||
clearPendingSharedAnnotations,
|
||||
} = useSharing(
|
||||
markdown,
|
||||
annotations,
|
||||
setMarkdown,
|
||||
setAnnotations,
|
||||
() => {
|
||||
// When loaded from share, go directly to editor
|
||||
setPage('editor');
|
||||
setIsLoading(false);
|
||||
}
|
||||
);
|
||||
|
||||
// Apply shared annotations to DOM after they're loaded
|
||||
useEffect(() => {
|
||||
if (pendingSharedAnnotations && pendingSharedAnnotations.length > 0) {
|
||||
// Small delay to ensure DOM is rendered
|
||||
const timer = setTimeout(() => {
|
||||
// Clear existing highlights first (important when loading new share URL)
|
||||
viewerRef.current?.clearAllHighlights();
|
||||
viewerRef.current?.applySharedAnnotations(pendingSharedAnnotations);
|
||||
clearPendingSharedAnnotations();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [pendingSharedAnnotations, clearPendingSharedAnnotations]);
|
||||
|
||||
const goToEditor = () => {
|
||||
window.history.pushState({}, '', '/editor');
|
||||
setPage('editor');
|
||||
};
|
||||
|
||||
const handleTaterModeChange = (enabled: boolean) => {
|
||||
setTaterMode(enabled);
|
||||
localStorage.setItem('plannotator-tater-mode', String(enabled));
|
||||
};
|
||||
|
||||
// Check if we're in API mode (served from Bun hook server)
|
||||
// Skip if we loaded from a shared URL
|
||||
useEffect(() => {
|
||||
if (isLoadingShared) return; // Wait for share check to complete
|
||||
if (isSharedSession) return; // Already loaded from share
|
||||
|
||||
fetch('/api/plan')
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Not in API mode');
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { plan: string }) => {
|
||||
setMarkdown(data.plan);
|
||||
setIsApiMode(true);
|
||||
setPage('editor'); // Skip landing page in API mode
|
||||
})
|
||||
.catch(() => {
|
||||
// Not in API mode - use default content
|
||||
setIsApiMode(false);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [isLoadingShared, isSharedSession]);
|
||||
|
||||
useEffect(() => {
|
||||
setBlocks(parseMarkdownToBlocks(markdown));
|
||||
}, [markdown]);
|
||||
|
||||
// API mode handlers
|
||||
const handleApprove = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await fetch('/api/approve', { method: 'POST' });
|
||||
setSubmitted('approved');
|
||||
} catch {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeny = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await fetch('/api/deny', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ feedback: diffOutput })
|
||||
});
|
||||
setSubmitted('denied');
|
||||
} catch {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAnnotation = (ann: Annotation) => {
|
||||
setAnnotations(prev => [...prev, ann]);
|
||||
setSelectedAnnotationId(ann.id);
|
||||
setIsPanelOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteAnnotation = (id: string) => {
|
||||
viewerRef.current?.removeHighlight(id);
|
||||
setAnnotations(prev => prev.filter(a => a.id !== id));
|
||||
if (selectedAnnotationId === id) setSelectedAnnotationId(null);
|
||||
};
|
||||
|
||||
const handleIdentityChange = (oldIdentity: string, newIdentity: string) => {
|
||||
setAnnotations(prev => prev.map(ann =>
|
||||
ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann
|
||||
));
|
||||
};
|
||||
|
||||
const diffOutput = useMemo(() => exportDiff(blocks, annotations), [blocks, annotations]);
|
||||
|
||||
if (page === 'landing') {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="dark">
|
||||
<Landing onEnter={goToEditor} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider defaultTheme="dark">
|
||||
<div className="h-screen flex flex-col bg-background overflow-hidden">
|
||||
{/* Tater sprites */}
|
||||
{taterMode && <TaterSpriteRunning />}
|
||||
{/* Minimal Header */}
|
||||
<header className="h-12 flex items-center justify-between px-2 md:px-4 border-b border-border/50 bg-card/50 backdrop-blur-xl z-50">
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<button
|
||||
onClick={() => { window.history.pushState({}, '', '/'); setPage('landing'); }}
|
||||
className="flex items-center gap-1.5 md:gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="w-6 h-6 rounded-md bg-primary/20 flex items-center justify-center">
|
||||
<svg className="w-3.5 h-3.5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tracking-tight hidden sm:inline">Plannotator</span>
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground font-mono opacity-60 hidden md:inline">v0.1</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
{isApiMode && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (annotations.length === 0) {
|
||||
setShowFeedbackPrompt(true);
|
||||
} else {
|
||||
handleDeny();
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className={`p-1.5 md:px-2.5 md:py-1 rounded-md text-xs font-medium transition-all ${
|
||||
isSubmitting
|
||||
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
|
||||
: 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/30'
|
||||
}`}
|
||||
title="Provide Feedback"
|
||||
>
|
||||
<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...' : 'Provide Feedback'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={isSubmitting}
|
||||
className={`px-2 py-1 md:px-2.5 rounded-md text-xs font-medium transition-all ${
|
||||
isSubmitting
|
||||
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
|
||||
: 'bg-green-600 text-white hover:bg-green-500'
|
||||
}`}
|
||||
>
|
||||
<span className="md:hidden">{isSubmitting ? '...' : 'OK'}</span>
|
||||
<span className="hidden md:inline">{isSubmitting ? 'Approving...' : 'Approve'}</span>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ModeToggle />
|
||||
<Settings taterMode={taterMode} onTaterModeChange={handleTaterModeChange} onIdentityChange={handleIdentityChange} />
|
||||
|
||||
<button
|
||||
onClick={() => setIsPanelOpen(!isPanelOpen)}
|
||||
className={`p-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
isPanelOpen
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowExport(true)}
|
||||
className="p-1.5 md:px-2.5 md:py-1 rounded-md text-xs font-medium bg-muted hover:bg-muted/80 transition-colors"
|
||||
title="Export"
|
||||
>
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
<span className="hidden md:inline">Export</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Document Area */}
|
||||
<main className="flex-1 overflow-y-auto bg-grid">
|
||||
<div className="min-h-full flex flex-col items-center p-3 md:p-8">
|
||||
{/* Mode Switcher */}
|
||||
<div className="w-full max-w-3xl mb-3 md:mb-4 flex justify-start">
|
||||
<ModeSwitcher mode={editorMode} onChange={setEditorMode} taterMode={taterMode} />
|
||||
</div>
|
||||
|
||||
<Viewer
|
||||
ref={viewerRef}
|
||||
blocks={blocks}
|
||||
annotations={annotations}
|
||||
onAddAnnotation={handleAddAnnotation}
|
||||
onSelectAnnotation={setSelectedAnnotationId}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
mode={editorMode}
|
||||
taterMode={taterMode}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Annotation Panel */}
|
||||
<AnnotationPanel
|
||||
isOpen={isPanelOpen}
|
||||
blocks={blocks}
|
||||
annotations={annotations}
|
||||
selectedId={selectedAnnotationId}
|
||||
onSelect={setSelectedAnnotationId}
|
||||
onDelete={handleDeleteAnnotation}
|
||||
shareUrl={shareUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Export Modal */}
|
||||
<ExportModal
|
||||
isOpen={showExport}
|
||||
onClose={() => setShowExport(false)}
|
||||
shareUrl={shareUrl}
|
||||
shareUrlSize={shareUrlSize}
|
||||
diffOutput={diffOutput}
|
||||
annotationCount={annotations.length}
|
||||
taterSprite={taterMode ? <TaterSpritePullup /> : undefined}
|
||||
/>
|
||||
|
||||
{/* Feedback prompt dialog */}
|
||||
{showFeedbackPrompt && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-card border border-border rounded-xl w-full max-w-sm shadow-2xl p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold">Add Annotations First</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
To provide feedback, select text in the plan and add annotations. Claude will use your annotations to revise the plan.
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowFeedbackPrompt(false)}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completion overlay - shown after approve/deny */}
|
||||
{submitted && (
|
||||
<div className="fixed inset-0 z-[100] bg-background flex items-center justify-center">
|
||||
<div className="text-center space-y-6 max-w-md px-8">
|
||||
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${
|
||||
submitted === 'approved'
|
||||
? 'bg-green-500/20 text-green-500'
|
||||
: 'bg-accent/20 text-accent'
|
||||
}`}>
|
||||
{submitted === 'approved' ? (
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{submitted === 'approved' ? 'Plan Approved' : 'Feedback Sent'}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{submitted === 'approved'
|
||||
? 'Claude will proceed with the implementation.'
|
||||
: 'Claude will revise the plan based on your annotations.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Return to your <span className="text-foreground font-medium">Claude Code terminal</span> to continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,240 @@
|
||||
# Plannotator
|
||||
|
||||
A plan review UI for Claude Code that intercepts `ExitPlanMode` and lets you approve or request changes with annotated feedback.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Hook Flow
|
||||
|
||||
```
|
||||
Claude calls ExitPlanMode
|
||||
│
|
||||
▼
|
||||
PermissionRequest hook fires
|
||||
│
|
||||
▼
|
||||
hooks/exit-plan-mode.sh reads plan from stdin JSON
|
||||
│
|
||||
▼
|
||||
Bun server starts (random port)
|
||||
│
|
||||
▼
|
||||
Browser opens to Plannotator UI
|
||||
│
|
||||
▼
|
||||
User reviews plan, optionally adds annotations
|
||||
│
|
||||
├─── Approve ───► JSON output: {"decision": {"behavior": "allow"}}
|
||||
│ │
|
||||
│ ▼
|
||||
│ Claude proceeds with implementation
|
||||
│
|
||||
└─── Request Changes ───► JSON output: {"decision": {"behavior": "deny", "message": "..."}}
|
||||
│
|
||||
▼
|
||||
Claude receives feedback, revises plan
|
||||
```
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `hooks/exit-plan-mode.sh` | Shell script invoked by Claude Code hook system |
|
||||
| `server/index.ts` | Bun server that serves UI and handles approve/deny API |
|
||||
| `App.tsx` | React app with API mode detection |
|
||||
| `dist/index.html` | Built single-file app served by Bun |
|
||||
|
||||
### Hook Configuration
|
||||
|
||||
The hook is configured in `.claude/settings.local.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PermissionRequest": [
|
||||
{
|
||||
"matcher": "ExitPlanMode",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/path/to/planning-hook/hooks/exit-plan-mode.sh",
|
||||
"timeout": 600
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why PermissionRequest instead of PreToolUse?**
|
||||
|
||||
- `PermissionRequest` fires when Claude Code is about to show a permission dialog
|
||||
- Hook can return JSON to `allow` or `deny` directly — no second dialog
|
||||
- `PreToolUse` would require a second approval step in Claude Code UI
|
||||
|
||||
### Server API
|
||||
|
||||
The Bun server exposes three endpoints:
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/api/plan` | GET | Returns plan content as JSON |
|
||||
| `/api/approve` | POST | User approved the plan |
|
||||
| `/api/deny` | POST | User denied with feedback in request body |
|
||||
|
||||
### Multi-Instance Support
|
||||
|
||||
Each Plannotator instance runs on a random port (`Bun.serve({ port: 0 })`). The browser's same-origin policy ensures each tab talks to its own server — no cross-instance interference.
|
||||
|
||||
### Hook Output Format
|
||||
|
||||
The server outputs JSON to stdout for the PermissionRequest hook:
|
||||
|
||||
**Approve:**
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PermissionRequest",
|
||||
"decision": {
|
||||
"behavior": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Deny:**
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PermissionRequest",
|
||||
"decision": {
|
||||
"behavior": "deny",
|
||||
"message": "# Plan Feedback\n\n..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Annotation Data Model
|
||||
|
||||
### Types (`types.ts`)
|
||||
|
||||
```typescript
|
||||
enum AnnotationType {
|
||||
DELETION = 'DELETION', // Remove this text
|
||||
INSERTION = 'INSERTION', // Add new text (originalText is context)
|
||||
REPLACEMENT = 'REPLACEMENT', // Replace originalText with text
|
||||
COMMENT = 'COMMENT', // Comment on originalText
|
||||
}
|
||||
|
||||
interface Annotation {
|
||||
id: string; // From web-highlighter or `codeblock-{timestamp}`
|
||||
blockId: string; // References Block.id (legacy, for sorting)
|
||||
startOffset: number; // Char offset in block (legacy)
|
||||
endOffset: number; // End char offset (legacy)
|
||||
type: AnnotationType;
|
||||
text?: string; // User text for comment/replacement/insertion
|
||||
originalText: string; // The selected/highlighted text
|
||||
createdA: number; // Timestamp for ordering
|
||||
author?: string; // Tater identity for collaborative sharing
|
||||
startMeta?: { parentTagName, parentIndex, textOffset }; // web-highlighter DOM pos
|
||||
endMeta?: { parentTagName, parentIndex, textOffset };
|
||||
}
|
||||
|
||||
interface Block {
|
||||
id: string; // e.g., "block-0"
|
||||
type: 'paragraph' | 'heading' | 'blockquote' | 'list-item' | 'code' | 'hr';
|
||||
content: string;
|
||||
level?: number; // For headings (1-6)
|
||||
order: number;
|
||||
startLine: number; // 1-based line in original markdown
|
||||
}
|
||||
```
|
||||
|
||||
### Annotation Creation Flow
|
||||
|
||||
1. User selects text → `web-highlighter` fires `CREATE` event
|
||||
2. In **selection mode**: Toolbar appears with type options
|
||||
3. In **redline mode**: Auto-creates `DELETION` annotation
|
||||
4. `createAnnotationFromSource()` builds `Annotation` object
|
||||
5. Annotation stored in `App.tsx` state: `useState<Annotation[]>([])`
|
||||
|
||||
### Code Block Annotations
|
||||
|
||||
Code blocks can't use web-highlighter (content not directly selectable). Instead:
|
||||
- Hover triggers `CodeBlockToolbar`
|
||||
- Manual `<mark>` wrapper created with `range.surroundContents()`
|
||||
- ID format: `codeblock-{timestamp}`
|
||||
- Must handle removal separately via `data-bind-id` attribute
|
||||
|
||||
## URL-Based Sharing
|
||||
|
||||
Plannotator supports decentralized sharing via URL hash. The entire plan and annotations are compressed into the URL — no server storage required.
|
||||
|
||||
### Implementation
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `utils/sharing.ts` | Compress/decompress using native `CompressionStream` (deflate-raw) + base64url |
|
||||
| `hooks/useSharing.ts` | URL state management, hash change detection |
|
||||
| `components/ExportModal.tsx` | Tabbed modal with Share and Raw Diff tabs |
|
||||
|
||||
### Share Format
|
||||
|
||||
```typescript
|
||||
interface SharePayload {
|
||||
p: string; // Plan markdown
|
||||
a: ShareableAnnotation[]; // Minimal annotation tuples
|
||||
}
|
||||
|
||||
// Annotations compressed to tuples: [type, originalText, text?, author?]
|
||||
type ShareableAnnotation =
|
||||
| ['D', string, string | null] // Deletion
|
||||
| ['R', string, string, string | null] // Replacement
|
||||
| ['C', string, string, string | null] // Comment
|
||||
| ['I', string, string, string | null]; // Insertion
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
**Sharing:**
|
||||
1. User clicks Share → payload compressed with `deflate-raw` → base64url encoded
|
||||
2. URL becomes `plannotator.app/#<compressed-data>`
|
||||
3. Typical plan + annotations: ~1-2KB URL
|
||||
|
||||
**Loading:**
|
||||
1. On mount, check `location.hash`
|
||||
2. If present, decompress → restore plan and annotations
|
||||
3. Find annotation text in DOM using `TreeWalker`
|
||||
4. Apply `<mark>` highlights programmatically
|
||||
|
||||
### Tater Identity System
|
||||
|
||||
Anonymous identities for collaborative annotation:
|
||||
- Format: `{adjective}-{noun}-tater` (e.g., "swift-falcon-tater")
|
||||
- Generated via `unique-username-generator` library
|
||||
- Stored in localStorage, shown on annotation cards
|
||||
- Regenerate anytime in Settings
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run dev server (for UI development)
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Test server standalone
|
||||
bun run server/index.ts "# Test Plan\n\nSome content"
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Bun runtime
|
||||
- Claude Code with hooks support
|
||||
- macOS (uses `open` command for browser)
|
||||
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1T58blWi735nbP9jxqohfRrCa584GUPIB
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
@@ -0,0 +1,235 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Annotation, AnnotationType, Block } from '../types';
|
||||
import { isCurrentUser } from '../utils/identity';
|
||||
|
||||
interface PanelProps {
|
||||
isOpen: boolean;
|
||||
annotations: Annotation[];
|
||||
blocks: Block[];
|
||||
onSelect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
selectedId: string | null;
|
||||
shareUrl?: string;
|
||||
}
|
||||
|
||||
export const AnnotationPanel: React.FC<PanelProps> = ({
|
||||
isOpen,
|
||||
annotations,
|
||||
blocks,
|
||||
onSelect,
|
||||
onDelete,
|
||||
selectedId,
|
||||
shareUrl
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const sortedAnnotations = [...annotations].sort((a, b) => a.createdA - b.createdA);
|
||||
|
||||
const handleQuickShare = async () => {
|
||||
if (!shareUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (e) {
|
||||
console.error('Failed to copy:', e);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<aside className="w-72 border-l border-border/50 bg-card/30 backdrop-blur-sm flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-border/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Annotations
|
||||
</h2>
|
||||
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
|
||||
{annotations.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1.5">
|
||||
{sortedAnnotations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-40 text-center px-4">
|
||||
<div className="w-10 h-10 rounded-full bg-muted/50 flex items-center justify-center mb-3">
|
||||
<svg className="w-5 h-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select text to add annotations
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedAnnotations.map(ann => (
|
||||
<AnnotationCard
|
||||
key={ann.id}
|
||||
annotation={ann}
|
||||
isSelected={selectedId === ann.id}
|
||||
onSelect={() => onSelect(ann.id)}
|
||||
onDelete={() => onDelete(ann.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Share Footer */}
|
||||
{shareUrl && annotations.length > 0 && (
|
||||
<div className="p-2 border-t border-border/50">
|
||||
<button
|
||||
onClick={handleQuickShare}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-xs font-medium transition-all text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
Quick Share
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - ts;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 60) return 'now';
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
if (hours < 24) return `${hours}h`;
|
||||
if (days < 7) return `${days}d`;
|
||||
|
||||
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
const AnnotationCard: React.FC<{
|
||||
annotation: Annotation;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
}> = ({ annotation, isSelected, onSelect, onDelete }) => {
|
||||
const typeConfig = {
|
||||
[AnnotationType.DELETION]: {
|
||||
label: 'Delete',
|
||||
color: 'text-destructive',
|
||||
bg: 'bg-destructive/10',
|
||||
icon: (
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
[AnnotationType.INSERTION]: {
|
||||
label: 'Insert',
|
||||
color: 'text-secondary',
|
||||
bg: 'bg-secondary/10',
|
||||
icon: (
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
[AnnotationType.REPLACEMENT]: {
|
||||
label: 'Replace',
|
||||
color: 'text-primary',
|
||||
bg: 'bg-primary/10',
|
||||
icon: (
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
[AnnotationType.COMMENT]: {
|
||||
label: 'Comment',
|
||||
color: 'text-accent',
|
||||
bg: 'bg-accent/10',
|
||||
icon: (
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
const config = typeConfig[annotation.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={`
|
||||
group relative p-2.5 rounded-lg border cursor-pointer transition-all
|
||||
${isSelected
|
||||
? 'bg-primary/5 border-primary/30 shadow-sm'
|
||||
: 'border-transparent hover:bg-muted/50 hover:border-border/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Author */}
|
||||
{annotation.author && (
|
||||
<div className={`flex items-center gap-1.5 text-[10px] font-mono truncate mb-1.5 ${isCurrentUser(annotation.author) ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}>
|
||||
<svg className="w-3 h-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span className="truncate">{annotation.author}{isCurrentUser(annotation.author) && ' (me)'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type Badge + Timestamp + Delete */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`flex items-center gap-1.5 ${config.color}`}>
|
||||
<span className={`p-1 rounded ${config.bg}`}>
|
||||
{config.icon}
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide">
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{formatTimestamp(annotation.createdA)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Original Text */}
|
||||
<div className="text-[11px] font-mono text-muted-foreground bg-muted/50 rounded px-2 py-1.5 truncate">
|
||||
"{annotation.originalText}"
|
||||
</div>
|
||||
|
||||
{/* Comment/Replacement Text */}
|
||||
{annotation.text && annotation.type !== AnnotationType.DELETION && (
|
||||
<div className="mt-2 text-xs text-foreground/90 pl-2 border-l-2 border-primary/50">
|
||||
{annotation.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Annotation, AnnotationType, Block } from '../types';
|
||||
|
||||
interface SidebarProps {
|
||||
annotations: Annotation[];
|
||||
blocks: Block[];
|
||||
onSelect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
selectedId: string | null;
|
||||
}
|
||||
|
||||
export const AnnotationSidebar: React.FC<SidebarProps> = ({
|
||||
annotations,
|
||||
blocks,
|
||||
onSelect,
|
||||
onDelete,
|
||||
selectedId
|
||||
}) => {
|
||||
const sortedAnnotations = [...annotations].sort((a, b) => {
|
||||
const blockA = blocks.findIndex(blk => blk.id === a.blockId);
|
||||
const blockB = blocks.findIndex(blk => blk.id === b.blockId);
|
||||
if (blockA !== blockB) return blockA - blockB;
|
||||
return a.startOffset - b.startOffset;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-80 border-l border-border/50 bg-card/50 backdrop-blur-sm h-full flex flex-col transition-colors">
|
||||
<div className="p-4 border-b border-border/50 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground">Review Changes</h2>
|
||||
<span className="text-xs bg-muted text-muted-foreground px-2 py-1 rounded-full">
|
||||
{annotations.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{sortedAnnotations.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground mt-10 text-sm">
|
||||
<p>No annotations yet.</p>
|
||||
<p className="mt-2 text-xs">Select text in the document to add comments or suggest changes.</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedAnnotations.map(ann => (
|
||||
<div
|
||||
key={ann.id}
|
||||
onClick={() => onSelect(ann.id)}
|
||||
className={`
|
||||
group relative p-3 rounded-lg border transition-all cursor-pointer
|
||||
${selectedId === ann.id
|
||||
? 'bg-accent/20 border-accent shadow-sm'
|
||||
: 'bg-card border-border hover:border-accent hover:shadow-sm'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<span className={`
|
||||
text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded
|
||||
${ann.type === AnnotationType.DELETION ? 'bg-destructive/20 text-destructive' :
|
||||
ann.type === AnnotationType.INSERTION ? 'bg-secondary/20 text-secondary' :
|
||||
ann.type === AnnotationType.REPLACEMENT ? 'bg-primary/20 text-primary' :
|
||||
'bg-accent/20 text-accent'}
|
||||
`}>
|
||||
{ann.type}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(ann.id); }}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-all p-1 rounded hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title="Remove Annotation"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground mb-2 font-mono bg-muted p-2 rounded-lg text-xs truncate">
|
||||
"{ann.originalText}"
|
||||
</div>
|
||||
|
||||
{(ann.text && ann.type !== AnnotationType.DELETION) && (
|
||||
<div className="text-sm text-foreground font-medium pl-3 border-l-2 border-primary">
|
||||
{ann.type === AnnotationType.REPLACEMENT ? '→ ' : ''}{ann.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface DecisionBarProps {
|
||||
onApprove: () => Promise<void>;
|
||||
onDeny: (feedback: string) => Promise<void>;
|
||||
annotationCount: number;
|
||||
getFeedback: () => string;
|
||||
}
|
||||
|
||||
export const DecisionBar: React.FC<DecisionBarProps> = ({
|
||||
onApprove,
|
||||
onDeny,
|
||||
annotationCount,
|
||||
getFeedback
|
||||
}) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null);
|
||||
|
||||
const handleApprove = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onApprove();
|
||||
setSubmitted('approved');
|
||||
} catch (error) {
|
||||
console.error('Failed to approve:', error);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeny = async () => {
|
||||
const feedback = getFeedback();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onDeny(feedback);
|
||||
setSubmitted('denied');
|
||||
} catch (error) {
|
||||
console.error('Failed to deny:', error);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-background flex items-center justify-center">
|
||||
<div className="text-center space-y-6 max-w-md px-8">
|
||||
{/* Icon */}
|
||||
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${
|
||||
submitted === 'approved'
|
||||
? 'bg-green-500/20 text-green-500'
|
||||
: 'bg-accent/20 text-accent'
|
||||
}`}>
|
||||
{submitted === 'approved' ? (
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{submitted === 'approved' ? 'Plan Approved' : 'Feedback Sent'}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{submitted === 'approved'
|
||||
? 'Claude will proceed with the implementation.'
|
||||
: 'Claude will revise the plan based on your annotations.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Instruction */}
|
||||
<div className="pt-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Return to your <span className="text-foreground font-medium">Claude Code terminal</span> to continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 p-4 bg-card/95 backdrop-blur-xl border-t border-border z-50">
|
||||
<div className="max-w-3xl mx-auto flex items-center gap-4">
|
||||
{/* Status info */}
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{annotationCount > 0 ? (
|
||||
<span>{annotationCount} annotation{annotationCount !== 1 ? 's' : ''} to send as feedback</span>
|
||||
) : (
|
||||
<span>Review the plan, then approve or request changes</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDeny}
|
||||
disabled={isSubmitting}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-all
|
||||
${isSubmitting
|
||||
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
|
||||
: 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/30'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Request Changes'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={isSubmitting}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-all
|
||||
${isSubmitting
|
||||
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
|
||||
: 'bg-green-600 text-white hover:bg-green-500'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSubmitting ? 'Approving...' : 'Approve Plan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Export Modal with tabs for Share and Raw Diff
|
||||
*
|
||||
* Share tab (default): Shows shareable URL with copy button
|
||||
* Raw Diff tab: Shows human-readable diff output with copy/download
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface ExportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
shareUrl: string;
|
||||
shareUrlSize: string;
|
||||
diffOutput: string;
|
||||
annotationCount: number;
|
||||
taterSprite?: React.ReactNode;
|
||||
}
|
||||
|
||||
type Tab = 'share' | 'diff';
|
||||
|
||||
export const ExportModal: React.FC<ExportModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
shareUrl,
|
||||
shareUrlSize,
|
||||
diffOutput,
|
||||
annotationCount,
|
||||
taterSprite,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('share');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (e) {
|
||||
console.error('Failed to copy:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyDiff = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(diffOutput);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (e) {
|
||||
console.error('Failed to copy:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadDiff = () => {
|
||||
const blob = new Blob([diffOutput], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'annotations.diff';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<div
|
||||
className="bg-card border border-border rounded-xl w-full max-w-2xl flex flex-col max-h-[80vh] shadow-2xl relative"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{taterSprite}
|
||||
|
||||
{/* Header with tabs */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-semibold text-sm">Export</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{annotationCount} annotation{annotationCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('share')}
|
||||
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
activeTab === 'share'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('diff')}
|
||||
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
activeTab === 'diff'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Raw Diff
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{activeTab === 'share' ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-2">
|
||||
Shareable URL
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<textarea
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
className="w-full h-32 bg-muted rounded-lg p-3 pr-20 text-xs font-mono resize-none focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
onClick={e => (e.target as HTMLTextAreaElement).select()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
className="absolute top-2 right-2 px-2 py-1 rounded text-xs font-medium bg-background/80 hover:bg-background border border-border/50 transition-colors flex items-center gap-1"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<div className="absolute bottom-2 right-2 text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{shareUrlSize}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This URL contains the full plan and all annotations. Anyone with this link can view and add to your annotations.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="bg-muted rounded-lg p-4 text-xs font-mono leading-relaxed overflow-x-auto whitespace-pre-wrap">
|
||||
{diffOutput}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions - only show for Raw Diff tab */}
|
||||
{activeTab === 'diff' && (
|
||||
<div className="p-4 border-t border-border flex justify-end gap-2">
|
||||
<button
|
||||
onClick={handleCopyDiff}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-medium bg-muted hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownloadDiff}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Download .diff
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,273 @@
|
||||
import React from 'react';
|
||||
import logoImage from '../plannotator.webp';
|
||||
|
||||
interface LandingProps {
|
||||
onEnter: () => void;
|
||||
}
|
||||
|
||||
export const Landing: React.FC<LandingProps> = ({ onEnter }) => {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
{/* Nav */}
|
||||
<nav className="fixed top-0 left-0 right-0 h-12 flex items-center justify-between px-6 bg-background/80 backdrop-blur-sm border-b border-border/30 z-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md bg-primary/20 flex items-center justify-center">
|
||||
<svg className="w-3.5 h-3.5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tracking-tight">Plannotator</span>
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/anthropics/claude-code"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Claude Code Docs
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{/* Content wrapper with vertical borders */}
|
||||
<div className="max-w-4xl mx-auto border-x border-border/30">
|
||||
{/* Hero */}
|
||||
<section className="pt-32 pb-20 px-8">
|
||||
<div className="max-w-2xl">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium mb-6">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||
For Claude Code
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 mb-4">
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight leading-[1.1]">
|
||||
Annotate plans.<br />
|
||||
<span className="text-muted-foreground">Not in the terminal.</span>
|
||||
</h1>
|
||||
<img
|
||||
src={logoImage}
|
||||
alt="Plannotator logo"
|
||||
className="w-20 h-20 md:w-24 md:h-24 object-contain shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-lg text-muted-foreground mb-8 max-w-lg">
|
||||
A visual interface for reviewing Claude Code plans. Select text, add feedback, export structured annotations that Claude understands.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={onEnter}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Open Editor
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Problem → Solution */}
|
||||
<section className="py-16 px-8 border-t border-border/30">
|
||||
<div className="grid md:grid-cols-2 gap-8 max-w-2xl">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">The Problem</h3>
|
||||
<p className="text-foreground/90">
|
||||
Claude Code shows plans in the terminal. You read them, approve or deny, but giving specific feedback means typing everything out. Hard to reference exact sections.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">The Solution</h3>
|
||||
<p className="text-foreground/90">
|
||||
Select the exact text you want to change. Mark it for deletion, add a comment, or suggest a replacement. Export as structured feedback Claude can act on.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Privacy */}
|
||||
<section className="py-16 px-8 border-t border-border/30">
|
||||
<div className="flex items-start gap-4 max-w-2xl">
|
||||
<div className="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center shrink-0">
|
||||
<svg className="w-5 h-5 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Runs locally. Your data stays private.</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No server. No network requests. Plannotator is a lightweight, local HTML page that runs in your browser. Plans never leave your machine.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Preview */}
|
||||
<section className="py-16 px-8 border-t border-border/30">
|
||||
<h2 className="text-xl font-semibold mb-8 text-center">What it looks like</h2>
|
||||
|
||||
{/* App Preview */}
|
||||
<div className="rounded-xl border border-border/50 bg-card/50 overflow-hidden shadow-2xl">
|
||||
{/* Mock Header */}
|
||||
<div className="h-10 bg-card/80 border-b border-border/30 flex items-center px-4 gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-destructive/60" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-accent/60" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-secondary/60" />
|
||||
</div>
|
||||
<div className="flex-1 flex justify-center">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="w-4 h-4 rounded bg-primary/20" />
|
||||
<span>Plannotator</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-16" />
|
||||
</div>
|
||||
|
||||
{/* Mock Content */}
|
||||
<div className="flex h-80">
|
||||
{/* Document Area */}
|
||||
<div className="flex-1 p-6 bg-background/50 flex items-start justify-center">
|
||||
<div className="w-full max-w-sm bg-card rounded-lg border border-border/30 p-5 shadow-lg">
|
||||
{/* Mock heading */}
|
||||
<div className="h-3 w-3/4 bg-foreground/80 rounded mb-4" />
|
||||
|
||||
{/* Mock paragraph */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="h-2 w-full bg-muted-foreground/30 rounded" />
|
||||
<div className="h-2 w-5/6 bg-muted-foreground/30 rounded" />
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-1/4 bg-muted-foreground/30 rounded" />
|
||||
<div className="h-2 w-1/3 bg-destructive/40 rounded line-through" />
|
||||
<div className="h-2 w-1/6 bg-muted-foreground/30 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mock subheading */}
|
||||
<div className="h-2.5 w-1/2 bg-foreground/60 rounded mb-3 mt-5" />
|
||||
|
||||
{/* Mock list */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1 h-1 rounded-full bg-primary/60" />
|
||||
<div className="h-2 w-4/5 bg-muted-foreground/30 rounded" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1 h-1 rounded-full bg-primary/60" />
|
||||
<div className="h-2 w-2/3 bg-accent/40 rounded border-b border-accent" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1 h-1 rounded-full bg-primary/60" />
|
||||
<div className="h-2 w-3/4 bg-muted-foreground/30 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mock code block */}
|
||||
<div className="bg-muted/50 rounded p-2 space-y-1.5">
|
||||
<div className="h-1.5 w-1/2 bg-primary/40 rounded" />
|
||||
<div className="h-1.5 w-2/3 bg-muted-foreground/30 rounded" />
|
||||
<div className="h-1.5 w-1/3 bg-secondary/40 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mock Sidebar */}
|
||||
<div className="w-48 border-l border-border/30 bg-card/30 p-3">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 flex items-center justify-between">
|
||||
<span>Annotations</span>
|
||||
<span className="bg-muted px-1.5 py-0.5 rounded text-[9px]">2</span>
|
||||
</div>
|
||||
|
||||
{/* Mock annotation cards */}
|
||||
<div className="space-y-2">
|
||||
<div className="p-2 rounded-md border border-destructive/30 bg-destructive/5">
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<div className="w-3 h-3 rounded bg-destructive/20" />
|
||||
<span className="text-[9px] font-semibold text-destructive uppercase">Delete</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-muted rounded" />
|
||||
</div>
|
||||
|
||||
<div className="p-2 rounded-md border border-accent/30 bg-accent/5">
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<div className="w-3 h-3 rounded bg-accent/20" />
|
||||
<span className="text-[9px] font-semibold text-accent uppercase">Comment</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-3/4 bg-muted rounded mb-1" />
|
||||
<div className="h-1.5 w-full bg-accent/20 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground mt-6">
|
||||
Select text → annotate → export feedback
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section className="py-16 px-8 border-t border-border/30 bg-card/30">
|
||||
<h2 className="text-xl font-semibold mb-8">How it works</h2>
|
||||
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<Step num={1} title="Hook into ExitPlanMode">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
|
||||
ExitPlanMode → opens Plannotator with plan content
|
||||
</code>
|
||||
</Step>
|
||||
|
||||
<Step num={2} title="Annotate visually">
|
||||
Select text → choose action (delete, comment) → annotations appear in the sidebar
|
||||
</Step>
|
||||
|
||||
<Step num={3} title="Export feedback">
|
||||
Structured markdown that Claude Code understands. Paste it back, Claude revises the plan.
|
||||
</Step>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Technical */}
|
||||
<section className="py-16 px-8 border-t border-border/30 bg-card/30">
|
||||
<h2 className="text-xl font-semibold mb-6">Technical details</h2>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground max-w-2xl">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-primary">•</span>
|
||||
Single HTML file build — easy to distribute
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-primary">•</span>
|
||||
Uses Claude Code hooks (PreToolUse/PostToolUse)
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-primary">•</span>
|
||||
Plan content injected at runtime via shell script
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-primary">•</span>
|
||||
React + Tailwind + Vite — hackable
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-8 px-8 border-t border-border/30 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Built for Claude Code users who want better plan reviews.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Step: React.FC<{ num: number; title: string; children: React.ReactNode }> = ({ num, title, children }) => (
|
||||
<div className="flex gap-4">
|
||||
<div className="w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-semibold flex items-center justify-center shrink-0">
|
||||
{num}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-1">{title}</h4>
|
||||
<p className="text-sm text-muted-foreground">{children}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useState } from 'react';
|
||||
import { EditorMode } from '../types';
|
||||
import { TaterSpritePullup } from './TaterSpritePullup';
|
||||
|
||||
interface ModeSwitcherProps {
|
||||
mode: EditorMode;
|
||||
onChange: (mode: EditorMode) => void;
|
||||
taterMode?: boolean;
|
||||
}
|
||||
|
||||
export const ModeSwitcher: React.FC<ModeSwitcherProps> = ({ mode, onChange, taterMode }) => {
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="inline-flex items-center bg-muted/50 rounded-lg p-0.5 border border-border/30">
|
||||
<ModeButton
|
||||
active={mode === 'selection'}
|
||||
onClick={() => onChange('selection')}
|
||||
icon={
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 20h-1a2 2 0 0 1-2-2 2 2 0 0 1-2 2H6"/>
|
||||
<path d="M13 8h7a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-7"/>
|
||||
<path d="M5 16H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h1"/>
|
||||
<path d="M6 4h1a2 2 0 0 1 2 2 2 2 0 0 1 2-2h1"/>
|
||||
<path d="M9 6v12"/>
|
||||
</svg>
|
||||
}
|
||||
label="Selection"
|
||||
/>
|
||||
<ModeButton
|
||||
active={mode === 'redline'}
|
||||
onClick={() => onChange('redline')}
|
||||
icon={
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9.75L14.25 12m0 0l2.25 2.25M14.25 12l2.25-2.25M14.25 12L12 14.25m-2.58 4.92l-6.375-6.375a1.125 1.125 0 010-1.59L9.42 4.83c.211-.211.498-.33.796-.33H19.5a2.25 2.25 0 012.25 2.25v10.5a2.25 2.25 0 01-2.25 2.25h-9.284c-.298 0-.585-.119-.796-.33z" />
|
||||
</svg>
|
||||
}
|
||||
label="Redline"
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Help link */}
|
||||
<button
|
||||
onClick={() => setShowHelp(true)}
|
||||
className="ml-2 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
how does this work?
|
||||
</button>
|
||||
|
||||
{/* Help Video Dialog */}
|
||||
{showHelp && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4"
|
||||
onClick={() => setShowHelp(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-card border border-border rounded-xl w-full max-w-2xl shadow-2xl overflow-hidden relative"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{taterMode && <TaterSpritePullup />}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h3 className="font-semibold text-sm">How Plannotator Works</h3>
|
||||
<button
|
||||
onClick={() => setShowHelp(false)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="aspect-video">
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
src="https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1"
|
||||
title="How Plannotator Works"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ModeButton: React.FC<{
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
destructive?: boolean;
|
||||
}> = ({ active, onClick, icon, label, destructive }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
active
|
||||
? destructive
|
||||
? 'bg-destructive/15 text-destructive shadow-sm'
|
||||
: 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTheme } from './ThemeProvider';
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title="Toggle theme"
|
||||
>
|
||||
{/* Sun */}
|
||||
<svg
|
||||
className="w-4 h-4 hidden light:block"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
{/* Moon */}
|
||||
<svg
|
||||
className="w-4 h-4 light:hidden"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-1 w-32 rounded-lg border border-border bg-popover shadow-xl z-50 overflow-hidden py-1">
|
||||
{(['light', 'dark', 'system'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { setTheme(t); setIsOpen(false); }}
|
||||
className={`w-full px-3 py-1.5 text-left text-xs capitalize transition-colors ${
|
||||
theme === t
|
||||
? 'text-primary bg-primary/10 font-medium'
|
||||
: 'text-popover-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { TaterSpritePullup } from './TaterSpritePullup';
|
||||
import { getIdentity, regenerateIdentity } from '../utils/identity';
|
||||
|
||||
interface SettingsProps {
|
||||
taterMode: boolean;
|
||||
onTaterModeChange: (enabled: boolean) => void;
|
||||
onIdentityChange?: (oldIdentity: string, newIdentity: string) => void;
|
||||
}
|
||||
|
||||
export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange }) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [identity, setIdentity] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (showDialog) {
|
||||
setIdentity(getIdentity());
|
||||
}
|
||||
}, [showDialog]);
|
||||
|
||||
const handleRegenerateIdentity = () => {
|
||||
const oldIdentity = identity;
|
||||
const newIdentity = regenerateIdentity();
|
||||
setIdentity(newIdentity);
|
||||
onIdentityChange?.(oldIdentity, newIdentity);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowDialog(true)}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title="Settings"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showDialog && createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4"
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-card border border-border rounded-xl w-full max-w-sm shadow-2xl relative"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{taterMode && <TaterSpritePullup />}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h3 className="font-semibold text-sm">Settings</h3>
|
||||
<button
|
||||
onClick={() => setShowDialog(false)}
|
||||
className="p-1.5 rounded-md bg-muted hover:bg-muted/80 text-foreground transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Identity Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Your Identity</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Used when sharing annotations with others
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 px-3 py-2 bg-muted rounded-lg text-xs font-mono truncate">
|
||||
{identity}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRegenerateIdentity}
|
||||
className="p-2 rounded-lg bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Regenerate identity"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Tater Mode */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Tater Mode</div>
|
||||
<div className="text-xs text-muted-foreground">Show animated sprites</div>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={taterMode}
|
||||
onClick={() => onTaterModeChange(!taterMode)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
taterMode ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
||||
taterMode ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import spriteSheet from '../sprite_package_pulluphang/sprite.png';
|
||||
|
||||
// Sprite specs: 96x96 native, 24 frames, 3 seconds
|
||||
const NATIVE_SIZE = 96;
|
||||
const DISPLAY_SIZE = 56;
|
||||
const FRAMES = 24;
|
||||
const SCALE = DISPLAY_SIZE / NATIVE_SIZE;
|
||||
const TOTAL_WIDTH = NATIVE_SIZE * FRAMES * SCALE;
|
||||
|
||||
export const TaterSpritePullup: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none hidden lg:block"
|
||||
style={{
|
||||
top: 56, // Hangs from bottom of dialog header
|
||||
left: 8,
|
||||
width: DISPLAY_SIZE,
|
||||
height: DISPLAY_SIZE,
|
||||
backgroundImage: `url(${spriteSheet})`,
|
||||
backgroundSize: `${TOTAL_WIDTH}px ${DISPLAY_SIZE}px`,
|
||||
animation: 'tater-pullup 3.5s steps(24) infinite',
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes tater-pullup {
|
||||
to { background-position: -${TOTAL_WIDTH}px 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import spriteSheet from '../sprite_package_additional/sprite.png';
|
||||
|
||||
// Sprite: 176x96 native, 24 frames, 5 second animation cycle
|
||||
// Running across screen should sync with animation speed
|
||||
const NATIVE_WIDTH = 176;
|
||||
const NATIVE_HEIGHT = 96;
|
||||
const DISPLAY_HEIGHT = 64;
|
||||
const SCALE = DISPLAY_HEIGHT / NATIVE_HEIGHT;
|
||||
const DISPLAY_WIDTH = NATIVE_WIDTH * SCALE; // ~117px
|
||||
const FRAMES = 24;
|
||||
const FRAME_DURATION = 5; // seconds for full run cycle
|
||||
const TOTAL_SPRITE_WIDTH = NATIVE_WIDTH * FRAMES * SCALE;
|
||||
|
||||
// Screen traverse: character runs across in ~12 seconds
|
||||
// This gives ~2.4 run cycles per screen width, feels natural
|
||||
const SCREEN_TRAVERSE_TIME = 18;
|
||||
|
||||
export const TaterSpriteRunning: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
className="fixed pointer-events-none hidden lg:block"
|
||||
style={{
|
||||
bottom: 0,
|
||||
right: -DISPLAY_WIDTH, // Start off-screen right
|
||||
width: DISPLAY_WIDTH,
|
||||
height: DISPLAY_HEIGHT,
|
||||
zIndex: 40, // Below plan document (z-50) but above other elements
|
||||
backgroundImage: `url(${spriteSheet})`,
|
||||
backgroundSize: `${TOTAL_SPRITE_WIDTH}px ${DISPLAY_HEIGHT}px`,
|
||||
backgroundPosition: 'left center',
|
||||
imageRendering: 'pixelated',
|
||||
animation: `tater-run-sprite ${FRAME_DURATION}s steps(${FRAMES}) infinite, tater-run-across ${SCREEN_TRAVERSE_TIME}s linear infinite`,
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes tater-run-sprite {
|
||||
to {
|
||||
background-position: -${TOTAL_SPRITE_WIDTH}px 0;
|
||||
}
|
||||
}
|
||||
@keyframes tater-run-across {
|
||||
from {
|
||||
right: -${DISPLAY_WIDTH}px;
|
||||
}
|
||||
to {
|
||||
right: 100vw;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import spriteSheet from '../sprite_package_new/sprite.png';
|
||||
|
||||
// Sprite: 96x96 native, 12 frames, scaled to 64x64 for display
|
||||
const NATIVE_SIZE = 96;
|
||||
const DISPLAY_SIZE = 64;
|
||||
const FRAMES = 12;
|
||||
const SCALE = DISPLAY_SIZE / NATIVE_SIZE;
|
||||
const TOTAL_WIDTH = NATIVE_SIZE * FRAMES * SCALE; // 768px at 64px scale
|
||||
|
||||
export const TaterSpriteSitting: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
className="hidden lg:block absolute pointer-events-none"
|
||||
style={{
|
||||
top: -40,
|
||||
right: -4,
|
||||
width: DISPLAY_SIZE,
|
||||
height: DISPLAY_SIZE,
|
||||
backgroundImage: `url(${spriteSheet})`,
|
||||
backgroundSize: `${TOTAL_WIDTH}px ${DISPLAY_SIZE}px`,
|
||||
backgroundPosition: 'left center',
|
||||
animation: 'tater-sit 3s steps(12) infinite',
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes tater-sit {
|
||||
to {
|
||||
background-position: -${TOTAL_WIDTH}px 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system';
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>({
|
||||
theme: 'dark',
|
||||
setTheme: () => null,
|
||||
});
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'dark',
|
||||
storageKey = 'plannotator-theme',
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
// Remove light class (dark is default, no class needed)
|
||||
root.classList.remove('light');
|
||||
|
||||
let effectiveTheme = theme;
|
||||
if (theme === 'system') {
|
||||
effectiveTheme = window.matchMedia('(prefers-color-scheme: light)').matches
|
||||
? 'light'
|
||||
: 'dark';
|
||||
}
|
||||
|
||||
// Only add 'light' class when in light mode
|
||||
if (effectiveTheme === 'light') {
|
||||
root.classList.add('light');
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)');
|
||||
const handleChange = () => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light');
|
||||
if (mediaQuery.matches) {
|
||||
root.classList.add('light');
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (newTheme: Theme) => {
|
||||
localStorage.setItem(storageKey, newTheme);
|
||||
setTheme(newTheme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { AnnotationType } from '../types';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ToolbarProps {
|
||||
highlightElement: HTMLElement | null;
|
||||
onAnnotate: (type: AnnotationType, text?: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const Toolbar: React.FC<ToolbarProps> = ({ highlightElement, onAnnotate, onClose }) => {
|
||||
const [step, setStep] = useState<'menu' | 'input'>('menu');
|
||||
const [activeType, setActiveType] = useState<AnnotationType | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === 'input') inputRef.current?.focus();
|
||||
}, [step]);
|
||||
|
||||
useEffect(() => {
|
||||
setStep('menu');
|
||||
setActiveType(null);
|
||||
setInputValue('');
|
||||
}, [highlightElement]);
|
||||
|
||||
// Update position on scroll/resize
|
||||
useEffect(() => {
|
||||
if (!highlightElement) {
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePosition = () => {
|
||||
const rect = highlightElement.getBoundingClientRect();
|
||||
const toolbarTop = rect.top - 48;
|
||||
|
||||
// If selection scrolled out of viewport, close the toolbar
|
||||
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
setPosition({
|
||||
top: toolbarTop,
|
||||
left: rect.left + rect.width / 2,
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, [highlightElement, onClose]);
|
||||
|
||||
if (!highlightElement || !position) return null;
|
||||
|
||||
const { top, left } = position;
|
||||
|
||||
const handleTypeSelect = (type: AnnotationType) => {
|
||||
if (type === AnnotationType.DELETION) {
|
||||
onAnnotate(type);
|
||||
} else {
|
||||
setActiveType(type);
|
||||
setStep('input');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (activeType && inputValue.trim()) {
|
||||
onAnnotate(activeType, inputValue);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="annotation-toolbar fixed z-[100] bg-popover border border-border rounded-lg shadow-2xl transform -translate-x-1/2 animate-in fade-in slide-in-from-bottom-2 duration-150"
|
||||
style={{ top, left }}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
{step === 'menu' ? (
|
||||
<div className="flex items-center p-1 gap-0.5">
|
||||
<ToolbarButton
|
||||
onClick={() => handleTypeSelect(AnnotationType.DELETION)}
|
||||
icon={
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
}
|
||||
label="Delete"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => handleTypeSelect(AnnotationType.COMMENT)}
|
||||
icon={
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
}
|
||||
label="Comment"
|
||||
className="text-accent hover:bg-accent/10"
|
||||
/>
|
||||
<div className="w-px h-5 bg-border mx-0.5" />
|
||||
<ToolbarButton
|
||||
onClick={onClose}
|
||||
icon={
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
}
|
||||
label="Cancel"
|
||||
className="text-muted-foreground hover:bg-muted"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-1.5 p-1.5 pl-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="bg-transparent border-none outline-none text-sm w-44 placeholder:text-muted-foreground"
|
||||
placeholder="Add a comment..."
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Escape' && setStep('menu')}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!inputValue.trim()}
|
||||
className="px-2 py-1 text-xs font-medium rounded bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('menu')}
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
const ToolbarButton: React.FC<{
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
className: string;
|
||||
}> = ({ onClick, icon, label, className }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
className={`p-1.5 rounded-md transition-colors ${className}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,747 @@
|
||||
import React, { useRef, useState, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Highlighter from 'web-highlighter';
|
||||
import { Block, Annotation, AnnotationType, EditorMode } from '../types';
|
||||
import { Toolbar } from './Toolbar';
|
||||
import { TaterSpriteSitting } from './TaterSpriteSitting';
|
||||
import { getIdentity } from '../utils/identity';
|
||||
|
||||
declare const hljs: { highlightAll: () => void };
|
||||
|
||||
interface ViewerProps {
|
||||
blocks: Block[];
|
||||
annotations: Annotation[];
|
||||
onAddAnnotation: (ann: Annotation) => void;
|
||||
onSelectAnnotation: (id: string | null) => void;
|
||||
selectedAnnotationId: string | null;
|
||||
mode: EditorMode;
|
||||
taterMode: boolean;
|
||||
}
|
||||
|
||||
export interface ViewerHandle {
|
||||
removeHighlight: (id: string) => void;
|
||||
clearAllHighlights: () => void;
|
||||
applySharedAnnotations: (annotations: Annotation[]) => void;
|
||||
}
|
||||
|
||||
export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
||||
blocks,
|
||||
annotations,
|
||||
onAddAnnotation,
|
||||
onSelectAnnotation,
|
||||
selectedAnnotationId,
|
||||
mode,
|
||||
taterMode
|
||||
}, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const highlighterRef = useRef<Highlighter | null>(null);
|
||||
const modeRef = useRef<EditorMode>(mode);
|
||||
const onAddAnnotationRef = useRef(onAddAnnotation);
|
||||
const pendingSourceRef = useRef<any>(null);
|
||||
const [toolbarState, setToolbarState] = useState<{ element: HTMLElement; source: any } | null>(null);
|
||||
const [hoveredCodeBlock, setHoveredCodeBlock] = useState<{ block: Block; element: HTMLElement } | null>(null);
|
||||
const [isCodeBlockToolbarExiting, setIsCodeBlockToolbarExiting] = useState(false);
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Keep refs in sync with props
|
||||
useEffect(() => {
|
||||
modeRef.current = mode;
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
onAddAnnotationRef.current = onAddAnnotation;
|
||||
}, [onAddAnnotation]);
|
||||
|
||||
// Helper to create annotation from highlighter source
|
||||
const createAnnotationFromSource = (
|
||||
highlighter: Highlighter,
|
||||
source: any,
|
||||
type: AnnotationType,
|
||||
text?: string
|
||||
) => {
|
||||
const doms = highlighter.getDoms(source.id);
|
||||
let blockId = '';
|
||||
let startOffset = 0;
|
||||
|
||||
if (doms?.length > 0) {
|
||||
const el = doms[0] as HTMLElement;
|
||||
let parent = el.parentElement;
|
||||
while (parent && !parent.dataset.blockId) {
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
if (parent?.dataset.blockId) {
|
||||
blockId = parent.dataset.blockId;
|
||||
const blockText = parent.textContent || '';
|
||||
const beforeText = blockText.split(source.text)[0];
|
||||
startOffset = beforeText?.length || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const newAnnotation: Annotation = {
|
||||
id: source.id,
|
||||
blockId,
|
||||
startOffset,
|
||||
endOffset: startOffset + source.text.length,
|
||||
type,
|
||||
text,
|
||||
originalText: source.text,
|
||||
createdA: Date.now(),
|
||||
author: getIdentity(),
|
||||
startMeta: source.startMeta,
|
||||
endMeta: source.endMeta,
|
||||
};
|
||||
|
||||
if (type === AnnotationType.DELETION) {
|
||||
highlighter.addClass('deletion', source.id);
|
||||
} else if (type === AnnotationType.COMMENT) {
|
||||
highlighter.addClass('comment', source.id);
|
||||
}
|
||||
|
||||
onAddAnnotationRef.current(newAnnotation);
|
||||
};
|
||||
|
||||
// Helper to find text in DOM and create a range
|
||||
const findTextInDOM = useCallback((searchText: string): Range | null => {
|
||||
if (!containerRef.current) return null;
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
containerRef.current,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null
|
||||
);
|
||||
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
const text = node.textContent || '';
|
||||
const index = text.indexOf(searchText);
|
||||
if (index !== -1) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, index);
|
||||
range.setEnd(node, index + searchText.length);
|
||||
return range;
|
||||
}
|
||||
}
|
||||
|
||||
// Try across multiple text nodes for multi-line content
|
||||
const fullText = containerRef.current.textContent || '';
|
||||
const searchIndex = fullText.indexOf(searchText);
|
||||
if (searchIndex === -1) return null;
|
||||
|
||||
// Use Selection API to find and select the text
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return null;
|
||||
|
||||
// Reset walker
|
||||
const walker2 = document.createTreeWalker(
|
||||
containerRef.current,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null
|
||||
);
|
||||
|
||||
let charCount = 0;
|
||||
let startNode: Text | null = null;
|
||||
let startOffset = 0;
|
||||
let endNode: Text | null = null;
|
||||
let endOffset = 0;
|
||||
|
||||
while ((node = walker2.nextNode() as Text | null)) {
|
||||
const nodeLength = node.textContent?.length || 0;
|
||||
|
||||
if (!startNode && charCount + nodeLength > searchIndex) {
|
||||
startNode = node;
|
||||
startOffset = searchIndex - charCount;
|
||||
}
|
||||
|
||||
if (startNode && charCount + nodeLength >= searchIndex + searchText.length) {
|
||||
endNode = node;
|
||||
endOffset = searchIndex + searchText.length - charCount;
|
||||
break;
|
||||
}
|
||||
|
||||
charCount += nodeLength;
|
||||
}
|
||||
|
||||
if (startNode && endNode) {
|
||||
const range = document.createRange();
|
||||
range.setStart(startNode, startOffset);
|
||||
range.setEnd(endNode, endOffset);
|
||||
return range;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
removeHighlight: (id: string) => {
|
||||
// Try highlighter first (for regular text selections)
|
||||
highlighterRef.current?.remove(id);
|
||||
|
||||
// Also handle manually created highlights (for code blocks)
|
||||
const manualHighlight = containerRef.current?.querySelector(`[data-bind-id="${id}"]`);
|
||||
if (manualHighlight) {
|
||||
// Unwrap the mark element, preserving its contents
|
||||
const parent = manualHighlight.parentNode;
|
||||
while (manualHighlight.firstChild) {
|
||||
parent?.insertBefore(manualHighlight.firstChild, manualHighlight);
|
||||
}
|
||||
manualHighlight.remove();
|
||||
}
|
||||
},
|
||||
|
||||
clearAllHighlights: () => {
|
||||
// Clear all manual highlights (shared annotations and code blocks)
|
||||
const manualHighlights = containerRef.current?.querySelectorAll('[data-bind-id]');
|
||||
manualHighlights?.forEach(el => {
|
||||
const parent = el.parentNode;
|
||||
while (el.firstChild) {
|
||||
parent?.insertBefore(el.firstChild, el);
|
||||
}
|
||||
el.remove();
|
||||
});
|
||||
|
||||
// Clear web-highlighter highlights
|
||||
const webHighlights = containerRef.current?.querySelectorAll('.annotation-highlight');
|
||||
webHighlights?.forEach(el => {
|
||||
const parent = el.parentNode;
|
||||
while (el.firstChild) {
|
||||
parent?.insertBefore(el.firstChild, el);
|
||||
}
|
||||
el.remove();
|
||||
});
|
||||
},
|
||||
|
||||
applySharedAnnotations: (sharedAnnotations: Annotation[]) => {
|
||||
const highlighter = highlighterRef.current;
|
||||
if (!highlighter || !containerRef.current) return;
|
||||
|
||||
sharedAnnotations.forEach(ann => {
|
||||
// Skip if already highlighted
|
||||
const existingDoms = highlighter.getDoms(ann.id);
|
||||
if (existingDoms && existingDoms.length > 0) return;
|
||||
|
||||
// Also skip if manually highlighted
|
||||
const existingManual = containerRef.current?.querySelector(`[data-bind-id="${ann.id}"]`);
|
||||
if (existingManual) return;
|
||||
|
||||
// Find the text in the DOM
|
||||
const range = findTextInDOM(ann.originalText);
|
||||
if (!range) {
|
||||
console.warn(`Could not find text for annotation ${ann.id}: "${ann.originalText.slice(0, 50)}..."`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a unique ID for this restored highlight
|
||||
const newId = ann.id;
|
||||
|
||||
// Wrap the range with a mark element
|
||||
const wrapper = document.createElement('mark');
|
||||
wrapper.className = 'annotation-highlight';
|
||||
wrapper.dataset.bindId = newId;
|
||||
|
||||
// Add type-specific class
|
||||
if (ann.type === AnnotationType.DELETION) {
|
||||
wrapper.classList.add('deletion');
|
||||
} else if (ann.type === AnnotationType.COMMENT) {
|
||||
wrapper.classList.add('comment');
|
||||
}
|
||||
|
||||
// surroundContents can fail if range spans multiple elements
|
||||
// In that case, we need to use extractContents + appendChild
|
||||
try {
|
||||
range.surroundContents(wrapper);
|
||||
} catch (e) {
|
||||
// Range spans multiple elements - extract and wrap
|
||||
const fragment = range.extractContents();
|
||||
wrapper.appendChild(fragment);
|
||||
range.insertNode(wrapper);
|
||||
}
|
||||
|
||||
// Make it clickable
|
||||
wrapper.addEventListener('click', () => {
|
||||
onSelectAnnotation(ann.id);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Failed to apply highlight for annotation ${ann.id}:`, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}), [findTextInDOM, onSelectAnnotation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const highlighter = new Highlighter({
|
||||
$root: containerRef.current,
|
||||
exceptSelectors: ['.annotation-toolbar', 'button'],
|
||||
wrapTag: 'mark',
|
||||
style: { className: 'annotation-highlight' }
|
||||
});
|
||||
|
||||
highlighterRef.current = highlighter;
|
||||
|
||||
highlighter.on(Highlighter.event.CREATE, ({ sources }: { sources: any[] }) => {
|
||||
if (sources.length > 0) {
|
||||
const source = sources[0];
|
||||
const doms = highlighter.getDoms(source.id);
|
||||
if (doms?.length > 0) {
|
||||
// Clean up previous pending highlight if exists
|
||||
if (pendingSourceRef.current) {
|
||||
highlighter.remove(pendingSourceRef.current.id);
|
||||
pendingSourceRef.current = null;
|
||||
}
|
||||
|
||||
if (modeRef.current === 'redline') {
|
||||
// Auto-delete in redline mode
|
||||
createAnnotationFromSource(highlighter, source, AnnotationType.DELETION);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
} else {
|
||||
// Show toolbar in selection mode
|
||||
pendingSourceRef.current = source;
|
||||
setToolbarState({ element: doms[0] as HTMLElement, source });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
highlighter.on(Highlighter.event.CLICK, ({ id }: { id: string }) => {
|
||||
onSelectAnnotation(id);
|
||||
});
|
||||
|
||||
highlighter.run();
|
||||
|
||||
return () => highlighter.dispose();
|
||||
}, [onSelectAnnotation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof hljs !== 'undefined') {
|
||||
hljs.highlightAll();
|
||||
}
|
||||
}, [blocks]);
|
||||
|
||||
useEffect(() => {
|
||||
const highlighter = highlighterRef.current;
|
||||
if (!highlighter) return;
|
||||
|
||||
annotations.forEach(ann => {
|
||||
try {
|
||||
const doms = highlighter.getDoms(ann.id);
|
||||
if (doms?.length > 0) {
|
||||
if (ann.type === AnnotationType.DELETION) {
|
||||
highlighter.addClass('deletion', ann.id);
|
||||
} else if (ann.type === AnnotationType.COMMENT) {
|
||||
highlighter.addClass('comment', ann.id);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
}, [annotations]);
|
||||
|
||||
const handleAnnotate = (type: AnnotationType, text?: string) => {
|
||||
const highlighter = highlighterRef.current;
|
||||
if (!toolbarState || !highlighter) return;
|
||||
|
||||
createAnnotationFromSource(highlighter, toolbarState.source, type, text);
|
||||
pendingSourceRef.current = null;
|
||||
setToolbarState(null);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
};
|
||||
|
||||
const handleToolbarClose = () => {
|
||||
if (toolbarState && highlighterRef.current) {
|
||||
highlighterRef.current.remove(toolbarState.source.id);
|
||||
}
|
||||
pendingSourceRef.current = null;
|
||||
setToolbarState(null);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
};
|
||||
|
||||
const handleCodeBlockAnnotate = (type: AnnotationType, text?: string) => {
|
||||
const highlighter = highlighterRef.current;
|
||||
if (!hoveredCodeBlock || !highlighter) return;
|
||||
|
||||
// Find the code element inside the pre
|
||||
const codeEl = hoveredCodeBlock.element.querySelector('code');
|
||||
if (!codeEl) return;
|
||||
|
||||
// Create a range that selects all content in the code block
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(codeEl);
|
||||
|
||||
// Set the browser selection to this range
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
// Use highlighter.fromRange which triggers CREATE event internally
|
||||
// We need to handle this synchronously, so we'll create the annotation directly
|
||||
const id = `codeblock-${Date.now()}`;
|
||||
const codeText = codeEl.textContent || '';
|
||||
|
||||
// Wrap the content manually
|
||||
const wrapper = document.createElement('mark');
|
||||
wrapper.className = 'annotation-highlight';
|
||||
wrapper.dataset.bindId = id;
|
||||
|
||||
// Extract and wrap content
|
||||
range.surroundContents(wrapper);
|
||||
|
||||
// Add the appropriate class
|
||||
if (type === AnnotationType.DELETION) {
|
||||
wrapper.classList.add('deletion');
|
||||
} else if (type === AnnotationType.COMMENT) {
|
||||
wrapper.classList.add('comment');
|
||||
}
|
||||
|
||||
// Create the annotation
|
||||
const newAnnotation: Annotation = {
|
||||
id,
|
||||
blockId: hoveredCodeBlock.block.id,
|
||||
startOffset: 0,
|
||||
endOffset: codeText.length,
|
||||
type,
|
||||
text,
|
||||
originalText: codeText,
|
||||
createdA: Date.now(),
|
||||
author: getIdentity(),
|
||||
};
|
||||
|
||||
onAddAnnotationRef.current(newAnnotation);
|
||||
|
||||
// Clear selection
|
||||
selection?.removeAllRanges();
|
||||
setHoveredCodeBlock(null);
|
||||
};
|
||||
|
||||
const handleCodeBlockToolbarClose = () => {
|
||||
setHoveredCodeBlock(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative z-50">
|
||||
{taterMode && <TaterSpriteSitting />}
|
||||
<article
|
||||
ref={containerRef}
|
||||
className="w-full max-w-3xl bg-card border border-border/50 rounded-xl shadow-xl p-5 md:p-10 lg:p-14"
|
||||
>
|
||||
{blocks.map(block => (
|
||||
block.type === 'code' ? (
|
||||
<CodeBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
onHover={(element) => {
|
||||
// Clear any pending leave timeout
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
// Cancel exit animation if re-entering
|
||||
setIsCodeBlockToolbarExiting(false);
|
||||
// Only show hover toolbar if no selection toolbar is active
|
||||
if (!toolbarState) {
|
||||
setHoveredCodeBlock({ block, element });
|
||||
}
|
||||
}}
|
||||
onLeave={() => {
|
||||
// Delay then start exit animation
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setIsCodeBlockToolbarExiting(true);
|
||||
// After exit animation, unmount
|
||||
setTimeout(() => {
|
||||
setHoveredCodeBlock(null);
|
||||
setIsCodeBlockToolbarExiting(false);
|
||||
}, 150);
|
||||
}, 100);
|
||||
}}
|
||||
isHovered={hoveredCodeBlock?.block.id === block.id}
|
||||
/>
|
||||
) : (
|
||||
<BlockRenderer key={block.id} block={block} />
|
||||
)
|
||||
))}
|
||||
|
||||
<Toolbar
|
||||
highlightElement={toolbarState?.element ?? null}
|
||||
onAnnotate={handleAnnotate}
|
||||
onClose={handleToolbarClose}
|
||||
/>
|
||||
|
||||
{/* Code block hover toolbar */}
|
||||
{hoveredCodeBlock && !toolbarState && (
|
||||
<CodeBlockToolbar
|
||||
element={hoveredCodeBlock.element}
|
||||
onAnnotate={handleCodeBlockAnnotate}
|
||||
onClose={handleCodeBlockToolbarClose}
|
||||
isExiting={isCodeBlockToolbarExiting}
|
||||
onMouseEnter={() => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
setIsCodeBlockToolbarExiting(false);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setIsCodeBlockToolbarExiting(true);
|
||||
setTimeout(() => {
|
||||
setHoveredCodeBlock(null);
|
||||
setIsCodeBlockToolbarExiting(false);
|
||||
}, 150);
|
||||
}, 100);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const BlockRenderer: React.FC<{ block: Block }> = ({ block }) => {
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
const Tag = `h${block.level || 1}` as keyof JSX.IntrinsicElements;
|
||||
const styles = {
|
||||
1: 'text-2xl font-bold mb-4 mt-6 first:mt-0 tracking-tight',
|
||||
2: 'text-xl font-semibold mb-3 mt-8 text-foreground/90',
|
||||
3: 'text-base font-semibold mb-2 mt-6 text-foreground/80',
|
||||
}[block.level || 1] || 'text-base font-semibold mb-2 mt-4';
|
||||
|
||||
return <Tag className={styles} data-block-id={block.id}>{block.content}</Tag>;
|
||||
|
||||
case 'blockquote':
|
||||
return (
|
||||
<blockquote
|
||||
className="border-l-2 border-primary/50 pl-4 my-4 text-muted-foreground italic"
|
||||
data-block-id={block.id}
|
||||
>
|
||||
{block.content}
|
||||
</blockquote>
|
||||
);
|
||||
|
||||
case 'list-item':
|
||||
return (
|
||||
<div className="flex gap-3 my-1.5" data-block-id={block.id}>
|
||||
<span className="text-primary/60 select-none">•</span>
|
||||
<span className="text-foreground/90 text-sm leading-relaxed">{block.content}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'code':
|
||||
return <CodeBlock block={block} />;
|
||||
|
||||
case 'hr':
|
||||
return <hr className="border-border/30 my-8" data-block-id={block.id} />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<p
|
||||
className="mb-4 leading-relaxed text-foreground/90 text-[15px]"
|
||||
data-block-id={block.id}
|
||||
>
|
||||
{block.content}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface CodeBlockProps {
|
||||
block: Block;
|
||||
onHover: (element: HTMLElement) => void;
|
||||
onLeave: () => void;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
const CodeBlock: React.FC<CodeBlockProps> = ({ block, onHover, onLeave, isHovered }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(block.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}, [block.content]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (containerRef.current) {
|
||||
onHover(containerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative group my-5"
|
||||
data-block-id={block.id}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={onLeave}
|
||||
>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-md bg-muted/80 hover:bg-muted text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
title={copied ? 'Copied!' : 'Copy code'}
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<pre className="rounded-lg text-[13px] overflow-x-auto bg-muted/50 border border-border/30">
|
||||
<code className="hljs font-mono">{block.content}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeBlockToolbar: React.FC<{
|
||||
element: HTMLElement;
|
||||
onAnnotate: (type: AnnotationType, text?: string) => void;
|
||||
onClose: () => void;
|
||||
isExiting: boolean;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
}> = ({ element, onAnnotate, onClose, isExiting, onMouseEnter, onMouseLeave }) => {
|
||||
const [step, setStep] = useState<'menu' | 'input'>('menu');
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [position, setPosition] = useState<{ top: number; right: number }>({ top: 0, right: 0 });
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === 'input') inputRef.current?.focus();
|
||||
}, [step]);
|
||||
|
||||
// Update position on scroll/resize
|
||||
useEffect(() => {
|
||||
const updatePosition = () => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.top - 40,
|
||||
right: window.innerWidth - rect.right,
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, [element]);
|
||||
|
||||
const { top, right } = position;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (inputValue.trim()) {
|
||||
onAnnotate(AnnotationType.COMMENT, inputValue);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="annotation-toolbar fixed z-[100] bg-popover border border-border rounded-lg shadow-2xl"
|
||||
style={{
|
||||
top,
|
||||
right,
|
||||
animation: isExiting ? 'code-toolbar-out 0.15s ease-in forwards' : 'code-toolbar-in 0.2s ease-out',
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes code-toolbar-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes code-toolbar-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
{step === 'menu' ? (
|
||||
<div className="flex items-center p-1 gap-0.5">
|
||||
<button
|
||||
onClick={() => onAnnotate(AnnotationType.DELETION)}
|
||||
title="Delete"
|
||||
className="p-1.5 rounded-md transition-colors text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep('input')}
|
||||
title="Comment"
|
||||
className="p-1.5 rounded-md transition-colors text-accent hover:bg-accent/10"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="w-px h-5 bg-border mx-0.5" />
|
||||
<button
|
||||
onClick={onClose}
|
||||
title="Cancel"
|
||||
className="p-1.5 rounded-md transition-colors text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-1.5 p-1.5 pl-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="bg-transparent border-none outline-none text-sm w-44 placeholder:text-muted-foreground"
|
||||
placeholder="Add a comment..."
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Escape' && setStep('menu')}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!inputValue.trim()}
|
||||
className="px-2 py-1 text-xs font-medium rounded bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('menu')}
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# ExitPlanMode Hook - Launches Plannotator for plan approval/denial
|
||||
#
|
||||
# This hook intercepts ExitPlanMode, spawns a Bun server with the plan content,
|
||||
# and blocks until the user approves or denies (with feedback).
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 = User approved → ExitPlanMode proceeds
|
||||
# 2 = User denied → feedback sent to Claude via stderr
|
||||
#
|
||||
|
||||
# Get the directory where this hook lives
|
||||
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PLANNING_HOOK_DIR="$(dirname "$HOOK_DIR")"
|
||||
SERVER_SCRIPT="$PLANNING_HOOK_DIR/server/index.ts"
|
||||
|
||||
# Read the event from stdin
|
||||
EVENT=$(cat)
|
||||
|
||||
# Check if this is ExitPlanMode
|
||||
TOOL_NAME=$(echo "$EVENT" | jq -r '.tool_name // empty')
|
||||
|
||||
if [ "$TOOL_NAME" = "ExitPlanMode" ]; then
|
||||
# Extract plan content
|
||||
PLAN=$(echo "$EVENT" | jq -r '.tool_input.plan // empty')
|
||||
|
||||
if [ -n "$PLAN" ] && [ "$PLAN" != "null" ]; then
|
||||
# Launch Bun server with plan content
|
||||
# Server blocks until user makes a decision (approve/deny)
|
||||
# Server exits 0 for approve, 2 for deny (with feedback to stderr)
|
||||
cd "$PLANNING_HOOK_DIR"
|
||||
bun run "$SERVER_SCRIPT" "$PLAN"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
|
||||
# If not ExitPlanMode or no plan, allow through
|
||||
exit 0
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Hook for URL-based state sharing in Plannotator
|
||||
*
|
||||
* Handles:
|
||||
* - Loading shared state from URL hash on mount
|
||||
* - Generating shareable URLs
|
||||
* - Tracking whether current session is from a shared link
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Annotation } from '../types';
|
||||
import {
|
||||
parseShareHash,
|
||||
generateShareUrl,
|
||||
fromShareable,
|
||||
formatUrlSize,
|
||||
} from '../utils/sharing';
|
||||
|
||||
interface UseSharingResult {
|
||||
/** Whether the current session was loaded from a shared URL */
|
||||
isSharedSession: boolean;
|
||||
|
||||
/** Whether we're currently loading from a shared URL */
|
||||
isLoadingShared: boolean;
|
||||
|
||||
/** The current shareable URL (updates when annotations change) */
|
||||
shareUrl: string;
|
||||
|
||||
/** Human-readable size of the share URL */
|
||||
shareUrlSize: string;
|
||||
|
||||
/** Annotations loaded from share that need to be applied to DOM */
|
||||
pendingSharedAnnotations: Annotation[] | null;
|
||||
|
||||
/** Call after applying shared annotations to clear the pending state */
|
||||
clearPendingSharedAnnotations: () => void;
|
||||
|
||||
/** Manually trigger share URL generation */
|
||||
refreshShareUrl: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSharing(
|
||||
markdown: string,
|
||||
annotations: Annotation[],
|
||||
setMarkdown: (m: string) => void,
|
||||
setAnnotations: (a: Annotation[]) => void,
|
||||
onSharedLoad?: () => void
|
||||
): UseSharingResult {
|
||||
const [isSharedSession, setIsSharedSession] = useState(false);
|
||||
const [isLoadingShared, setIsLoadingShared] = useState(true);
|
||||
const [shareUrl, setShareUrl] = useState('');
|
||||
const [shareUrlSize, setShareUrlSize] = useState('');
|
||||
const [pendingSharedAnnotations, setPendingSharedAnnotations] = useState<Annotation[] | null>(null);
|
||||
|
||||
const clearPendingSharedAnnotations = useCallback(() => {
|
||||
setPendingSharedAnnotations(null);
|
||||
}, []);
|
||||
|
||||
// Load shared state from URL hash
|
||||
const loadFromHash = useCallback(async () => {
|
||||
try {
|
||||
const payload = await parseShareHash();
|
||||
|
||||
if (payload) {
|
||||
// Set plan content
|
||||
setMarkdown(payload.p);
|
||||
|
||||
// Convert shareable annotations to full annotations
|
||||
const restoredAnnotations = fromShareable(payload.a);
|
||||
setAnnotations(restoredAnnotations);
|
||||
|
||||
// Store for later application to DOM
|
||||
setPendingSharedAnnotations(restoredAnnotations);
|
||||
|
||||
setIsSharedSession(true);
|
||||
|
||||
// Notify parent that we loaded from a share
|
||||
onSharedLoad?.();
|
||||
|
||||
// Clear the hash from URL to prevent re-loading on refresh
|
||||
// but keep the state in memory
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
window.location.pathname
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('Failed to load from share hash:', e);
|
||||
return false;
|
||||
}
|
||||
}, [setMarkdown, setAnnotations, onSharedLoad]);
|
||||
|
||||
// Load from hash on mount
|
||||
useEffect(() => {
|
||||
loadFromHash().finally(() => setIsLoadingShared(false));
|
||||
}, []); // Only run on mount
|
||||
|
||||
// Listen for hash changes (when user pastes a new share URL)
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
if (window.location.hash.length > 1) {
|
||||
loadFromHash();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, [loadFromHash]);
|
||||
|
||||
// Generate share URL when markdown or annotations change
|
||||
const refreshShareUrl = useCallback(async () => {
|
||||
try {
|
||||
const url = await generateShareUrl(markdown, annotations);
|
||||
setShareUrl(url);
|
||||
setShareUrlSize(formatUrlSize(url));
|
||||
} catch (e) {
|
||||
console.error('Failed to generate share URL:', e);
|
||||
setShareUrl('');
|
||||
setShareUrlSize('');
|
||||
}
|
||||
}, [markdown, annotations]);
|
||||
|
||||
// Auto-refresh share URL when dependencies change
|
||||
useEffect(() => {
|
||||
refreshShareUrl();
|
||||
}, [refreshShareUrl]);
|
||||
|
||||
return {
|
||||
isSharedSession,
|
||||
isLoadingShared,
|
||||
shareUrl,
|
||||
shareUrlSize,
|
||||
pendingSharedAnnotations,
|
||||
clearPendingSharedAnnotations,
|
||||
refreshShareUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: oklch(0.13 0.02 260);
|
||||
--foreground: oklch(0.95 0.01 260);
|
||||
--card: oklch(0.16 0.02 260);
|
||||
--card-foreground: oklch(0.95 0.01 260);
|
||||
--popover: oklch(0.18 0.025 260);
|
||||
--popover-foreground: oklch(0.95 0.01 260);
|
||||
--primary: oklch(0.75 0.18 280);
|
||||
--primary-foreground: oklch(0.13 0.02 260);
|
||||
--secondary: oklch(0.65 0.15 180);
|
||||
--secondary-foreground: oklch(0.13 0.02 260);
|
||||
--muted: oklch(0.22 0.02 260);
|
||||
--muted-foreground: oklch(0.65 0.02 260);
|
||||
--accent: oklch(0.70 0.20 60);
|
||||
--accent-foreground: oklch(0.13 0.02 260);
|
||||
--destructive: oklch(0.65 0.22 25);
|
||||
--destructive-foreground: oklch(0.98 0 0);
|
||||
--border: oklch(0.28 0.02 260);
|
||||
--input: oklch(0.22 0.02 260);
|
||||
--ring: oklch(0.75 0.18 280);
|
||||
|
||||
--font-sans: 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.light {
|
||||
--background: oklch(0.97 0.005 260);
|
||||
--foreground: oklch(0.18 0.02 260);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.18 0.02 260);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.18 0.02 260);
|
||||
--primary: oklch(0.50 0.25 280);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.50 0.18 180);
|
||||
--secondary-foreground: oklch(1 0 0);
|
||||
--muted: oklch(0.92 0.01 260);
|
||||
--muted-foreground: oklch(0.40 0.02 260);
|
||||
--accent: oklch(0.60 0.22 50);
|
||||
--accent-foreground: oklch(0.18 0.02 260);
|
||||
--destructive: oklch(0.50 0.25 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.88 0.01 260);
|
||||
--input: oklch(0.92 0.01 260);
|
||||
--ring: oklch(0.50 0.25 280);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-feature-settings: "ss01", "ss02", "cv01";
|
||||
}
|
||||
|
||||
/* Subtle grid background */
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, oklch(0.25 0.02 260 / 0.5) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, oklch(0.25 0.02 260 / 0.5) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
.light .bg-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, oklch(0.90 0.01 260 / 0.6) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, oklch(0.90 0.01 260 / 0.6) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
/* Glow effects */
|
||||
.glow-primary {
|
||||
box-shadow: 0 0 20px oklch(0.75 0.18 280 / 0.15), 0 0 40px oklch(0.75 0.18 280 / 0.05);
|
||||
}
|
||||
|
||||
.glow-sm {
|
||||
box-shadow: 0 0 10px oklch(0.75 0.18 280 / 0.1);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); }
|
||||
|
||||
/* Code blocks */
|
||||
pre code.hljs {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--muted) !important;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Light mode code - override hljs dark theme */
|
||||
.light pre code.hljs {
|
||||
color: oklch(0.25 0.02 260) !important;
|
||||
}
|
||||
|
||||
.light .hljs-keyword,
|
||||
.light .hljs-selector-tag,
|
||||
.light .hljs-built_in,
|
||||
.light .hljs-name,
|
||||
.light .hljs-tag {
|
||||
color: oklch(0.45 0.25 280) !important;
|
||||
}
|
||||
|
||||
.light .hljs-string,
|
||||
.light .hljs-title,
|
||||
.light .hljs-section,
|
||||
.light .hljs-attribute,
|
||||
.light .hljs-literal,
|
||||
.light .hljs-template-tag,
|
||||
.light .hljs-template-variable,
|
||||
.light .hljs-type {
|
||||
color: oklch(0.45 0.18 150) !important;
|
||||
}
|
||||
|
||||
.light .hljs-comment,
|
||||
.light .hljs-quote {
|
||||
color: oklch(0.55 0.02 260) !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.light .hljs-number,
|
||||
.light .hljs-symbol,
|
||||
.light .hljs-bullet {
|
||||
color: oklch(0.50 0.20 50) !important;
|
||||
}
|
||||
|
||||
.light .hljs-attr,
|
||||
.light .hljs-variable,
|
||||
.light .hljs-template-variable,
|
||||
.light .hljs-class .hljs-title,
|
||||
.light .hljs-function {
|
||||
color: oklch(0.45 0.20 280) !important;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: oklch(0.75 0.18 280 / 0.3);
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-property: color, background-color, border-color, box-shadow, opacity, transform;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* Focus states */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Annotation highlights */
|
||||
.annotation-highlight {
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
margin: 0 -2px;
|
||||
}
|
||||
|
||||
.annotation-highlight.deletion {
|
||||
background: oklch(0.65 0.22 25 / 0.25);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--destructive);
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.annotation-highlight.comment {
|
||||
background: oklch(0.70 0.20 60 / 0.2);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.annotation-highlight:hover {
|
||||
filter: brightness(1.2);
|
||||
cursor: pointer;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Plannotator</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Syntax Highlighting -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react/": "https://esm.sh/react@^19.2.3/",
|
||||
"react": "https://esm.sh/react@^19.2.3",
|
||||
"react-dom": "https://esm.sh/react-dom@^19.2.3",
|
||||
"react-dom/": "https://esm.sh/react-dom@^19.2.3/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body class="min-h-screen antialiased">
|
||||
<div id="root" class="h-full"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Redline Planning Hook Installer
|
||||
#
|
||||
# Usage: ./install.sh [target-project-dir]
|
||||
#
|
||||
# This script:
|
||||
# 1. Builds the standalone Redline HTML if needed
|
||||
# 2. Adds the ExitPlanMode hook to the target project's Claude settings
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
HOOK_SCRIPT="$SCRIPT_DIR/hooks/exit-plan-mode.sh"
|
||||
REDLINE_HTML="$SCRIPT_DIR/dist/redline.html"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Redline Planning Hook Installer ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Check for required tools
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo -e "${RED}Error: jq is required but not installed.${NC}"
|
||||
echo "Install with: brew install jq"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine target directory
|
||||
TARGET_DIR="${1:-$(pwd)}"
|
||||
if [ ! -d "$TARGET_DIR" ]; then
|
||||
echo -e "${RED}Error: Target directory does not exist: $TARGET_DIR${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Target project: $TARGET_DIR${NC}"
|
||||
echo ""
|
||||
|
||||
# Build if needed
|
||||
if [ ! -f "$REDLINE_HTML" ]; then
|
||||
echo -e "${YELLOW}Building Redline HTML...${NC}"
|
||||
cd "$SCRIPT_DIR"
|
||||
npm install
|
||||
npm run build
|
||||
echo -e "${GREEN}✓ Build complete${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Create .claude directory if needed
|
||||
CLAUDE_DIR="$TARGET_DIR/.claude"
|
||||
mkdir -p "$CLAUDE_DIR"
|
||||
|
||||
SETTINGS_FILE="$CLAUDE_DIR/settings.local.json"
|
||||
|
||||
# Create or merge settings
|
||||
if [ -f "$SETTINGS_FILE" ]; then
|
||||
echo -e "${YELLOW}Merging with existing settings...${NC}"
|
||||
|
||||
# Read existing settings
|
||||
EXISTING=$(cat "$SETTINGS_FILE")
|
||||
|
||||
# Check if hook already exists
|
||||
if echo "$EXISTING" | jq -e '.hooks.PreToolUse[]?.hooks[]? | select(.command | contains("exit-plan-mode.sh"))' > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ Hook already installed${NC}"
|
||||
else
|
||||
# Add the new hook
|
||||
NEW_HOOK=$(cat <<EOF
|
||||
{
|
||||
"matcher": "ExitPlanMode",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$HOOK_SCRIPT"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Merge into existing settings
|
||||
UPDATED=$(echo "$EXISTING" | jq --argjson hook "$NEW_HOOK" '
|
||||
.hooks.PreToolUse = (.hooks.PreToolUse // []) + [$hook]
|
||||
')
|
||||
|
||||
echo "$UPDATED" > "$SETTINGS_FILE"
|
||||
echo -e "${GREEN}✓ Hook added to existing settings${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}Creating new settings file...${NC}"
|
||||
|
||||
# Create new settings with hook
|
||||
cat > "$SETTINGS_FILE" <<EOF
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "ExitPlanMode",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$HOOK_SCRIPT"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
echo -e "${GREEN}✓ Settings file created${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} Installation complete!${NC}"
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo "When Claude calls ExitPlanMode in this project,"
|
||||
echo "the Redline editor will open with the plan content."
|
||||
echo ""
|
||||
echo -e "${BLUE}Settings file: $SETTINGS_FILE${NC}"
|
||||
echo ""
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Redline - Markdown Editor",
|
||||
"description": "An interactive Markdown annotation and redlining tool. Collaborate, review, and export structured diffs with ease.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
Generated
+2383
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "redline-planning-hook",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && cp dist/index.html dist/redline.html",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"unique-username-generator": "^1.5.1",
|
||||
"web-highlighter": "^0.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0",
|
||||
"vite-plugin-singlefile": "^2.0.3"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
+138
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Plannotator Ephemeral Server
|
||||
*
|
||||
* Spawned by ExitPlanMode hook to serve Plannotator UI and handle approve/deny decisions.
|
||||
* Uses random port to support multiple concurrent Claude Code sessions.
|
||||
*
|
||||
* Usage: bun run server/index.ts "<plan markdown content>"
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
import { join, dirname } from "path";
|
||||
|
||||
const planContent = process.argv[2] || Bun.env.PLAN_CONTENT || "";
|
||||
|
||||
if (!planContent) {
|
||||
console.error("No plan content provided. Usage: bun run server/index.ts \"<plan>\"");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Promise that resolves when user makes a decision
|
||||
let resolveDecision: (result: { approved: boolean; feedback?: string }) => void;
|
||||
const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>(
|
||||
(resolve) => { resolveDecision = resolve; }
|
||||
);
|
||||
|
||||
// Resolve paths relative to this script
|
||||
const serverDir = dirname(import.meta.path);
|
||||
const distDir = join(serverDir, "..", "dist");
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 0, // Random available port - critical for multi-instance support
|
||||
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// API: Get plan content
|
||||
if (url.pathname === "/api/plan") {
|
||||
return Response.json({ plan: planContent });
|
||||
}
|
||||
|
||||
// API: Approve plan
|
||||
if (url.pathname === "/api/approve" && req.method === "POST") {
|
||||
resolveDecision({ approved: true });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
// API: Deny with feedback
|
||||
if (url.pathname === "/api/deny" && req.method === "POST") {
|
||||
try {
|
||||
const body = await req.json() as { feedback?: string };
|
||||
resolveDecision({ approved: false, feedback: body.feedback || "Plan rejected by user" });
|
||||
} catch {
|
||||
resolveDecision({ approved: false, feedback: "Plan rejected by user" });
|
||||
}
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
// Serve static files from dist/
|
||||
let filePath = url.pathname;
|
||||
if (filePath === "/" || filePath === "") {
|
||||
filePath = "/index.html";
|
||||
}
|
||||
|
||||
const file = Bun.file(join(distDir, filePath));
|
||||
if (await file.exists()) {
|
||||
// Set appropriate content types
|
||||
const contentType = getContentType(filePath);
|
||||
return new Response(file, {
|
||||
headers: contentType ? { "Content-Type": contentType } : {}
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to index.html for SPA routing
|
||||
const indexFile = Bun.file(join(distDir, "index.html"));
|
||||
if (await indexFile.exists()) {
|
||||
return new Response(indexFile, {
|
||||
headers: { "Content-Type": "text/html" }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
function getContentType(path: string): string | null {
|
||||
if (path.endsWith(".html")) return "text/html";
|
||||
if (path.endsWith(".js")) return "application/javascript";
|
||||
if (path.endsWith(".css")) return "text/css";
|
||||
if (path.endsWith(".json")) return "application/json";
|
||||
if (path.endsWith(".svg")) return "image/svg+xml";
|
||||
if (path.endsWith(".png")) return "image/png";
|
||||
if (path.endsWith(".ico")) return "image/x-icon";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Log to stderr so it doesn't interfere with hook stdout
|
||||
console.error(`Plannotator server running on http://localhost:${server.port}`);
|
||||
|
||||
// Open browser
|
||||
try {
|
||||
await $`open http://localhost:${server.port}`.quiet();
|
||||
} catch {
|
||||
// Fallback for non-macOS
|
||||
console.error(`Open browser manually: http://localhost:${server.port}`);
|
||||
}
|
||||
|
||||
// Wait for user decision (blocks until approve/deny)
|
||||
const result = await decisionPromise;
|
||||
|
||||
// Give browser time to receive response and update UI
|
||||
await Bun.sleep(1500);
|
||||
|
||||
// Cleanup
|
||||
server.stop();
|
||||
|
||||
// Output JSON for PermissionRequest hook decision control
|
||||
if (result.approved) {
|
||||
console.log(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PermissionRequest",
|
||||
decision: {
|
||||
behavior: "allow"
|
||||
}
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
console.log(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PermissionRequest",
|
||||
decision: {
|
||||
behavior: "deny",
|
||||
message: result.feedback || "Plan changes requested"
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,4 @@
|
||||
// AI Features have been disabled.
|
||||
export const getGeminiSuggestions = async (markdownContent: string): Promise<any[]> => {
|
||||
return [];
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export enum AnnotationType {
|
||||
DELETION = 'DELETION',
|
||||
INSERTION = 'INSERTION',
|
||||
REPLACEMENT = 'REPLACEMENT',
|
||||
COMMENT = 'COMMENT',
|
||||
}
|
||||
|
||||
export type EditorMode = 'selection' | 'redline';
|
||||
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
blockId: string; // Legacy - not used with web-highlighter
|
||||
startOffset: number; // Legacy
|
||||
endOffset: number; // Legacy
|
||||
type: AnnotationType;
|
||||
text?: string; // For comments
|
||||
originalText: string; // The text that was selected
|
||||
createdA: number;
|
||||
author?: string; // Tater identity for collaborative sharing
|
||||
// web-highlighter metadata for cross-element selections
|
||||
startMeta?: {
|
||||
parentTagName: string;
|
||||
parentIndex: number;
|
||||
textOffset: number;
|
||||
};
|
||||
endMeta?: {
|
||||
parentTagName: string;
|
||||
parentIndex: number;
|
||||
textOffset: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
id: string;
|
||||
type: 'paragraph' | 'heading' | 'blockquote' | 'list-item' | 'code' | 'hr';
|
||||
content: string; // Plain text content
|
||||
level?: number; // For headings (1-6)
|
||||
order: number; // Sorting order
|
||||
startLine: number; // 1-based line number in source
|
||||
}
|
||||
|
||||
export interface DiffResult {
|
||||
original: string;
|
||||
modified: string;
|
||||
diffText: string;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Tater Identity System
|
||||
*
|
||||
* Generates anonymous identities for collaborative annotation sharing.
|
||||
* Format: {adjective}-tater-{noun}
|
||||
* Examples: "swift-tater-falcon", "gentle-tater-crystal"
|
||||
*/
|
||||
|
||||
import { uniqueUsernameGenerator, adjectives, nouns } from 'unique-username-generator';
|
||||
|
||||
const STORAGE_KEY = 'plannotator-identity';
|
||||
|
||||
/**
|
||||
* Generate a new tater identity
|
||||
*/
|
||||
export function generateIdentity(): string {
|
||||
// Use a unique separator to split adjective from noun, avoiding issues
|
||||
// with compound words that contain hyphens (e.g., "behind-the-scenes")
|
||||
const generated = uniqueUsernameGenerator({
|
||||
dictionaries: [adjectives, nouns],
|
||||
separator: '|||',
|
||||
style: 'lowerCase',
|
||||
randomDigits: 0,
|
||||
length: 50, // Prevent word truncation (default is too short)
|
||||
});
|
||||
|
||||
const [adjective, noun] = generated.split('|||');
|
||||
return `${adjective}-${noun}-tater`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current identity from localStorage, or generate one if none exists
|
||||
*/
|
||||
export function getIdentity(): string {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return stored;
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage not available
|
||||
}
|
||||
|
||||
const identity = generateIdentity();
|
||||
saveIdentity(identity);
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save identity to localStorage
|
||||
*/
|
||||
export function saveIdentity(identity: string): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, identity);
|
||||
} catch (e) {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate identity and save to localStorage
|
||||
*/
|
||||
export function regenerateIdentity(): string {
|
||||
const identity = generateIdentity();
|
||||
saveIdentity(identity);
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an identity belongs to the current user
|
||||
*/
|
||||
export function isCurrentUser(author: string | undefined): boolean {
|
||||
if (!author) return false;
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === author;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import { Block } from '../types';
|
||||
|
||||
/**
|
||||
* A simplified markdown parser that splits content into linear blocks.
|
||||
* For a production app, we would use a robust AST walker (remark),
|
||||
* but for this demo, we want predictable text-anchoring.
|
||||
*/
|
||||
export const parseMarkdownToBlocks = (markdown: string): Block[] => {
|
||||
const lines = markdown.split('\n');
|
||||
const blocks: Block[] = [];
|
||||
let currentId = 0;
|
||||
|
||||
let buffer: string[] = [];
|
||||
let currentType: Block['type'] = 'paragraph';
|
||||
let currentLevel = 0;
|
||||
let bufferStartLine = 1; // Track the start line of the current buffer
|
||||
|
||||
const flush = () => {
|
||||
if (buffer.length > 0) {
|
||||
const content = buffer.join('\n');
|
||||
blocks.push({
|
||||
id: `block-${currentId++}`,
|
||||
type: currentType,
|
||||
content: content,
|
||||
level: currentLevel,
|
||||
order: currentId,
|
||||
startLine: bufferStartLine
|
||||
});
|
||||
buffer = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
const currentLineNum = i + 1; // 1-based index
|
||||
|
||||
// Headings
|
||||
if (trimmed.startsWith('#')) {
|
||||
flush();
|
||||
const level = trimmed.match(/^#+/)?.[0].length || 1;
|
||||
blocks.push({
|
||||
id: `block-${currentId++}`,
|
||||
type: 'heading',
|
||||
content: trimmed.replace(/^#+\s*/, ''),
|
||||
level,
|
||||
order: currentId,
|
||||
startLine: currentLineNum
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal Rule
|
||||
if (trimmed === '---' || trimmed === '***') {
|
||||
flush();
|
||||
blocks.push({
|
||||
id: `block-${currentId++}`,
|
||||
type: 'hr',
|
||||
content: '',
|
||||
order: currentId,
|
||||
startLine: currentLineNum
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// List Items (Simple detection)
|
||||
if (trimmed.match(/^(\*|-|\d+\.)\s/)) {
|
||||
flush(); // Treat each list item as a separate block for easier annotation
|
||||
blocks.push({
|
||||
id: `block-${currentId++}`,
|
||||
type: 'list-item',
|
||||
content: trimmed.replace(/^(\*|-|\d+\.)\s/, ''),
|
||||
order: currentId,
|
||||
startLine: currentLineNum
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquotes
|
||||
if (trimmed.startsWith('>')) {
|
||||
// Check if previous was blockquote, if so, merge? No, separate for now
|
||||
flush();
|
||||
blocks.push({
|
||||
id: `block-${currentId++}`,
|
||||
type: 'blockquote',
|
||||
content: trimmed.replace(/^>\s*/, ''),
|
||||
order: currentId,
|
||||
startLine: currentLineNum
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code blocks (naive)
|
||||
if (trimmed.startsWith('```')) {
|
||||
flush();
|
||||
const codeStartLine = currentLineNum;
|
||||
// Fast forward until end of code block
|
||||
let codeContent = [];
|
||||
i++; // Skip start fence
|
||||
while(i < lines.length && !lines[i].trim().startsWith('```')) {
|
||||
codeContent.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
blocks.push({
|
||||
id: `block-${currentId++}`,
|
||||
type: 'code',
|
||||
content: codeContent.join('\n'),
|
||||
order: currentId,
|
||||
startLine: codeStartLine
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty lines separate paragraphs
|
||||
if (trimmed === '') {
|
||||
flush();
|
||||
currentType = 'paragraph';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accumulate paragraph text
|
||||
if (buffer.length === 0) {
|
||||
bufferStartLine = currentLineNum;
|
||||
}
|
||||
buffer.push(line);
|
||||
}
|
||||
|
||||
flush(); // Final flush
|
||||
|
||||
return blocks;
|
||||
};
|
||||
|
||||
export const exportDiff = (blocks: Block[], annotations: any[]): string => {
|
||||
if (annotations.length === 0) {
|
||||
return 'No changes detected.';
|
||||
}
|
||||
|
||||
// Sort annotations by block and offset
|
||||
const sortedAnns = [...annotations].sort((a, b) => {
|
||||
const blockA = blocks.findIndex(blk => blk.id === a.blockId);
|
||||
const blockB = blocks.findIndex(blk => blk.id === b.blockId);
|
||||
if (blockA !== blockB) return blockA - blockB;
|
||||
return a.startOffset - b.startOffset;
|
||||
});
|
||||
|
||||
let output = `# Plan Feedback\n\n`;
|
||||
output += `I've reviewed this plan and have ${annotations.length} piece${annotations.length > 1 ? 's' : ''} of feedback:\n\n`;
|
||||
|
||||
sortedAnns.forEach((ann, index) => {
|
||||
const block = blocks.find(b => b.id === ann.blockId);
|
||||
|
||||
output += `## ${index + 1}. `;
|
||||
|
||||
switch (ann.type) {
|
||||
case 'DELETION':
|
||||
output += `Remove this\n`;
|
||||
output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`;
|
||||
output += `> I don't want this in the plan.\n`;
|
||||
break;
|
||||
|
||||
case 'INSERTION':
|
||||
output += `Add this\n`;
|
||||
output += `\`\`\`\n${ann.text}\n\`\`\`\n`;
|
||||
break;
|
||||
|
||||
case 'REPLACEMENT':
|
||||
output += `Change this\n`;
|
||||
output += `**From:**\n\`\`\`\n${ann.originalText}\n\`\`\`\n`;
|
||||
output += `**To:**\n\`\`\`\n${ann.text}\n\`\`\`\n`;
|
||||
break;
|
||||
|
||||
case 'COMMENT':
|
||||
output += `Feedback on: "${ann.originalText}"\n`;
|
||||
output += `> ${ann.text}\n`;
|
||||
break;
|
||||
}
|
||||
|
||||
output += '\n';
|
||||
});
|
||||
|
||||
output += `---\n`;
|
||||
|
||||
return output;
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Portable sharing utilities for Plannotator
|
||||
*
|
||||
* Enables sharing plan + annotations via URL hash using:
|
||||
* - Native CompressionStream/DecompressionStream (deflate-raw)
|
||||
* - Base64url encoding for URL safety
|
||||
*
|
||||
* Inspired by textarea.my's approach.
|
||||
*/
|
||||
|
||||
import { Annotation, AnnotationType } from '../types';
|
||||
|
||||
// Minimal shareable annotation format: [type, originalText, text?, author?]
|
||||
export type ShareableAnnotation =
|
||||
| ['D', string, string | null] // Deletion: type, original, author
|
||||
| ['R', string, string, string | null] // Replacement: type, original, replacement, author
|
||||
| ['C', string, string, string | null] // Comment: type, original, comment, author
|
||||
| ['I', string, string, string | null]; // Insertion: type, context, new text, author
|
||||
|
||||
export interface SharePayload {
|
||||
p: string; // plan markdown
|
||||
a: ShareableAnnotation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress a SharePayload to a base64url string
|
||||
*/
|
||||
export async function compress(payload: SharePayload): Promise<string> {
|
||||
const json = JSON.stringify(payload);
|
||||
const byteArray = new TextEncoder().encode(json);
|
||||
|
||||
const stream = new CompressionStream('deflate-raw');
|
||||
const writer = stream.writable.getWriter();
|
||||
writer.write(byteArray);
|
||||
writer.close();
|
||||
|
||||
const buffer = await new Response(stream.readable).arrayBuffer();
|
||||
const compressed = new Uint8Array(buffer);
|
||||
|
||||
// Convert to base64url (URL-safe base64)
|
||||
const base64 = btoa(String.fromCharCode(...compressed));
|
||||
return base64
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress a base64url string back to SharePayload
|
||||
*/
|
||||
export async function decompress(b64: string): Promise<SharePayload> {
|
||||
// Restore standard base64
|
||||
const base64 = b64
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const binary = atob(base64);
|
||||
const byteArray = Uint8Array.from(binary, c => c.charCodeAt(0));
|
||||
|
||||
const stream = new DecompressionStream('deflate-raw');
|
||||
const writer = stream.writable.getWriter();
|
||||
writer.write(byteArray);
|
||||
writer.close();
|
||||
|
||||
const buffer = await new Response(stream.readable).arrayBuffer();
|
||||
const json = new TextDecoder().decode(buffer);
|
||||
|
||||
return JSON.parse(json) as SharePayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert full Annotation objects to minimal shareable format
|
||||
*/
|
||||
export function toShareable(annotations: Annotation[]): ShareableAnnotation[] {
|
||||
return annotations.map(ann => {
|
||||
const type = ann.type[0] as 'D' | 'R' | 'C' | 'I';
|
||||
const author = ann.author || null;
|
||||
|
||||
if (type === 'D') {
|
||||
return ['D', ann.originalText, author] as ShareableAnnotation;
|
||||
}
|
||||
|
||||
// R, C, I all have text
|
||||
return [type, ann.originalText, ann.text || '', author] as ShareableAnnotation;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert shareable format back to full Annotation objects
|
||||
* Note: blockId, offsets, and meta will need to be populated separately
|
||||
* by finding the text in the rendered document.
|
||||
*/
|
||||
export function fromShareable(data: ShareableAnnotation[]): Annotation[] {
|
||||
const typeMap: Record<string, AnnotationType> = {
|
||||
'D': AnnotationType.DELETION,
|
||||
'R': AnnotationType.REPLACEMENT,
|
||||
'C': AnnotationType.COMMENT,
|
||||
'I': AnnotationType.INSERTION,
|
||||
};
|
||||
|
||||
return data.map((item, index) => {
|
||||
const type = item[0];
|
||||
const originalText = item[1];
|
||||
// For deletion: [type, original, author]
|
||||
// For others: [type, original, text, author]
|
||||
const text = type === 'D' ? undefined : item[2] as string;
|
||||
const author = type === 'D' ? item[2] as string | null : item[3] as string | null;
|
||||
|
||||
return {
|
||||
id: `shared-${index}-${Date.now()}`,
|
||||
blockId: '', // Will be populated during highlight restoration
|
||||
startOffset: 0,
|
||||
endOffset: 0,
|
||||
type: typeMap[type],
|
||||
text: text || undefined,
|
||||
originalText,
|
||||
createdA: Date.now() + index, // Preserve order
|
||||
author: author || undefined,
|
||||
// startMeta/endMeta will be set by web-highlighter
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a full shareable URL from plan and annotations
|
||||
*/
|
||||
export async function generateShareUrl(
|
||||
markdown: string,
|
||||
annotations: Annotation[]
|
||||
): Promise<string> {
|
||||
const payload: SharePayload = {
|
||||
p: markdown,
|
||||
a: toShareable(annotations),
|
||||
};
|
||||
|
||||
const hash = await compress(payload);
|
||||
return `${window.location.origin}${window.location.pathname}#${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a share URL hash and return the payload
|
||||
* Returns null if no valid hash or parsing fails
|
||||
*/
|
||||
export async function parseShareHash(): Promise<SharePayload | null> {
|
||||
const hash = window.location.hash.slice(1); // Remove leading #
|
||||
|
||||
if (!hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await decompress(hash);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse share hash:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of a URL in a human-readable format
|
||||
*/
|
||||
export function formatUrlSize(url: string): string {
|
||||
const bytes = new Blob([url]).size;
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.webp' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
plugins: [react(), tailwindcss(), viteSingleFile()],
|
||||
define: {
|
||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
assetsInlineLimit: 100000000,
|
||||
chunkSizeWarningLimit: 100000000,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user