/** * AwarenessManager – Tracks user presence, cursor position, and selection. * Uses a Y.Map inside the shared doc so awareness state syncs via the same * raw-update WebSocket protocol as the rest of the document. */ import * as Y from 'yjs'; import type { YjsDocument } from './YjsDocument'; export interface UserCursor { userId: string; userName: string; color: string; x: number; y: number; visible: boolean; } export interface UserSelection { userId: string; elementIds: string[]; } export interface AwarenessState { cursors: Y.Map; selections: Y.Map; } export class AwarenessManager { readonly state: AwarenessState; private yjsDoc: YjsDocument; private userId: string; private listeners: Set<() => void> = new Set(); constructor(yjsDoc: YjsDocument, userId: string) { this.yjsDoc = yjsDoc; this.userId = userId; this.state = { cursors: this.yjsDoc.doc.getMap('awareness-cursors'), selections: this.yjsDoc.doc.getMap('awareness-selections'), }; } /** Set the local user's cursor position */ setCursor(x: number, y: number, userName: string, color: string): void { this.state.cursors.set(this.userId, { userId: this.userId, userName, color, x, y, visible: true, }); } /** Hide the local user's cursor */ hideCursor(): void { const cur = this.state.cursors.get(this.userId); if (cur) { this.state.cursors.set(this.userId, { ...cur, visible: false }); } } /** Set the local user's selection */ setSelection(elementIds: string[]): void { this.state.selections.set(this.userId, { userId: this.userId, elementIds, }); } /** Clear the local user's selection */ clearSelection(): void { this.state.selections.delete(this.userId); } /** Remove the local user from awareness (on disconnect) */ removeSelf(): void { this.state.cursors.delete(this.userId); this.state.selections.delete(this.userId); } /** Get all active cursors */ getAllCursors(): UserCursor[] { return Array.from(this.state.cursors.values()).filter((c) => c.visible); } /** Get all selections */ getAllSelections(): UserSelection[] { return Array.from(this.state.selections.values()); } /** Get a specific user's cursor */ getCursor(userId: string): UserCursor | undefined { return this.state.cursors.get(userId); } /** Get a specific user's selection */ getSelection(userId: string): UserSelection | undefined { return this.state.selections.get(userId); } /** Register a callback for awareness changes */ onChange(callback: () => void): () => void { this.listeners.add(callback); const cursorObserver = () => this.notifyListeners(); const selectionObserver = () => this.notifyListeners(); this.state.cursors.observe(cursorObserver); this.state.selections.observe(selectionObserver); return () => { this.listeners.delete(callback); this.state.cursors.unobserve(cursorObserver); this.state.selections.unobserve(selectionObserver); }; } private notifyListeners(): void { for (const cb of this.listeners) { cb(); } } }