fix(crdt): sync groups and bgConfig via Yjs (Issue #6)

This commit is contained in:
2026-06-29 23:42:03 +02:00
parent cf62a777d2
commit 8493138b4b
3 changed files with 167 additions and 4 deletions
+44 -2
View File
@@ -266,7 +266,14 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
setDataLoaded(true); setDataLoaded(true);
// Push initial data to Yjs CRDT after load // Push initial data to Yjs CRDT after load
if (collab.status === 'connected') { if (collab.status === 'connected') {
collab.loadFromState({ elements: data.elements, layers: data.layers, blocks: data.blocks }); const bgConfigs = bgConfig ? [bgConfig] : [];
collab.loadFromState({
elements: data.elements,
layers: data.layers,
blocks: data.blocks,
groups,
bgConfigs,
});
} }
} catch (err) { } catch (err) {
console.error('Failed to load project:', err); console.error('Failed to load project:', err);
@@ -316,8 +323,43 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
collab.blocks.every((b, i) => b.id === blocks[i]?.id); collab.blocks.every((b, i) => b.id === blocks[i]?.id);
if (!sameBlocks) setBlocks(collab.blocks); if (!sameBlocks) setBlocks(collab.blocks);
} }
// Sync groups from remote → local
if (collab.groups.length > 0) {
const sameGroups = collab.groups.length === groups.length &&
collab.groups.every((g, i) => g.id === groups[i]?.id);
if (!sameGroups) setGroups(collab.groups);
}
// Sync bgConfig from remote → local
if (collab.bgConfigs.length > 0) {
const remoteBg = collab.bgConfigs[0];
if (!bgConfig || remoteBg.src !== bgConfig.src || remoteBg.scale !== bgConfig.scale ||
remoteBg.offsetX !== bgConfig.offsetX || remoteBg.offsetY !== bgConfig.offsetY ||
remoteBg.visible !== bgConfig.visible || remoteBg.opacity !== bgConfig.opacity) {
setBgConfig(remoteBg);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [collab.elements, collab.layers, collab.blocks, collab.status]); }, [collab.elements, collab.layers, collab.blocks, collab.groups, collab.bgConfigs, collab.status]);
// Sync local → remote: push local groups to Yjs when groups change
React.useEffect(() => {
if (collab.status !== 'connected') return;
for (const group of groups) {
collab.setGroup(group);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groups, collab.status]);
// Sync local → remote: push local bgConfig to Yjs when bgConfig changes
React.useEffect(() => {
if (collab.status !== 'connected') return;
if (bgConfig) {
collab.setBgConfig(bgConfig.name || 'default', bgConfig);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bgConfig, collab.status]);
const handleElementCreated = useCallback((el: CADElement) => { const handleElementCreated = useCallback((el: CADElement) => {
const elWithLayer = el.layerId ? el : { ...el, layerId: activeLayerId }; const elWithLayer = el.layerId ? el : { ...el, layerId: activeLayerId };
+59
View File
@@ -4,12 +4,16 @@
*/ */
import * as Y from 'yjs'; import * as Y from 'yjs';
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types'; import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types';
import type { ElementGroup } from '../tools/modification/GroupTool';
import type { BackgroundConfig } from '../services/backgroundService';
export interface YjsDocumentData { export interface YjsDocumentData {
elements: Y.Map<CADElement>; elements: Y.Map<CADElement>;
layers: Y.Map<CADLayer>; layers: Y.Map<CADLayer>;
blocks: Y.Map<BlockDefinition>; blocks: Y.Map<BlockDefinition>;
meta: Y.Map<unknown>; meta: Y.Map<unknown>;
groups: Y.Map<ElementGroup>;
bgConfig: Y.Map<BackgroundConfig>;
} }
export class YjsDocument { export class YjsDocument {
@@ -23,6 +27,8 @@ export class YjsDocument {
layers: this.doc.getMap<CADLayer>('layers'), layers: this.doc.getMap<CADLayer>('layers'),
blocks: this.doc.getMap<BlockDefinition>('blocks'), blocks: this.doc.getMap<BlockDefinition>('blocks'),
meta: this.doc.getMap<unknown>('meta'), meta: this.doc.getMap<unknown>('meta'),
groups: this.doc.getMap<ElementGroup>('groups'),
bgConfig: this.doc.getMap<BackgroundConfig>('bgConfig'),
}; };
} }
@@ -41,6 +47,16 @@ export class YjsDocument {
return Array.from(this.data.blocks.values()); return Array.from(this.data.blocks.values());
} }
/** Get all groups as a plain array */
getGroups(): ElementGroup[] {
return Array.from(this.data.groups.values());
}
/** Get all background configs as a plain array */
getBgConfigs(): BackgroundConfig[] {
return Array.from(this.data.bgConfig.values());
}
/** Add or update a single element */ /** Add or update a single element */
setElement(el: CADElement): void { setElement(el: CADElement): void {
this.doc.transact(() => { this.doc.transact(() => {
@@ -73,11 +89,37 @@ export class YjsDocument {
this.data.blocks.delete(id); this.data.blocks.delete(id);
} }
/** Add or update a group */
setGroup(group: ElementGroup): void {
this.doc.transact(() => {
this.data.groups.set(group.id, group);
});
}
/** Remove a group by id */
deleteGroup(id: string): void {
this.data.groups.delete(id);
}
/** Add or update a background config */
setBgConfig(id: string, config: BackgroundConfig): void {
this.doc.transact(() => {
this.data.bgConfig.set(id, config);
});
}
/** Remove a background config by id */
deleteBgConfig(id: string): void {
this.data.bgConfig.delete(id);
}
/** Bulk-load local state into the Y.Doc (replaces all content) */ /** Bulk-load local state into the Y.Doc (replaces all content) */
loadFromState(state: { loadFromState(state: {
elements: CADElement[]; elements: CADElement[];
layers: CADLayer[]; layers: CADLayer[];
blocks: BlockDefinition[]; blocks: BlockDefinition[];
groups?: ElementGroup[];
bgConfigs?: BackgroundConfig[];
}): void { }): void {
this.doc.transact(() => { this.doc.transact(() => {
this.data.elements.clear(); this.data.elements.clear();
@@ -92,6 +134,19 @@ export class YjsDocument {
for (const block of state.blocks) { for (const block of state.blocks) {
this.data.blocks.set(block.id, block); this.data.blocks.set(block.id, block);
} }
if (state.groups) {
this.data.groups.clear();
for (const group of state.groups) {
this.data.groups.set(group.id, group);
}
}
if (state.bgConfigs) {
this.data.bgConfig.clear();
for (const cfg of state.bgConfigs) {
// Use 'default' as the key since there is typically one background config
this.data.bgConfig.set(cfg.name || 'default', cfg);
}
}
}); });
} }
@@ -100,11 +155,15 @@ export class YjsDocument {
elements: CADElement[]; elements: CADElement[];
layers: CADLayer[]; layers: CADLayer[];
blocks: BlockDefinition[]; blocks: BlockDefinition[];
groups: ElementGroup[];
bgConfigs: BackgroundConfig[];
} { } {
return { return {
elements: this.getElements(), elements: this.getElements(),
layers: this.getLayers(), layers: this.getLayers(),
blocks: this.getBlocks(), blocks: this.getBlocks(),
groups: this.getGroups(),
bgConfigs: this.getBgConfigs(),
}; };
} }
+64 -2
View File
@@ -8,6 +8,8 @@ import { YjsDocument } from './YjsDocument';
import { WebSocketProvider, type ConnectionStatus } from './WebSocketProvider'; import { WebSocketProvider, type ConnectionStatus } from './WebSocketProvider';
import { AwarenessManager, type UserCursor, type UserSelection } from './AwarenessManager'; import { AwarenessManager, type UserCursor, type UserSelection } from './AwarenessManager';
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types'; 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 { export interface UseYjsBindingOptions {
docName: string; docName: string;
@@ -27,6 +29,8 @@ export interface UseYjsBindingResult {
elements: CADElement[]; elements: CADElement[];
layers: CADLayer[]; layers: CADLayer[];
blocks: BlockDefinition[]; blocks: BlockDefinition[];
groups: ElementGroup[];
bgConfigs: BackgroundConfig[];
cursors: UserCursor[]; cursors: UserCursor[];
selections: UserSelection[]; selections: UserSelection[];
setElement: (el: CADElement) => void; setElement: (el: CADElement) => void;
@@ -35,7 +39,17 @@ export interface UseYjsBindingResult {
deleteLayer: (id: string) => void; deleteLayer: (id: string) => void;
setBlock: (block: BlockDefinition) => void; setBlock: (block: BlockDefinition) => void;
deleteBlock: (id: string) => void; deleteBlock: (id: string) => void;
loadFromState: (state: { elements: CADElement[]; layers: CADLayer[]; blocks: BlockDefinition[] }) => 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; setCursor: (x: number, y: number) => void;
hideCursor: () => void; hideCursor: () => void;
setSelection: (elementIds: string[]) => void; setSelection: (elementIds: string[]) => void;
@@ -56,6 +70,8 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
const [elements, setElements] = useState<CADElement[]>([]); const [elements, setElements] = useState<CADElement[]>([]);
const [layers, setLayers] = useState<CADLayer[]>([]); const [layers, setLayers] = useState<CADLayer[]>([]);
const [blocks, setBlocks] = useState<BlockDefinition[]>([]); const [blocks, setBlocks] = useState<BlockDefinition[]>([]);
const [groups, setGroups] = useState<ElementGroup[]>([]);
const [bgConfigs, setBgConfigs] = useState<BackgroundConfig[]>([]);
const [cursors, setCursors] = useState<UserCursor[]>([]); const [cursors, setCursors] = useState<UserCursor[]>([]);
const [selections, setSelections] = useState<UserSelection[]>([]); const [selections, setSelections] = useState<UserSelection[]>([]);
@@ -82,6 +98,8 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
setElements(doc.getElements()); setElements(doc.getElements());
setLayers(doc.getLayers()); setLayers(doc.getLayers());
setBlocks(doc.getBlocks()); setBlocks(doc.getBlocks());
setGroups(doc.getGroups());
setBgConfigs(doc.getBgConfigs());
setCursors(awareness.getAllCursors()); setCursors(awareness.getAllCursors());
setSelections(awareness.getAllSelections()); setSelections(awareness.getAllSelections());
}; };
@@ -89,6 +107,20 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
const unbindDoc = doc.onChange(syncState); const unbindDoc = doc.onChange(syncState);
const unbindAwareness = awareness.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 // Forward local updates to server
const unbindLocal = provider.bindLocalUpdates(); const unbindLocal = provider.bindLocalUpdates();
@@ -98,6 +130,8 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
// Cleanup // Cleanup
return () => { return () => {
groupsMap.unobserve(groupsObserver);
bgConfigMap.unobserve(bgConfigObserver);
unbindDoc(); unbindDoc();
unbindAwareness(); unbindAwareness();
unbindLocal(); unbindLocal();
@@ -135,7 +169,29 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
yjsDocRef.current?.deleteBlock(id); yjsDocRef.current?.deleteBlock(id);
}, []); }, []);
const loadFromState = useCallback((state: { elements: CADElement[]; layers: CADLayer[]; blocks: BlockDefinition[] }) => { 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); yjsDocRef.current?.loadFromState(state);
}, []); }, []);
@@ -163,6 +219,8 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
elements, elements,
layers, layers,
blocks, blocks,
groups,
bgConfigs,
cursors, cursors,
selections, selections,
setElement, setElement,
@@ -171,6 +229,10 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
deleteLayer, deleteLayer,
setBlock, setBlock,
deleteBlock, deleteBlock,
setGroup,
deleteGroup,
setBgConfig,
deleteBgConfig,
loadFromState, loadFromState,
setCursor, setCursor,
hideCursor, hideCursor,