fix(crdt): sync groups and bgConfig via Yjs (Issue #6)
This commit is contained in:
+44
-2
@@ -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 };
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user