822 lines
23 KiB
TypeScript
822 lines
23 KiB
TypeScript
import * as Automerge from "@automerge/automerge";
|
|
import type { FolkShape } from "./folk-shape";
|
|
import type { OfflineStore } from "./offline-store";
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Automerge document structure
|
|
export interface CommunityDoc {
|
|
meta: {
|
|
name: string;
|
|
slug: string;
|
|
createdAt: string;
|
|
};
|
|
shapes: {
|
|
[id: string]: ShapeData;
|
|
};
|
|
}
|
|
|
|
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;
|
|
#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;
|
|
#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;
|
|
}
|
|
|
|
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<CommunityDoc>(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 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 we received new patches
|
|
const patch = result[2] as { patches: Automerge.Patch[] } | null;
|
|
if (patch && patch.patches && patch.patches.length > 0) {
|
|
this.#applyPatchesToDOM(patch.patches);
|
|
}
|
|
|
|
// 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<string, unknown>): 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<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 — 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<string, unknown>).forgotten = true;
|
|
(doc.shapes[shapeId] as Record<string, unknown>).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<string, unknown>).forgotten = false;
|
|
(doc.shapes[shapeId] as Record<string, unknown>).forgottenAt = 0;
|
|
(doc.shapes[shapeId] as Record<string, unknown>).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 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 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<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
|
|
}
|
|
}
|
|
|
|
}
|