Add shared types and client core packages
The wire contract every client codes against, plus the annotation engine, canvas renderer and connection policy shared by both clients. Claude-Session: https://claude.ai/code/session_01SS9F92jb51bMCRCKem6QtD
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import type { CompletedStroke, NormalizedPoint } from "@pistation/shared-types";
|
||||
|
||||
import type { AnnotationState } from "./annotation-state.js";
|
||||
|
||||
export interface RenderTarget {
|
||||
context: CanvasRenderingContext2D;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
/// Participant id to display name, used to label remote pointers.
|
||||
labels?: Map<string, string>;
|
||||
}
|
||||
|
||||
export function renderAnnotations(
|
||||
target: RenderTarget,
|
||||
state: AnnotationState,
|
||||
options: RenderOptions = {}
|
||||
): void {
|
||||
const { context, width, height } = target;
|
||||
context.clearRect(0, 0, width, height);
|
||||
|
||||
for (const stroke of state.strokes) {
|
||||
drawStroke(target, stroke);
|
||||
}
|
||||
|
||||
for (const pointer of state.pointers) {
|
||||
drawPointer(target, pointer.point, pointer.color, options.labels?.get(pointer.participantId));
|
||||
}
|
||||
}
|
||||
|
||||
function drawStroke(target: RenderTarget, stroke: CompletedStroke): void {
|
||||
const { context, width, height } = target;
|
||||
if (stroke.points.length === 0) return;
|
||||
|
||||
context.save();
|
||||
context.globalAlpha = stroke.style.opacity;
|
||||
context.strokeStyle = stroke.style.color;
|
||||
context.fillStyle = stroke.style.color;
|
||||
context.lineWidth = Math.max(1, stroke.style.width * width);
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
|
||||
const first = toPixels(stroke.points[0], width, height);
|
||||
const last = toPixels(stroke.points[stroke.points.length - 1], width, height);
|
||||
|
||||
switch (stroke.tool) {
|
||||
case "rectangle":
|
||||
context.strokeRect(first.x, first.y, last.x - first.x, last.y - first.y);
|
||||
break;
|
||||
|
||||
case "ellipse": {
|
||||
const centerX = (first.x + last.x) / 2;
|
||||
const centerY = (first.y + last.y) / 2;
|
||||
context.beginPath();
|
||||
context.ellipse(
|
||||
centerX,
|
||||
centerY,
|
||||
Math.abs(last.x - first.x) / 2,
|
||||
Math.abs(last.y - first.y) / 2,
|
||||
0,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
context.stroke();
|
||||
break;
|
||||
}
|
||||
|
||||
case "arrow":
|
||||
drawArrow(context, first, last, context.lineWidth);
|
||||
break;
|
||||
|
||||
case "laser":
|
||||
drawPointer(target, stroke.points[stroke.points.length - 1], stroke.style.color);
|
||||
break;
|
||||
|
||||
default:
|
||||
drawFreehand(context, stroke.points, width, height);
|
||||
break;
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawFreehand(
|
||||
context: CanvasRenderingContext2D,
|
||||
points: NormalizedPoint[],
|
||||
width: number,
|
||||
height: number
|
||||
): void {
|
||||
context.beginPath();
|
||||
const start = toPixels(points[0], width, height);
|
||||
context.moveTo(start.x, start.y);
|
||||
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const previous = toPixels(points[index - 1], width, height);
|
||||
const current = toPixels(points[index], width, height);
|
||||
const midpoint = { x: (previous.x + current.x) / 2, y: (previous.y + current.y) / 2 };
|
||||
context.quadraticCurveTo(previous.x, previous.y, midpoint.x, midpoint.y);
|
||||
}
|
||||
|
||||
const final = toPixels(points[points.length - 1], width, height);
|
||||
context.lineTo(final.x, final.y);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function drawArrow(
|
||||
context: CanvasRenderingContext2D,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
lineWidth: number
|
||||
): void {
|
||||
const deltaX = to.x - from.x;
|
||||
const deltaY = to.y - from.y;
|
||||
const length = Math.hypot(deltaX, deltaY);
|
||||
if (length < 1) return;
|
||||
|
||||
// Unit vector along the arrow, and the perpendicular used for the head's width.
|
||||
const alongX = deltaX / length;
|
||||
const alongY = deltaY / length;
|
||||
const acrossX = -alongY;
|
||||
const acrossY = alongX;
|
||||
|
||||
// The head grows with the line, but never takes more than part of a short arrow.
|
||||
const headLength = Math.min(length * 0.45, Math.max(lineWidth * 3.6, 10));
|
||||
const halfWidth = headLength * 0.42;
|
||||
|
||||
const baseX = to.x - alongX * headLength;
|
||||
const baseY = to.y - alongY * headLength;
|
||||
|
||||
// Stopping the shaft just inside the head keeps a thick round cap from bulging out
|
||||
// through the point of the arrow.
|
||||
context.beginPath();
|
||||
context.moveTo(from.x, from.y);
|
||||
context.lineTo(baseX + alongX * lineWidth * 0.5, baseY + alongY * lineWidth * 0.5);
|
||||
context.stroke();
|
||||
|
||||
context.save();
|
||||
context.lineJoin = "miter";
|
||||
context.beginPath();
|
||||
context.moveTo(to.x, to.y);
|
||||
context.lineTo(baseX + acrossX * halfWidth, baseY + acrossY * halfWidth);
|
||||
context.lineTo(baseX - acrossX * halfWidth, baseY - acrossY * halfWidth);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawPointer(
|
||||
target: RenderTarget,
|
||||
point: NormalizedPoint,
|
||||
color: string,
|
||||
label?: string
|
||||
): void {
|
||||
const { context, width, height } = target;
|
||||
const position = toPixels(point, width, height);
|
||||
const radius = Math.max(3, width * 0.0028);
|
||||
|
||||
context.save();
|
||||
context.globalAlpha = 0.22;
|
||||
context.fillStyle = color;
|
||||
context.beginPath();
|
||||
context.arc(position.x, position.y, radius * 2.1, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
|
||||
context.globalAlpha = 1;
|
||||
context.beginPath();
|
||||
context.arc(position.x, position.y, radius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
|
||||
if (label) {
|
||||
drawPointerLabel(context, position, radius, color, label, width);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawPointerLabel(
|
||||
context: CanvasRenderingContext2D,
|
||||
position: { x: number; y: number },
|
||||
radius: number,
|
||||
color: string,
|
||||
label: string,
|
||||
width: number
|
||||
): void {
|
||||
const fontSize = Math.max(10, width * 0.0072);
|
||||
context.font = `600 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
context.textBaseline = "middle";
|
||||
|
||||
const paddingX = fontSize * 0.5;
|
||||
const paddingY = fontSize * 0.3;
|
||||
const textWidth = context.measureText(label).width;
|
||||
const boxWidth = textWidth + paddingX * 2;
|
||||
const boxHeight = fontSize + paddingY * 2;
|
||||
|
||||
const boxX = position.x + radius * 2.6;
|
||||
const boxY = position.y - boxHeight / 2;
|
||||
|
||||
context.globalAlpha = 0.92;
|
||||
context.fillStyle = color;
|
||||
context.fillRect(boxX, boxY, boxWidth, boxHeight);
|
||||
|
||||
context.globalAlpha = 1;
|
||||
context.fillStyle = "#0b0d10";
|
||||
context.fillText(label, boxX + paddingX, position.y);
|
||||
}
|
||||
|
||||
function toPixels(point: NormalizedPoint, width: number, height: number) {
|
||||
return { x: point.x * width, y: point.y * height };
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type {
|
||||
AnnotationEvent,
|
||||
CompletedStroke,
|
||||
NormalizedPoint,
|
||||
StrokeStyle
|
||||
} from "@pistation/shared-types";
|
||||
import { isPersistentTool } from "@pistation/shared-types";
|
||||
|
||||
export interface LiveStroke extends CompletedStroke {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export interface RemotePointer {
|
||||
participantId: string;
|
||||
point: NormalizedPoint;
|
||||
color: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface AnnotationState {
|
||||
strokes: LiveStroke[];
|
||||
pointers: RemotePointer[];
|
||||
}
|
||||
|
||||
export const POINTER_TIMEOUT_MS = 4000;
|
||||
|
||||
export function createAnnotationState(): AnnotationState {
|
||||
return { strokes: [], pointers: [] };
|
||||
}
|
||||
|
||||
export function applyAnnotationEvent(
|
||||
state: AnnotationState,
|
||||
event: AnnotationEvent,
|
||||
authorId: string
|
||||
): AnnotationState {
|
||||
switch (event.type) {
|
||||
case "stroke.start":
|
||||
return {
|
||||
...state,
|
||||
strokes: [
|
||||
...state.strokes.filter((stroke) => stroke.strokeId !== event.strokeId),
|
||||
{
|
||||
strokeId: event.strokeId,
|
||||
authorId,
|
||||
tool: event.tool,
|
||||
style: event.style,
|
||||
points: [event.point],
|
||||
isOpen: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
case "stroke.append":
|
||||
return {
|
||||
...state,
|
||||
strokes: state.strokes.map((stroke) =>
|
||||
stroke.strokeId === event.strokeId
|
||||
? { ...stroke, points: [...stroke.points, ...event.points] }
|
||||
: stroke
|
||||
)
|
||||
};
|
||||
|
||||
case "stroke.end":
|
||||
return {
|
||||
...state,
|
||||
strokes: state.strokes
|
||||
.map((stroke) =>
|
||||
stroke.strokeId === event.strokeId ? { ...stroke, isOpen: false } : stroke
|
||||
)
|
||||
.filter((stroke) => isPersistentTool(stroke.tool))
|
||||
};
|
||||
|
||||
case "stroke.erase":
|
||||
return {
|
||||
...state,
|
||||
strokes: state.strokes.filter((stroke) => !event.strokeIds.includes(stroke.strokeId))
|
||||
};
|
||||
|
||||
case "canvas.clear":
|
||||
return { ...state, strokes: [] };
|
||||
|
||||
case "pointer.move": {
|
||||
const others = state.pointers.filter((pointer) => pointer.participantId !== authorId);
|
||||
if (!event.point) return { ...state, pointers: others };
|
||||
return {
|
||||
...state,
|
||||
pointers: [
|
||||
...others,
|
||||
{
|
||||
participantId: authorId,
|
||||
point: event.point,
|
||||
color: event.color,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function prunePointers(state: AnnotationState, now: number): AnnotationState {
|
||||
const fresh = state.pointers.filter((pointer) => now - pointer.updatedAt < POINTER_TIMEOUT_MS);
|
||||
return fresh.length === state.pointers.length ? state : { ...state, pointers: fresh };
|
||||
}
|
||||
|
||||
export function eraseAtPoint(
|
||||
state: AnnotationState,
|
||||
point: NormalizedPoint,
|
||||
radius: number
|
||||
): string[] {
|
||||
const hits: string[] = [];
|
||||
for (const stroke of state.strokes) {
|
||||
if (stroke.points.some((candidate) => distance(candidate, point) <= radius)) {
|
||||
hits.push(stroke.strokeId);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/// Authors with a stroke still in progress, so the UI can say who is drawing right now.
|
||||
export function activeAuthors(state: AnnotationState): string[] {
|
||||
const authors = new Set<string>();
|
||||
for (const stroke of state.strokes) {
|
||||
if (stroke.isOpen) authors.add(stroke.authorId);
|
||||
}
|
||||
return [...authors];
|
||||
}
|
||||
|
||||
export function makeStrokeId(): string {
|
||||
return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function withColor(style: StrokeStyle, color: string): StrokeStyle {
|
||||
return { ...style, color };
|
||||
}
|
||||
|
||||
function distance(a: NormalizedPoint, b: NormalizedPoint): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./annotation-renderer.js";
|
||||
export * from "./annotation-state.js";
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { DataEnvelope, DataTopic, TopicPayloadMap } from "@pistation/shared-types";
|
||||
import { decodeEnvelope, encodeEnvelope } from "@pistation/shared-types";
|
||||
import { Room, RoomEvent } from "livekit-client";
|
||||
|
||||
export type ConnectionStatus =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnected"
|
||||
| "failed";
|
||||
|
||||
export interface Credentials {
|
||||
livekitUrl: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface ConnectionHandlers {
|
||||
onStatus(status: ConnectionStatus): void;
|
||||
onEnvelope(envelope: DataEnvelope): void;
|
||||
onReconnected?(): void;
|
||||
onConnectionError?(detail: string): void;
|
||||
}
|
||||
|
||||
const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 15000, 30000];
|
||||
|
||||
export function createRoom(): Room {
|
||||
return new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
disconnectOnPageLeave: true
|
||||
});
|
||||
}
|
||||
|
||||
export function publishEnvelope<T extends DataTopic>(
|
||||
room: Room,
|
||||
topic: T,
|
||||
senderId: string,
|
||||
payload: TopicPayloadMap[T],
|
||||
reliable = true
|
||||
): void {
|
||||
if (room.state !== "connected") return;
|
||||
const data = encodeEnvelope(topic, senderId, payload);
|
||||
void room.localParticipant.publishData(data, { reliable, topic });
|
||||
}
|
||||
|
||||
export class RoomConnection {
|
||||
readonly room: Room;
|
||||
|
||||
private handlers: ConnectionHandlers;
|
||||
private fetchCredentials: () => Promise<Credentials>;
|
||||
private retryIndex = 0;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stopped = false;
|
||||
private visibilityListener: (() => void) | null = null;
|
||||
|
||||
constructor(fetchCredentials: () => Promise<Credentials>, handlers: ConnectionHandlers) {
|
||||
this.room = createRoom();
|
||||
this.handlers = handlers;
|
||||
this.fetchCredentials = fetchCredentials;
|
||||
this.bindRoomEvents();
|
||||
this.bindVisibility();
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.stopped = false;
|
||||
await this.attempt();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
this.clearRetry();
|
||||
if (this.visibilityListener && typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", this.visibilityListener);
|
||||
this.visibilityListener = null;
|
||||
}
|
||||
await this.room.disconnect();
|
||||
this.handlers.onStatus("disconnected");
|
||||
}
|
||||
|
||||
private bindRoomEvents(): void {
|
||||
this.room
|
||||
.on(RoomEvent.Connected, () => {
|
||||
this.retryIndex = 0;
|
||||
this.handlers.onStatus("connected");
|
||||
})
|
||||
.on(RoomEvent.Reconnecting, () => this.handlers.onStatus("reconnecting"))
|
||||
.on(RoomEvent.Reconnected, () => {
|
||||
this.retryIndex = 0;
|
||||
this.handlers.onStatus("connected");
|
||||
this.handlers.onReconnected?.();
|
||||
})
|
||||
.on(RoomEvent.Disconnected, () => {
|
||||
if (this.stopped) return;
|
||||
this.handlers.onStatus("disconnected");
|
||||
this.scheduleRetry();
|
||||
})
|
||||
.on(RoomEvent.DataReceived, (payload: Uint8Array) => {
|
||||
const envelope = decodeEnvelope(payload);
|
||||
if (envelope) this.handlers.onEnvelope(envelope);
|
||||
});
|
||||
}
|
||||
|
||||
private bindVisibility(): void {
|
||||
if (typeof document === "undefined") return;
|
||||
this.visibilityListener = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (this.stopped) return;
|
||||
if (this.room.state === "connected") return;
|
||||
this.clearRetry();
|
||||
this.retryIndex = 0;
|
||||
void this.attempt();
|
||||
};
|
||||
document.addEventListener("visibilitychange", this.visibilityListener);
|
||||
}
|
||||
|
||||
private async attempt(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
this.handlers.onStatus("connecting");
|
||||
|
||||
try {
|
||||
const credentials = await this.fetchCredentials();
|
||||
await this.room.connect(credentials.livekitUrl, credentials.accessToken);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[pistation] livekit connection failed: ${detail}`);
|
||||
this.handlers.onConnectionError?.(detail);
|
||||
this.scheduleRetry();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRetry(): void {
|
||||
if (this.stopped || this.retryTimer) return;
|
||||
|
||||
const delay = RETRY_DELAYS_MS[Math.min(this.retryIndex, RETRY_DELAYS_MS.length - 1)];
|
||||
this.retryIndex += 1;
|
||||
this.handlers.onStatus("reconnecting");
|
||||
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
void this.attempt();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private clearRetry(): void {
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./annotation-renderer.js";
|
||||
export * from "./annotation-state.js";
|
||||
export * from "./connection.js";
|
||||
Reference in New Issue
Block a user