Skip to content

Commit 03a19f5

Browse files
authored
refactor: consolidate annotation toolbars into single component (#46)
- Create shared AnnotationToolbar component replacing duplicate code - Delete old Toolbar.tsx and inline CodeBlockToolbar from Viewer.tsx - Add copy button to toolbar (left side, separated from annotation icons) - Fix copy to preserve line breaks using window.getSelection().toString() - Net reduction of ~175 lines of duplicate code
1 parent 6736589 commit 03a19f5

3 files changed

Lines changed: 297 additions & 418 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import React, { useState, useEffect, useRef } from "react";
2+
import { AnnotationType } from "../types";
3+
import { createPortal } from "react-dom";
4+
import { AttachmentsButton } from "./AttachmentsButton";
5+
6+
type PositionMode = 'center-above' | 'top-right';
7+
8+
interface AnnotationToolbarProps {
9+
element: HTMLElement;
10+
positionMode: PositionMode;
11+
onAnnotate: (type: AnnotationType, text?: string, imagePaths?: string[]) => void;
12+
onClose: () => void;
13+
/** Text to copy (for text selection, pass source.text) */
14+
copyText?: string;
15+
/** Close toolbar when element scrolls out of viewport (only in menu step) */
16+
closeOnScrollOut?: boolean;
17+
/** Exit animation state */
18+
isExiting?: boolean;
19+
/** Hover callbacks for code block behavior */
20+
onMouseEnter?: () => void;
21+
onMouseLeave?: () => void;
22+
onLockChange?: (locked: boolean) => void;
23+
}
24+
25+
export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
26+
element,
27+
positionMode,
28+
onAnnotate,
29+
onClose,
30+
copyText,
31+
closeOnScrollOut = false,
32+
isExiting = false,
33+
onMouseEnter,
34+
onMouseLeave,
35+
onLockChange,
36+
}) => {
37+
const [step, setStep] = useState<"menu" | "input">("menu");
38+
const [activeType, setActiveType] = useState<AnnotationType | null>(null);
39+
const [inputValue, setInputValue] = useState("");
40+
const [imagePaths, setImagePaths] = useState<string[]>([]);
41+
const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
42+
const [copied, setCopied] = useState(false);
43+
const inputRef = useRef<HTMLTextAreaElement>(null);
44+
45+
const handleCopy = async () => {
46+
// Use provided copyText, or fall back to code element / element text
47+
let textToCopy = copyText;
48+
if (!textToCopy) {
49+
const codeEl = element.querySelector('code');
50+
textToCopy = codeEl?.textContent || element.textContent || '';
51+
}
52+
await navigator.clipboard.writeText(textToCopy);
53+
setCopied(true);
54+
setTimeout(() => setCopied(false), 1500);
55+
};
56+
57+
// Focus input when entering input step
58+
useEffect(() => {
59+
if (step === "input") inputRef.current?.focus();
60+
}, [step]);
61+
62+
// Reset state when element changes
63+
useEffect(() => {
64+
setStep("menu");
65+
setActiveType(null);
66+
setInputValue("");
67+
setImagePaths([]);
68+
setCopied(false);
69+
}, [element]);
70+
71+
// Notify parent when locked (in input mode)
72+
useEffect(() => {
73+
onLockChange?.(step === "input");
74+
}, [step, onLockChange]);
75+
76+
// Update position on scroll/resize
77+
useEffect(() => {
78+
const updatePosition = () => {
79+
const rect = element.getBoundingClientRect();
80+
81+
// Close if scrolled out of viewport (only in menu step if enabled)
82+
if (closeOnScrollOut && step === "menu" && (rect.bottom < 0 || rect.top > window.innerHeight)) {
83+
onClose();
84+
return;
85+
}
86+
87+
if (positionMode === 'center-above') {
88+
setPosition({
89+
top: rect.top - 48,
90+
left: rect.left + rect.width / 2,
91+
});
92+
} else {
93+
setPosition({
94+
top: rect.top - 40,
95+
right: window.innerWidth - rect.right,
96+
});
97+
}
98+
};
99+
100+
updatePosition();
101+
window.addEventListener("scroll", updatePosition, true);
102+
window.addEventListener("resize", updatePosition);
103+
104+
return () => {
105+
window.removeEventListener("scroll", updatePosition, true);
106+
window.removeEventListener("resize", updatePosition);
107+
};
108+
}, [element, positionMode, closeOnScrollOut, step, onClose]);
109+
110+
if (!position) return null;
111+
112+
const handleTypeSelect = (type: AnnotationType) => {
113+
if (type === AnnotationType.DELETION) {
114+
onAnnotate(type);
115+
} else {
116+
setActiveType(type);
117+
setStep("input");
118+
}
119+
};
120+
121+
const handleSubmit = (e: React.FormEvent) => {
122+
e.preventDefault();
123+
if (activeType && (inputValue.trim() || imagePaths.length > 0)) {
124+
onAnnotate(activeType, inputValue || undefined, imagePaths.length > 0 ? imagePaths : undefined);
125+
}
126+
};
127+
128+
const isCentered = position.left !== undefined;
129+
const translateX = isCentered ? ' translateX(-50%)' : '';
130+
131+
const style: React.CSSProperties = {
132+
top: position.top,
133+
...(isCentered
134+
? { left: position.left, transform: 'translateX(-50%)' }
135+
: { right: position.right }),
136+
animation: isExiting
137+
? 'annotation-toolbar-out 0.15s ease-in forwards'
138+
: 'annotation-toolbar-in 0.15s ease-out',
139+
};
140+
141+
return createPortal(
142+
<div
143+
className="annotation-toolbar fixed z-[100] bg-popover border border-border rounded-lg shadow-2xl"
144+
style={style}
145+
onMouseDown={(e) => e.stopPropagation()}
146+
onMouseEnter={onMouseEnter}
147+
onMouseLeave={onMouseLeave}
148+
>
149+
<style>{`
150+
@keyframes annotation-toolbar-in {
151+
from { opacity: 0; transform: translateY(12px)${translateX}; }
152+
to { opacity: 1; transform: translateY(0)${translateX}; }
153+
}
154+
@keyframes annotation-toolbar-out {
155+
from { opacity: 1; transform: translateY(0)${translateX}; }
156+
to { opacity: 0; transform: translateY(8px)${translateX}; }
157+
}
158+
`}</style>
159+
{step === "menu" ? (
160+
<div className="flex items-center p-1 gap-0.5">
161+
<ToolbarButton
162+
onClick={handleCopy}
163+
icon={copied ? <CheckIcon /> : <CopyIcon />}
164+
label={copied ? "Copied!" : "Copy"}
165+
className={copied ? "text-green-500" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
166+
/>
167+
<div className="w-px h-5 bg-border mx-0.5" />
168+
<ToolbarButton
169+
onClick={() => handleTypeSelect(AnnotationType.DELETION)}
170+
icon={<TrashIcon />}
171+
label="Delete"
172+
className="text-destructive hover:bg-destructive/10"
173+
/>
174+
<ToolbarButton
175+
onClick={() => handleTypeSelect(AnnotationType.COMMENT)}
176+
icon={<CommentIcon />}
177+
label="Comment"
178+
className="text-accent hover:bg-accent/10"
179+
/>
180+
<div className="w-px h-5 bg-border mx-0.5" />
181+
<ToolbarButton
182+
onClick={onClose}
183+
icon={<CloseIcon />}
184+
label="Cancel"
185+
className="text-muted-foreground hover:bg-muted"
186+
/>
187+
</div>
188+
) : (
189+
<form onSubmit={handleSubmit} className="flex items-start gap-1.5 p-1.5 pl-3">
190+
<textarea
191+
ref={inputRef}
192+
rows={1}
193+
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"
194+
style={{ fieldSizing: "content" } as React.CSSProperties}
195+
placeholder="Add a comment..."
196+
value={inputValue}
197+
onChange={(e) => setInputValue(e.target.value)}
198+
onKeyDown={(e) => {
199+
if (e.key === "Escape") setStep("menu");
200+
if (e.key === "Enter" && !e.shiftKey) {
201+
e.preventDefault();
202+
if (inputValue.trim() || imagePaths.length > 0) {
203+
onAnnotate(activeType!, inputValue || undefined, imagePaths.length > 0 ? imagePaths : undefined);
204+
}
205+
}
206+
}}
207+
/>
208+
<AttachmentsButton
209+
paths={imagePaths}
210+
onAdd={(path) => setImagePaths((prev) => [...prev, path])}
211+
onRemove={(path) => setImagePaths((prev) => prev.filter((p) => p !== path))}
212+
variant="inline"
213+
/>
214+
<button
215+
type="submit"
216+
disabled={!inputValue.trim() && imagePaths.length === 0}
217+
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"
218+
>
219+
Save
220+
</button>
221+
<button
222+
type="button"
223+
onClick={() => setStep("menu")}
224+
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
225+
>
226+
<CloseIcon small />
227+
</button>
228+
</form>
229+
)}
230+
</div>,
231+
document.body
232+
);
233+
};
234+
235+
// Icons
236+
const CopyIcon = () => (
237+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
238+
<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" />
239+
</svg>
240+
);
241+
242+
const CheckIcon = () => (
243+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
244+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
245+
</svg>
246+
);
247+
248+
const TrashIcon = () => (
249+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
250+
<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" />
251+
</svg>
252+
);
253+
254+
const CommentIcon = () => (
255+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
256+
<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" />
257+
</svg>
258+
);
259+
260+
const CloseIcon: React.FC<{ small?: boolean }> = ({ small }) => (
261+
<svg className={small ? "w-3.5 h-3.5" : "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
262+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
263+
</svg>
264+
);
265+
266+
const ToolbarButton: React.FC<{
267+
onClick: () => void;
268+
icon: React.ReactNode;
269+
label: string;
270+
className: string;
271+
}> = ({ onClick, icon, label, className }) => (
272+
<button
273+
onClick={onClick}
274+
title={label}
275+
className={`p-1.5 rounded-md transition-colors ${className}`}
276+
>
277+
{icon}
278+
</button>
279+
);

0 commit comments

Comments
 (0)