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 { type RemoteScreenShare, useCall } from '../context/CallContext';
|
||||||
import { MonitorShareIcon } from './icons';
|
import { MonitorShareIcon } from './icons';
|
||||||
|
import { ScreenShareAnnotations } from './ScreenShareAnnotations';
|
||||||
|
|
||||||
interface ScreenShareViewerProps {
|
interface ScreenShareViewerProps {
|
||||||
share: RemoteScreenShare;
|
share: RemoteScreenShare;
|
||||||
@@ -34,6 +35,7 @@ export function ScreenShareViewer({
|
|||||||
const { watchingShareUserIds, watchShare } = useCall();
|
const { watchingShareUserIds, watchShare } = useCall();
|
||||||
const watching = watchingShareUserIds.has(share.participantId);
|
const watching = watchingShareUserIds.has(share.participantId);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [annotateEnabled, setAnnotateEnabled] = useState(false);
|
||||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,6 +98,7 @@ export function ScreenShareViewer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{watching ? (
|
{watching ? (
|
||||||
|
<div className="relative h-full w-full flex-1">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
autoPlay
|
autoPlay
|
||||||
@@ -109,8 +112,14 @@ export function ScreenShareViewer({
|
|||||||
// modes (where the video covers the whole tile).
|
// modes (where the video covers the whole tile).
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
onDoubleClick={toggleFullscreen}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user