diff --git a/apps/desktop/src/components/ScreenShareAnnotations.tsx b/apps/desktop/src/components/ScreenShareAnnotations.tsx new file mode 100644 index 0000000..93bc18a --- /dev/null +++ b/apps/desktop/src/components/ScreenShareAnnotations.tsx @@ -0,0 +1,220 @@ +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) => ( +
+ )} +
+ + ); +} diff --git a/apps/desktop/src/components/ScreenShareViewer.tsx b/apps/desktop/src/components/ScreenShareViewer.tsx index e62cb62..abe9b1e 100644 --- a/apps/desktop/src/components/ScreenShareViewer.tsx +++ b/apps/desktop/src/components/ScreenShareViewer.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { type RemoteScreenShare, useCall } from '../context/CallContext'; import { MonitorShareIcon } from './icons'; +import { ScreenShareAnnotations } from './ScreenShareAnnotations'; interface ScreenShareViewerProps { share: RemoteScreenShare; @@ -34,6 +35,7 @@ export function ScreenShareViewer({ const { watchingShareUserIds, watchShare } = useCall(); const watching = watchingShareUserIds.has(share.participantId); const [isFullscreen, setIsFullscreen] = useState(false); + const [annotateEnabled, setAnnotateEnabled] = useState(false); const letter = displayName.trim().charAt(0).toUpperCase() || '?'; useEffect(() => { @@ -96,21 +98,28 @@ export function ScreenShareViewer({ {watching ? ( -