feat: initial commit web-cad-neu with docker-compose, frontend and backend
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* WebSocketProvider – Custom WebSocket provider matching the backend raw-update protocol.
|
||||
* Backend sends Y.encodeStateAsUpdate on connect and applies raw updates on message.
|
||||
*/
|
||||
import * as Y from 'yjs';
|
||||
import type { YjsDocument } from './YjsDocument';
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export interface WebSocketProviderOptions {
|
||||
url: string;
|
||||
docName: string;
|
||||
yjsDoc: YjsDocument;
|
||||
onStatusChange?: (status: ConnectionStatus) => void;
|
||||
onSync?: () => void;
|
||||
}
|
||||
|
||||
export class WebSocketProvider {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private docName: string;
|
||||
private yjsDoc: YjsDocument;
|
||||
private status: ConnectionStatus = 'disconnected';
|
||||
private onStatusChange?: (status: ConnectionStatus) => void;
|
||||
private onSync?: () => void;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectDelay = 1000;
|
||||
private maxReconnectDelay = 30000;
|
||||
private shouldReconnect = true;
|
||||
|
||||
constructor(opts: WebSocketProviderOptions) {
|
||||
this.url = opts.url;
|
||||
this.docName = opts.docName;
|
||||
this.yjsDoc = opts.yjsDoc;
|
||||
this.onStatusChange = opts.onStatusChange;
|
||||
this.onSync = opts.onSync;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldReconnect = true;
|
||||
this.setStatus('connecting');
|
||||
|
||||
const fullUrl = `${this.url}/ws/collab/${encodeURIComponent(this.docName)}`;
|
||||
this.ws = new WebSocket(fullUrl);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
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);
|
||||
}
|
||||
this.onSync?.();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = new Uint8Array(event.data as ArrayBuffer);
|
||||
this.yjsDoc.applyUpdate(data);
|
||||
} catch (err) {
|
||||
console.warn('[WebSocketProvider] Failed to apply update:', err);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.setStatus('error');
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.ws = null;
|
||||
this.setStatus('disconnected');
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Send a local Y.Doc update to the server */
|
||||
sendUpdate(update: Uint8Array): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(update);
|
||||
}
|
||||
}
|
||||
|
||||
/** Listen for local doc changes and forward to server */
|
||||
bindLocalUpdates(): () => void {
|
||||
const handler = (update: Uint8Array, origin: unknown) => {
|
||||
// Only send updates that originated locally (not from remote apply)
|
||||
if (origin !== 'remote') {
|
||||
this.sendUpdate(update);
|
||||
}
|
||||
};
|
||||
this.yjsDoc.doc.on('update', handler);
|
||||
return () => {
|
||||
this.yjsDoc.doc.off('update', handler);
|
||||
};
|
||||
}
|
||||
|
||||
getStatus(): ConnectionStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.setStatus('disconnected');
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) return;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, this.reconnectDelay);
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
||||
}
|
||||
|
||||
private setStatus(status: ConnectionStatus): void {
|
||||
if (this.status !== status) {
|
||||
this.status = status;
|
||||
this.onStatusChange?.(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* YjsDocument – Manages a Y.Doc with shared maps for CAD elements, layers, and blocks.
|
||||
* Provides helpers to sync local state with the CRDT document.
|
||||
*/
|
||||
import * as Y from 'yjs';
|
||||
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types';
|
||||
|
||||
export interface YjsDocumentData {
|
||||
elements: Y.Map<CADElement>;
|
||||
layers: Y.Map<CADLayer>;
|
||||
blocks: Y.Map<BlockDefinition>;
|
||||
meta: Y.Map<unknown>;
|
||||
}
|
||||
|
||||
export class YjsDocument {
|
||||
readonly doc: Y.Doc;
|
||||
readonly data: YjsDocumentData;
|
||||
|
||||
constructor() {
|
||||
this.doc = new Y.Doc();
|
||||
this.data = {
|
||||
elements: this.doc.getMap<CADElement>('elements'),
|
||||
layers: this.doc.getMap<CADLayer>('layers'),
|
||||
blocks: this.doc.getMap<BlockDefinition>('blocks'),
|
||||
meta: this.doc.getMap<unknown>('meta'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Get all elements as a plain array */
|
||||
getElements(): CADElement[] {
|
||||
return Array.from(this.data.elements.values());
|
||||
}
|
||||
|
||||
/** Get all layers as a plain array */
|
||||
getLayers(): CADLayer[] {
|
||||
return Array.from(this.data.layers.values());
|
||||
}
|
||||
|
||||
/** Get all blocks as a plain array */
|
||||
getBlocks(): BlockDefinition[] {
|
||||
return Array.from(this.data.blocks.values());
|
||||
}
|
||||
|
||||
/** Add or update a single element */
|
||||
setElement(el: CADElement): void {
|
||||
this.doc.transact(() => {
|
||||
this.data.elements.set(el.id, el);
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove an element by id */
|
||||
deleteElement(id: string): void {
|
||||
this.data.elements.delete(id);
|
||||
}
|
||||
|
||||
/** Add or update a layer */
|
||||
setLayer(layer: CADLayer): void {
|
||||
this.data.layers.set(layer.id, layer);
|
||||
}
|
||||
|
||||
/** Remove a layer by id */
|
||||
deleteLayer(id: string): void {
|
||||
this.data.layers.delete(id);
|
||||
}
|
||||
|
||||
/** Add or update a block definition */
|
||||
setBlock(block: BlockDefinition): void {
|
||||
this.data.blocks.set(block.id, block);
|
||||
}
|
||||
|
||||
/** Remove a block by id */
|
||||
deleteBlock(id: string): void {
|
||||
this.data.blocks.delete(id);
|
||||
}
|
||||
|
||||
/** Bulk-load local state into the Y.Doc (replaces all content) */
|
||||
loadFromState(state: {
|
||||
elements: CADElement[];
|
||||
layers: CADLayer[];
|
||||
blocks: BlockDefinition[];
|
||||
}): void {
|
||||
this.doc.transact(() => {
|
||||
this.data.elements.clear();
|
||||
for (const el of state.elements) {
|
||||
this.data.elements.set(el.id, el);
|
||||
}
|
||||
this.data.layers.clear();
|
||||
for (const layer of state.layers) {
|
||||
this.data.layers.set(layer.id, layer);
|
||||
}
|
||||
this.data.blocks.clear();
|
||||
for (const block of state.blocks) {
|
||||
this.data.blocks.set(block.id, block);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Export current state as a plain object */
|
||||
toState(): {
|
||||
elements: CADElement[];
|
||||
layers: CADLayer[];
|
||||
blocks: BlockDefinition[];
|
||||
} {
|
||||
return {
|
||||
elements: this.getElements(),
|
||||
layers: this.getLayers(),
|
||||
blocks: this.getBlocks(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Encode full document state as update binary */
|
||||
encodeState(): Uint8Array {
|
||||
return Y.encodeStateAsUpdate(this.doc);
|
||||
}
|
||||
|
||||
/** Apply a remote update binary */
|
||||
applyUpdate(update: Uint8Array): void {
|
||||
Y.applyUpdate(this.doc, update);
|
||||
}
|
||||
|
||||
/** Register a callback for document changes */
|
||||
onChange(callback: () => void): () => void {
|
||||
this.doc.on('update', callback);
|
||||
return () => this.doc.off('update', callback);
|
||||
}
|
||||
|
||||
/** Destroy the document */
|
||||
destroy(): void {
|
||||
this.doc.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* CRDT module – Real-time collaboration via Yjs
|
||||
*/
|
||||
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 { useYjsBinding, type UseYjsBindingOptions, type UseYjsBindingResult } from './useYjsBinding';
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* useYjsBinding – React hook that ties YjsDocument, WebSocketProvider,
|
||||
* and AwarenessManager together for real-time collaboration.
|
||||
*/
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
import { YjsDocument } from './YjsDocument';
|
||||
import { WebSocketProvider, type ConnectionStatus } from './WebSocketProvider';
|
||||
import { AwarenessManager, type UserCursor, type UserSelection } from './AwarenessManager';
|
||||
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types';
|
||||
|
||||
export interface UseYjsBindingOptions {
|
||||
docName: string;
|
||||
wsUrl?: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
userColor: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseYjsBindingResult {
|
||||
yjsDoc: YjsDocument | null;
|
||||
provider: WebSocketProvider | null;
|
||||
awareness: AwarenessManager | null;
|
||||
status: ConnectionStatus;
|
||||
elements: CADElement[];
|
||||
layers: CADLayer[];
|
||||
blocks: BlockDefinition[];
|
||||
cursors: UserCursor[];
|
||||
selections: UserSelection[];
|
||||
setElement: (el: CADElement) => void;
|
||||
deleteElement: (id: string) => void;
|
||||
setLayer: (layer: CADLayer) => void;
|
||||
deleteLayer: (id: string) => void;
|
||||
setBlock: (block: BlockDefinition) => void;
|
||||
deleteBlock: (id: string) => void;
|
||||
loadFromState: (state: { elements: CADElement[]; layers: CADLayer[]; blocks: BlockDefinition[] }) => void;
|
||||
setCursor: (x: number, y: number) => void;
|
||||
hideCursor: () => void;
|
||||
setSelection: (elementIds: string[]) => void;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_WS_URL = `ws://${window.location.hostname}:3001`;
|
||||
|
||||
export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
||||
const { docName, wsUrl = DEFAULT_WS_URL, userId, userName, userColor, enabled = true } = opts;
|
||||
|
||||
const yjsDocRef = useRef<YjsDocument | null>(null);
|
||||
const providerRef = useRef<WebSocketProvider | null>(null);
|
||||
const awarenessRef = useRef<AwarenessManager | null>(null);
|
||||
const unbindRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
||||
const [elements, setElements] = useState<CADElement[]>([]);
|
||||
const [layers, setLayers] = useState<CADLayer[]>([]);
|
||||
const [blocks, setBlocks] = useState<BlockDefinition[]>([]);
|
||||
const [cursors, setCursors] = useState<UserCursor[]>([]);
|
||||
const [selections, setSelections] = useState<UserSelection[]>([]);
|
||||
|
||||
// Initialize Yjs document, provider, and awareness
|
||||
useEffect(() => {
|
||||
if (!enabled || !docName) return;
|
||||
|
||||
const doc = new YjsDocument();
|
||||
yjsDocRef.current = doc;
|
||||
|
||||
const provider = new WebSocketProvider({
|
||||
url: wsUrl,
|
||||
docName,
|
||||
yjsDoc: doc,
|
||||
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());
|
||||
setLayers(doc.getLayers());
|
||||
setBlocks(doc.getBlocks());
|
||||
setCursors(awareness.getAllCursors());
|
||||
setSelections(awareness.getAllSelections());
|
||||
};
|
||||
|
||||
const unbindDoc = doc.onChange(syncState);
|
||||
const unbindAwareness = awareness.onChange(syncState);
|
||||
|
||||
// Forward local updates to server
|
||||
const unbindLocal = provider.bindLocalUpdates();
|
||||
|
||||
// Connect
|
||||
provider.connect();
|
||||
syncState();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
unbindDoc();
|
||||
unbindAwareness();
|
||||
unbindLocal();
|
||||
awareness.removeSelf();
|
||||
provider.disconnect();
|
||||
doc.destroy();
|
||||
yjsDocRef.current = null;
|
||||
providerRef.current = null;
|
||||
awarenessRef.current = null;
|
||||
};
|
||||
}, [docName, wsUrl, userId, userName, userColor, enabled]);
|
||||
|
||||
// Mutators
|
||||
const setElement = useCallback((el: CADElement) => {
|
||||
yjsDocRef.current?.setElement(el);
|
||||
}, []);
|
||||
|
||||
const deleteElement = useCallback((id: string) => {
|
||||
yjsDocRef.current?.deleteElement(id);
|
||||
}, []);
|
||||
|
||||
const setLayer = useCallback((layer: CADLayer) => {
|
||||
yjsDocRef.current?.setLayer(layer);
|
||||
}, []);
|
||||
|
||||
const deleteLayer = useCallback((id: string) => {
|
||||
yjsDocRef.current?.deleteLayer(id);
|
||||
}, []);
|
||||
|
||||
const setBlock = useCallback((block: BlockDefinition) => {
|
||||
yjsDocRef.current?.setBlock(block);
|
||||
}, []);
|
||||
|
||||
const deleteBlock = useCallback((id: string) => {
|
||||
yjsDocRef.current?.deleteBlock(id);
|
||||
}, []);
|
||||
|
||||
const loadFromState = useCallback((state: { elements: CADElement[]; layers: CADLayer[]; blocks: BlockDefinition[] }) => {
|
||||
yjsDocRef.current?.loadFromState(state);
|
||||
}, []);
|
||||
|
||||
const setCursor = useCallback((x: number, y: number) => {
|
||||
awarenessRef.current?.setCursor(x, y, userName, userColor);
|
||||
}, [userName, userColor]);
|
||||
|
||||
const hideCursor = useCallback(() => {
|
||||
awarenessRef.current?.hideCursor();
|
||||
}, []);
|
||||
|
||||
const setSelection = useCallback((elementIds: string[]) => {
|
||||
awarenessRef.current?.setSelection(elementIds);
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
awarenessRef.current?.clearSelection();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
yjsDoc: yjsDocRef.current,
|
||||
provider: providerRef.current,
|
||||
awareness: awarenessRef.current,
|
||||
status,
|
||||
elements,
|
||||
layers,
|
||||
blocks,
|
||||
cursors,
|
||||
selections,
|
||||
setElement,
|
||||
deleteElement,
|
||||
setLayer,
|
||||
deleteLayer,
|
||||
setBlock,
|
||||
deleteBlock,
|
||||
loadFromState,
|
||||
setCursor,
|
||||
hideCursor,
|
||||
setSelection,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user