feat(call): shared annotation overlay on screen share
This commit is contained in:
@@ -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<string>(COLORS[0]);
|
||||
const [strokes, setStrokes] = useState<Stroke[]>([]);
|
||||
const draftRef = useRef<Stroke | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
if (!draftRef.current) return;
|
||||
draftRef.current.points.push(normalized(e));
|
||||
setStrokes((prev) => prev.slice()); // cheap re-render trigger
|
||||
};
|
||||
|
||||
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
className={
|
||||
'absolute inset-0 z-10 ' +
|
||||
(enabled ? 'cursor-crosshair touch-none' : 'pointer-events-none')
|
||||
}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-3 top-12 z-20 flex flex-col items-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleEnabled(!enabled)}
|
||||
aria-pressed={enabled}
|
||||
title={enabled ? 'Annotation aus' : 'Annotation an'}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-white shadow-lg transition ' +
|
||||
(enabled ? 'bg-accent' : 'bg-black/60 hover:bg-black/80')
|
||||
}
|
||||
>
|
||||
{enabled ? <XIcon className="h-3.5 w-3.5" /> : <PencilIcon className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
{enabled && (
|
||||
<div className="flex items-center gap-1 rounded-full bg-black/60 p-1 shadow-lg">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
aria-label={c}
|
||||
aria-pressed={c === color}
|
||||
className={
|
||||
'h-5 w-5 cursor-pointer rounded-full border-2 transition ' +
|
||||
(c === color ? 'border-white scale-110' : 'border-white/30 hover:scale-105')
|
||||
}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +98,7 @@ export function ScreenShareViewer({
|
||||
</div>
|
||||
|
||||
{watching ? (
|
||||
<div className="relative h-full w-full flex-1">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
@@ -109,8 +112,14 @@ export function ScreenShareViewer({
|
||||
// modes (where the video covers the whole tile).
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
|
||||
className="block h-full w-full cursor-zoom-in bg-black object-contain"
|
||||
/>
|
||||
<ScreenShareAnnotations
|
||||
shareKey={share.participantId}
|
||||
enabled={annotateEnabled}
|
||||
onToggleEnabled={setAnnotateEnabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user