fix: HIGH issues #13,#14,#18,#19,#20 — frontend state/sync: copy-paste selection, dynamic activeLayer, group creation, Yjs awareness protocol, connect sync
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* 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.
|
||||
* Uses the Yjs awareness protocol (y-protocols/awareness) for ephemeral
|
||||
* state that should NOT be persisted in the shared Y.Doc.
|
||||
*
|
||||
* Reference: https://docs.yjs.dev/ecosystem/awareness
|
||||
*/
|
||||
import * as Y from 'yjs';
|
||||
import { Awareness, encodeAwarenessUpdate, applyAwarenessUpdate } from 'y-protocols/awareness';
|
||||
import type { YjsDocument } from './YjsDocument';
|
||||
|
||||
export interface UserCursor {
|
||||
@@ -20,13 +22,28 @@ export interface UserSelection {
|
||||
elementIds: string[];
|
||||
}
|
||||
|
||||
export interface AwarenessState {
|
||||
cursors: Y.Map<UserCursor>;
|
||||
selections: Y.Map<UserSelection>;
|
||||
/**
|
||||
* Payload stored under the 'cursor' field of each client's awareness state.
|
||||
*/
|
||||
interface CursorAwarenessPayload {
|
||||
userId: string;
|
||||
userName: string;
|
||||
color: string;
|
||||
x: number;
|
||||
y: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload stored under the 'selection' field of each client's awareness state.
|
||||
*/
|
||||
interface SelectionAwarenessPayload {
|
||||
userId: string;
|
||||
elementIds: string[];
|
||||
}
|
||||
|
||||
export class AwarenessManager {
|
||||
readonly state: AwarenessState;
|
||||
readonly awareness: Awareness;
|
||||
private yjsDoc: YjsDocument;
|
||||
private userId: string;
|
||||
private listeners: Set<() => void> = new Set();
|
||||
@@ -34,85 +51,160 @@ export class AwarenessManager {
|
||||
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'),
|
||||
};
|
||||
this.awareness = new Awareness(yjsDoc.doc);
|
||||
}
|
||||
|
||||
/** Set the local user's cursor position */
|
||||
setCursor(x: number, y: number, userName: string, color: string): void {
|
||||
this.state.cursors.set(this.userId, {
|
||||
const payload: CursorAwarenessPayload = {
|
||||
userId: this.userId,
|
||||
userName,
|
||||
color,
|
||||
x,
|
||||
y,
|
||||
visible: true,
|
||||
});
|
||||
};
|
||||
this.awareness.setLocalStateField('cursor', payload);
|
||||
}
|
||||
|
||||
/** 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 });
|
||||
const localState = this.awareness.getLocalState();
|
||||
if (localState && localState.cursor) {
|
||||
const cursor = localState.cursor as CursorAwarenessPayload;
|
||||
this.awareness.setLocalStateField('cursor', {
|
||||
...cursor,
|
||||
visible: false,
|
||||
} as CursorAwarenessPayload);
|
||||
}
|
||||
}
|
||||
|
||||
/** Set the local user's selection */
|
||||
setSelection(elementIds: string[]): void {
|
||||
this.state.selections.set(this.userId, {
|
||||
const payload: SelectionAwarenessPayload = {
|
||||
userId: this.userId,
|
||||
elementIds,
|
||||
});
|
||||
};
|
||||
this.awareness.setLocalStateField('selection', payload);
|
||||
}
|
||||
|
||||
/** Clear the local user's selection */
|
||||
clearSelection(): void {
|
||||
this.state.selections.delete(this.userId);
|
||||
this.awareness.setLocalStateField('selection', null);
|
||||
}
|
||||
|
||||
/** Remove the local user from awareness (on disconnect) */
|
||||
removeSelf(): void {
|
||||
this.state.cursors.delete(this.userId);
|
||||
this.state.selections.delete(this.userId);
|
||||
this.awareness.setLocalState(null);
|
||||
}
|
||||
|
||||
/** Get all active cursors */
|
||||
/**
|
||||
* Encode an awareness update for the local client.
|
||||
* Used by WebSocketProvider to send awareness state over the wire.
|
||||
*/
|
||||
encodeLocalState(): Uint8Array | null {
|
||||
const localState = this.awareness.getLocalState();
|
||||
if (!localState) return null;
|
||||
return encodeAwarenessUpdate(this.awareness, [this.awareness.clientID]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a remote awareness update received from the server.
|
||||
* Used by WebSocketProvider to process incoming awareness messages.
|
||||
*/
|
||||
applyRemoteUpdate(update: Uint8Array, origin: unknown = 'remote'): void {
|
||||
applyAwarenessUpdate(this.awareness, update, origin);
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/** Get all active cursors from all connected clients */
|
||||
getAllCursors(): UserCursor[] {
|
||||
return Array.from(this.state.cursors.values()).filter((c) => c.visible);
|
||||
const cursors: UserCursor[] = [];
|
||||
for (const [, state] of this.awareness.getStates()) {
|
||||
if (state && state.cursor) {
|
||||
const c = state.cursor as CursorAwarenessPayload;
|
||||
if (c.visible) {
|
||||
cursors.push({
|
||||
userId: c.userId,
|
||||
userName: c.userName,
|
||||
color: c.color,
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
visible: c.visible,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return cursors;
|
||||
}
|
||||
|
||||
/** Get all selections */
|
||||
/** Get all selections from all connected clients */
|
||||
getAllSelections(): UserSelection[] {
|
||||
return Array.from(this.state.selections.values());
|
||||
const selections: UserSelection[] = [];
|
||||
for (const [, state] of this.awareness.getStates()) {
|
||||
if (state && state.selection) {
|
||||
const s = state.selection as SelectionAwarenessPayload;
|
||||
selections.push({
|
||||
userId: s.userId,
|
||||
elementIds: s.elementIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
return selections;
|
||||
}
|
||||
|
||||
/** Get a specific user's cursor */
|
||||
getCursor(userId: string): UserCursor | undefined {
|
||||
return this.state.cursors.get(userId);
|
||||
for (const [, state] of this.awareness.getStates()) {
|
||||
if (state && state.cursor) {
|
||||
const c = state.cursor as CursorAwarenessPayload;
|
||||
if (c.userId === userId) {
|
||||
return {
|
||||
userId: c.userId,
|
||||
userName: c.userName,
|
||||
color: c.color,
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
visible: c.visible,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Get a specific user's selection */
|
||||
getSelection(userId: string): UserSelection | undefined {
|
||||
return this.state.selections.get(userId);
|
||||
for (const [, state] of this.awareness.getStates()) {
|
||||
if (state && state.selection) {
|
||||
const s = state.selection as SelectionAwarenessPayload;
|
||||
if (s.userId === userId) {
|
||||
return {
|
||||
userId: s.userId,
|
||||
elementIds: s.elementIds,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
const observer = () => this.notifyListeners();
|
||||
this.awareness.on('change', observer);
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
this.state.cursors.unobserve(cursorObserver);
|
||||
this.state.selections.unobserve(selectionObserver);
|
||||
this.awareness.off('change', observer);
|
||||
};
|
||||
}
|
||||
|
||||
/** Destroy the awareness instance */
|
||||
destroy(): void {
|
||||
this.awareness.destroy();
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
for (const cb of this.listeners) {
|
||||
cb();
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* WebSocketProvider – Custom WebSocket provider matching the backend raw-update protocol.
|
||||
* Backend sends Y.encodeStateAsUpdate on connect and applies raw updates on message.
|
||||
*
|
||||
* Issue #20: On connect, the client must NOT send its local state immediately.
|
||||
* Instead, the server sends its state first (Y.encodeStateAsUpdate). The client
|
||||
* applies that update to sync, then sends only incremental local updates.
|
||||
*/
|
||||
import * as Y from 'yjs';
|
||||
import type { YjsDocument } from './YjsDocument';
|
||||
import type { AwarenessManager } from './AwarenessManager';
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
@@ -11,15 +16,22 @@ export interface WebSocketProviderOptions {
|
||||
url: string;
|
||||
docName: string;
|
||||
yjsDoc: YjsDocument;
|
||||
awareness?: AwarenessManager;
|
||||
onStatusChange?: (status: ConnectionStatus) => void;
|
||||
onSync?: () => void;
|
||||
}
|
||||
|
||||
// Message type prefixes for multiplexing Y.Doc updates and awareness updates
|
||||
// Using a simple byte-prefix scheme: 0 = Y.Doc update, 1 = awareness update
|
||||
const MSG_DOC_UPDATE = 0;
|
||||
const MSG_AWARENESS = 1;
|
||||
|
||||
export class WebSocketProvider {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private docName: string;
|
||||
private yjsDoc: YjsDocument;
|
||||
private awareness?: AwarenessManager;
|
||||
private status: ConnectionStatus = 'disconnected';
|
||||
private onStatusChange?: (status: ConnectionStatus) => void;
|
||||
private onSync?: () => void;
|
||||
@@ -28,11 +40,13 @@ export class WebSocketProvider {
|
||||
private maxReconnectDelay = 30000;
|
||||
private shouldReconnect = true;
|
||||
private authToken: string | undefined;
|
||||
private synced = false;
|
||||
|
||||
constructor(opts: WebSocketProviderOptions) {
|
||||
this.url = opts.url;
|
||||
this.docName = opts.docName;
|
||||
this.yjsDoc = opts.yjsDoc;
|
||||
this.awareness = opts.awareness;
|
||||
this.onStatusChange = opts.onStatusChange;
|
||||
this.onSync = opts.onSync;
|
||||
}
|
||||
@@ -44,6 +58,7 @@ export class WebSocketProvider {
|
||||
|
||||
this.authToken = token;
|
||||
this.shouldReconnect = true;
|
||||
this.synced = false;
|
||||
this.setStatus('connecting');
|
||||
|
||||
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
@@ -61,20 +76,50 @@ export class WebSocketProvider {
|
||||
this.ws.onopen = () => {
|
||||
this.setStatus('connected');
|
||||
this.reconnectDelay = 1000;
|
||||
// Send local state to server for initial sync
|
||||
const stateUpdate = Y.encodeStateAsUpdate(this.yjsDoc.doc);
|
||||
if (stateUpdate.length > 0) {
|
||||
this.ws?.send(stateUpdate);
|
||||
// Issue #20: Do NOT send local state on connect.
|
||||
// The server sends its state (Y.encodeStateAsUpdate) to new clients.
|
||||
// We wait for that server state, apply it, then mark as synced.
|
||||
// Only after sync do we forward incremental local updates.
|
||||
//
|
||||
// If we have awareness, send our local awareness state after connect.
|
||||
if (this.awareness) {
|
||||
const awarenessUpdate = this.awareness.encodeLocalState();
|
||||
if (awarenessUpdate && awarenessUpdate.length > 0) {
|
||||
this.sendAwarenessUpdate(awarenessUpdate);
|
||||
}
|
||||
}
|
||||
this.onSync?.();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = new Uint8Array(event.data as ArrayBuffer);
|
||||
this.yjsDoc.applyUpdate(data);
|
||||
if (data.length === 0) return;
|
||||
|
||||
// Check message type prefix (first byte)
|
||||
const msgType = data[0];
|
||||
const payload = data.slice(1);
|
||||
|
||||
if (msgType === MSG_AWARENESS) {
|
||||
// Awareness update — apply to awareness manager
|
||||
if (this.awareness) {
|
||||
this.awareness.applyRemoteUpdate(payload, 'remote');
|
||||
}
|
||||
} else {
|
||||
// Y.Doc update — apply to Y.Doc
|
||||
// Support both prefixed and non-prefixed messages for backward compatibility
|
||||
// If the first byte looks like a valid Y.Doc update, use the full data;
|
||||
// otherwise treat the whole message as the Y.Doc update (legacy mode).
|
||||
const updateData = msgType === MSG_DOC_UPDATE ? payload : data;
|
||||
this.yjsDoc.applyUpdate(updateData);
|
||||
|
||||
// Mark as synced after receiving the first server state
|
||||
if (!this.synced) {
|
||||
this.synced = true;
|
||||
this.onSync?.();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[WebSocketProvider] Failed to apply update:', err);
|
||||
console.warn('[WebSocketProvider] Failed to process message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -84,6 +129,7 @@ export class WebSocketProvider {
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.ws = null;
|
||||
this.synced = false;
|
||||
this.setStatus('disconnected');
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
@@ -93,8 +139,22 @@ export class WebSocketProvider {
|
||||
|
||||
/** Send a local Y.Doc update to the server */
|
||||
sendUpdate(update: Uint8Array): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.synced) {
|
||||
// Prefix with MSG_DOC_UPDATE byte
|
||||
const msg = new Uint8Array(update.length + 1);
|
||||
msg[0] = MSG_DOC_UPDATE;
|
||||
msg.set(update, 1);
|
||||
this.ws.send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/** Send a local awareness update to the server */
|
||||
sendAwarenessUpdate(update: Uint8Array): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(update);
|
||||
const msg = new Uint8Array(update.length + 1);
|
||||
msg[0] = MSG_AWARENESS;
|
||||
msg.set(update, 1);
|
||||
this.ws.send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +172,25 @@ export class WebSocketProvider {
|
||||
};
|
||||
}
|
||||
|
||||
/** Listen for local awareness changes and forward to server */
|
||||
bindAwarenessUpdates(): () => void {
|
||||
if (!this.awareness) return () => {};
|
||||
const handler = () => {
|
||||
const update = this.awareness!.encodeLocalState();
|
||||
if (update && update.length > 0) {
|
||||
this.sendAwarenessUpdate(update);
|
||||
}
|
||||
};
|
||||
this.awareness.awareness.on('update', handler);
|
||||
return () => {
|
||||
this.awareness!.awareness.off('update', handler);
|
||||
};
|
||||
}
|
||||
|
||||
isSynced(): boolean {
|
||||
return this.synced;
|
||||
}
|
||||
|
||||
getStatus(): ConnectionStatus {
|
||||
return this.status;
|
||||
}
|
||||
@@ -126,6 +205,7 @@ export class WebSocketProvider {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.synced = false;
|
||||
this.setStatus('disconnected');
|
||||
}
|
||||
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
*/
|
||||
export { YjsDocument, type YjsDocumentData } from './YjsDocument';
|
||||
export { WebSocketProvider, type ConnectionStatus, type WebSocketProviderOptions } from './WebSocketProvider';
|
||||
export { AwarenessManager, type UserCursor, type UserSelection, type AwarenessState } from './AwarenessManager';
|
||||
export { AwarenessManager, type UserCursor, type UserSelection } from './AwarenessManager';
|
||||
export { useYjsBinding, type UseYjsBindingOptions, type UseYjsBindingResult } from './useYjsBinding';
|
||||
|
||||
@@ -82,17 +82,18 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
||||
const doc = new YjsDocument();
|
||||
yjsDocRef.current = doc;
|
||||
|
||||
const awareness = new AwarenessManager(doc, userId);
|
||||
awarenessRef.current = awareness;
|
||||
|
||||
const provider = new WebSocketProvider({
|
||||
url: wsUrl,
|
||||
docName,
|
||||
yjsDoc: doc,
|
||||
awareness,
|
||||
onStatusChange: (s) => setStatus(s),
|
||||
});
|
||||
providerRef.current = provider;
|
||||
|
||||
const awareness = new AwarenessManager(doc, userId);
|
||||
awarenessRef.current = awareness;
|
||||
|
||||
// Sync local state from Y.Doc on changes
|
||||
const syncState = () => {
|
||||
setElements(doc.getElements());
|
||||
@@ -123,6 +124,8 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
||||
|
||||
// Forward local updates to server
|
||||
const unbindLocal = provider.bindLocalUpdates();
|
||||
// Forward local awareness updates to server
|
||||
const unbindAwarenessUpdates = provider.bindAwarenessUpdates();
|
||||
|
||||
// Connect
|
||||
provider.connect(token);
|
||||
@@ -135,7 +138,9 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
||||
unbindDoc();
|
||||
unbindAwareness();
|
||||
unbindLocal();
|
||||
unbindAwarenessUpdates();
|
||||
awareness.removeSelf();
|
||||
awareness.destroy();
|
||||
provider.disconnect();
|
||||
doc.destroy();
|
||||
yjsDocRef.current = null;
|
||||
|
||||
Reference in New Issue
Block a user