|
| 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