diff --git a/CLAUDE.md b/CLAUDE.md index c3fe11a55..e5f536af8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ plannotator/ │ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views │ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser │ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts -│ │ ├── hooks/ # useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts +│ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts │ │ └── types.ts │ ├── shared/ # Cross-package types (EditorAnnotation) │ ├── editor/ # Plan review App.tsx @@ -227,6 +227,10 @@ When a user denies a plan and Claude resubmits, the UI shows what changed betwee **State** (`packages/ui/hooks/usePlanDiff.ts`): Manages base version selection, diff computation, and version fetching. The server sends `previousPlan` with the initial `/api/plan` response; the hook auto-diffs against it. Users can select any prior version from the sidebar Version Browser. +**Diff annotations:** The clean diff view supports block-level annotation — hover over added/removed/modified sections to annotate entire blocks. Annotations carry a `diffContext` field (`added`/`removed`/`modified`). Exported feedback includes `[In diff content]` labels. + +**Annotation hook** (`packages/ui/hooks/useAnnotationHighlighter.ts`): Annotation infrastructure used by `Viewer.tsx`. Manages web-highlighter lifecycle, toolbar/popover state, annotation creation, text-based restoration, and scroll-to-selected. The diff view uses its own block-level hover system instead. + **Sidebar** (`packages/ui/hooks/useSidebar.ts`): Shared left sidebar with two tabs — Table of Contents and Version Browser. The "Auto-open Sidebar" setting controls whether it opens on load (TOC tab only). ## Data Types @@ -258,6 +262,7 @@ interface Annotation { createdA: number; // Timestamp author?: string; // Tater identity images?: ImageAttachment[]; // Attached images with names + diffContext?: 'added' | 'removed' | 'modified'; // Set when annotation created in plan diff view startMeta?: { parentTagName; parentIndex; textOffset }; endMeta?: { parentTagName; parentIndex; textOffset }; } @@ -286,7 +291,7 @@ interface Block { - Horizontal rules (`---`) - Paragraphs (default) -`exportAnnotations(blocks, annotations, globalAttachments)` generates human-readable feedback for Claude. Images are referenced by name: `[image-name] /tmp/path...`. +`exportAnnotations(blocks, annotations, globalAttachments)` generates human-readable feedback for Claude. Images are referenced by name: `[image-name] /tmp/path...`. Annotations with `diffContext` include `[In diff content]` labels. ## Annotation System @@ -311,6 +316,7 @@ interface SharePayload { p: string; // Plan markdown a: ShareableAnnotation[]; // Compact annotations g?: ShareableImage[]; // Global attachments + d?: (string | null)[]; // diffContext per annotation, parallel to `a` } type ShareableAnnotation = diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index e1f0f0bc9..b8861e386 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -264,7 +264,7 @@ const App: React.FC = () => { if (restoredGlobal.length > 0) setGlobalAttachments(restoredGlobal); // Apply highlights to DOM after a tick setTimeout(() => { - viewerRef.current?.applySharedAnnotations(restored); + viewerRef.current?.applySharedAnnotations(restored.filter(a => !a.diffContext)); }, 100); } }, [restoreDraft]); @@ -279,7 +279,7 @@ const App: React.FC = () => { const timer = setTimeout(() => { // Clear existing highlights first (important when loading new share URL) viewerRef.current?.clearAllHighlights(); - viewerRef.current?.applySharedAnnotations(pendingSharedAnnotations); + viewerRef.current?.applySharedAnnotations(pendingSharedAnnotations.filter(a => !a.diffContext)); clearPendingSharedAnnotations(); }, 100); return () => clearTimeout(timer); @@ -1152,18 +1152,16 @@ const App: React.FC = () => { showCancel />
element)
if (parent && parent.nodeName === 'CODE') {
- // For code blocks, we need to restore the plain text and re-highlight
const codeEl = parent as HTMLElement;
const plainText = el.textContent || '';
+ el.remove();
codeEl.textContent = plainText;
-
- // Re-apply syntax highlighting
const block = blocks.find(b => b.id === codeEl.closest('[data-block-id]')?.getAttribute('data-block-id'));
- if (block?.language) {
- codeEl.className = `hljs font-mono language-${block.language}`;
- hljs.highlightElement(codeEl);
- }
- } else {
- // For regular text, unwrap the mark
- while (el.firstChild) {
- parent?.insertBefore(el.firstChild, el);
- }
- }
- el.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);
+ codeEl.removeAttribute('data-highlighted');
+ codeEl.className = `hljs font-mono${block?.language ? ` language-${block.language}` : ''}`;
+ hljs.highlightElement(codeEl);
}
- 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();
- });
+ hookRemoveHighlight(id);
},
+ clearAllHighlights,
+ applySharedAnnotations: applyAnnotations,
+ }), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks]);
- 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 {
- // Multi-mark approach: wrap each text node portion separately
- // This avoids destructive extractContents() that breaks DOM structure
- const textNodes: { node: Text; start: number; end: number }[] = [];
-
- // Collect all text nodes within the range
- const walker = document.createTreeWalker(
- range.commonAncestorContainer.nodeType === Node.TEXT_NODE
- ? range.commonAncestorContainer.parentNode!
- : range.commonAncestorContainer,
- NodeFilter.SHOW_TEXT,
- null
- );
-
- let node: Text | null;
- let inRange = false;
-
- while ((node = walker.nextNode() as Text | null)) {
- // Check if this node is the start container
- if (node === range.startContainer) {
- inRange = true;
- const start = range.startOffset;
- const end = node === range.endContainer ? range.endOffset : node.length;
- if (end > start) {
- textNodes.push({ node, start, end });
- }
- if (node === range.endContainer) break;
- continue;
- }
-
- // Check if this node is the end container
- if (node === range.endContainer) {
- if (inRange) {
- const end = range.endOffset;
- if (end > 0) {
- textNodes.push({ node, start: 0, end });
- }
- }
- break;
- }
-
- // Node is fully within range
- if (inRange && node.length > 0) {
- textNodes.push({ node, start: 0, end: node.length });
- }
- }
-
- // If we only have one text node and it's fully contained, use simple approach
- if (textNodes.length === 0) {
- console.warn(`No text nodes found for annotation ${ann.id}`);
- return;
- }
-
- // Wrap each text node portion with its own mark (process in reverse to avoid offset issues)
- textNodes.reverse().forEach(({ node, start, end }) => {
- try {
- const nodeRange = document.createRange();
- nodeRange.setStart(node, start);
- nodeRange.setEnd(node, end);
-
- const mark = document.createElement('mark');
- mark.className = 'annotation-highlight';
- mark.dataset.bindId = ann.id;
-
- if (ann.type === AnnotationType.DELETION) {
- mark.classList.add('deletion');
- } else if (ann.type === AnnotationType.COMMENT) {
- mark.classList.add('comment');
- }
-
- // surroundContents works reliably for single text node ranges
- nodeRange.surroundContents(mark);
-
- // Make it clickable
- mark.addEventListener('click', () => {
- onSelectAnnotation(ann.id);
- });
- } catch (e) {
- console.warn(`Failed to wrap text node for annotation ${ann.id}:`, e);
- }
- });
- } catch (e) {
- console.warn(`Failed to apply highlight for annotation ${ann.id}:`, e);
- }
- });
- }
- }), [findTextInDOM, onSelectAnnotation]);
-
- // Track last mouse position for cursor-anchored quick label picker
- useEffect(() => {
- const track = (e: MouseEvent) => { lastMousePosRef.current = { x: e.clientX, y: e.clientY }; };
- document.addEventListener('mouseup', track, true);
- return () => document.removeEventListener('mouseup', track, true);
- }, []);
-
- 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 and dismiss open popover
- if (pendingSourceRef.current) {
- highlighter.remove(pendingSourceRef.current.id);
- pendingSourceRef.current = null;
- }
- setCommentPopover(null);
- setQuickLabelPicker(null);
-
- if (modeRef.current === 'redline') {
- // Auto-delete in redline mode
- createAnnotationFromSource(highlighter, source, AnnotationType.DELETION);
- window.getSelection()?.removeAllRanges();
- } else if (modeRef.current === 'comment') {
- // Comment mode - open CommentPopover directly
- pendingSourceRef.current = source;
- setCommentPopover({
- anchorEl: doms[0] as HTMLElement,
- contextText: source.text.slice(0, 80),
- isGlobal: false,
- source,
- });
- } else if (modeRef.current === 'quickLabel') {
- // Quick Label mode - show floating label picker directly
- pendingSourceRef.current = source;
- setQuickLabelPicker({
- anchorEl: doms[0] as HTMLElement,
- cursorHint: lastMousePosRef.current,
- source,
- });
- } else {
- // Selection mode - show toolbar menu
- const selectionText = source.text;
- pendingSourceRef.current = source;
- setToolbarState({ element: doms[0] as HTMLElement, source, selectionText });
- }
- }
- }
- });
-
- highlighter.on(Highlighter.event.CLICK, ({ id }: { id: string }) => {
- onSelectAnnotation(id);
- });
-
- highlighter.run();
-
- // Mobile: bridge native text selection (long-press) to the highlighter's CREATE flow.
- // On mobile/touch, native selection handles don't reliably fire touchend on the content
- // root, so the web-highlighter's built-in PointerEnd listener never triggers.
- // This selectionchange listener detects valid selections and uses the highlighter's
- // public fromRange() API to programmatically create the highlight and emit CREATE.
- // Use (pointer: coarse) instead of 'ontouchstart' in window — the latter is true on
- // desktop Chrome when the machine has a touchscreen or DevTools touch was toggled.
- const isTouchPrimary = window.matchMedia('(pointer: coarse)').matches;
- let selectionTimer: ReturnType;
- const handleSelectionChange = isTouchPrimary ? () => {
- clearTimeout(selectionTimer);
- selectionTimer = setTimeout(() => {
- const sel = window.getSelection();
- if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
- if (!containerRef.current?.contains(sel.anchorNode)) return;
-
- const range = sel.getRangeAt(0);
- highlighter.fromRange(range);
- }, 400);
- } : null;
-
- if (handleSelectionChange) {
- document.addEventListener('selectionchange', handleSelectionChange);
- }
-
- return () => {
- if (handleSelectionChange) {
- clearTimeout(selectionTimer);
- document.removeEventListener('selectionchange', handleSelectionChange);
- }
- highlighter.dispose();
- };
- }, [onSelectAnnotation]);
-
- 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]);
-
- // Scroll to and focus the selected annotation's highlight in the content
- useEffect(() => {
- if (!containerRef.current) return;
-
- // Clear all previously focused highlights
- containerRef.current.querySelectorAll('.annotation-highlight.focused').forEach(el => {
- el.classList.remove('focused');
- });
-
- if (!selectedAnnotationId) return;
-
- // Skip scroll+focus when annotation was just created (user is already looking at it)
- if (justCreatedIdRef.current === selectedAnnotationId) {
- justCreatedIdRef.current = null;
- return;
- }
-
- // Find highlight elements: try web-highlighter first, then manual marks
- const highlighter = highlighterRef.current;
- let targetElements: Element[] = [];
-
- if (highlighter) {
- try {
- const doms = highlighter.getDoms(selectedAnnotationId);
- if (doms && doms.length > 0) {
- targetElements = Array.from(doms);
- }
- } catch (e) {}
- }
-
- if (targetElements.length === 0) {
- const manualMarks = containerRef.current.querySelectorAll(
- `[data-bind-id="${selectedAnnotationId}"]`
- );
- if (manualMarks.length > 0) {
- targetElements = Array.from(manualMarks);
- }
- }
-
- if (targetElements.length === 0) return;
-
- // Apply focused class to all elements and scroll the first one into view
- targetElements.forEach(el => el.classList.add('focused'));
- targetElements[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
- }, [selectedAnnotationId]);
-
- const handleAnnotate = (type: AnnotationType) => {
- const highlighter = highlighterRef.current;
- if (!toolbarState || !highlighter) return;
-
- createAnnotationFromSource(highlighter, toolbarState.source, type);
- pendingSourceRef.current = null;
- setToolbarState(null);
- window.getSelection()?.removeAllRanges();
- };
-
- const handleQuickLabel = (label: QuickLabel) => {
- const highlighter = highlighterRef.current;
- if (!toolbarState || !highlighter) return;
-
- createAnnotationFromSource(
- highlighter, toolbarState.source, AnnotationType.COMMENT,
- `${label.emoji} ${label.text}`, undefined, true, label.tip
- );
- pendingSourceRef.current = null;
- setToolbarState(null);
- window.getSelection()?.removeAllRanges();
- };
-
- const handleFloatingQuickLabel = useCallback((label: QuickLabel) => {
- if (!quickLabelPicker) return;
-
- if (quickLabelPicker.source && highlighterRef.current) {
- createAnnotationFromSource(
- highlighterRef.current, quickLabelPicker.source, AnnotationType.COMMENT,
- `${label.emoji} ${label.text}`, undefined, true, label.tip
- );
- pendingSourceRef.current = null;
- } else if (quickLabelPicker.codeBlock) {
- const codeEl = quickLabelPicker.codeBlock.element.querySelector('code');
- if (codeEl) {
- applyCodeBlockAnnotation(
- quickLabelPicker.codeBlock.block.id, codeEl, AnnotationType.COMMENT,
- `${label.emoji} ${label.text}`, undefined, true, label.tip
- );
- }
- }
-
- setQuickLabelPicker(null);
- window.getSelection()?.removeAllRanges();
- }, [quickLabelPicker]);
-
- const handleQuickLabelPickerDismiss = useCallback(() => {
- if (quickLabelPicker?.source && highlighterRef.current) {
- highlighterRef.current.remove(quickLabelPicker.source.id);
- pendingSourceRef.current = null;
- }
- setQuickLabelPicker(null);
- window.getSelection()?.removeAllRanges();
- }, [quickLabelPicker]);
-
- const handleToolbarClose = () => {
- if (toolbarState && highlighterRef.current) {
- highlighterRef.current.remove(toolbarState.source.id);
- }
- pendingSourceRef.current = null;
- setToolbarState(null);
- window.getSelection()?.removeAllRanges();
- };
+ // --- Viewer-specific: code block annotation ---
const applyCodeBlockAnnotation = (
blockId: string,
@@ -826,7 +345,6 @@ export const Viewer = forwardRef(({
...(quickLabelTip ? { quickLabelTip } : {}),
};
- justCreatedIdRef.current = newAnnotation.id;
onAddAnnotationRef.current(newAnnotation);
window.getSelection()?.removeAllRanges();
};
@@ -854,25 +372,12 @@ export const Viewer = forwardRef(({
setHoveredCodeBlock(null);
};
- // CommentPopover handlers
-
- const handleRequestComment = (initialChar?: string) => {
- if (!toolbarState) return;
- setCommentPopover({
- anchorEl: toolbarState.element,
- contextText: toolbarState.selectionText.slice(0, 80),
- initialText: initialChar,
- isGlobal: false,
- source: toolbarState.source,
- });
- // Close toolbar but keep pendingSourceRef
- setToolbarState(null);
- };
+ // Viewer-specific comment popover handlers (code blocks + global comments)
const handleCodeBlockRequestComment = (initialChar?: string) => {
if (!hoveredCodeBlock) return;
const codeText = hoveredCodeBlock.element.querySelector('code')?.textContent || '';
- setCommentPopover({
+ setViewerCommentPopover({
anchorEl: hoveredCodeBlock.element,
contextText: codeText.slice(0, 80),
initialText: initialChar,
@@ -882,10 +387,10 @@ export const Viewer = forwardRef(({
setHoveredCodeBlock(null);
};
- const handleCommentSubmit = (text: string, images?: ImageAttachment[]) => {
- if (!commentPopover) return;
+ const handleViewerCommentSubmit = (text: string, images?: ImageAttachment[]) => {
+ if (!viewerCommentPopover) return;
- if (commentPopover.isGlobal) {
+ if (viewerCommentPopover.isGlobal) {
const newAnnotation: Annotation = {
id: `global-${Date.now()}`,
blockId: '',
@@ -899,35 +404,18 @@ export const Viewer = forwardRef(({
images,
};
onAddAnnotation(newAnnotation);
- } else if (commentPopover.source && highlighterRef.current) {
- createAnnotationFromSource(
- highlighterRef.current,
- commentPopover.source,
- AnnotationType.COMMENT,
- text,
- images
- );
- pendingSourceRef.current = null;
- window.getSelection()?.removeAllRanges();
- } else if (commentPopover.codeBlock) {
- const codeEl = commentPopover.codeBlock.element.querySelector('code');
+ } else if (viewerCommentPopover.codeBlock) {
+ const codeEl = viewerCommentPopover.codeBlock.element.querySelector('code');
if (codeEl) {
- applyCodeBlockAnnotation(commentPopover.codeBlock.block.id, codeEl, AnnotationType.COMMENT, text, images);
+ applyCodeBlockAnnotation(viewerCommentPopover.codeBlock.block.id, codeEl, AnnotationType.COMMENT, text, images);
}
}
- setCommentPopover(null);
+ setViewerCommentPopover(null);
};
- const handleCommentClose = useCallback(() => {
- setCommentPopover((prev) => {
- if (prev?.source && highlighterRef.current) {
- highlighterRef.current.remove(prev.source.id);
- pendingSourceRef.current = null;
- }
- return null;
- });
- window.getSelection()?.removeAllRanges();
+ const handleViewerCommentClose = useCallback(() => {
+ setViewerCommentPopover(null);
}, []);
return (
@@ -1015,7 +503,7 @@ export const Viewer = forwardRef(({