merge: combine all features from both codebases - mobile dashboard + layer panel + notifications + shares + all fixes

This commit is contained in:
Leopoldadmin
2026-07-04 16:43:55 +02:00
parent 0606dbb501
commit be62470b53
79 changed files with 9562 additions and 3132 deletions
+125 -33
View File
@@ -1,9 +1,11 @@
/**
* AwarenessManager Tracks user presence, cursor position, and selection.
* Uses a Y.Map inside the shared doc so awareness state syncs via the same
* raw-update WebSocket protocol as the rest of the document.
* Uses the Yjs awareness protocol (y-protocols/awareness) for ephemeral
* state that should NOT be persisted in the shared Y.Doc.
*
* Reference: https://docs.yjs.dev/ecosystem/awareness
*/
import * as Y from 'yjs';
import { Awareness, encodeAwarenessUpdate, applyAwarenessUpdate } from 'y-protocols/awareness';
import type { YjsDocument } from './YjsDocument';
export interface UserCursor {
@@ -20,13 +22,28 @@ export interface UserSelection {
elementIds: string[];
}
export interface AwarenessState {
cursors: Y.Map<UserCursor>;
selections: Y.Map<UserSelection>;
/**
* Payload stored under the 'cursor' field of each client's awareness state.
*/
interface CursorAwarenessPayload {
userId: string;
userName: string;
color: string;
x: number;
y: number;
visible: boolean;
}
/**
* Payload stored under the 'selection' field of each client's awareness state.
*/
interface SelectionAwarenessPayload {
userId: string;
elementIds: string[];
}
export class AwarenessManager {
readonly state: AwarenessState;
readonly awareness: Awareness;
private yjsDoc: YjsDocument;
private userId: string;
private listeners: Set<() => void> = new Set();
@@ -34,85 +51,160 @@ export class AwarenessManager {
constructor(yjsDoc: YjsDocument, userId: string) {
this.yjsDoc = yjsDoc;
this.userId = userId;
this.state = {
cursors: this.yjsDoc.doc.getMap<UserCursor>('awareness-cursors'),
selections: this.yjsDoc.doc.getMap<UserSelection>('awareness-selections'),
};
this.awareness = new Awareness(yjsDoc.doc);
}
/** Set the local user's cursor position */
setCursor(x: number, y: number, userName: string, color: string): void {
this.state.cursors.set(this.userId, {
const payload: CursorAwarenessPayload = {
userId: this.userId,
userName,
color,
x,
y,
visible: true,
});
};
this.awareness.setLocalStateField('cursor', payload);
}
/** Hide the local user's cursor */
hideCursor(): void {
const cur = this.state.cursors.get(this.userId);
if (cur) {
this.state.cursors.set(this.userId, { ...cur, visible: false });
const localState = this.awareness.getLocalState();
if (localState && localState.cursor) {
const cursor = localState.cursor as CursorAwarenessPayload;
this.awareness.setLocalStateField('cursor', {
...cursor,
visible: false,
} as CursorAwarenessPayload);
}
}
/** Set the local user's selection */
setSelection(elementIds: string[]): void {
this.state.selections.set(this.userId, {
const payload: SelectionAwarenessPayload = {
userId: this.userId,
elementIds,
});
};
this.awareness.setLocalStateField('selection', payload);
}
/** Clear the local user's selection */
clearSelection(): void {
this.state.selections.delete(this.userId);
this.awareness.setLocalStateField('selection', null);
}
/** Remove the local user from awareness (on disconnect) */
removeSelf(): void {
this.state.cursors.delete(this.userId);
this.state.selections.delete(this.userId);
this.awareness.setLocalState(null);
}
/** Get all active cursors */
/**
* Encode an awareness update for the local client.
* Used by WebSocketProvider to send awareness state over the wire.
*/
encodeLocalState(): Uint8Array | null {
const localState = this.awareness.getLocalState();
if (!localState) return null;
return encodeAwarenessUpdate(this.awareness, [this.awareness.clientID]);
}
/**
* Apply a remote awareness update received from the server.
* Used by WebSocketProvider to process incoming awareness messages.
*/
applyRemoteUpdate(update: Uint8Array, origin: unknown = 'remote'): void {
applyAwarenessUpdate(this.awareness, update, origin);
this.notifyListeners();
}
/** Get all active cursors from all connected clients */
getAllCursors(): UserCursor[] {
return Array.from(this.state.cursors.values()).filter((c) => c.visible);
const cursors: UserCursor[] = [];
for (const [, state] of this.awareness.getStates()) {
if (state && state.cursor) {
const c = state.cursor as CursorAwarenessPayload;
if (c.visible) {
cursors.push({
userId: c.userId,
userName: c.userName,
color: c.color,
x: c.x,
y: c.y,
visible: c.visible,
});
}
}
}
return cursors;
}
/** Get all selections */
/** Get all selections from all connected clients */
getAllSelections(): UserSelection[] {
return Array.from(this.state.selections.values());
const selections: UserSelection[] = [];
for (const [, state] of this.awareness.getStates()) {
if (state && state.selection) {
const s = state.selection as SelectionAwarenessPayload;
selections.push({
userId: s.userId,
elementIds: s.elementIds,
});
}
}
return selections;
}
/** Get a specific user's cursor */
getCursor(userId: string): UserCursor | undefined {
return this.state.cursors.get(userId);
for (const [, state] of this.awareness.getStates()) {
if (state && state.cursor) {
const c = state.cursor as CursorAwarenessPayload;
if (c.userId === userId) {
return {
userId: c.userId,
userName: c.userName,
color: c.color,
x: c.x,
y: c.y,
visible: c.visible,
};
}
}
}
return undefined;
}
/** Get a specific user's selection */
getSelection(userId: string): UserSelection | undefined {
return this.state.selections.get(userId);
for (const [, state] of this.awareness.getStates()) {
if (state && state.selection) {
const s = state.selection as SelectionAwarenessPayload;
if (s.userId === userId) {
return {
userId: s.userId,
elementIds: s.elementIds,
};
}
}
}
return undefined;
}
/** Register a callback for awareness changes */
onChange(callback: () => void): () => void {
this.listeners.add(callback);
const cursorObserver = () => this.notifyListeners();
const selectionObserver = () => this.notifyListeners();
this.state.cursors.observe(cursorObserver);
this.state.selections.observe(selectionObserver);
const observer = () => this.notifyListeners();
this.awareness.on('change', observer);
return () => {
this.listeners.delete(callback);
this.state.cursors.unobserve(cursorObserver);
this.state.selections.unobserve(selectionObserver);
this.awareness.off('change', observer);
};
}
/** Destroy the awareness instance */
destroy(): void {
this.awareness.destroy();
}
private notifyListeners(): void {
for (const cb of this.listeners) {
cb();
+94 -11
View File
@@ -1,9 +1,14 @@
/**
* WebSocketProvider Custom WebSocket provider matching the backend raw-update protocol.
* Backend sends Y.encodeStateAsUpdate on connect and applies raw updates on message.
*
* Issue #20: On connect, the client must NOT send its local state immediately.
* Instead, the server sends its state first (Y.encodeStateAsUpdate). The client
* applies that update to sync, then sends only incremental local updates.
*/
import * as Y from 'yjs';
import type { YjsDocument } from './YjsDocument';
import type { AwarenessManager } from './AwarenessManager';
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
@@ -11,15 +16,22 @@ export interface WebSocketProviderOptions {
url: string;
docName: string;
yjsDoc: YjsDocument;
awareness?: AwarenessManager;
onStatusChange?: (status: ConnectionStatus) => void;
onSync?: () => void;
}
// Message type prefixes for multiplexing Y.Doc updates and awareness updates
// Using a simple byte-prefix scheme: 0 = Y.Doc update, 1 = awareness update
const MSG_DOC_UPDATE = 0;
const MSG_AWARENESS = 1;
export class WebSocketProvider {
private ws: WebSocket | null = null;
private url: string;
private docName: string;
private yjsDoc: YjsDocument;
private awareness?: AwarenessManager;
private status: ConnectionStatus = 'disconnected';
private onStatusChange?: (status: ConnectionStatus) => void;
private onSync?: () => void;
@@ -27,24 +39,30 @@ export class WebSocketProvider {
private reconnectDelay = 1000;
private maxReconnectDelay = 30000;
private shouldReconnect = true;
private authToken: string | undefined;
private synced = false;
constructor(opts: WebSocketProviderOptions) {
this.url = opts.url;
this.docName = opts.docName;
this.yjsDoc = opts.yjsDoc;
this.awareness = opts.awareness;
this.onStatusChange = opts.onStatusChange;
this.onSync = opts.onSync;
}
connect(): void {
connect(token?: string): void {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
this.authToken = token;
this.shouldReconnect = true;
this.synced = false;
this.setStatus('connecting');
const fullUrl = `${this.url}/ws/collab/${encodeURIComponent(this.docName)}`;
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
const fullUrl = `${this.url}/ws/collab/${encodeURIComponent(this.docName)}${tokenParam}`;
try {
this.ws = new WebSocket(fullUrl);
} catch (err) {
@@ -58,20 +76,50 @@ export class WebSocketProvider {
this.ws.onopen = () => {
this.setStatus('connected');
this.reconnectDelay = 1000;
// Send local state to server for initial sync
const stateUpdate = Y.encodeStateAsUpdate(this.yjsDoc.doc);
if (stateUpdate.length > 0) {
this.ws?.send(stateUpdate);
// Issue #20: Do NOT send local state on connect.
// The server sends its state (Y.encodeStateAsUpdate) to new clients.
// We wait for that server state, apply it, then mark as synced.
// Only after sync do we forward incremental local updates.
//
// If we have awareness, send our local awareness state after connect.
if (this.awareness) {
const awarenessUpdate = this.awareness.encodeLocalState();
if (awarenessUpdate && awarenessUpdate.length > 0) {
this.sendAwarenessUpdate(awarenessUpdate);
}
}
this.onSync?.();
};
this.ws.onmessage = (event: MessageEvent) => {
try {
const data = new Uint8Array(event.data as ArrayBuffer);
this.yjsDoc.applyUpdate(data);
if (data.length === 0) return;
// Check message type prefix (first byte)
const msgType = data[0];
const payload = data.slice(1);
if (msgType === MSG_AWARENESS) {
// Awareness update — apply to awareness manager
if (this.awareness) {
this.awareness.applyRemoteUpdate(payload, 'remote');
}
} else {
// Y.Doc update — apply to Y.Doc
// Support both prefixed and non-prefixed messages for backward compatibility
// If the first byte looks like a valid Y.Doc update, use the full data;
// otherwise treat the whole message as the Y.Doc update (legacy mode).
const updateData = msgType === MSG_DOC_UPDATE ? payload : data;
this.yjsDoc.applyUpdate(updateData);
// Mark as synced after receiving the first server state
if (!this.synced) {
this.synced = true;
this.onSync?.();
}
}
} catch (err) {
console.warn('[WebSocketProvider] Failed to apply update:', err);
console.warn('[WebSocketProvider] Failed to process message:', err);
}
};
@@ -81,6 +129,7 @@ export class WebSocketProvider {
this.ws.onclose = () => {
this.ws = null;
this.synced = false;
this.setStatus('disconnected');
if (this.shouldReconnect) {
this.scheduleReconnect();
@@ -90,8 +139,22 @@ export class WebSocketProvider {
/** Send a local Y.Doc update to the server */
sendUpdate(update: Uint8Array): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.synced) {
// Prefix with MSG_DOC_UPDATE byte
const msg = new Uint8Array(update.length + 1);
msg[0] = MSG_DOC_UPDATE;
msg.set(update, 1);
this.ws.send(msg);
}
}
/** Send a local awareness update to the server */
sendAwarenessUpdate(update: Uint8Array): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(update);
const msg = new Uint8Array(update.length + 1);
msg[0] = MSG_AWARENESS;
msg.set(update, 1);
this.ws.send(msg);
}
}
@@ -109,6 +172,25 @@ export class WebSocketProvider {
};
}
/** Listen for local awareness changes and forward to server */
bindAwarenessUpdates(): () => void {
if (!this.awareness) return () => {};
const handler = () => {
const update = this.awareness!.encodeLocalState();
if (update && update.length > 0) {
this.sendAwarenessUpdate(update);
}
};
this.awareness.awareness.on('update', handler);
return () => {
this.awareness!.awareness.off('update', handler);
};
}
isSynced(): boolean {
return this.synced;
}
getStatus(): ConnectionStatus {
return this.status;
}
@@ -123,6 +205,7 @@ export class WebSocketProvider {
this.ws.close();
this.ws = null;
}
this.synced = false;
this.setStatus('disconnected');
}
@@ -130,7 +213,7 @@ export class WebSocketProvider {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
this.connect(this.authToken);
}, this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
}
+76 -9
View File
@@ -4,12 +4,16 @@
*/
import * as Y from 'yjs';
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 {
elements: Y.Map<CADElement>;
layers: Y.Map<CADLayer>;
blocks: Y.Map<BlockDefinition>;
meta: Y.Map<unknown>;
groups: Y.Map<ElementGroup>;
bgConfig: Y.Map<BackgroundConfig>;
}
export class YjsDocument {
@@ -23,6 +27,8 @@ export class YjsDocument {
layers: this.doc.getMap<CADLayer>('layers'),
blocks: this.doc.getMap<BlockDefinition>('blocks'),
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());
}
/** 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 */
setElement(el: CADElement): void {
this.doc.transact(() => {
@@ -73,24 +89,71 @@ export class YjsDocument {
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) */
loadFromState(state: {
elements: CADElement[];
layers: CADLayer[];
blocks: BlockDefinition[];
groups?: ElementGroup[];
bgConfigs?: BackgroundConfig[];
}): void {
this.doc.transact(() => {
this.data.elements.clear();
for (const el of state.elements) {
this.data.elements.set(el.id, el);
// Issue #7: Only clear and replace a map if the incoming array is non-empty.
// Clearing maps when data is empty wipes CRDT data from other connected clients.
if (state.elements.length > 0) {
this.data.elements.clear();
for (const el of state.elements) {
this.data.elements.set(el.id, el);
}
}
this.data.layers.clear();
for (const layer of state.layers) {
this.data.layers.set(layer.id, layer);
if (state.layers.length > 0) {
this.data.layers.clear();
for (const layer of state.layers) {
this.data.layers.set(layer.id, layer);
}
}
this.data.blocks.clear();
for (const block of state.blocks) {
this.data.blocks.set(block.id, block);
if (state.blocks.length > 0) {
this.data.blocks.clear();
for (const block of state.blocks) {
this.data.blocks.set(block.id, block);
}
}
if (state.groups && state.groups.length > 0) {
this.data.groups.clear();
for (const group of state.groups) {
this.data.groups.set(group.id, group);
}
}
if (state.bgConfigs && state.bgConfigs.length > 0) {
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 +163,15 @@ export class YjsDocument {
elements: CADElement[];
layers: CADLayer[];
blocks: BlockDefinition[];
groups: ElementGroup[];
bgConfigs: BackgroundConfig[];
} {
return {
elements: this.getElements(),
layers: this.getLayers(),
blocks: this.getBlocks(),
groups: this.getGroups(),
bgConfigs: this.getBgConfigs(),
};
}
+1 -1
View File
@@ -3,5 +3,5 @@
*/
export { YjsDocument, type YjsDocumentData } from './YjsDocument';
export { WebSocketProvider, type ConnectionStatus, type WebSocketProviderOptions } from './WebSocketProvider';
export { AwarenessManager, type UserCursor, type UserSelection, type AwarenessState } from './AwarenessManager';
export { AwarenessManager, type UserCursor, type UserSelection } from './AwarenessManager';
export { useYjsBinding, type UseYjsBindingOptions, type UseYjsBindingResult } from './useYjsBinding';
+86 -10
View File
@@ -8,6 +8,8 @@ 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;
@@ -16,6 +18,7 @@ export interface UseYjsBindingOptions {
userName: string;
userColor: string;
enabled?: boolean;
token?: string;
}
export interface UseYjsBindingResult {
@@ -26,6 +29,8 @@ export interface UseYjsBindingResult {
elements: CADElement[];
layers: CADLayer[];
blocks: BlockDefinition[];
groups: ElementGroup[];
bgConfigs: BackgroundConfig[];
cursors: UserCursor[];
selections: UserSelection[];
setElement: (el: CADElement) => void;
@@ -34,7 +39,17 @@ export interface UseYjsBindingResult {
deleteLayer: (id: string) => void;
setBlock: (block: BlockDefinition) => 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;
hideCursor: () => void;
setSelection: (elementIds: string[]) => void;
@@ -44,7 +59,13 @@ export interface UseYjsBindingResult {
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 } = opts;
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);
@@ -55,32 +76,39 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
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 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: wsUrl,
url: currentWsUrl,
docName,
yjsDoc: doc,
awareness,
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());
};
@@ -88,26 +116,46 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
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();
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, wsUrl, userId, userName, userColor, enabled]);
}, [docName, userId, token]);
// Mutators
const setElement = useCallback((el: CADElement) => {
@@ -134,7 +182,29 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
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);
}, []);
@@ -162,6 +232,8 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
elements,
layers,
blocks,
groups,
bgConfigs,
cursors,
selections,
setElement,
@@ -170,6 +242,10 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
deleteLayer,
setBlock,
deleteBlock,
setGroup,
deleteGroup,
setBgConfig,
deleteBgConfig,
loadFromState,
setCursor,
hideCursor,