import { useCallback, useEffect, useRef, useState } from 'react'; import { useAuth } from '../context/AuthContext'; import { supabase } from '../lib/supabase'; import { PencilIcon, XIcon } from './icons'; interface Stroke { id: string; userId: string; color: string; // normalized 0..1 coordinates so any viewer's canvas size renders consistently points: Array<[number, number]>; bornAt: number; } interface Props { /** Stable per-share key. Use the share's participantId. */ shareKey: string; /** Render annotations transparently (off when the toolbar is closed). */ enabled: boolean; onToggleEnabled: (next: boolean) => void; } const FADE_MS = 8000; const COLORS = ['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#000000'] as const; export function ScreenShareAnnotations({ shareKey, enabled, onToggleEnabled }: Props) { const { session } = useAuth(); const userId = session?.user.id ?? 'anon'; const [color, setColor] = useState(COLORS[0]); const [strokes, setStrokes] = useState([]); const draftRef = useRef(null); const containerRef = useRef(null); const canvasRef = useRef(null); const broadcastRef = useRef<((s: Stroke) => void) | null>(null); // Subscribe to remote strokes. useEffect(() => { const channel = supabase.channel('screen-annotation:' + shareKey, { config: { broadcast: { self: false } }, }); channel.on('broadcast', { event: 'stroke' }, (payload) => { const s = payload.payload as Stroke | undefined; if (!s || s.userId === userId) return; setStrokes((prev) => [...prev, { ...s, bornAt: Date.now() }]); }); channel.subscribe(); broadcastRef.current = (s: Stroke) => { void channel.send({ type: 'broadcast', event: 'stroke', payload: s, }); }; return () => { broadcastRef.current = null; void supabase.removeChannel(channel); }; }, [shareKey, userId]); // Garbage-collect faded strokes after FADE_MS + a small grace window. useEffect(() => { if (strokes.length === 0) return; const id = setInterval(() => { const cutoff = Date.now() - FADE_MS - 500; setStrokes((prev) => { const next = prev.filter((s) => s.bornAt > cutoff); return next.length === prev.length ? prev : next; }); }, 1000); return () => clearInterval(id); }, [strokes.length]); // Paint the canvas on every render tick. useEffect(() => { const cv = canvasRef.current; const container = containerRef.current; if (!cv || !container) return; const rect = container.getBoundingClientRect(); if (cv.width !== rect.width || cv.height !== rect.height) { cv.width = Math.max(1, Math.floor(rect.width)); cv.height = Math.max(1, Math.floor(rect.height)); } const ctx = cv.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, cv.width, cv.height); const now = Date.now(); const drawStroke = (s: Stroke) => { const age = now - s.bornAt; const alpha = Math.max(0, 1 - age / FADE_MS); if (alpha <= 0) return; ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = s.color; ctx.lineWidth = 3; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); for (let i = 0; i < s.points.length; i++) { const [nx, ny] = s.points[i]!; const x = nx * cv.width; const y = ny * cv.height; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); ctx.restore(); }; for (const s of strokes) drawStroke(s); if (draftRef.current) drawStroke(draftRef.current); }); // Animation frame loop so faded strokes visually decay between paints. useEffect(() => { if (strokes.length === 0 && !draftRef.current) return; let raf = 0; const tick = () => { // Nudge state to force a re-paint. Slightly hacky but cheaper than a // dedicated refresh state. setStrokes((prev) => prev.slice()); raf = window.requestAnimationFrame(tick); }; raf = window.requestAnimationFrame(tick); return () => window.cancelAnimationFrame(raf); }, [strokes.length]); const normalized = useCallback((e: React.PointerEvent) => { const container = containerRef.current; if (!container) return [0, 0] as [number, number]; const rect = container.getBoundingClientRect(); const nx = (e.clientX - rect.left) / rect.width; const ny = (e.clientY - rect.top) / rect.height; return [Math.min(1, Math.max(0, nx)), Math.min(1, Math.max(0, ny))] as [number, number]; }, []); const onPointerDown = (e: React.PointerEvent) => { if (!enabled) return; (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); draftRef.current = { id: Math.random().toString(36).slice(2), userId, color, points: [normalized(e)], bornAt: Date.now(), }; }; const onPointerMove = (e: React.PointerEvent) => { if (!draftRef.current) return; draftRef.current.points.push(normalized(e)); setStrokes((prev) => prev.slice()); // cheap re-render trigger }; const onPointerUp = (e: React.PointerEvent) => { const target = e.currentTarget as HTMLDivElement; if (target.hasPointerCapture(e.pointerId)) target.releasePointerCapture(e.pointerId); const draft = draftRef.current; draftRef.current = null; if (!draft || draft.points.length < 2) { setStrokes((prev) => prev.slice()); return; } setStrokes((prev) => [...prev, draft]); broadcastRef.current?.(draft); }; return ( <>
{enabled && (
{COLORS.map((c) => (
)}
); }