Skip to content

Commit 79dd053

Browse files
backnotpropclaude
andcommitted
feat: replace comment textarea with CommentPopover (#219)
Replace the cramped toolbar textarea and awkward global comment inline form with a new CommentPopover component that provides a proper writing surface for long comments. - New CommentPopover with popover mode (w-96) and expand-to-dialog mode - Simplified AnnotationToolbar to menu-only (removed input step) - Global comment button now opens CommentPopover instead of inline form - Cmd/Ctrl+Enter to submit, Enter for newline (GitHub/Linear pattern) - Popover tracks anchor element on scroll to stay pinned to content - Image attachments supported in both modes Closes #219 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a6f08d2 commit 79dd053

File tree

3 files changed

+520
-269
lines changed

3 files changed

+520
-269
lines changed

packages/ui/components/AnnotationToolbar.tsx

Lines changed: 44 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import React, { useState, useEffect, useRef } from "react";
2-
import { AnnotationType, type ImageAttachment } from "../types";
2+
import { AnnotationType } from "../types";
33
import { createPortal } from "react-dom";
4-
import { AttachmentsButton } from "./AttachmentsButton";
54
import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
65

76
type PositionMode = 'center-above' | 'top-right';
@@ -16,49 +15,38 @@ const isEditableElement = (node: EventTarget | Element | null): boolean => {
1615
interface AnnotationToolbarProps {
1716
element: HTMLElement;
1817
positionMode: PositionMode;
19-
onAnnotate: (type: AnnotationType, text?: string, images?: ImageAttachment[]) => void;
18+
onAnnotate: (type: AnnotationType) => void;
2019
onClose: () => void;
20+
/** Called when user wants to write a comment (opens CommentPopover in parent) */
21+
onRequestComment?: (initialChar?: string) => void;
2122
/** Text to copy (for text selection, pass source.text) */
2223
copyText?: string;
23-
/** Close toolbar when element scrolls out of viewport (only in menu step) */
24+
/** Close toolbar when element scrolls out of viewport */
2425
closeOnScrollOut?: boolean;
2526
/** Exit animation state */
2627
isExiting?: boolean;
2728
/** Hover callbacks for code block behavior */
2829
onMouseEnter?: () => void;
2930
onMouseLeave?: () => void;
30-
onLockChange?: (locked: boolean) => void;
31-
/** Start in input step instead of menu (for comment mode) */
32-
initialStep?: 'menu' | 'input';
33-
/** Pre-set annotation type when starting in input step */
34-
initialType?: AnnotationType;
3531
}
3632

3733
export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
3834
element,
3935
positionMode,
4036
onAnnotate,
4137
onClose,
38+
onRequestComment,
4239
copyText,
4340
closeOnScrollOut = false,
4441
isExiting = false,
4542
onMouseEnter,
4643
onMouseLeave,
47-
onLockChange,
48-
initialStep = 'menu',
49-
initialType,
5044
}) => {
51-
const [step, setStep] = useState<"menu" | "input">(initialStep);
52-
const [activeType, setActiveType] = useState<AnnotationType | null>(initialType ?? null);
53-
const [inputValue, setInputValue] = useState("");
54-
const [images, setImages] = useState<ImageAttachment[]>([]);
5545
const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
5646
const [copied, setCopied] = useState(false);
57-
const inputRef = useRef<HTMLTextAreaElement>(null);
5847
const toolbarRef = useRef<HTMLDivElement>(null);
5948

6049
const handleCopy = async () => {
61-
// Use provided copyText, or fall back to code element / element text
6250
let textToCopy = copyText;
6351
if (!textToCopy) {
6452
const codeEl = element.querySelector('code');
@@ -69,43 +57,17 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
6957
setTimeout(() => setCopied(false), 1500);
7058
};
7159

72-
// Focus input when entering input step (including on mount with initialStep='input')
60+
// Reset copied state when element changes
7361
useEffect(() => {
74-
if (step === "input") {
75-
// Use setTimeout to ensure DOM is fully ready (portals can have timing issues)
76-
const timeoutId = setTimeout(() => {
77-
const input = inputRef.current;
78-
if (input) {
79-
input.focus();
80-
// Move cursor to end (for type-to-comment with pre-populated char)
81-
input.selectionStart = input.selectionEnd = input.value.length;
82-
}
83-
}, 0);
84-
return () => clearTimeout(timeoutId);
85-
}
86-
}, [step, element]); // Also re-run when element changes (new selection)
87-
88-
// Reset state when element changes
89-
useEffect(() => {
90-
setStep(initialStep);
91-
setActiveType(initialType ?? null);
92-
setInputValue("");
93-
setImages([]);
9462
setCopied(false);
95-
}, [element, initialStep, initialType]);
96-
97-
// Notify parent when locked (in input mode)
98-
useEffect(() => {
99-
onLockChange?.(step === "input");
100-
}, [step, onLockChange]);
63+
}, [element]);
10164

10265
// Update position on scroll/resize
10366
useEffect(() => {
10467
const updatePosition = () => {
10568
const rect = element.getBoundingClientRect();
10669

107-
// Close if scrolled out of viewport (only in menu step if enabled)
108-
if (closeOnScrollOut && step === "menu" && (rect.bottom < 0 || rect.top > window.innerHeight)) {
70+
if (closeOnScrollOut && (rect.bottom < 0 || rect.top > window.innerHeight)) {
10971
onClose();
11072
return;
11173
}
@@ -131,34 +93,24 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
13193
window.removeEventListener("scroll", updatePosition, true);
13294
window.removeEventListener("resize", updatePosition);
13395
};
134-
}, [element, positionMode, closeOnScrollOut, step, onClose]);
96+
}, [element, positionMode, closeOnScrollOut, onClose]);
13597

136-
// Type-to-comment: start typing in menu step → auto-transition to comment input
98+
// Type-to-comment: typing opens CommentPopover via parent
13799
useEffect(() => {
138-
if (step !== "menu") return;
139-
140100
const handleKeyDown = (e: KeyboardEvent) => {
141-
// Protect global comment and any editable field focus from type-to-comment capture.
142101
if (e.isComposing) return;
143102
if (isEditableElement(e.target) || isEditableElement(document.activeElement)) return;
144-
// Escape closes the toolbar
145103
if (e.key === "Escape") { onClose(); return; }
146-
// Ignore if modifier keys are held (except shift for capitals)
147104
if (e.ctrlKey || e.metaKey || e.altKey) return;
148-
// Ignore special keys
149105
if (e.key === "Tab" || e.key === "Enter") return;
150-
// Only trigger on printable characters (single char keys)
151106
if (e.key.length !== 1) return;
152107

153-
// Transition to comment mode with the typed character
154-
setActiveType(AnnotationType.COMMENT);
155-
setInputValue(e.key);
156-
setStep("input");
108+
onRequestComment?.(e.key);
157109
};
158110

159111
window.addEventListener("keydown", handleKeyDown);
160112
return () => window.removeEventListener("keydown", handleKeyDown);
161-
}, [step, onClose]);
113+
}, [onClose, onRequestComment]);
162114

163115
useDismissOnOutsideAndEscape({
164116
enabled: true,
@@ -172,15 +124,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
172124
if (type === AnnotationType.DELETION) {
173125
onAnnotate(type);
174126
} else {
175-
setActiveType(type);
176-
setStep("input");
177-
}
178-
};
179-
180-
const handleSubmit = (e: React.FormEvent) => {
181-
e.preventDefault();
182-
if (activeType && (inputValue.trim() || images.length > 0)) {
183-
onAnnotate(activeType, inputValue || undefined, images.length > 0 ? images : undefined);
127+
onRequestComment?.();
184128
}
185129
};
186130

@@ -216,77 +160,34 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
216160
to { opacity: 0; transform: translateY(8px)${translateX}; }
217161
}
218162
`}</style>
219-
{step === "menu" ? (
220-
<div className="flex items-center p-1 gap-0.5">
221-
<ToolbarButton
222-
onClick={handleCopy}
223-
icon={copied ? <CheckIcon /> : <CopyIcon />}
224-
label={copied ? "Copied!" : "Copy"}
225-
className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
226-
/>
227-
<div className="w-px h-5 bg-border mx-0.5" />
228-
<ToolbarButton
229-
onClick={() => handleTypeSelect(AnnotationType.DELETION)}
230-
icon={<TrashIcon />}
231-
label="Delete"
232-
className="text-destructive hover:bg-destructive/10"
233-
/>
234-
<ToolbarButton
235-
onClick={() => handleTypeSelect(AnnotationType.COMMENT)}
236-
icon={<CommentIcon />}
237-
label="Comment"
238-
className="text-accent hover:bg-accent/10"
239-
/>
240-
<div className="w-px h-5 bg-border mx-0.5" />
241-
<ToolbarButton
242-
onClick={onClose}
243-
icon={<CloseIcon />}
244-
label="Cancel"
245-
className="text-muted-foreground hover:bg-muted"
246-
/>
247-
</div>
248-
) : (
249-
<form onSubmit={handleSubmit} className="flex items-start gap-1.5 p-1.5 pl-3">
250-
<textarea
251-
ref={inputRef}
252-
rows={1}
253-
className="bg-transparent text-sm min-w-44 max-w-80 max-h-32 placeholder:text-muted-foreground resize-none px-2 py-1.5 focus:outline-none focus:bg-muted/30"
254-
style={{ fieldSizing: "content" } as React.CSSProperties}
255-
placeholder="Add a comment..."
256-
value={inputValue}
257-
onChange={(e) => setInputValue(e.target.value)}
258-
onKeyDown={(e) => {
259-
if (e.key === "Escape") setStep("menu");
260-
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
261-
e.preventDefault();
262-
if (inputValue.trim() || images.length > 0) {
263-
onAnnotate(activeType!, inputValue || undefined, images.length > 0 ? images : undefined);
264-
}
265-
}
266-
}}
267-
/>
268-
<AttachmentsButton
269-
images={images}
270-
onAdd={(img) => setImages((prev) => [...prev, img])}
271-
onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))}
272-
variant="inline"
273-
/>
274-
<button
275-
type="submit"
276-
disabled={!inputValue.trim() && images.length === 0}
277-
className="px-[15px] py-1 text-xs font-medium rounded bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-opacity self-stretch"
278-
>
279-
Save
280-
</button>
281-
<button
282-
type="button"
283-
onClick={() => setStep("menu")}
284-
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
285-
>
286-
<CloseIcon small />
287-
</button>
288-
</form>
289-
)}
163+
<div className="flex items-center p-1 gap-0.5">
164+
<ToolbarButton
165+
onClick={handleCopy}
166+
icon={copied ? <CheckIcon /> : <CopyIcon />}
167+
label={copied ? "Copied!" : "Copy"}
168+
className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
169+
/>
170+
<div className="w-px h-5 bg-border mx-0.5" />
171+
<ToolbarButton
172+
onClick={() => handleTypeSelect(AnnotationType.DELETION)}
173+
icon={<TrashIcon />}
174+
label="Delete"
175+
className="text-destructive hover:bg-destructive/10"
176+
/>
177+
<ToolbarButton
178+
onClick={() => handleTypeSelect(AnnotationType.COMMENT)}
179+
icon={<CommentIcon />}
180+
label="Comment"
181+
className="text-accent hover:bg-accent/10"
182+
/>
183+
<div className="w-px h-5 bg-border mx-0.5" />
184+
<ToolbarButton
185+
onClick={onClose}
186+
icon={<CloseIcon />}
187+
label="Cancel"
188+
className="text-muted-foreground hover:bg-muted"
189+
/>
190+
</div>
290191
</div>,
291192
document.body
292193
);
@@ -317,8 +218,8 @@ const CommentIcon = () => (
317218
</svg>
318219
);
319220

320-
const CloseIcon: React.FC<{ small?: boolean }> = ({ small }) => (
321-
<svg className={small ? "w-3.5 h-3.5" : "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
221+
const CloseIcon = () => (
222+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
322223
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
323224
</svg>
324225
);

0 commit comments

Comments
 (0)