rspace-online/lib/community-sync.ts

1300 lines
37 KiB
TypeScript

import * as Automerge from "@automerge/automerge";
import type { FolkShape } from "./folk-shape";
import type { OfflineStore } from "./offline-store";
import type { Layer, LayerFlow, FlowKind } from "./layer-types";
import type {
SpaceConnection,
ConnectionState,
MembranePermeability,
} from "./connection-types";
import { computeMembranePermeability } from "./connection-types";
import { makeChangeMessage, parseChangeMessage } from "../shared/local-first/change-message";
import type { HistoryEntry } from "../shared/components/rstack-history-panel";
import type { EventEntry } from "./event-bus";
// Shape data stored in Automerge document
export interface ShapeData {
type: string;
id: string;
x: number;
y: number;
width: number;
height: number;
rotation: number;
content?: string;
// Arrow-specific
sourceId?: string;
targetId?: string;
color?: string;
strokeWidth?: number;
// Wrapper-specific
title?: string;
icon?: string;
primaryColor?: string;
isMinimized?: boolean;
isPinned?: boolean;
tags?: string[];
// Whiteboard SVG drawing
svgMarkup?: string;
// Data pipe fields (arrow port connections)
sourcePort?: string;
targetPort?: string;
// Shape port values (for data piping)
ports?: Record<string, unknown>;
// Event bus subscriptions (channel names this shape listens to)
subscriptions?: string[];
// Allow arbitrary shape-specific properties from toJSON()
[key: string]: unknown;
}
// ── Nested space types (client-side) ──
export interface NestPermissions {
read: boolean;
write: boolean;
addShapes: boolean;
deleteShapes: boolean;
reshare: boolean;
expiry?: number;
}
export interface SpaceRefFilter {
shapeTypes?: string[];
shapeIds?: string[];
tags?: string[];
moduleIds?: string[];
}
export interface SpaceRef {
id: string;
sourceSlug: string;
sourceDID?: string;
filter?: SpaceRefFilter;
x: number;
y: number;
width: number;
height: number;
rotation: number;
permissions: NestPermissions;
collapsed?: boolean;
label?: string;
createdAt: number;
createdBy: string;
}
// Automerge document structure
export interface CommunityDoc {
meta: {
name: string;
slug: string;
createdAt: string;
enabledModules?: string[];
description?: string;
avatar?: string;
};
shapes: {
[id: string]: ShapeData;
};
nestedSpaces?: {
[refId: string]: SpaceRef;
};
/** Tab/layer system — each layer is an rApp page in this space */
layers?: {
[id: string]: Layer;
};
/** Inter-layer flows (economic, trust, data, etc.) */
flows?: {
[id: string]: LayerFlow;
};
/** Bilateral typed connections to other spaces */
connections?: {
[connId: string]: SpaceConnection;
};
/** Currently active layer ID */
activeLayerId?: string;
/** Layer view mode: flat (tabs) or stack (side view) */
layerViewMode?: "flat" | "stack";
/** Pub/sub event log — bounded ring buffer (last 100 entries) */
eventLog?: EventEntry[];
}
type SyncState = Automerge.SyncState;
/**
* CommunitySync - Bridges FolkJS shapes with Automerge CRDT sync
*
* Handles:
* - Local shape changes → Automerge document → WebSocket broadcast
* - Remote Automerge sync messages → Local document → DOM updates
*/
export class CommunitySync extends EventTarget {
#doc: Automerge.Doc<CommunityDoc>;
#syncState: SyncState;
#ws: WebSocket | null = null;
#disconnectedIntentionally = false;
#communitySlug: string;
#shapes: Map<string, FolkShape> = new Map();
#pendingChanges: boolean = false;
#reconnectAttempts = 0;
#maxReconnectAttempts = 5;
#reconnectDelay = 1000;
#offlineStore: OfflineStore | null = null;
#saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
#syncedDebounceTimer: ReturnType<typeof setTimeout> | null = null;
#wsUrl: string | null = null;
constructor(communitySlug: string, offlineStore?: OfflineStore) {
super();
this.#communitySlug = communitySlug;
// Initialize empty Automerge document
this.#doc = Automerge.init<CommunityDoc>();
this.#doc = Automerge.change(this.#doc, "Initialize community", (doc) => {
doc.meta = {
name: communitySlug,
slug: communitySlug,
createdAt: new Date().toISOString(),
};
doc.shapes = {};
});
this.#syncState = Automerge.initSyncState();
if (offlineStore) {
this.#offlineStore = offlineStore;
}
}
/**
* Load document and sync state from offline cache.
* Call BEFORE connect() to show cached content immediately.
* @returns true if cached data was loaded.
*/
async initFromCache(): Promise<boolean> {
if (!this.#offlineStore) return false;
try {
const docBinary = await this.#offlineStore.loadDoc(this.#communitySlug);
if (!docBinary) return false;
this.#doc = Automerge.load<CommunityDoc>(docBinary);
// Try to restore sync state for incremental reconnection
const syncStateBinary = await this.#offlineStore.loadSyncState(this.#communitySlug);
if (syncStateBinary) {
this.#syncState = Automerge.decodeSyncState(syncStateBinary);
}
// Apply cached doc to DOM
this.#applyDocToDOM();
this.dispatchEvent(new CustomEvent("offline-loaded", {
detail: { slug: this.#communitySlug }
}));
return true;
} catch (e) {
console.error("[CommunitySync] Failed to load from cache:", e);
return false;
}
}
get doc(): Automerge.Doc<CommunityDoc> {
return this.#doc;
}
get shapes(): Map<string, FolkShape> {
return this.#shapes;
}
/**
* Connect to WebSocket server for real-time sync
*/
connect(wsUrl: string): void {
this.#wsUrl = wsUrl;
if (this.#ws?.readyState === WebSocket.OPEN) {
return;
}
// Attach auth token for private/authenticated spaces
let authUrl = wsUrl;
try {
const sess = JSON.parse(localStorage.getItem('encryptid_session') || '');
if (sess?.accessToken) {
const sep = wsUrl.includes('?') ? '&' : '?';
authUrl = `${wsUrl}${sep}token=${encodeURIComponent(sess.accessToken)}`;
}
} catch {}
this.#ws = new WebSocket(authUrl);
this.#ws.binaryType = "arraybuffer";
this.#ws.onopen = () => {
console.log(`[CommunitySync] Connected to ${this.#communitySlug}`);
this.#reconnectAttempts = 0;
// Request initial sync
this.#requestSync();
this.dispatchEvent(new CustomEvent("connected"));
// Re-announce presence on every (re)connect
if (this.#announceData) {
this.#send({ type: "announce", ...this.#announceData });
}
if (this.#offlineStore) {
this.#offlineStore.markSynced(this.#communitySlug);
}
};
this.#ws.onmessage = (event) => {
this.#handleMessage(event.data);
};
this.#ws.onclose = () => {
console.log(`[CommunitySync] Disconnected from ${this.#communitySlug}`);
this.dispatchEvent(new CustomEvent("disconnected"));
if (!this.#disconnectedIntentionally) {
this.#attemptReconnect(wsUrl);
}
};
this.#ws.onerror = (error) => {
console.error("[CommunitySync] WebSocket error:", error);
this.dispatchEvent(new CustomEvent("error", { detail: error }));
};
}
#attemptReconnect(wsUrl: string): void {
// When offline store is available, keep retrying forever (user has local persistence)
// Without offline store, give up after maxReconnectAttempts
if (!this.#offlineStore && this.#reconnectAttempts >= this.#maxReconnectAttempts) {
console.error("[CommunitySync] Max reconnect attempts reached");
return;
}
this.#reconnectAttempts++;
const maxDelay = this.#offlineStore ? 30000 : 16000;
const delay = Math.min(
this.#reconnectDelay * Math.pow(2, this.#reconnectAttempts - 1),
maxDelay
);
console.log(`[CommunitySync] Reconnecting in ${delay}ms (attempt ${this.#reconnectAttempts})`);
setTimeout(() => {
this.connect(wsUrl);
}, delay);
}
/**
* Request sync from server (sends our sync state)
*/
#requestSync(): void {
const [nextSyncState, syncMessage] = Automerge.generateSyncMessage(
this.#doc,
this.#syncState
);
this.#syncState = nextSyncState;
this.#persistSyncState();
if (syncMessage) {
this.#send({
type: "sync",
data: Array.from(syncMessage),
});
}
}
/**
* Handle incoming WebSocket messages
*/
#handleMessage(data: ArrayBuffer | string): void {
try {
// Handle binary Automerge sync messages
if (data instanceof ArrayBuffer) {
const message = new Uint8Array(data);
this.#applySyncMessage(message);
return;
}
// Handle JSON messages
const msg = JSON.parse(data as string);
switch (msg.type) {
case "sync":
// Server sending sync message as JSON array
if (Array.isArray(msg.data)) {
const syncMessage = new Uint8Array(msg.data);
this.#applySyncMessage(syncMessage);
}
break;
case "full-sync":
// Server sending full document — merge to preserve local changes
if (msg.doc) {
const binary = new Uint8Array(msg.doc);
const serverDoc = Automerge.load<CommunityDoc>(binary);
this.#doc = Automerge.merge(this.#doc, serverDoc);
this.#syncState = Automerge.initSyncState();
this.#applyDocToDOM();
this.#scheduleSave();
}
break;
case "presence":
// Handle presence updates (cursors, selections)
this.dispatchEvent(new CustomEvent("presence", { detail: msg }));
break;
case "peer-list":
this.dispatchEvent(new CustomEvent("peer-list", { detail: msg }));
break;
case "peer-joined":
this.dispatchEvent(new CustomEvent("peer-joined", { detail: msg }));
break;
case "peer-left":
this.dispatchEvent(new CustomEvent("peer-left", { detail: msg }));
break;
case "ping-user":
this.dispatchEvent(new CustomEvent("ping-user", { detail: msg }));
break;
case "clock":
// Ephemeral clock event from server — dispatch for event bus delivery
this.dispatchEvent(new CustomEvent("clock-event", {
detail: { channel: msg.channel, payload: msg.payload }
}));
break;
case "notification":
// Dispatch to window so the notification bell component picks it up
window.dispatchEvent(new CustomEvent("rspace-notification", { detail: msg }));
break;
}
} catch (e) {
console.error("[CommunitySync] Failed to handle message:", e);
}
}
/**
* Apply incoming Automerge sync message
*/
#applySyncMessage(message: Uint8Array): void {
const oldDoc = this.#doc;
const result = Automerge.receiveSyncMessage(
this.#doc,
this.#syncState,
message
);
this.#doc = result[0];
this.#syncState = result[1];
// Persist after receiving remote changes
this.#scheduleSave();
this.#persistSyncState();
// Apply changes to DOM if the document changed
if (this.#doc !== oldDoc) {
const patch = result[2] as { patches: Automerge.Patch[] } | null;
if (patch && patch.patches && patch.patches.length > 0) {
this.#applyPatchesToDOM(patch.patches);
} else {
// Automerge 2.x receiveSyncMessage may not return patches;
// fall back to full document-to-DOM reconciliation
this.#applyDocToDOM();
}
}
// Generate response if needed
const [nextSyncState, responseMessage] = Automerge.generateSyncMessage(
this.#doc,
this.#syncState
);
this.#syncState = nextSyncState;
this.#persistSyncState();
if (responseMessage) {
this.#send({
type: "sync",
data: Array.from(responseMessage),
});
}
}
/**
* Send message over WebSocket
*/
#send(message: object): void {
if (this.#ws?.readyState === WebSocket.OPEN) {
this.#ws.send(JSON.stringify(message));
}
}
/**
* Send presence update (cursor position, selection)
*/
sendPresence(data: { cursor?: { x: number; y: number }; selection?: string; username?: string; color?: string }): void {
this.#send({
type: "presence",
...data,
});
}
// ── People Online (announce / ping) ──
#announceData: { peerId: string; username: string; color: string } | null = null;
/**
* Store announce payload and send immediately if already connected.
*/
setAnnounceData(data: { peerId: string; username: string; color: string }): void {
this.#announceData = data;
if (this.#ws?.readyState === WebSocket.OPEN) {
this.#send({ type: "announce", ...data });
}
}
/**
* Ask the server to relay a "come here" ping to a specific peer.
*/
sendPingUser(targetPeerId: string, viewport: { x: number; y: number }): void {
this.#send({ type: "ping-user", targetPeerId, viewport });
}
/**
* Send a keep-alive ping to prevent WebSocket idle timeout
*/
ping(): void {
this.#send({ type: "ping" });
}
/**
* Register a shape element for syncing
*/
registerShape(shape: FolkShape): void {
this.#shapes.set(shape.id, shape);
// Listen for transform events
shape.addEventListener("folk-transform", ((e: CustomEvent) => {
this.#handleShapeChange(shape);
}) as EventListener);
// Listen for content changes (for markdown shapes)
shape.addEventListener("content-change", ((e: CustomEvent) => {
this.#handleShapeChange(shape);
}) as EventListener);
// Add to document if not exists
if (!this.#doc.shapes[shape.id]) {
this.#updateShapeInDoc(shape);
}
}
/**
* Unregister a shape
*/
unregisterShape(shapeId: string): void {
this.#shapes.delete(shapeId);
}
/**
* Handle local shape change - update Automerge doc and sync
*/
#handleShapeChange(shape: FolkShape): void {
this.#updateShapeInDoc(shape);
this.#syncToServer();
// Broadcast to parent frame (for rtrips.online integration)
const shapeData = this.#shapeToData(shape);
this.#postMessageToParent("shape-updated", shapeData);
}
/**
* Update shape data in Automerge document
*/
#updateShapeInDoc(shape: FolkShape): void {
const shapeData = this.#shapeToData(shape);
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update shape ${shape.id}`), (doc) => {
if (!doc.shapes) doc.shapes = {};
doc.shapes[shape.id] = JSON.parse(JSON.stringify(shapeData));
});
this.#scheduleSave();
}
/**
* Convert FolkShape to serializable data using the shape's own toJSON() method.
* This ensures all shape-specific properties are captured for every shape type.
*/
#shapeToData(shape: FolkShape): ShapeData {
const json = (shape as any).toJSON?.() ?? {};
const data: ShapeData = {
type: (json.type as string) || shape.tagName.toLowerCase(),
id: (json.id as string) || shape.id,
x: (json.x as number) ?? shape.x,
y: (json.y as number) ?? shape.y,
width: (json.width as number) ?? shape.width,
height: (json.height as number) ?? shape.height,
rotation: (json.rotation as number) ?? shape.rotation,
};
// Merge all extra properties from toJSON
for (const [key, value] of Object.entries(json)) {
if (!(key in data)) {
data[key] = typeof value === 'object' && value !== null
? JSON.parse(JSON.stringify(value))
: value;
}
}
return data;
}
/**
* Sync local changes to server
*/
#syncToServer(): void {
const [nextSyncState, syncMessage] = Automerge.generateSyncMessage(
this.#doc,
this.#syncState
);
this.#syncState = nextSyncState;
this.#persistSyncState();
if (syncMessage) {
this.#send({
type: "sync",
data: Array.from(syncMessage),
});
}
}
/**
* Delete a shape — now aliases to forgetShape for backward compat.
* For true hard-delete, use hardDeleteShape().
*/
deleteShape(shapeId: string, did?: string): void {
this.forgetShape(shapeId, did || 'unknown');
}
/**
* Add raw shape data directly (for shapes without DOM elements, like wb-svg drawings).
*/
addShapeData(shapeData: ShapeData): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add shape ${shapeData.id}`), (doc) => {
if (!doc.shapes) doc.shapes = {};
doc.shapes[shapeData.id] = shapeData;
});
this.#scheduleSave();
this.#syncToServer();
}
/**
* FUN: Update — explicitly update specific fields of a shape.
* Use this for programmatic updates (API calls, module callbacks).
* Shape transform/content changes are auto-captured via registerShape().
*/
updateShape(shapeId: string, fields: Record<string, unknown>): void {
const existing = this.#doc.shapes?.[shapeId];
if (!existing) return;
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update shape ${shapeId}`), (doc) => {
if (doc.shapes && doc.shapes[shapeId]) {
for (const [key, value] of Object.entries(fields)) {
(doc.shapes[shapeId] as Record<string, unknown>)[key] = value;
}
}
});
// Sync the updated shape to DOM and server
const shape = this.#shapes.get(shapeId);
if (shape) {
this.#updateShapeElement(shape, this.#doc.shapes[shapeId]);
}
this.#scheduleSave();
this.#syncToServer();
}
/**
* Forget a shape — add DID to forgottenBy map. Shape fades but stays in DOM.
* Three-state: present → forgotten (faded) → deleted
*/
forgetShape(shapeId: string, did: string): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Forget shape ${shapeId}`), (doc) => {
if (doc.shapes && doc.shapes[shapeId]) {
const shape = doc.shapes[shapeId] as Record<string, unknown>;
if (!shape.forgottenBy || typeof shape.forgottenBy !== 'object') {
shape.forgottenBy = {};
}
(shape.forgottenBy as Record<string, number>)[did] = Date.now();
// Legacy compat
shape.forgotten = true;
shape.forgottenAt = Date.now();
}
});
// Don't remove from DOM — just update visual state
this.dispatchEvent(new CustomEvent("shape-state-changed", {
detail: { shapeId, state: 'forgotten', data: this.#doc.shapes?.[shapeId] }
}));
this.#scheduleSave();
this.#syncToServer();
}
/**
* Remember a forgotten shape — clear forgottenBy + deleted, restore to present.
*/
rememberShape(shapeId: string): void {
const shapeData = this.#doc.shapes?.[shapeId];
if (!shapeData) return;
const wasDeleted = !!(shapeData as Record<string, unknown>).deleted;
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remember shape ${shapeId}`), (doc) => {
if (doc.shapes && doc.shapes[shapeId]) {
const shape = doc.shapes[shapeId] as Record<string, unknown>;
shape.forgottenBy = {};
shape.deleted = false;
// Legacy compat
shape.forgotten = false;
shape.forgottenAt = 0;
}
});
if (wasDeleted) {
// Re-add to DOM if was hard-deleted
this.#applyShapeToDOM(this.#doc.shapes[shapeId]);
}
this.dispatchEvent(new CustomEvent("shape-state-changed", {
detail: { shapeId, state: 'present', data: this.#doc.shapes?.[shapeId] }
}));
this.#scheduleSave();
this.#syncToServer();
}
/**
* Hard-delete a shape — set deleted: true, remove from DOM.
* Shape stays in Automerge doc for restore from memory panel.
*/
hardDeleteShape(shapeId: string): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Delete shape ${shapeId}`), (doc) => {
if (doc.shapes && doc.shapes[shapeId]) {
(doc.shapes[shapeId] as Record<string, unknown>).deleted = true;
}
});
this.#removeShapeFromDOM(shapeId);
this.dispatchEvent(new CustomEvent("shape-state-changed", {
detail: { shapeId, state: 'deleted', data: this.#doc.shapes?.[shapeId] }
}));
this.#scheduleSave();
this.#syncToServer();
}
/**
* Get the visual state of a shape: 'present' | 'forgotten' | 'deleted'
*/
getShapeVisualState(shapeId: string): 'present' | 'forgotten' | 'deleted' {
const data = this.#doc.shapes?.[shapeId] as Record<string, unknown> | undefined;
if (!data) return 'deleted';
if (data.deleted === true) return 'deleted';
const fb = data.forgottenBy;
if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) return 'forgotten';
return 'present';
}
/**
* Check if a specific user has forgotten a shape
*/
hasUserForgotten(shapeId: string, did: string): boolean {
const data = this.#doc.shapes?.[shapeId] as Record<string, unknown> | undefined;
if (!data) return false;
const fb = data.forgottenBy as Record<string, number> | undefined;
return !!(fb && fb[did]);
}
/**
* Get all forgotten (faded but not deleted) shapes
*/
getFadedShapes(): ShapeData[] {
const shapes = this.#doc.shapes || {};
return Object.values(shapes).filter(s => {
const d = s as Record<string, unknown>;
if (d.deleted === true) return false;
const fb = d.forgottenBy;
return fb && typeof fb === 'object' && Object.keys(fb).length > 0;
});
}
/**
* Get all hard-deleted shapes (for memory panel "Deleted" section)
*/
getDeletedShapes(): ShapeData[] {
const shapes = this.#doc.shapes || {};
return Object.values(shapes).filter(s => (s as Record<string, unknown>).deleted === true);
}
/**
* Get all forgotten shapes — includes both faded and deleted (for backward compat).
*/
getForgottenShapes(): ShapeData[] {
const shapes = this.#doc.shapes || {};
return Object.values(shapes).filter(s => {
const d = s as Record<string, unknown>;
if (d.deleted === true) return true;
const fb = d.forgottenBy;
if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) return true;
return !!d.forgotten;
});
}
/**
* Apply full document to DOM (for initial load).
* Three-state: deleted shapes are skipped, forgotten shapes are rendered faded.
*/
#applyDocToDOM(): void {
const shapes = this.#doc.shapes || {};
for (const [id, shapeData] of Object.entries(shapes)) {
const d = shapeData as Record<string, unknown>;
if (d.deleted === true) continue; // Deleted: not in DOM
this.#applyShapeToDOM(shapeData);
// If forgotten (faded), emit state-changed so canvas can apply visual
const fb = d.forgottenBy;
if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) {
this.dispatchEvent(new CustomEvent("shape-state-changed", {
detail: { shapeId: id, state: 'forgotten', data: shapeData }
}));
}
}
// Notify event bus if there are any events to process
if (this.#doc.eventLog && this.#doc.eventLog.length > 0) {
this.dispatchEvent(new CustomEvent("eventlog-changed"));
}
// Debounce the synced event — during initial sync negotiation, #applyDocToDOM()
// is called for every Automerge sync message (100+ round-trips). Debounce to
// fire once after the burst settles.
if (this.#syncedDebounceTimer) clearTimeout(this.#syncedDebounceTimer);
this.#syncedDebounceTimer = setTimeout(() => {
this.#syncedDebounceTimer = null;
this.dispatchEvent(new CustomEvent("synced", { detail: { shapes } }));
}, 300);
}
/**
* Apply Automerge patches to DOM.
* Three-state: forgotten shapes stay in DOM (faded), deleted shapes are removed.
*/
#applyPatchesToDOM(patches: Automerge.Patch[]): void {
let eventLogChanged = false;
for (const patch of patches) {
const path = patch.path;
// Detect eventLog changes for the event bus
if (path[0] === "eventLog") {
eventLogChanged = true;
continue;
}
// Handle shape updates: ["shapes", shapeId, ...]
if (path[0] === "shapes" && typeof path[1] === "string") {
const shapeId = path[1];
const shapeData = this.#doc.shapes?.[shapeId];
if (patch.action === "del" && path.length === 2) {
// Shape truly removed from Automerge doc
this.#removeShapeFromDOM(shapeId);
} else if (shapeData) {
const d = shapeData as Record<string, unknown>;
const state = this.getShapeVisualState(shapeId);
if (state === 'deleted') {
// Hard-deleted: remove from DOM
this.#removeShapeFromDOM(shapeId);
this.dispatchEvent(new CustomEvent("shape-state-changed", {
detail: { shapeId, state: 'deleted', data: shapeData }
}));
} else if (state === 'forgotten') {
// Forgotten: keep in DOM, emit state change for fade visual
this.#applyShapeToDOM(shapeData);
this.dispatchEvent(new CustomEvent("shape-state-changed", {
detail: { shapeId, state: 'forgotten', data: shapeData }
}));
this.dispatchEvent(new CustomEvent("shape-forgotten", { detail: { shapeId, data: shapeData } }));
} else {
// Present: render normally
this.#applyShapeToDOM(shapeData);
this.dispatchEvent(new CustomEvent("shape-state-changed", {
detail: { shapeId, state: 'present', data: shapeData }
}));
this.#postMessageToParent("shape-updated", shapeData);
}
}
}
}
// Notify event bus of new remote events
if (eventLogChanged) {
this.dispatchEvent(new CustomEvent("eventlog-changed"));
}
}
/**
* Apply shape data to DOM element
*/
#applyShapeToDOM(shapeData: ShapeData): void {
let shape = this.#shapes.get(shapeData.id);
if (!shape) {
// FUN: New — instantiate shape element
shape = this.#newShapeElement(shapeData);
if (shape) {
this.#shapes.set(shapeData.id, shape);
this.dispatchEvent(new CustomEvent("shape-new", { detail: { shape, data: shapeData } }));
}
return;
}
// Update existing shape (avoid triggering our own change events)
this.#updateShapeElement(shape, shapeData);
}
/**
* FUN: New — emit event for the canvas to instantiate a new shape from data
*/
#newShapeElement(data: ShapeData): FolkShape | undefined {
this.dispatchEvent(new CustomEvent("new-shape", { detail: data }));
return undefined;
}
/**
* Update shape element without triggering change events.
* Delegates to each shape's applyData() method (co-located with toJSON/fromData).
*/
#updateShapeElement(shape: FolkShape, data: ShapeData): void {
if (typeof (shape as any).applyData === "function") {
(shape as any).applyData(data);
} else {
// Fallback for shapes without applyData (e.g. wb-svg)
if (shape.x !== data.x) shape.x = data.x;
if (shape.y !== data.y) shape.y = data.y;
if (shape.width !== data.width) shape.width = data.width;
if (shape.height !== data.height) shape.height = data.height;
if (shape.rotation !== data.rotation) shape.rotation = data.rotation;
}
}
/**
* FUN: Forget — remove shape from visible DOM (shape remains in Automerge doc)
*/
#removeShapeFromDOM(shapeId: string): void {
const shape = this.#shapes.get(shapeId);
if (shape) {
this.#shapes.delete(shapeId);
this.dispatchEvent(new CustomEvent("shape-removed", { detail: { shapeId, shape } }));
}
}
/**
* Save current state immediately. Call from beforeunload handler.
* Uses synchronous localStorage fallback since IndexedDB may not complete.
*/
saveBeforeUnload(): void {
if (this.#saveDebounceTimer) {
clearTimeout(this.#saveDebounceTimer);
this.#saveDebounceTimer = null;
}
try {
const binary = Automerge.save(this.#doc);
// Synchronous localStorage fallback (guaranteed to complete)
if (this.#offlineStore) {
this.#offlineStore.saveDocEmergency(this.#communitySlug, binary);
}
// Also fire off IndexedDB save (may not complete before page dies)
if (this.#offlineStore) {
this.#offlineStore.saveDocImmediate(this.#communitySlug, binary);
}
// Push any pending Automerge changes to the server
this.#syncToServer();
} catch (e) {
console.warn("[CommunitySync] Failed to save before unload:", e);
}
}
#scheduleSave(): void {
if (!this.#offlineStore) return;
if (this.#saveDebounceTimer) {
clearTimeout(this.#saveDebounceTimer);
}
this.#saveDebounceTimer = setTimeout(() => {
this.#saveDebounceTimer = null;
this.#offlineStore!.saveDoc(
this.#communitySlug,
Automerge.save(this.#doc)
);
}, 2000);
}
#persistSyncState(): void {
if (!this.#offlineStore) return;
try {
const encoded = Automerge.encodeSyncState(this.#syncState);
this.#offlineStore.saveSyncState(this.#communitySlug, encoded);
} catch (e) {
console.warn("[CommunitySync] Failed to save sync state:", e);
}
}
// ── Layer & Flow API ──
/** Add a layer to the document */
addLayer(layer: Layer): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add layer ${layer.id}`), (doc) => {
if (!doc.layers) doc.layers = {};
doc.layers[layer.id] = layer;
});
this.#scheduleSave();
this.#syncToServer();
this.dispatchEvent(new CustomEvent("layer-added", { detail: layer }));
}
/** Remove a layer */
removeLayer(layerId: string): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remove layer ${layerId}`), (doc) => {
if (doc.layers && doc.layers[layerId]) {
delete doc.layers[layerId];
}
// Remove flows connected to this layer
if (doc.flows) {
for (const [fid, flow] of Object.entries(doc.flows)) {
if (flow.sourceLayerId === layerId || flow.targetLayerId === layerId) {
delete doc.flows[fid];
}
}
}
// If active layer was removed, switch to first remaining
if (doc.activeLayerId === layerId) {
const remaining = doc.layers ? Object.keys(doc.layers) : [];
doc.activeLayerId = remaining[0] || "";
}
});
this.#scheduleSave();
this.#syncToServer();
this.dispatchEvent(new CustomEvent("layer-removed", { detail: { layerId } }));
}
/** Update a layer's properties */
updateLayer(layerId: string, updates: Partial<Layer>): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update layer ${layerId}`), (doc) => {
if (doc.layers && doc.layers[layerId]) {
for (const [key, value] of Object.entries(updates)) {
(doc.layers[layerId] as unknown as Record<string, unknown>)[key] = value;
}
}
});
this.#scheduleSave();
this.#syncToServer();
}
/** Set active layer */
setActiveLayer(layerId: string): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Switch to layer ${layerId}`), (doc) => {
doc.activeLayerId = layerId;
});
this.#scheduleSave();
this.#syncToServer();
this.dispatchEvent(new CustomEvent("active-layer-changed", { detail: { layerId } }));
}
/** Set layer view mode */
setLayerViewMode(mode: "flat" | "stack"): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Set view mode ${mode}`), (doc) => {
doc.layerViewMode = mode;
});
this.#scheduleSave();
this.#syncToServer();
}
/** Add a flow between layers */
addFlow(flow: LayerFlow): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add flow ${flow.id}`), (doc) => {
if (!doc.flows) doc.flows = {};
doc.flows[flow.id] = flow;
});
this.#scheduleSave();
this.#syncToServer();
this.dispatchEvent(new CustomEvent("flow-added", { detail: flow }));
}
/** Remove a flow */
removeFlow(flowId: string): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remove flow ${flowId}`), (doc) => {
if (doc.flows && doc.flows[flowId]) {
delete doc.flows[flowId];
}
});
this.#scheduleSave();
this.#syncToServer();
}
/** Update flow properties */
updateFlow(flowId: string, updates: Partial<LayerFlow>): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update flow ${flowId}`), (doc) => {
if (doc.flows && doc.flows[flowId]) {
for (const [key, value] of Object.entries(updates)) {
(doc.flows[flowId] as unknown as Record<string, unknown>)[key] = value;
}
}
});
this.#scheduleSave();
this.#syncToServer();
}
/** Get all layers (sorted by order) */
getLayers(): Layer[] {
const layers = this.#doc.layers || {};
return Object.values(layers).sort((a, b) => a.order - b.order);
}
/** Get all flows */
getFlows(): LayerFlow[] {
const flows = this.#doc.flows || {};
return Object.values(flows);
}
/** Get flows for a specific layer (as source or target) */
getFlowsForLayer(layerId: string): LayerFlow[] {
return this.getFlows().filter(
f => f.sourceLayerId === layerId || f.targetLayerId === layerId
);
}
// ── Connection API ──
/** Add a connection to the document */
addConnection(conn: SpaceConnection): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add connection ${conn.id}`), (doc) => {
if (!doc.connections) doc.connections = {};
doc.connections[conn.id] = conn;
});
this.#scheduleSave();
this.#syncToServer();
this.dispatchEvent(new CustomEvent("connection-added", { detail: conn }));
}
/** Remove a connection */
removeConnection(connId: string): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remove connection ${connId}`), (doc) => {
if (doc.connections && doc.connections[connId]) {
delete doc.connections[connId];
}
});
this.#scheduleSave();
this.#syncToServer();
this.dispatchEvent(new CustomEvent("connection-removed", { detail: { connId } }));
}
/** Update connection properties */
updateConnection(connId: string, updates: Partial<SpaceConnection>): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update connection ${connId}`), (doc) => {
if (doc.connections && doc.connections[connId]) {
for (const [key, value] of Object.entries(updates)) {
if (key === 'id') continue;
(doc.connections[connId] as unknown as Record<string, unknown>)[key] = value;
}
}
});
this.#scheduleSave();
this.#syncToServer();
this.dispatchEvent(new CustomEvent("connection-updated", { detail: { connId, updates } }));
}
/** Get all connections */
getConnections(): SpaceConnection[] {
const connections = this.#doc.connections || {};
return Object.values(connections);
}
/** Get connections filtered by state */
getConnectionsByState(state: ConnectionState): SpaceConnection[] {
return this.getConnections().filter(c => c.state === state);
}
/** Get connections filtered by flow kind */
getConnectionsByFlowKind(kind: FlowKind): SpaceConnection[] {
return this.getConnections().filter(c => c.flowKinds.includes(kind));
}
/** Compute membrane permeability from all connections */
getMembranePermeability(): MembranePermeability {
return computeMembranePermeability(this.getConnections(), this.#communitySlug);
}
// ── Event Bus API ──
/** Max event log entries before oldest are pruned. */
static readonly MAX_EVENT_LOG = 100;
/** Append an event entry to the CRDT eventLog (ring-buffered). */
appendEvent(entry: EventEntry): void {
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Event: ${entry.channel}`), (doc) => {
if (!doc.eventLog) doc.eventLog = [];
doc.eventLog.push(entry as any);
// Ring buffer: prune oldest entries beyond limit
while (doc.eventLog.length > CommunitySync.MAX_EVENT_LOG) {
doc.eventLog.splice(0, 1);
}
});
this.#scheduleSave();
this.#syncToServer();
}
/** Read the current event log. */
getEventLog(): EventEntry[] {
return (this.#doc.eventLog as EventEntry[] | undefined) || [];
}
/** Set event subscriptions for a shape. */
setShapeSubscriptions(shapeId: string, channels: string[]): void {
if (!this.#doc.shapes?.[shapeId]) return;
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Subscribe ${shapeId}`), (doc) => {
if (doc.shapes[shapeId]) {
(doc.shapes[shapeId] as Record<string, unknown>).subscriptions = channels;
}
});
this.#scheduleSave();
this.#syncToServer();
}
/** Get event subscriptions for a shape. */
getShapeSubscriptions(shapeId: string): string[] {
const data = this.#doc.shapes?.[shapeId] as Record<string, unknown> | undefined;
return (data?.subscriptions as string[] | undefined) || [];
}
/** Get all shape IDs subscribed to a given channel. */
getShapesSubscribedTo(channel: string): string[] {
const shapes = this.#doc.shapes || {};
const result: string[] = [];
for (const [id, data] of Object.entries(shapes)) {
const subs = (data as Record<string, unknown>).subscriptions as string[] | undefined;
if (subs && subs.includes(channel)) {
result.push(id);
}
}
return result;
}
/** Get a shape DOM element by ID (for event delivery). */
getShapeElement(shapeId: string): FolkShape | undefined {
return this.#shapes.get(shapeId);
}
// ── History API ──
/**
* View document state at a specific point in history.
*/
viewAt(heads: string[]): CommunityDoc {
return Automerge.view(this.#doc, heads);
}
/**
* Get parsed history entries for the activity feed.
*/
getHistoryEntries(limit?: number): HistoryEntry[] {
try {
const changes = Automerge.getHistory(this.#doc);
const entries: HistoryEntry[] = changes.map((entry, i) => {
const change = entry.change;
const parsed = parseChangeMessage(change.message || null);
return {
hash: change.hash || `change-${i}`,
actor: change.actor || "unknown",
username: parsed.user || "Unknown",
time: parsed.ts || (change.time ? change.time * 1000 : 0),
message: parsed.text,
opsCount: change.ops?.length || 0,
seq: change.seq || i,
};
});
return limit ? entries.slice(-limit) : entries;
} catch {
return [];
}
}
/**
* Disconnect from server
*/
disconnect(): void {
this.#disconnectedIntentionally = true;
if (this.#ws) {
this.#ws.close();
this.#ws = null;
}
}
/**
* Get document as binary for storage
*/
getDocumentBinary(): Uint8Array {
return Automerge.save(this.#doc);
}
/**
* Load document from binary
*/
loadDocumentBinary(binary: Uint8Array): void {
this.#doc = Automerge.load<CommunityDoc>(binary);
this.#syncState = Automerge.initSyncState();
this.#applyDocToDOM();
}
/**
* Broadcast shape updates to parent frame (for iframe embedding in rtrips.online)
*/
#postMessageToParent(type: string, data: ShapeData): void {
if (typeof window === "undefined" || window.parent === window) return;
try {
window.parent.postMessage(
{
source: "rspace-canvas",
type,
communitySlug: this.#communitySlug,
shapeId: data.id,
data,
},
"*"
);
} catch {
// postMessage may fail in certain security contexts
}
}
}