import { useState, useEffect, useMemo } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { MessageSquare, X, Check, Image as ImageIcon, Type, StickyNote, Download, Trash2, List } from "lucide-react"; import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; import { finder } from "@medv/finder"; function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } export interface Annotation { id: string; x: number; y: number; selector: string; type: "text" | "image" | "note"; originalContent: string; newContent: string; url: string; elementRect: Record | null; scrollX: number; scrollY: number; viewportWidth: number; viewportHeight: number; userAgent: string; timestamp: string; } export interface AnnotatorProps { assets?: string[]; // Array of image URLs for the media browser onSubmit?: (annotations: Annotation[]) => Promise; } export function Annotator({ assets = [], onSubmit }: AnnotatorProps) { const [isActive, setIsActive] = useState(false); const [hoveredElement, setHoveredElement] = useState(null); const [selectedElement, setSelectedElement] = useState(null); const [annotations, setAnnotations] = useState(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("mintel-annotations"); if (saved) { try { return JSON.parse(saved); } catch (e) { console.error("Failed to parse saved annotations", e); } } } return []; }); useEffect(() => { if (typeof window !== "undefined") { localStorage.setItem("mintel-annotations", JSON.stringify(annotations)); } }, [annotations]); // Modal state const [currentType, setCurrentType] = useState<"text" | "image" | "note">("text"); const [currentText, setCurrentText] = useState(""); const [selectedAsset, setSelectedAsset] = useState(null); const [showList, setShowList] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); // Disable overlays if needed (e.g. inside iframes) const isExcluded = useMemo(() => { if (typeof window === "undefined") return false; return window.self !== window.top; }, []); const getSelector = (el: HTMLElement): string => { try { return finder(el, { root: document.body, className: (name) => !name.startsWith("annotator-") && !name.includes("[") && !name.includes("/") && !name.match(/^[a-z]-[0-9]/), idName: (name) => !name.startsWith("__next") && !name.includes(":"), }); } catch { return "unknown"; } }; useEffect(() => { if (!isActive) { setHoveredElement(null); return; } const handleMouseMove = (e: MouseEvent) => { if (selectedElement) return; const target = e.target as HTMLElement; if (target.closest(".annotator-ui-ignore")) { setHoveredElement(null); return; } setHoveredElement(target); }; const handleClick = (e: MouseEvent) => { if (selectedElement) return; const target = e.target as HTMLElement; if (target.closest(".annotator-ui-ignore")) return; e.preventDefault(); e.stopPropagation(); setSelectedElement(target); setHoveredElement(null); // Auto-detect type if (target.tagName.toLowerCase() === "img") { setCurrentType("image"); } else { setCurrentType("text"); setCurrentText(target.innerText || ""); } }; window.addEventListener("mousemove", handleMouseMove); window.addEventListener("click", handleClick, true); return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("click", handleClick, true); }; }, [isActive, selectedElement]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { if (selectedElement) { setSelectedElement(null); } else if (isActive) { setIsActive(false); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isActive, selectedElement]); const saveAnnotation = () => { if (!selectedElement) return; const rect = selectedElement.getBoundingClientRect(); const newContent = currentType === "image" ? selectedAsset || "" : currentText; if (!newContent) return; const annotation: Annotation = { id: Math.random().toString(36).substring(2, 9), x: rect.left + rect.width / 2 + window.scrollX, y: rect.top + rect.height / 2 + window.scrollY, selector: getSelector(selectedElement), type: currentType, originalContent: currentType === "image" ? (selectedElement as HTMLImageElement).src || "" : selectedElement.innerText, newContent, url: window.location.href, elementRect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height, top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left } : null, scrollX: window.scrollX, scrollY: window.scrollY, viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, userAgent: navigator.userAgent, timestamp: new Date().toISOString(), }; setAnnotations([...annotations, annotation]); setSelectedElement(null); setCurrentText(""); setSelectedAsset(null); }; const exportJSON = () => { const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(annotations, null, 2)); const downloadAnchorNode = document.createElement("a"); downloadAnchorNode.setAttribute("href", dataStr); downloadAnchorNode.setAttribute("download", "corrections.json"); document.body.appendChild(downloadAnchorNode); downloadAnchorNode.click(); downloadAnchorNode.remove(); }; const handleSubmit = async () => { if (!onSubmit || annotations.length === 0) return; setIsSubmitting(true); try { await onSubmit(annotations); setAnnotations([]); setShowList(false); } catch (error) { console.error("Submission failed", error); alert("Fehler beim Senden der Korrekturen. Bitte JSON Export nutzen."); } finally { setIsSubmitting(false); } }; const deleteAnnotation = (id: string) => { setAnnotations(annotations.filter((a) => a.id !== id)); }; const [updateTick, setUpdateTick] = useState(0); useEffect(() => { if (!isActive && !selectedElement) return; let rafId: number; const handleScroll = () => { cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => setUpdateTick(t => t + 1)); }; // Use capture phase to catch scrolls on any nested container window.addEventListener("scroll", handleScroll, true); window.addEventListener("resize", handleScroll); return () => { window.removeEventListener("scroll", handleScroll, true); window.removeEventListener("resize", handleScroll); cancelAnimationFrame(rafId); }; }, [isActive, selectedElement]); const hoveredRect = useMemo(() => hoveredElement?.getBoundingClientRect(), [hoveredElement, updateTick]); const selectedRect = useMemo(() => selectedElement?.getBoundingClientRect(), [selectedElement, updateTick]); if (isExcluded) return null; return (
{/* 1. Global Toolbar */}
{annotations.length > 0 && ( <>
)}
{/* 2. Highlights */} {isActive && (
{hoveredRect && ( )} {selectedRect && ( )}
)}
{/* 3. Action Modal */} {selectedElement && (

Änderung erfassen

{currentType === "text" && (