import * as Automerge from "@automerge/automerge"; import type { FolkShape } from "./folk-shape"; import type { OfflineStore } from "./offline-store"; import type { Layer, LayerFlow } from "./layer-types"; // 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[]; // 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; }; /** Currently active layer ID */ activeLayerId?: string; /** Layer view mode: flat (tabs) or stack (side view) */ layerViewMode?: "flat" | "stack"; } 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; #syncState: SyncState; #ws: WebSocket | null = null; #communitySlug: string; #shapes: Map = new Map(); #pendingChanges: boolean = false; #reconnectAttempts = 0; #maxReconnectAttempts = 5; #reconnectDelay = 1000; #offlineStore: OfflineStore | null = null; #saveDebounceTimer: ReturnType | null = null; #wsUrl: string | null = null; constructor(communitySlug: string, offlineStore?: OfflineStore) { super(); this.#communitySlug = communitySlug; // Initialize empty Automerge document this.#doc = Automerge.init(); 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 { if (!this.#offlineStore) return false; try { const docBinary = await this.#offlineStore.loadDoc(this.#communitySlug); if (!docBinary) return false; this.#doc = Automerge.load(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 { return this.#doc; } get shapes(): Map { 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; } this.#ws = new WebSocket(wsUrl); 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")); 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")); // Attempt reconnect 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 (for initial load) if (msg.doc) { const binary = new Uint8Array(msg.doc); this.#doc = Automerge.load(binary); this.#syncState = Automerge.initSyncState(); this.#applyDocToDOM(); this.#scheduleSave(); } break; case "presence": // Handle presence updates (cursors, selections) this.dispatchEvent(new CustomEvent("presence", { 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, }); } /** * 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, `Update shape ${shape.id}`, (doc) => { if (!doc.shapes) doc.shapes = {}; doc.shapes[shape.id] = 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] = 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 from the document (hard delete — use forgetShape instead) */ deleteShape(shapeId: string): void { this.forgetShape(shapeId); } /** * 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): void { const existing = this.#doc.shapes?.[shapeId]; if (!existing) return; this.#doc = Automerge.change(this.#doc, `Update shape ${shapeId}`, (doc) => { if (doc.shapes && doc.shapes[shapeId]) { for (const [key, value] of Object.entries(fields)) { (doc.shapes[shapeId] as Record)[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 — soft-delete. Shape stays in the doc but is hidden. */ forgetShape(shapeId: string): void { this.#doc = Automerge.change(this.#doc, `Forget shape ${shapeId}`, (doc) => { if (doc.shapes && doc.shapes[shapeId]) { (doc.shapes[shapeId] as Record).forgotten = true; (doc.shapes[shapeId] as Record).forgottenAt = Date.now(); } }); // Remove from visible DOM this.#removeShapeFromDOM(shapeId); this.#scheduleSave(); this.#syncToServer(); } /** * Remember a forgotten shape — restore it to the canvas. */ rememberShape(shapeId: string): void { const shapeData = this.#doc.shapes?.[shapeId]; if (!shapeData) return; this.#doc = Automerge.change(this.#doc, `Remember shape ${shapeId}`, (doc) => { if (doc.shapes && doc.shapes[shapeId]) { (doc.shapes[shapeId] as Record).forgotten = false; (doc.shapes[shapeId] as Record).forgottenAt = 0; (doc.shapes[shapeId] as Record).forgottenBy = ''; } }); // Re-add to DOM this.#applyShapeToDOM(this.#doc.shapes[shapeId]); this.#scheduleSave(); this.#syncToServer(); } /** * Get all forgotten shapes (for the memory layer UI). */ getForgottenShapes(): ShapeData[] { const shapes = this.#doc.shapes || {}; return Object.values(shapes).filter(s => s.forgotten); } /** * Apply full document to DOM (for initial load). * Skips forgotten shapes — they live in the doc but are hidden from view. */ #applyDocToDOM(): void { const shapes = this.#doc.shapes || {}; for (const [id, shapeData] of Object.entries(shapes)) { if (shapeData.forgotten) continue; // FUN: forgotten shapes stay in doc, hidden from canvas this.#applyShapeToDOM(shapeData); } this.dispatchEvent(new CustomEvent("synced", { detail: { shapes } })); } /** * Apply Automerge patches to DOM. * Handles forgotten state: when a shape becomes forgotten, remove it from * the visible canvas; when remembered, re-add it. */ #applyPatchesToDOM(patches: Automerge.Patch[]): void { for (const patch of patches) { const path = patch.path; // 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 hard-deleted this.#removeShapeFromDOM(shapeId); } else if (shapeData) { // FUN: if shape was just forgotten, remove from DOM if (shapeData.forgotten) { this.#removeShapeFromDOM(shapeId); this.dispatchEvent(new CustomEvent("shape-forgotten", { detail: { shapeId, data: shapeData } })); } else { // Shape created, updated, or remembered — render it this.#applyShapeToDOM(shapeData); this.#postMessageToParent("shape-updated", shapeData); } } } } } /** * 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 */ #updateShapeElement(shape: FolkShape, data: ShapeData): void { // Temporarily remove event listeners to avoid feedback loop const isOurChange = shape.x === data.x && shape.y === data.y && shape.width === data.width && shape.height === data.height && shape.rotation === data.rotation; if (isOurChange && !("content" in data)) { return; // No change needed } // Update position and size 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; // Update content for markdown shapes if ("content" in data && "content" in shape) { const shapeWithContent = shape as any; if (shapeWithContent.content !== data.content) { shapeWithContent.content = data.content; } } // Update arrow-specific properties if (data.type === "folk-arrow") { const arrow = shape as any; if (data.sourceId !== undefined && arrow.sourceId !== data.sourceId) { arrow.sourceId = data.sourceId; } if (data.targetId !== undefined && arrow.targetId !== data.targetId) { arrow.targetId = data.targetId; } if (data.color !== undefined && arrow.color !== data.color) { arrow.color = data.color; } if (data.strokeWidth !== undefined && arrow.strokeWidth !== data.strokeWidth) { arrow.strokeWidth = data.strokeWidth; } } // Update wrapper-specific properties if (data.type === "folk-wrapper") { const wrapper = shape as any; if (data.title !== undefined && wrapper.title !== data.title) { wrapper.title = data.title; } if (data.icon !== undefined && wrapper.icon !== data.icon) { wrapper.icon = data.icon; } if (data.primaryColor !== undefined && wrapper.primaryColor !== data.primaryColor) { wrapper.primaryColor = data.primaryColor; } if (data.isMinimized !== undefined && wrapper.isMinimized !== data.isMinimized) { wrapper.isMinimized = data.isMinimized; } if (data.isPinned !== undefined && wrapper.isPinned !== data.isPinned) { wrapper.isPinned = data.isPinned; } if (data.tags !== undefined) { wrapper.tags = data.tags; } } // Update token mint properties if (data.type === "folk-token-mint") { const mint = shape as any; if (data.tokenName !== undefined && mint.tokenName !== data.tokenName) mint.tokenName = data.tokenName; if (data.tokenSymbol !== undefined && mint.tokenSymbol !== data.tokenSymbol) mint.tokenSymbol = data.tokenSymbol; if (data.description !== undefined && mint.description !== data.description) mint.description = data.description; if (data.totalSupply !== undefined && mint.totalSupply !== data.totalSupply) mint.totalSupply = data.totalSupply; if (data.issuedSupply !== undefined && mint.issuedSupply !== data.issuedSupply) mint.issuedSupply = data.issuedSupply; if (data.tokenColor !== undefined && mint.tokenColor !== data.tokenColor) mint.tokenColor = data.tokenColor; if (data.tokenIcon !== undefined && mint.tokenIcon !== data.tokenIcon) mint.tokenIcon = data.tokenIcon; } // Update token ledger properties if (data.type === "folk-token-ledger") { const ledger = shape as any; if (data.mintId !== undefined && ledger.mintId !== data.mintId) ledger.mintId = data.mintId; if (data.entries !== undefined) ledger.entries = data.entries; } // Update choice-vote properties if (data.type === "folk-choice-vote") { const vote = shape as any; if (data.title !== undefined && vote.title !== data.title) vote.title = data.title; if (data.options !== undefined) vote.options = data.options; if (data.mode !== undefined && vote.mode !== data.mode) vote.mode = data.mode; if (data.budget !== undefined && vote.budget !== data.budget) vote.budget = data.budget; if (data.votes !== undefined) vote.votes = data.votes; } // Update choice-rank properties if (data.type === "folk-choice-rank") { const rank = shape as any; if (data.title !== undefined && rank.title !== data.title) rank.title = data.title; if (data.options !== undefined) rank.options = data.options; if (data.rankings !== undefined) rank.rankings = data.rankings; } // Update nested canvas properties if (data.type === "folk-canvas") { const canvas = shape as any; if (data.sourceSlug !== undefined && canvas.sourceSlug !== data.sourceSlug) canvas.sourceSlug = data.sourceSlug; if (data.sourceDID !== undefined && canvas.sourceDID !== data.sourceDID) canvas.sourceDID = data.sourceDID; if (data.permissions !== undefined) canvas.permissions = data.permissions; if (data.collapsed !== undefined && canvas.collapsed !== data.collapsed) canvas.collapsed = data.collapsed; if (data.label !== undefined && canvas.label !== data.label) canvas.label = data.label; } // Update choice-spider properties if (data.type === "folk-choice-spider") { const spider = shape as any; if (data.title !== undefined && spider.title !== data.title) spider.title = data.title; if (data.options !== undefined) spider.options = data.options; if (data.criteria !== undefined) spider.criteria = data.criteria; if (data.scores !== undefined) spider.scores = data.scores; } // Update rApp embed properties if (data.type === "folk-rapp") { const rapp = shape as any; if (data.moduleId !== undefined && rapp.moduleId !== data.moduleId) rapp.moduleId = data.moduleId; if (data.spaceSlug !== undefined && rapp.spaceSlug !== data.spaceSlug) rapp.spaceSlug = data.spaceSlug; } // Update feed shape properties if (data.type === "folk-feed") { const feed = shape as any; if (data.sourceLayer !== undefined && feed.sourceLayer !== data.sourceLayer) feed.sourceLayer = data.sourceLayer; if (data.sourceModule !== undefined && feed.sourceModule !== data.sourceModule) feed.sourceModule = data.sourceModule; if (data.feedId !== undefined && feed.feedId !== data.feedId) feed.feedId = data.feedId; if (data.flowKind !== undefined && feed.flowKind !== data.flowKind) feed.flowKind = data.flowKind; if (data.feedFilter !== undefined && feed.feedFilter !== data.feedFilter) feed.feedFilter = data.feedFilter; if (data.maxItems !== undefined && feed.maxItems !== data.maxItems) feed.maxItems = data.maxItems; if (data.refreshInterval !== undefined && feed.refreshInterval !== data.refreshInterval) feed.refreshInterval = data.refreshInterval; } // Update social-post properties if (data.type === "folk-social-post") { const post = shape as any; if (data.platform !== undefined && post.platform !== data.platform) post.platform = data.platform; if (data.postType !== undefined && post.postType !== data.postType) post.postType = data.postType; if (data.mediaUrl !== undefined && post.mediaUrl !== data.mediaUrl) post.mediaUrl = data.mediaUrl; if (data.mediaType !== undefined && post.mediaType !== data.mediaType) post.mediaType = data.mediaType; if (data.scheduledAt !== undefined && post.scheduledAt !== data.scheduledAt) post.scheduledAt = data.scheduledAt; if (data.status !== undefined && post.status !== data.status) post.status = data.status; if (data.hashtags !== undefined) post.hashtags = data.hashtags; if (data.stepNumber !== undefined && post.stepNumber !== data.stepNumber) post.stepNumber = data.stepNumber; } } /** * 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. */ saveBeforeUnload(): void { if (!this.#offlineStore) return; if (this.#saveDebounceTimer) { clearTimeout(this.#saveDebounceTimer); this.#saveDebounceTimer = null; } try { this.#offlineStore.saveDocImmediate( this.#communitySlug, Automerge.save(this.#doc) ); } 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, `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, `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): void { this.#doc = Automerge.change(this.#doc, `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)[key] = value; } } }); this.#scheduleSave(); this.#syncToServer(); } /** Set active layer */ setActiveLayer(layerId: string): void { this.#doc = Automerge.change(this.#doc, `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, `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, `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, `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): void { this.#doc = Automerge.change(this.#doc, `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)[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 ); } /** * Disconnect from server */ disconnect(): void { 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(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 } } }