"use client"; import { useState, useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from "react"; import styles from "./styles.module.scss"; import { originalSetTimeout } from "../../core/freeze-animations"; import { IconTrash } from "../icons"; // ============================================================================= // Helpers // ============================================================================= /** Focus an element while temporarily blocking focus-trap libraries (e.g. Radix * FocusScope) from reclaiming focus via focusin/focusout handlers. */ function focusBypassingTraps(el: HTMLElement | null) { if (!el) return; const trap = (e: Event) => e.stopImmediatePropagation(); document.addEventListener("focusin", trap, true); document.addEventListener("focusout", trap, true); try { el.focus(); } finally { document.removeEventListener("focusin", trap, true); document.removeEventListener("focusout", trap, true); } } // ============================================================================= // Types // ============================================================================= export interface AnnotationPopupCSSProps { /** Element name to display in header */ element: string; /** Optional timestamp display (e.g., "@ 1.23s" for animation feedback) */ timestamp?: string; /** Optional selected/highlighted text */ selectedText?: string; /** Placeholder text for the textarea */ placeholder?: string; /** Initial value for textarea (for edit mode) */ initialValue?: string; /** Label for submit button (default: "Add") */ submitLabel?: string; /** Called when annotation is submitted with text */ onSubmit: (text: string) => void; /** Called when popup is cancelled/dismissed */ onCancel: () => void; /** Called when delete button is clicked (only shown if provided) */ onDelete?: () => void; /** Read-only preview mode — shows comment text with a single Close button */ readOnly?: boolean; /** Position styles (left, top) */ style?: React.CSSProperties; /** Custom color for submit button and textarea focus (hex) */ accentColor?: string; /** External exit state (parent controls exit animation) */ isExiting?: boolean; /** Light mode styling */ lightMode?: boolean; /** Show the element/component path header (default: true) */ showElementPath?: boolean; /** Computed styles for the selected element */ computedStyles?: Record; } export interface AnnotationPopupCSSHandle { /** Shake the popup (e.g., when user clicks outside) */ shake: () => void; } // ============================================================================= // Component // ============================================================================= export const AnnotationPopupCSS = forwardRef( function AnnotationPopupCSS( { element, timestamp, selectedText, placeholder = "Add a comment...", initialValue = "", submitLabel = "Add", onSubmit, onCancel, onDelete, readOnly = false, style, accentColor = "#3c82f7", isExiting = false, lightMode = false, showElementPath = true, computedStyles, }, ref ) { const [text, setText] = useState(initialValue); const [isShaking, setIsShaking] = useState(false); useEffect(() => { setText(initialValue); }, [initialValue]); const [animState, setAnimState] = useState<"initial" | "enter" | "entered" | "exit">("initial"); const [isFocused, setIsFocused] = useState(false); const [isStylesExpanded, setIsStylesExpanded] = useState(false); // Computed styles accordion state const textareaRef = useRef(null); const popupRef = useRef(null); const cancelTimerRef = useRef | null>(null); const shakeTimerRef = useRef | null>(null); // Sync with parent exit state useEffect(() => { if (isExiting && animState !== "exit") { setAnimState("exit"); } }, [isExiting, animState]); // Animate in on mount and focus textarea useEffect(() => { // Start enter animation (use originalSetTimeout to bypass freeze patch) originalSetTimeout(() => { setAnimState("enter"); }, 0); // Transition to entered state after animation completes const enterTimer = originalSetTimeout(() => { setAnimState("entered"); }, 200); // Match animation duration const focusTimer = originalSetTimeout(() => { if (readOnly) return; const textarea = textareaRef.current; if (textarea) { focusBypassingTraps(textarea); textarea.selectionStart = textarea.selectionEnd = textarea.value.length; textarea.scrollTop = textarea.scrollHeight; } }, 50); return () => { clearTimeout(enterTimer); clearTimeout(focusTimer); if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current); if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current); }; }, [readOnly]); // Shake animation const shake = useCallback(() => { if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current); setIsShaking(true); shakeTimerRef.current = originalSetTimeout(() => { setIsShaking(false); focusBypassingTraps(textareaRef.current); }, 250); }, []); // Expose shake to parent via ref useImperativeHandle(ref, () => ({ shake, }), [shake]); // Handle cancel with exit animation const handleCancel = useCallback(() => { setAnimState("exit"); cancelTimerRef.current = originalSetTimeout(() => { onCancel(); }, 150); // Match exit animation duration }, [onCancel]); // Handle submit const handleSubmit = useCallback(() => { if (!text.trim()) return; onSubmit(text.trim()); }, [text, onSubmit]); // Handle keyboard const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { e.stopPropagation(); if (e.nativeEvent.isComposing) return; if (!readOnly && e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } if (e.key === "Escape") { handleCancel(); } }, [readOnly, handleSubmit, handleCancel] ); const popupClassName = [ styles.popup, lightMode ? styles.light : "", animState === "enter" ? styles.enter : "", animState === "entered" ? styles.entered : "", animState === "exit" ? styles.exit : "", isShaking ? styles.shake : "", ].filter(Boolean).join(" "); return (
e.stopPropagation()} > {!readOnly && showElementPath && ( <>
{computedStyles && Object.keys(computedStyles).length > 0 ? ( ) : ( {element} )} {timestamp && {timestamp}}
{/* Collapsible computed styles section - uses grid-template-rows for smooth animation */} {computedStyles && Object.keys(computedStyles).length > 0 && (
{Object.entries(computedStyles).map(([key, value]) => (
{key.replace(/([A-Z])/g, "-$1").toLowerCase()} : {value};
))}
)} )} {selectedText && (
“{selectedText.slice(0, 80)} {selectedText.length > 80 ? "..." : ""}”
)} {readOnly ? (

{text}

) : (