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

243 lines
7.1 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';
import type { ElementGroup } from '../tools/modification/GroupTool';
import type { BackgroundConfig } from '../services/backgroundService';
export interface UseYjsBindingOptions {
docName: string;
wsUrl?: string;
userId: string;
userName: string;
userColor: string;
enabled?: boolean;
token?: string;
}
export interface UseYjsBindingResult {
yjsDoc: YjsDocument | null;
provider: WebSocketProvider | null;
awareness: AwarenessManager | null;
status: ConnectionStatus;
elements: CADElement[];
layers: CADLayer[];
blocks: BlockDefinition[];
groups: ElementGroup[];
bgConfigs: BackgroundConfig[];
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;
setGroup: (group: ElementGroup) => void;
deleteGroup: (id: string) => void;
setBgConfig: (id: string, config: BackgroundConfig) => void;
deleteBgConfig: (id: string) => void;
loadFromState: (state: {
elements: CADElement[];
layers: CADLayer[];
blocks: BlockDefinition[];
groups?: ElementGroup[];
bgConfigs?: BackgroundConfig[];
}) => void;
setCursor: (x: number, y: number) => void;
hideCursor: () => void;
setSelection: (elementIds: string[]) => void;
clearSelection: () => void;
}
const DEFAULT_WS_URL = `wss://${window.location.host}`;
export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
const { docName, wsUrl = DEFAULT_WS_URL, userId, userName, userColor, enabled = true, token } = 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 [groups, setGroups] = useState<ElementGroup[]>([]);
const [bgConfigs, setBgConfigs] = useState<BackgroundConfig[]>([]);
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());
setGroups(doc.getGroups());
setBgConfigs(doc.getBgConfigs());
setCursors(awareness.getAllCursors());
setSelections(awareness.getAllSelections());
};
const unbindDoc = doc.onChange(syncState);
const unbindAwareness = awareness.onChange(syncState);
// Dedicated observers for groups and bgConfig maps
const groupsMap = doc.data.groups;
const bgConfigMap = doc.data.bgConfig;
const groupsObserver = () => {
setGroups(Array.from(groupsMap.values()));
};
const bgConfigObserver = () => {
setBgConfigs(Array.from(bgConfigMap.values()));
};
groupsMap.observe(groupsObserver);
bgConfigMap.observe(bgConfigObserver);
// Forward local updates to server
const unbindLocal = provider.bindLocalUpdates();
// Connect
provider.connect(token);
syncState();
// Cleanup
return () => {
groupsMap.unobserve(groupsObserver);
bgConfigMap.unobserve(bgConfigObserver);
unbindDoc();
unbindAwareness();
unbindLocal();
awareness.removeSelf();
provider.disconnect();
doc.destroy();
yjsDocRef.current = null;
providerRef.current = null;
awarenessRef.current = null;
};
}, [docName, wsUrl, userId, userName, userColor, enabled, token]);
// 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 setGroup = useCallback((group: ElementGroup) => {
yjsDocRef.current?.setGroup(group);
}, []);
const deleteGroup = useCallback((id: string) => {
yjsDocRef.current?.deleteGroup(id);
}, []);
const setBgConfig = useCallback((id: string, config: BackgroundConfig) => {
yjsDocRef.current?.setBgConfig(id, config);
}, []);
const deleteBgConfig = useCallback((id: string) => {
yjsDocRef.current?.deleteBgConfig(id);
}, []);
const loadFromState = useCallback((state: {
elements: CADElement[];
layers: CADLayer[];
blocks: BlockDefinition[];
groups?: ElementGroup[];
bgConfigs?: BackgroundConfig[];
}) => {
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,
groups,
bgConfigs,
cursors,
selections,
setElement,
deleteElement,
setLayer,
deleteLayer,
setBlock,
deleteBlock,
setGroup,
deleteGroup,
setBgConfig,
deleteBgConfig,
loadFromState,
setCursor,
hideCursor,
setSelection,
clearSelection,
};
}