256 lines
7.5 KiB
TypeScript
256 lines
7.5 KiB
TypeScript
/**
|
||
* 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;
|
||
|
||
// Refs for stable values that don't need to trigger effect re-runs
|
||
const wsUrlRef = useRef(wsUrl);
|
||
wsUrlRef.current = wsUrl;
|
||
const enabledRef = useRef(enabled);
|
||
enabledRef.current = enabled;
|
||
|
||
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(() => {
|
||
const currentWsUrl = wsUrlRef.current;
|
||
const currentEnabled = enabledRef.current;
|
||
if (!currentEnabled || !docName) return;
|
||
|
||
const doc = new YjsDocument();
|
||
yjsDocRef.current = doc;
|
||
|
||
const awareness = new AwarenessManager(doc, userId);
|
||
awarenessRef.current = awareness;
|
||
|
||
const provider = new WebSocketProvider({
|
||
url: currentWsUrl,
|
||
docName,
|
||
yjsDoc: doc,
|
||
awareness,
|
||
onStatusChange: (s) => setStatus(s),
|
||
});
|
||
providerRef.current = provider;
|
||
|
||
// 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();
|
||
// Forward local awareness updates to server
|
||
const unbindAwarenessUpdates = provider.bindAwarenessUpdates();
|
||
|
||
// Connect
|
||
provider.connect(token);
|
||
syncState();
|
||
|
||
// Cleanup
|
||
return () => {
|
||
groupsMap.unobserve(groupsObserver);
|
||
bgConfigMap.unobserve(bgConfigObserver);
|
||
unbindDoc();
|
||
unbindAwareness();
|
||
unbindLocal();
|
||
unbindAwarenessUpdates();
|
||
awareness.removeSelf();
|
||
awareness.destroy();
|
||
provider.disconnect();
|
||
doc.destroy();
|
||
yjsDocRef.current = null;
|
||
providerRef.current = null;
|
||
awarenessRef.current = null;
|
||
};
|
||
}, [docName, userId, 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,
|
||
};
|
||
}
|