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:
A0 Orchestrator
2026-06-30 12:42:49 +02:00
parent 5a21fc0bfa
commit d09b1dfc2e
8 changed files with 353 additions and 62 deletions
+24 -8
View File
@@ -111,6 +111,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
// Right sidebar
const [activeRightPanel, setActiveRightPanel] = useState<RightPanel>('tool');
const [selectedElement, setSelectedElement] = useState<CADElement | null>(null);
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
const [layers, setLayers] = useState<CADLayer[]>(initialLayers);
const [activeLayerId, setActiveLayerId] = useState<string>('layer-0');
const [blocks, setBlocks] = useState<BlockDefinition[]>(initialBlocks);
@@ -191,6 +192,8 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
bgConfigRef.current = bgConfig;
const activeLayerIdRef = useRef(activeLayerId);
activeLayerIdRef.current = activeLayerId;
const selectedElementIdsRef = useRef(selectedElementIds);
selectedElementIdsRef.current = selectedElementIds;
// Clipboard (for copy/paste)
const clipboardRef = React.useRef<CADElement[] | null>(null);
@@ -642,11 +645,16 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
}, [blocks, activeLayerId, collab, drawingId, token]);
const handleSelectionChange = useCallback((selectedIds: string[]) => {
setSelectedElementIds(selectedIds);
if (selectedIds.length === 1) {
const el = elements.find(e => e.id === selectedIds[0]);
setSelectedElement(el ?? null);
} else {
} else if (selectedIds.length === 0) {
setSelectedElement(null);
} else {
// Multiple elements selected — keep the first one as a representative for the properties panel
const el = elements.find(e => e.id === selectedIds[0]);
setSelectedElement(el ?? null);
}
}, [elements]);
@@ -776,7 +784,8 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
if (action === 'undo') { handleUndo(); return; }
if (action === 'redo') { handleRedo(); return; }
if (action === 'copy') {
const selected = selectedElement ? [selectedElement] : [];
const ids = selectedElementIdsRef.current;
const selected = ids.length > 0 ? elements.filter(e => ids.includes(e.id)) : [];
const clip = selected.length > 0 ? selected : elements.slice(0, 1);
clipboardRef.current = clip;
setCommandHistory((prev) => [...prev, { prefix: '·', text: `${clip.length} Element(e) kopiert`, type: 'info' }]);
@@ -1136,10 +1145,17 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
// Group command: create group from currently selected elements
if (upper === 'GROUP' || upper === 'GRP') {
const gm = groupManagerRef.current;
// For now, group all elements (selection state is in InteractionEngine, not accessible here)
// In a full implementation, we'd need selected IDs from the interaction engine
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe erstellt (Auswahl im Canvas erforderlich)', type: 'info' }]);
setGroups(gm.getGroups());
const ids = selectedElementIdsRef.current;
if (ids.length < 2) {
setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Gruppe: Mindestens 2 Elemente auswählen', type: 'info' }]);
return;
}
const group = gm.createGroup(ids);
const newGroups = gm.getGroups();
setGroups(newGroups);
// Sync group to Yjs CRDT for real-time collaboration
collab.setGroup(group);
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Gruppe erstellt: ${group.name} (${ids.length} Elemente)`, type: 'info' }]);
return;
}
@@ -1194,7 +1210,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
setCommandHistory((prev) => [...prev, { prefix: '·', text: `Unbekannter Befehl: ${cmd}`, type: 'info' }]);
}
}
}, [handleUndo, handleRedo, handleRibbonAction]);
}, [handleUndo, handleRedo, handleRibbonAction, collab]);
const handleKISend = useCallback(async (text: string) => {
const userMsg: KIMessage = { id: `ki-${Date.now()}`, role: 'user', content: text };
@@ -1249,7 +1265,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
handleKISend(suggestion.label);
}, [handleKISend]);
const activeLayerName = layers.find((l) => l.id === 'layer-0')?.name ?? '—';
const activeLayerName = layers.find((l) => l.id === activeLayerId)?.name ?? '—';
// ─── Render ─────────────────────────────────────────────
return (
+125 -33
View File
@@ -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();
+88 -8
View File
@@ -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');
}
+1 -1
View File
@@ -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';
+8 -3
View File
@@ -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;