122 lines
3.2 KiB
TypeScript
122 lines
3.2 KiB
TypeScript
/**
|
||
* 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<UserCursor>;
|
||
selections: Y.Map<UserSelection>;
|
||
}
|
||
|
||
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<UserCursor>('awareness-cursors'),
|
||
selections: this.yjsDoc.doc.getMap<UserSelection>('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();
|
||
}
|
||
}
|
||
}
|