Files
web-cad/frontend/src/crdt/useYjsBinding.ts
T

180 lines
5.3 KiB
TypeScript
Raw Normal View History

/**
* 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,
};
}