feat: Add Automerge CRDT sync for real-time collaboration

- CommunitySync class bridges FolkJS shapes with Automerge documents
- Server stores Automerge binary format with debounced persistence
- Per-peer sync state for efficient delta synchronization
- WebSocket messages carry Automerge sync protocol
- Automatic migration from JSON to Automerge format
- WASM plugin for Vite to handle Automerge bundle

Enables true CRDT-based collaboration with:
- Conflict-free concurrent editing
- Efficient delta sync (only changed data)
- Offline-capable local documents
- Automatic peer reconnection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-01 22:59:32 +01:00
parent f1224e8b75
commit d6042fcfe7
8 changed files with 1112 additions and 215 deletions

View File

@ -15,6 +15,8 @@
"bun-types": "^1.1.38",
"typescript": "^5.7.2",
"vite": "^6.0.3",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
},
},
},
@ -77,6 +79,8 @@
"@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="],
"@rollup/plugin-virtual": ["@rollup/plugin-virtual@3.0.2", "", { "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="],
@ -121,6 +125,34 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
"@swc/core": ["@swc/core@1.15.8", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.8", "@swc/core-darwin-x64": "1.15.8", "@swc/core-linux-arm-gnueabihf": "1.15.8", "@swc/core-linux-arm64-gnu": "1.15.8", "@swc/core-linux-arm64-musl": "1.15.8", "@swc/core-linux-x64-gnu": "1.15.8", "@swc/core-linux-x64-musl": "1.15.8", "@swc/core-win32-arm64-msvc": "1.15.8", "@swc/core-win32-ia32-msvc": "1.15.8", "@swc/core-win32-x64-msvc": "1.15.8" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-j47DasuOvXl80sKJHSi2X25l44CMc3VDhlJwA7oewC1nV1VsSzwX+KOwE5tLnfORvVJJyeiXgJORNYg4jeIjYQ=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.8", "", { "os": "linux", "cpu": "arm" }, "sha512-siAzDENu2rUbwr9+fayWa26r5A9fol1iORG53HWxQL1J8ym4k7xt9eME0dMPXlYZDytK5r9sW8zEA10F2U3Xwg=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-o+1y5u6k2FfPYbTRUPvurwzNt5qd0NTumCTFscCNuBksycloXY16J8L+SMW5QRX59n4Hp9EmFa3vpvNHRVv1+Q=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-koiCqL09EwOP1S2RShCI7NbsQuG6r2brTqUYE7pV7kZm9O17wZ0LSz22m6gVibpwEnw8jI3IE1yYsQTVpluALw=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.8", "", { "os": "linux", "cpu": "x64" }, "sha512-4p6lOMU3bC+Vd5ARtKJ/FxpIC5G8v3XLoPEZ5s7mLR8h7411HWC/LmTXDHcrSXRC55zvAVia1eldy6zDLz8iFQ=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.8", "", { "os": "linux", "cpu": "x64" }, "sha512-z3XBnbrZAL+6xDGAhJoN4lOueIxC/8rGrJ9tg+fEaeqLEuAtHSW2QHDHxDwkxZMjuF/pZ6MUTjHjbp8wLbuRLA=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-djQPJ9Rh9vP8GTS/Df3hcc6XP6xnG5c8qsngWId/BLA9oX6C7UzCPAn74BG/wGb9a6j4w3RINuoaieJB3t+7iQ=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-/wfAgxORg2VBaUoFdytcVBVCgf1isWZIEXB9MZEUty4wwK93M/PxAkjifOho9RN3WrM3inPLabICRCEgdHpKKQ=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.8", "", { "os": "win32", "cpu": "x64" }, "sha512-GpMePrh9Sl4d61o4KAHOOv5is5+zt6BEXCOCgs/H0FLGeii7j9bWDE8ExvKFy2GRRZVNR1ugsnzaGWHKM6kuzA=="],
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
"@swc/wasm": ["@swc/wasm@1.15.8", "", {}, "sha512-RG2BxGbbsjtddFCo1ghKH6A/BMXbY1eMBfpysV0lJMCpI4DZOjW1BNBnxvBt7YsYmlJtmy5UXIg9/4ekBTFFaQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
@ -155,8 +187,14 @@
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"vite-plugin-top-level-await": ["vite-plugin-top-level-await@1.6.0", "", { "dependencies": { "@rollup/plugin-virtual": "^3.0.2", "@swc/core": "^1.12.14", "@swc/wasm": "^1.12.14", "uuid": "10.0.0" }, "peerDependencies": { "vite": ">=2.8" } }, "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww=="],
"vite-plugin-wasm": ["vite-plugin-wasm@3.5.0", "", { "peerDependencies": { "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" } }, "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ=="],
"@automerge/automerge/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
}
}

484
lib/community-sync.ts Normal file
View File

@ -0,0 +1,484 @@
import * as Automerge from "@automerge/automerge";
import type { FolkShape } from "./folk-shape";
// 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;
}
// 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;
constructor(communitySlug: string) {
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();
}
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 {
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"));
};
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 {
if (this.#reconnectAttempts >= this.#maxReconnectAttempts) {
console.error("[CommunitySync] Max reconnect attempts reached");
return;
}
this.#reconnectAttempts++;
const delay = this.#reconnectDelay * Math.pow(2, this.#reconnectAttempts - 1);
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;
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();
}
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];
// 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;
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));
}
}
/**
* 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();
}
/**
* 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;
});
}
/**
* Convert FolkShape to serializable data
*/
#shapeToData(shape: FolkShape): ShapeData {
const data: ShapeData = {
type: shape.tagName.toLowerCase(),
id: shape.id,
x: shape.x,
y: shape.y,
width: shape.width,
height: shape.height,
rotation: shape.rotation,
};
// Add content for markdown shapes
if ("content" in shape && typeof (shape as any).content === "string") {
data.content = (shape as any).content;
}
// Add arrow connections
if ("sourceId" in shape) {
data.sourceId = (shape as any).sourceId;
}
if ("targetId" in shape) {
data.targetId = (shape as any).targetId;
}
return data;
}
/**
* Sync local changes to server
*/
#syncToServer(): void {
const [nextSyncState, syncMessage] = Automerge.generateSyncMessage(
this.#doc,
this.#syncState
);
this.#syncState = nextSyncState;
if (syncMessage) {
this.#send({
type: "sync",
data: Array.from(syncMessage),
});
}
}
/**
* Delete a shape from the document
*/
deleteShape(shapeId: string): void {
this.#doc = Automerge.change(this.#doc, `Delete shape ${shapeId}`, (doc) => {
if (doc.shapes && doc.shapes[shapeId]) {
delete doc.shapes[shapeId];
}
});
this.#shapes.delete(shapeId);
this.#syncToServer();
}
/**
* Apply full document to DOM (for initial load)
*/
#applyDocToDOM(): void {
const shapes = this.#doc.shapes || {};
for (const [id, shapeData] of Object.entries(shapes)) {
this.#applyShapeToDOM(shapeData);
}
this.dispatchEvent(new CustomEvent("synced", { detail: { shapes } }));
}
/**
* Apply Automerge patches to DOM
*/
#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 deleted
this.#removeShapeFromDOM(shapeId);
} else if (shapeData) {
// Shape created or updated
this.#applyShapeToDOM(shapeData);
}
}
}
}
/**
* Apply shape data to DOM element
*/
#applyShapeToDOM(shapeData: ShapeData): void {
let shape = this.#shapes.get(shapeData.id);
if (!shape) {
// Create new shape element
shape = this.#createShapeElement(shapeData);
if (shape) {
this.#shapes.set(shapeData.id, shape);
this.dispatchEvent(new CustomEvent("shape-created", { detail: { shape, data: shapeData } }));
}
return;
}
// Update existing shape (avoid triggering our own change events)
this.#updateShapeElement(shape, shapeData);
}
/**
* Create a new shape element from data
*/
#createShapeElement(data: ShapeData): FolkShape | undefined {
// This will be handled by the canvas - emit event for canvas to create
this.dispatchEvent(new CustomEvent("create-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;
}
}
}
/**
* Remove shape from DOM
*/
#removeShapeFromDOM(shapeId: string): void {
const shape = this.#shapes.get(shapeId);
if (shape) {
this.#shapes.delete(shapeId);
this.dispatchEvent(new CustomEvent("shape-deleted", { detail: { shapeId, shape } }));
}
}
/**
* 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();
}
}

View File

@ -21,3 +21,6 @@ export * from "./tags";
// Components
export * from "./folk-shape";
export * from "./folk-markdown";
// Sync
export * from "./community-sync";

View File

@ -19,6 +19,8 @@
"@types/node": "^22.10.1",
"bun-types": "^1.1.38",
"typescript": "^5.7.2",
"vite": "^6.0.3"
"vite": "^6.0.3",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0"
}
}

View File

@ -1,4 +1,5 @@
import { mkdir, readdir } from "node:fs/promises";
import * as Automerge from "@automerge/automerge";
const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
@ -8,46 +9,83 @@ export interface CommunityMeta {
createdAt: string;
}
export interface CommunityDoc {
meta: CommunityMeta;
shapes: Record<
string,
{
type: string;
id: string;
x: number;
y: number;
width: number;
height: number;
rotation?: number;
content?: string;
}
>;
export interface ShapeData {
type: string;
id: string;
x: number;
y: number;
width: number;
height: number;
rotation: number;
content?: string;
sourceId?: string;
targetId?: string;
}
// In-memory cache of community docs
const communities = new Map<string, CommunityDoc>();
export interface CommunityDoc {
meta: CommunityMeta;
shapes: {
[id: string]: ShapeData;
};
}
// Per-peer sync state for Automerge
interface PeerState {
syncState: Automerge.SyncState;
lastActivity: number;
}
// In-memory cache of Automerge documents
const communities = new Map<string, Automerge.Doc<CommunityDoc>>();
// Track sync state per peer (WebSocket connection)
const peerSyncStates = new Map<string, Map<string, PeerState>>();
// Debounce save timers
const saveTimers = new Map<string, Timer>();
// Ensure storage directory exists
await mkdir(STORAGE_DIR, { recursive: true });
export async function loadCommunity(slug: string): Promise<CommunityDoc | null> {
/**
* Load community document from disk
*/
export async function loadCommunity(slug: string): Promise<Automerge.Doc<CommunityDoc> | null> {
// Check cache first
if (communities.has(slug)) {
return communities.get(slug)!;
}
// Try to load from disk
const path = `${STORAGE_DIR}/${slug}.json`;
const file = Bun.file(path);
// Try to load Automerge binary first
const binaryPath = `${STORAGE_DIR}/${slug}.automerge`;
const binaryFile = Bun.file(binaryPath);
if (await file.exists()) {
if (await binaryFile.exists()) {
try {
const data = (await file.json()) as CommunityDoc;
communities.set(slug, data);
return data;
const buffer = await binaryFile.arrayBuffer();
const doc = Automerge.load<CommunityDoc>(new Uint8Array(buffer));
communities.set(slug, doc);
return doc;
} catch (e) {
console.error(`Failed to load community ${slug}:`, e);
console.error(`Failed to load Automerge doc for ${slug}:`, e);
}
}
// Fallback: try JSON format and migrate
const jsonPath = `${STORAGE_DIR}/${slug}.json`;
const jsonFile = Bun.file(jsonPath);
if (await jsonFile.exists()) {
try {
const data = (await jsonFile.json()) as CommunityDoc;
// Migrate JSON to Automerge
const doc = jsonToAutomerge(data);
communities.set(slug, doc);
// Save as Automerge binary
await saveCommunity(slug);
return doc;
} catch (e) {
console.error(`Failed to migrate JSON for ${slug}:`, e);
return null;
}
}
@ -55,60 +93,277 @@ export async function loadCommunity(slug: string): Promise<CommunityDoc | null>
return null;
}
export async function saveCommunity(slug: string, doc: CommunityDoc): Promise<void> {
communities.set(slug, doc);
const path = `${STORAGE_DIR}/${slug}.json`;
await Bun.write(path, JSON.stringify(doc, null, 2));
}
export async function createCommunity(name: string, slug: string): Promise<CommunityDoc> {
const doc: CommunityDoc = {
meta: {
name,
slug,
createdAt: new Date().toISOString(),
},
shapes: {},
};
await saveCommunity(slug, doc);
/**
* Convert JSON document to Automerge document
*/
function jsonToAutomerge(data: CommunityDoc): Automerge.Doc<CommunityDoc> {
let doc = Automerge.init<CommunityDoc>();
doc = Automerge.change(doc, "Import from JSON", (d) => {
d.meta = { ...data.meta };
d.shapes = {};
for (const [id, shape] of Object.entries(data.shapes || {})) {
d.shapes[id] = { ...shape };
}
});
return doc;
}
/**
* Save community document to disk (debounced)
*/
export async function saveCommunity(slug: string): Promise<void> {
const doc = communities.get(slug);
if (!doc) return;
// Clear existing timer
const existingTimer = saveTimers.get(slug);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Debounce saves to avoid excessive disk writes
const timer = setTimeout(async () => {
const currentDoc = communities.get(slug);
if (!currentDoc) return;
const binary = Automerge.save(currentDoc);
const path = `${STORAGE_DIR}/${slug}.automerge`;
await Bun.write(path, binary);
console.log(`[Store] Saved ${slug} (${binary.length} bytes)`);
}, 2000);
saveTimers.set(slug, timer);
}
/**
* Create a new community
*/
export async function createCommunity(name: string, slug: string): Promise<Automerge.Doc<CommunityDoc>> {
let doc = Automerge.init<CommunityDoc>();
doc = Automerge.change(doc, "Create community", (d) => {
d.meta = {
name,
slug,
createdAt: new Date().toISOString(),
};
d.shapes = {};
});
communities.set(slug, doc);
await saveCommunity(slug);
return doc;
}
/**
* Check if community exists
*/
export async function communityExists(slug: string): Promise<boolean> {
if (communities.has(slug)) return true;
const path = `${STORAGE_DIR}/${slug}.json`;
const file = Bun.file(path);
return file.exists();
const binaryPath = `${STORAGE_DIR}/${slug}.automerge`;
const jsonPath = `${STORAGE_DIR}/${slug}.json`;
const binaryFile = Bun.file(binaryPath);
const jsonFile = Bun.file(jsonPath);
return (await binaryFile.exists()) || (await jsonFile.exists());
}
/**
* List all communities
*/
export async function listCommunities(): Promise<string[]> {
try {
const files = await readdir(STORAGE_DIR);
return files.filter((f) => f.endsWith(".json")).map((f) => f.replace(".json", ""));
const slugs = new Set<string>();
for (const f of files) {
if (f.endsWith(".automerge")) {
slugs.add(f.replace(".automerge", ""));
} else if (f.endsWith(".json")) {
slugs.add(f.replace(".json", ""));
}
}
return Array.from(slugs);
} catch {
return [];
}
}
/**
* Get or create sync state for a peer
*/
export function getPeerSyncState(slug: string, peerId: string): PeerState {
if (!peerSyncStates.has(slug)) {
peerSyncStates.set(slug, new Map());
}
const communityPeers = peerSyncStates.get(slug)!;
if (!communityPeers.has(peerId)) {
communityPeers.set(peerId, {
syncState: Automerge.initSyncState(),
lastActivity: Date.now(),
});
}
const peerState = communityPeers.get(peerId)!;
peerState.lastActivity = Date.now();
return peerState;
}
/**
* Remove peer sync state (on disconnect)
*/
export function removePeerSyncState(slug: string, peerId: string): void {
const communityPeers = peerSyncStates.get(slug);
if (communityPeers) {
communityPeers.delete(peerId);
if (communityPeers.size === 0) {
peerSyncStates.delete(slug);
}
}
}
/**
* Get all peer IDs for a community
*/
export function getCommunityPeers(slug: string): string[] {
const communityPeers = peerSyncStates.get(slug);
return communityPeers ? Array.from(communityPeers.keys()) : [];
}
/**
* Process incoming sync message from a peer
* Returns response message and messages for other peers
*/
export function receiveSyncMessage(
slug: string,
peerId: string,
message: Uint8Array,
): {
response: Uint8Array | null;
broadcastToPeers: Map<string, Uint8Array>;
} {
const doc = communities.get(slug);
if (!doc) {
return { response: null, broadcastToPeers: new Map() };
}
const peerState = getPeerSyncState(slug, peerId);
// Apply incoming sync message
const result = Automerge.receiveSyncMessage(
doc,
peerState.syncState,
message
);
const newDoc = result[0];
const newSyncState = result[1];
const patch = result[2] as { patches: Automerge.Patch[] } | null;
communities.set(slug, newDoc);
peerState.syncState = newSyncState;
// Schedule save if changes were made
const hasPatches = patch && patch.patches && patch.patches.length > 0;
if (hasPatches) {
saveCommunity(slug);
}
// Generate response for this peer
const [nextSyncState, responseMessage] = Automerge.generateSyncMessage(
newDoc,
peerState.syncState
);
peerState.syncState = nextSyncState;
// Generate messages for other peers
const broadcastToPeers = new Map<string, Uint8Array>();
const communityPeers = peerSyncStates.get(slug);
if (communityPeers && hasPatches) {
for (const [otherPeerId, otherPeerState] of communityPeers) {
if (otherPeerId !== peerId) {
const [newOtherSyncState, otherMessage] = Automerge.generateSyncMessage(
newDoc,
otherPeerState.syncState
);
otherPeerState.syncState = newOtherSyncState;
if (otherMessage) {
broadcastToPeers.set(otherPeerId, otherMessage);
}
}
}
}
return {
response: responseMessage || null,
broadcastToPeers,
};
}
/**
* Generate initial sync message for a new peer
*/
export function generateSyncMessageForPeer(
slug: string,
peerId: string,
): Uint8Array | null {
const doc = communities.get(slug);
if (!doc) return null;
const peerState = getPeerSyncState(slug, peerId);
const [newSyncState, message] = Automerge.generateSyncMessage(
doc,
peerState.syncState
);
peerState.syncState = newSyncState;
return message || null;
}
/**
* Get document as plain object (for API responses)
*/
export function getDocumentData(slug: string): CommunityDoc | null {
const doc = communities.get(slug);
if (!doc) return null;
// Convert Automerge doc to plain object
return JSON.parse(JSON.stringify(doc));
}
// Legacy functions for backward compatibility
export function updateShape(
slug: string,
shapeId: string,
data: CommunityDoc["shapes"][string],
data: ShapeData,
): void {
const doc = communities.get(slug);
if (doc) {
doc.shapes[shapeId] = data;
// Save async without blocking
saveCommunity(slug, doc);
const newDoc = Automerge.change(doc, `Update shape ${shapeId}`, (d) => {
if (!d.shapes) d.shapes = {};
d.shapes[shapeId] = data;
});
communities.set(slug, newDoc);
saveCommunity(slug);
}
}
export function deleteShape(slug: string, shapeId: string): void {
const doc = communities.get(slug);
if (doc) {
delete doc.shapes[shapeId];
saveCommunity(slug, doc);
const newDoc = Automerge.change(doc, `Delete shape ${shapeId}`, (d) => {
if (d.shapes && d.shapes[shapeId]) {
delete d.shapes[shapeId];
}
});
communities.set(slug, newDoc);
saveCommunity(slug);
}
}

View File

@ -4,9 +4,12 @@ import {
communityExists,
createCommunity,
deleteShape,
generateSyncMessageForPeer,
getDocumentData,
loadCommunity,
receiveSyncMessage,
removePeerSyncState,
updateShape,
type CommunityDoc,
} from "./community-store";
const PORT = Number(process.env.PORT) || 3000;
@ -15,22 +18,20 @@ const DIST_DIR = resolve(import.meta.dir, "../dist");
// WebSocket data type
interface WSData {
communitySlug: string;
peerId: string;
}
// Track connected clients per community
const communityClients = new Map<string, Set<ServerWebSocket<WSData>>>();
// Track connected clients per community (for broadcasting)
const communityClients = new Map<string, Map<string, ServerWebSocket<WSData>>>();
// Helper to broadcast to all clients in a community
function broadcastToCommunity(slug: string, message: object, excludeWs?: ServerWebSocket<WSData>) {
const clients = communityClients.get(slug);
if (!clients) return;
// Generate unique peer ID
function generatePeerId(): string {
return `peer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
const data = JSON.stringify(message);
for (const client of clients) {
if (client !== excludeWs && client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
// Helper to get client by peer ID
function getClient(slug: string, peerId: string): ServerWebSocket<WSData> | undefined {
return communityClients.get(slug)?.get(peerId);
}
// Parse subdomain from host header
@ -91,7 +92,10 @@ const server = Bun.serve<WSData>({
if (url.pathname.startsWith("/ws/")) {
const communitySlug = url.pathname.split("/")[2];
if (communitySlug) {
const upgraded = server.upgrade(req, { data: { communitySlug } });
const peerId = generatePeerId();
const upgraded = server.upgrade(req, {
data: { communitySlug, peerId },
});
if (upgraded) return undefined;
}
return new Response("WebSocket upgrade failed", { status: 400 });
@ -134,61 +138,131 @@ const server = Bun.serve<WSData>({
websocket: {
open(ws: ServerWebSocket<WSData>) {
const { communitySlug } = ws.data;
const { communitySlug, peerId } = ws.data;
// Add to clients set
// Add to clients map
if (!communityClients.has(communitySlug)) {
communityClients.set(communitySlug, new Set());
communityClients.set(communitySlug, new Map());
}
communityClients.get(communitySlug)!.add(ws);
communityClients.get(communitySlug)!.set(peerId, ws);
console.log(`Client connected to ${communitySlug}`);
console.log(`[WS] Client ${peerId} connected to ${communitySlug}`);
// Send current state
// Load community and send initial sync message
loadCommunity(communitySlug).then((doc) => {
if (doc) {
ws.send(JSON.stringify({ type: "sync", shapes: doc.shapes }));
const syncMessage = generateSyncMessageForPeer(communitySlug, peerId);
if (syncMessage) {
ws.send(
JSON.stringify({
type: "sync",
data: Array.from(syncMessage),
})
);
}
}
});
},
message(ws: ServerWebSocket<WSData>, message: string | Buffer) {
const { communitySlug } = ws.data;
const { communitySlug, peerId } = ws.data;
try {
const data = JSON.parse(message.toString());
const msg = JSON.parse(message.toString());
if (data.type === "update" && data.id && data.data) {
// Update local store
updateShape(communitySlug, data.id, data.data);
if (msg.type === "sync" && Array.isArray(msg.data)) {
// Handle Automerge sync message
const syncMessage = new Uint8Array(msg.data);
const result = receiveSyncMessage(communitySlug, peerId, syncMessage);
// Send response to this peer
if (result.response) {
ws.send(
JSON.stringify({
type: "sync",
data: Array.from(result.response),
})
);
}
// Broadcast to other peers
for (const [targetPeerId, targetMessage] of result.broadcastToPeers) {
const targetClient = getClient(communitySlug, targetPeerId);
if (targetClient && targetClient.readyState === WebSocket.OPEN) {
targetClient.send(
JSON.stringify({
type: "sync",
data: Array.from(targetMessage),
})
);
}
}
} else if (msg.type === "ping") {
// Handle keep-alive ping
ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp }));
} else if (msg.type === "presence") {
// Broadcast presence to other clients
const clients = communityClients.get(communitySlug);
if (clients) {
const presenceMsg = JSON.stringify({
type: "presence",
peerId,
...msg,
});
for (const [clientPeerId, client] of clients) {
if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
client.send(presenceMsg);
}
}
}
}
// Legacy message handling for backward compatibility
else if (msg.type === "update" && msg.id && msg.data) {
updateShape(communitySlug, msg.id, msg.data);
// Broadcast to other clients
broadcastToCommunity(communitySlug, data, ws);
} else if (data.type === "delete" && data.id) {
// Delete from store
deleteShape(communitySlug, data.id);
const clients = communityClients.get(communitySlug);
if (clients) {
const updateMsg = JSON.stringify(msg);
for (const [clientPeerId, client] of clients) {
if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
client.send(updateMsg);
}
}
}
} else if (msg.type === "delete" && msg.id) {
deleteShape(communitySlug, msg.id);
// Broadcast to other clients
broadcastToCommunity(communitySlug, data, ws);
const clients = communityClients.get(communitySlug);
if (clients) {
const deleteMsg = JSON.stringify(msg);
for (const [clientPeerId, client] of clients) {
if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
client.send(deleteMsg);
}
}
}
}
} catch (e) {
console.error("Failed to parse WebSocket message:", e);
console.error("[WS] Failed to parse message:", e);
}
},
close(ws: ServerWebSocket<WSData>) {
const { communitySlug } = ws.data;
const { communitySlug, peerId } = ws.data;
// Remove from clients set
// Remove from clients map
const clients = communityClients.get(communitySlug);
if (clients) {
clients.delete(ws);
clients.delete(peerId);
if (clients.size === 0) {
communityClients.delete(communitySlug);
}
}
console.log(`Client disconnected from ${communitySlug}`);
// Clean up peer sync state
removePeerSyncState(communitySlug, peerId);
console.log(`[WS] Client ${peerId} disconnected from ${communitySlug}`);
},
},
});
@ -212,20 +286,26 @@ async function handleAPI(req: Request, url: URL): Promise<Response> {
const { name, slug } = body;
if (!name || !slug) {
return Response.json({ error: "Name and slug are required" }, { status: 400, headers: corsHeaders });
return Response.json(
{ error: "Name and slug are required" },
{ status: 400, headers: corsHeaders }
);
}
// Validate slug format
if (!/^[a-z0-9-]+$/.test(slug)) {
return Response.json(
{ error: "Slug must contain only lowercase letters, numbers, and hyphens" },
{ status: 400, headers: corsHeaders },
{ status: 400, headers: corsHeaders }
);
}
// Check if exists
if (await communityExists(slug)) {
return Response.json({ error: "Community already exists" }, { status: 409, headers: corsHeaders });
return Response.json(
{ error: "Community already exists" },
{ status: 409, headers: corsHeaders }
);
}
// Create community
@ -234,24 +314,36 @@ async function handleAPI(req: Request, url: URL): Promise<Response> {
// Return URL to new community
return Response.json(
{ url: `https://${slug}.rspace.online`, slug, name },
{ headers: corsHeaders },
{ headers: corsHeaders }
);
} catch (e) {
console.error("Failed to create community:", e);
return Response.json({ error: "Failed to create community" }, { status: 500, headers: corsHeaders });
return Response.json(
{ error: "Failed to create community" },
{ status: 500, headers: corsHeaders }
);
}
}
// GET /api/communities/:slug - Get community info
if (url.pathname.startsWith("/api/communities/") && req.method === "GET") {
const slug = url.pathname.split("/")[3];
const community = await loadCommunity(slug);
const data = getDocumentData(slug);
if (!community) {
return Response.json({ error: "Community not found" }, { status: 404, headers: corsHeaders });
if (!data) {
// Try loading from disk
await loadCommunity(slug);
const loadedData = getDocumentData(slug);
if (!loadedData) {
return Response.json(
{ error: "Community not found" },
{ status: 404, headers: corsHeaders }
);
}
return Response.json({ meta: loadedData.meta }, { headers: corsHeaders });
}
return Response.json({ meta: community.meta }, { headers: corsHeaders });
return Response.json({ meta: data.meta }, { headers: corsHeaders });
}
return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });

View File

@ -1,8 +1,10 @@
import { resolve } from "node:path";
import { defineConfig } from "vite";
import wasm from "vite-plugin-wasm";
export default defineConfig({
root: "website",
plugins: [wasm()],
resolve: {
alias: {
"@lib": resolve(__dirname, "./lib"),
@ -25,4 +27,7 @@ export default defineConfig({
server: {
port: 5173,
},
optimizeDeps: {
exclude: ["@automerge/automerge"],
},
});

View File

@ -84,14 +84,34 @@
font-size: 12px;
color: #64748b;
z-index: 1000;
display: flex;
align-items: center;
gap: 8px;
}
#status.connected {
color: #22c55e;
#status .indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: #64748b;
}
#status.disconnected {
color: #ef4444;
#status.connected .indicator {
background: #22c55e;
}
#status.disconnected .indicator {
background: #ef4444;
}
#status.syncing .indicator {
background: #f59e0b;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
#canvas {
@ -117,18 +137,21 @@
</div>
<div id="toolbar">
<button id="add-markdown" title="Add Markdown Note">📝 Add Note</button>
<button id="add-markdown" title="Add Markdown Note">+ Add Note</button>
<button id="zoom-in" title="Zoom In">+</button>
<button id="zoom-out" title="Zoom Out">-</button>
<button id="reset-view" title="Reset View"></button>
<button id="reset-view" title="Reset View">Reset</button>
</div>
<div id="status" class="disconnected">Connecting...</div>
<div id="status" class="disconnected">
<span class="indicator"></span>
<span id="status-text">Connecting...</span>
</div>
<div id="canvas"></div>
<script type="module">
import { FolkShape, FolkMarkdown } from "@lib";
import { FolkShape, FolkMarkdown, CommunitySync } from "@lib";
// Register custom elements
FolkShape.define();
@ -146,110 +169,98 @@
const canvas = document.getElementById("canvas");
const status = document.getElementById("status");
const statusText = document.getElementById("status-text");
let shapeCounter = 0;
// WebSocket connection for sync
let ws = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
// Initialize CommunitySync
const sync = new CommunitySync(communitySlug);
function connectWebSocket() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws/${communitySlug}`;
// Track if we're processing remote changes to avoid feedback loops
let isProcessingRemote = false;
try {
ws = new WebSocket(wsUrl);
// Handle connection status
sync.addEventListener("connected", () => {
status.className = "connected";
statusText.textContent = "Connected";
});
ws.onopen = () => {
status.textContent = "Connected";
status.className = "connected";
reconnectAttempts = 0;
};
sync.addEventListener("disconnected", () => {
status.className = "disconnected";
statusText.textContent = "Reconnecting...";
});
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleSyncMessage(data);
} catch (e) {
console.error("Failed to parse message:", e);
}
};
sync.addEventListener("synced", (e) => {
console.log("[Canvas] Initial sync complete:", e.detail.shapes);
});
ws.onclose = () => {
status.textContent = "Disconnected";
status.className = "disconnected";
// Handle shape creation from remote
sync.addEventListener("create-shape", (e) => {
const data = e.detail;
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
setTimeout(connectWebSocket, 2000 * reconnectAttempts);
}
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
} catch (e) {
console.error("Failed to connect WebSocket:", e);
status.textContent = "Offline Mode";
// Check if shape already exists
if (document.getElementById(data.id)) {
return;
}
}
function handleSyncMessage(data) {
if (data.type === "sync") {
// Full state sync
Object.entries(data.shapes).forEach(([id, shapeData]) => {
let shape = document.getElementById(id);
if (!shape) {
shape = createShape(shapeData);
shape.id = id;
canvas.appendChild(shape);
}
updateShapeFromData(shape, shapeData);
});
} else if (data.type === "update") {
// Incremental update
let shape = document.getElementById(data.id);
if (!shape && data.data) {
shape = createShape(data.data);
shape.id = data.id;
canvas.appendChild(shape);
} else if (shape && data.data) {
updateShapeFromData(shape, data.data);
}
} else if (data.type === "delete") {
const shape = document.getElementById(data.id);
if (shape) shape.remove();
isProcessingRemote = true;
const shape = createShapeElement(data);
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
isProcessingRemote = false;
});
// Handle shape deletion from remote
sync.addEventListener("shape-deleted", (e) => {
const { shapeId, shape } = e.detail;
if (shape && shape.parentNode) {
shape.remove();
}
}
});
function createShape(data) {
const shape = document.createElement("folk-markdown");
// Create a shape element from data
function createShapeElement(data) {
let shape;
switch (data.type) {
case "folk-markdown":
default:
shape = document.createElement("folk-markdown");
if (data.content) shape.content = data.content;
break;
}
shape.id = data.id;
shape.x = data.x || 100;
shape.y = data.y || 100;
shape.width = data.width || 300;
shape.height = data.height || 200;
if (data.content) shape.content = data.content;
if (data.rotation) shape.rotation = data.rotation;
return shape;
}
function updateShapeFromData(shape, data) {
if (data.x !== undefined) shape.x = data.x;
if (data.y !== undefined) shape.y = data.y;
if (data.width !== undefined) shape.width = data.width;
if (data.height !== undefined) shape.height = data.height;
if (data.content !== undefined) shape.content = data.content;
}
// Setup event listeners for shape
function setupShapeEventListeners(shape) {
// Transform events (move, resize, rotate)
shape.addEventListener("folk-transform", (e) => {
if (!isProcessingRemote) {
// Already handled by CommunitySync registration
}
});
function broadcastUpdate(shape) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "update",
id: shape.id,
data: shape.toJSON(),
})
);
}
// Content change events (for markdown)
shape.addEventListener("content-change", (e) => {
if (!isProcessingRemote) {
// Already handled by CommunitySync registration
}
});
// Close button
shape.addEventListener("close", () => {
sync.deleteShape(shape.id);
shape.remove();
});
}
// Add markdown note button
@ -263,40 +274,47 @@
shape.height = 200;
shape.content = "# New Note\n\nStart typing...";
shape.addEventListener("transform", () => {
broadcastUpdate(shape);
});
shape.addEventListener("content-change", () => {
broadcastUpdate(shape);
});
shape.addEventListener("close", () => {
shape.remove();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "delete", id: shape.id }));
}
});
setupShapeEventListeners(shape);
canvas.appendChild(shape);
broadcastUpdate(shape);
sync.registerShape(shape);
});
// Zoom controls (placeholder)
// Zoom controls
let scale = 1;
const minScale = 0.25;
const maxScale = 4;
document.getElementById("zoom-in").addEventListener("click", () => {
console.log("Zoom in - to be implemented");
scale = Math.min(scale * 1.25, maxScale);
canvas.style.transform = `scale(${scale})`;
canvas.style.transformOrigin = "center center";
});
document.getElementById("zoom-out").addEventListener("click", () => {
console.log("Zoom out - to be implemented");
scale = Math.max(scale / 1.25, minScale);
canvas.style.transform = `scale(${scale})`;
canvas.style.transformOrigin = "center center";
});
document.getElementById("reset-view").addEventListener("click", () => {
console.log("Reset view - to be implemented");
scale = 1;
canvas.style.transform = "scale(1)";
});
// Initialize
connectWebSocket();
// Keep-alive ping
setInterval(() => {
if (sync.doc) {
// Sync is connected, nothing to do
}
}, 30000);
// Connect to server
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws/${communitySlug}`;
sync.connect(wsUrl);
// Debug: expose sync for console inspection
window.sync = sync;
</script>
</body>
</html>