import { useMemo, useRef, useState } from "react"; import type { Hotspot } from "../types/annotations"; import { renderInlineMarkdown } from "../lib/markdownInline"; interface HotspotOverlayProps { hotspots: Hotspot[]; currentFrame: number; editable?: boolean; selectedHotspotId?: string | null; onSelectHotspot?: (id: string) => void; onDeleteHotspot?: (id: string) => void; onUpdateHotspot?: (id: string, updater: (hotspot: Hotspot) => Hotspot) => void; onSeekToFrame?: (frame: number) => void; onNavigateToStep?: ( destination: { type: "next_step" } | { type: "step"; stepId: string } | { type: "this_step" }, hotspotId: string ) => void; onHotspotActivated?: (hotspotId: string) => void; } function HotspotOverlay({ hotspots, currentFrame, editable = false, selectedHotspotId, onSeekToFrame, onSelectHotspot, onDeleteHotspot, onUpdateHotspot, onNavigateToStep, onHotspotActivated, }: HotspotOverlayProps) { const [activeTooltipId, setActiveTooltipId] = useState(null); const [hoveredId, setHoveredId] = useState(null); const rootRef = useRef(null); const suppressClickRef = useRef(false); const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); const hexToRgba = (hex: string, alpha: number) => { const normalized = hex.trim().replace(/^#/, ""); const safeAlpha = clamp(alpha, 0, 1); const normalizedHex = normalized.length === 3 ? normalized .split("") .map((char) => `${char}${char}`) .join("") : normalized; const parsed = Number.parseInt(normalizedHex, 16); if (normalizedHex.length !== 6 || Number.isNaN(parsed)) { return `rgba(11,16,32,${safeAlpha})`; } const r = (parsed >> 16) & 255; const g = (parsed >> 8) & 255; const b = parsed & 255; return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`; }; const spotlightRadius = (shape: Hotspot["spotlightShape"]) => { if (shape === "square") return "0px"; if (shape === "circle") return "9999px"; return "var(--radius-md)"; }; const clientToPercent = (clientX: number, clientY: number) => { const rect = rootRef.current?.getBoundingClientRect(); if (!rect || rect.width <= 0 || rect.height <= 0) return null; const x = ((clientX - rect.left) / rect.width) * 100; const y = ((clientY - rect.top) / rect.height) * 100; return { x: clamp(x, 0, 100), y: clamp(y, 0, 100), rect }; }; const startMove = (event: React.PointerEvent, hotspot: Hotspot) => { if (!editable) return; if (!onUpdateHotspot) return; event.preventDefault(); event.stopPropagation(); onSelectHotspot?.(hotspot.id); const start = clientToPercent(event.clientX, event.clientY); if (!start) return; suppressClickRef.current = false; const startClientX = event.clientX; const startClientY = event.clientY; const offsetX = start.x - hotspot.x; const offsetY = start.y - hotspot.y; const onMove = (e: PointerEvent) => { const next = clientToPercent(e.clientX, e.clientY); if (!next) return; if (!suppressClickRef.current) { const dx = Math.abs(e.clientX - startClientX); const dy = Math.abs(e.clientY - startClientY); if (dx + dy > 3) suppressClickRef.current = true; } onUpdateHotspot(hotspot.id, (c) => { let x = next.x - offsetX; let y = next.y - offsetY; if (c.style === "rectangle") { const halfW = Math.max(0.5, c.width / 2); const halfH = Math.max(0.5, c.height / 2); x = clamp(x, halfW, 100 - halfW); y = clamp(y, halfH, 100 - halfH); } else { x = clamp(x, 0, 100); y = clamp(y, 0, 100); } return { ...c, x, y }; }); }; const onUp = () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); }; const startResizeArea = ( event: React.PointerEvent, hotspot: Hotspot, corner: "tl" | "tr" | "bl" | "br" ) => { if (!editable) return; if (!onUpdateHotspot) return; if (hotspot.style !== "rectangle") return; event.preventDefault(); event.stopPropagation(); onSelectHotspot?.(hotspot.id); const start = clientToPercent(event.clientX, event.clientY); if (!start) return; suppressClickRef.current = false; const startClientX = event.clientX; const startClientY = event.clientY; const startLeft = hotspot.x - hotspot.width / 2; const startRight = hotspot.x + hotspot.width / 2; const startTop = hotspot.y - hotspot.height / 2; const startBottom = hotspot.y + hotspot.height / 2; const MIN_SIZE = 2; // percent const onMove = (e: PointerEvent) => { const next = clientToPercent(e.clientX, e.clientY); if (!next) return; if (!suppressClickRef.current) { const dx = Math.abs(e.clientX - startClientX); const dy = Math.abs(e.clientY - startClientY); if (dx + dy > 3) suppressClickRef.current = true; } let left = startLeft; let right = startRight; let top = startTop; let bottom = startBottom; if (corner === "tl" || corner === "bl") left = next.x; if (corner === "tr" || corner === "br") right = next.x; if (corner === "tl" || corner === "tr") top = next.y; if (corner === "bl" || corner === "br") bottom = next.y; left = clamp(left, 0, 100); right = clamp(right, 0, 100); top = clamp(top, 0, 100); bottom = clamp(bottom, 0, 100); // Enforce minimum size while keeping the opposite edge fixed. if (right - left < MIN_SIZE) { if (corner === "tl" || corner === "bl") { left = right - MIN_SIZE; } else { right = left + MIN_SIZE; } left = clamp(left, 0, 100); right = clamp(right, 0, 100); } if (bottom - top < MIN_SIZE) { if (corner === "tl" || corner === "tr") { top = bottom - MIN_SIZE; } else { bottom = top + MIN_SIZE; } top = clamp(top, 0, 100); bottom = clamp(bottom, 0, 100); } const width = clamp(right - left, MIN_SIZE, 100); const height = clamp(bottom - top, MIN_SIZE, 100); const x = clamp(left + width / 2, width / 2, 100 - width / 2); const y = clamp(top + height / 2, height / 2, 100 - height / 2); onUpdateHotspot(hotspot.id, (c) => ({ ...c, x, y, width, height, style: "rectangle" })); }; const onUp = () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); }; const visibleHotspots = useMemo( () => hotspots.filter( (hotspot) => currentFrame >= hotspot.frameStart && currentFrame <= hotspot.frameEnd ), [currentFrame, hotspots] ); return (
{visibleHotspots.map((hotspot) => { const isSelected = selectedHotspotId === hotspot.id; const isHovered = hoveredId === hotspot.id; const isTooltipVisible = activeTooltipId === hotspot.id; const baseColor = hotspot.backgroundColor ?? "#5E6AD2"; const textColor = hotspot.textColor ?? "#FFFFFF"; const isArea = hotspot.style === "rectangle"; const isCallout = hotspot.style === "callout"; const compactPercent = Math.max(0.6, Math.min(hotspot.width, hotspot.height)); const compactSize = `clamp(14px, ${compactPercent}%, 24px)`; const ringColor = isSelected || isHovered || editable ? baseColor : `color-mix(in srgb, ${baseColor} 80%, transparent)`; const displayText = hotspot.text ?? (hotspot.action.type === "tooltip" ? hotspot.action.text : ""); const showCallout = isCallout; const spotlightEnabled = Boolean(hotspot.spotlight && isArea); const spotlightOverlayColor = hotspot.spotlightOverlayColor ?? "#0B1020"; const spotlightOverlayOpacity = typeof hotspot.spotlightOverlayOpacity === "number" ? clamp(hotspot.spotlightOverlayOpacity, 0, 0.9) : 0.58; const spotlightBorderOpacity = typeof hotspot.spotlightBorderOpacity === "number" ? clamp(hotspot.spotlightBorderOpacity, 0.1, 1) : 1; const spotlightShape = hotspot.spotlightShape ?? "rounded"; const isDestinationAction = hotspot.action.type === "next_step" || hotspot.action.type === "step" || hotspot.action.type === "this_step"; return (
setHoveredId(hotspot.id)} onMouseLeave={() => setHoveredId(null)} onClick={(event) => { event.stopPropagation(); if (suppressClickRef.current) { suppressClickRef.current = false; return; } if (editable) { onSelectHotspot?.(hotspot.id); return; } onHotspotActivated?.(hotspot.id); if (hotspot.action.type === "this_step") { onNavigateToStep?.({ type: "this_step" }, hotspot.id); return; } if (hotspot.action.type === "next_step") { onNavigateToStep?.({ type: "next_step" }, hotspot.id); return; } if (hotspot.action.type === "step") { onNavigateToStep?.(hotspot.action, hotspot.id); return; } if (hotspot.action.type === "seek") { onSeekToFrame?.(hotspot.action.targetFrame); } else if (hotspot.action.type === "url") { window.open( hotspot.action.href, hotspot.action.newTab ? "_blank" : "_self", hotspot.action.newTab ? "noopener,noreferrer" : undefined ); } else { if (hotspot.style === "callout") return; if (!displayText.trim()) return; setActiveTooltipId((c) => (c === hotspot.id ? null : hotspot.id)); } }} style={{ position: "absolute", left: `${hotspot.x}%`, top: `${hotspot.y}%`, width: isCallout ? "0px" : isArea ? `${hotspot.width}%` : compactSize, height: isCallout ? "0px" : isArea ? `${hotspot.height}%` : compactSize, transform: "translate(-50%, -50%)", borderRadius: isArea ? spotlightRadius(spotlightShape) : "999px", border: isCallout ? "none" : isArea ? isSelected ? `2px solid ${hexToRgba(baseColor, spotlightBorderOpacity)}` : isHovered || editable ? `1.5px solid ${hexToRgba(baseColor, spotlightBorderOpacity)}` : `1px solid ${hexToRgba(baseColor, spotlightBorderOpacity * 0.45)}` : `2px solid ${ringColor}`, background: isCallout ? "transparent" : isArea ? isSelected ? `${hexToRgba(baseColor, 0.2)}` : isHovered ? `${hexToRgba(baseColor, 0.1)}` : "transparent" : "transparent", cursor: editable ? "grab" : "pointer", pointerEvents: "auto", transition: "all 150ms var(--ease-out)", boxShadow: isSelected && !isCallout ? "var(--shadow-glow)" : "none", ...(spotlightEnabled ? { boxShadow: `0 0 0 9999px ${hexToRgba( spotlightOverlayColor, spotlightOverlayOpacity )}`, } : null), }} onPointerDown={(event) => { if (!editable) return; if (isCallout) return; // callout is dragged from its bubble startMove(event, hotspot); }} > {/* Inner dot for compact styles (Arcade-like) */} {!isArea && !isCallout && (
)} {hotspot.style === "pulse" && !isCallout && (
)} {/* Selection indicator - purple dot in corner when editing */} {editable && !isSelected && !isCallout && (
)} {/* Selection handles - only when selected */} {editable && isSelected && !isCallout && ( <> {onDeleteHotspot && ( )} {[ { key: "tl" as const, top: -7, left: -7, cursor: "nwse-resize", }, { key: "tr" as const, top: -7, right: -7, cursor: "nesw-resize", }, { key: "bl" as const, bottom: -7, left: -7, cursor: "nesw-resize", }, { key: "br" as const, bottom: -7, right: -7, cursor: "nwse-resize", }, ].map((pos) => (
{ if (!editable) return; if (!isArea) return; startResizeArea(event, hotspot, pos.key); }} style={{ position: "absolute", ...pos, width: isArea ? "16px" : "8px", height: isArea ? "16px" : "8px", background: baseColor, borderRadius: isArea ? "999px" : "2px", boxShadow: isArea ? "0 3px 10px rgba(0,0,0,0.35)" : "0 1px 3px rgba(0,0,0,0.4)", cursor: isArea ? pos.cursor : "default", pointerEvents: isArea ? "auto" : "none", }} /> ))} )} {/* Callout: always-visible bubble */} {showCallout && (
{ if (!editable) return; // Don't start drag if user interacted with the button area. const target = event.target as HTMLElement | null; if (target?.closest("button")) return; startMove(event, hotspot); }} style={{ position: "absolute", // Position bubble below the pointer. The pointer triangle then // "connects" the bubble back to the hotspot coordinate. top: "10px", left: "50%", transform: "translateX(-50%)", minWidth: "220px", maxWidth: "340px", background: baseColor, color: textColor, padding: "14px", fontSize: "13px", lineHeight: 1.45, borderRadius: "14px", boxShadow: "0 16px 46px rgba(0,0,0,0.45)", zIndex: 100, pointerEvents: "auto", display: "flex", alignItems: "center", gap: "12px", cursor: editable ? "grab" : !editable && isDestinationAction ? "pointer" : "default", }} > {isDestinationAction && ( )} {displayText.trim().length > 0 && (
{renderInlineMarkdown(displayText)}
)} {editable && isSelected && onDeleteHotspot && ( )}
)} {/* Tooltip */} {!editable && hotspot.style !== "callout" && displayText.trim().length > 0 && isTooltipVisible && (
{renderInlineMarkdown(displayText)}
)}
); })}
); } export default HotspotOverlay;