diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 6b35f3b..ab26942 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -23,6 +23,8 @@ export interface ShapeData { isMinimized?: boolean; isPinned?: boolean; tags?: string[]; + // Allow arbitrary shape-specific properties from toJSON() + [key: string]: unknown; } // Automerge document structure @@ -255,6 +257,13 @@ export class CommunitySync extends EventTarget { }); } + /** + * Send a keep-alive ping to prevent WebSocket idle timeout + */ + ping(): void { + this.#send({ type: "ping" }); + } + /** * Register a shape element for syncing */ @@ -309,42 +318,27 @@ export class CommunitySync extends EventTarget { } /** - * Convert FolkShape to serializable data + * 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: shape.tagName.toLowerCase(), - id: shape.id, - x: shape.x, - y: shape.y, - width: shape.width, - height: shape.height, - rotation: shape.rotation, + 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, }; - // Add content for markdown shapes - if ("content" in shape && typeof (shape as any).content === "string") { - data.content = (shape as any).content; - } - - // Add arrow properties - if (shape.tagName.toLowerCase() === "folk-arrow") { - const arrow = shape as any; - if (arrow.sourceId) data.sourceId = arrow.sourceId; - if (arrow.targetId) data.targetId = arrow.targetId; - if (arrow.color) data.color = arrow.color; - if (arrow.strokeWidth) data.strokeWidth = arrow.strokeWidth; - } - - // Add wrapper properties - if (shape.tagName.toLowerCase() === "folk-wrapper") { - const wrapper = shape as any; - if (wrapper.title) data.title = wrapper.title; - if (wrapper.icon) data.icon = wrapper.icon; - if (wrapper.primaryColor) data.primaryColor = wrapper.primaryColor; - if (wrapper.isMinimized !== undefined) data.isMinimized = wrapper.isMinimized; - if (wrapper.isPinned !== undefined) data.isPinned = wrapper.isPinned; - if (wrapper.tags?.length) data.tags = wrapper.tags; + // Merge all extra properties from toJSON + for (const [key, value] of Object.entries(json)) { + if (!(key in data)) { + data[key] = value; + } } return data; diff --git a/website/canvas.html b/website/canvas.html index ef071de..ef97197 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -162,17 +162,30 @@ folk-transcription, folk-video-chat, folk-obs-note, - folk-workflow-block { + folk-workflow-block, + folk-itinerary, + folk-destination, + folk-budget, + folk-packing-list, + folk-booking { position: absolute; } - .connect-mode folk-markdown, - .connect-mode folk-wrapper { + .connect-mode :is(folk-markdown, folk-wrapper, folk-slide, folk-chat, + folk-google-item, folk-piano, folk-embed, folk-calendar, folk-map, + folk-image-gen, folk-video-gen, folk-prompt, folk-transcription, + folk-video-chat, folk-obs-note, folk-workflow-block, + folk-itinerary, folk-destination, folk-budget, folk-packing-list, + folk-booking) { cursor: crosshair; } - .connect-mode folk-markdown:hover, - .connect-mode folk-wrapper:hover { + .connect-mode :is(folk-markdown, folk-wrapper, folk-slide, folk-chat, + folk-google-item, folk-piano, folk-embed, folk-calendar, folk-map, + folk-image-gen, folk-video-gen, folk-prompt, folk-transcription, + folk-video-chat, folk-obs-note, folk-workflow-block, + folk-itinerary, folk-destination, folk-budget, folk-packing-list, + folk-booking):hover { outline: 2px dashed #3b82f6; outline-offset: 4px; } @@ -196,6 +209,7 @@ + @@ -293,6 +307,16 @@ const statusText = document.getElementById("status-text"); let shapeCounter = 0; + // All shape tag names that can be arrow connection endpoints + const CONNECTABLE_SELECTOR = [ + "folk-markdown", "folk-wrapper", "folk-slide", "folk-chat", + "folk-google-item", "folk-piano", "folk-embed", "folk-calendar", + "folk-map", "folk-image-gen", "folk-video-gen", "folk-prompt", + "folk-transcription", "folk-video-chat", "folk-obs-note", + "folk-workflow-block", "folk-itinerary", "folk-destination", + "folk-budget", "folk-packing-list", "folk-booking" + ].join(", "); + // Initialize CommunitySync const sync = new CommunitySync(communitySlug); @@ -369,12 +393,19 @@ return; } - isProcessingRemote = true; - const shape = createShapeElement(data); - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - isProcessingRemote = false; + try { + isProcessingRemote = true; + const shape = createShapeElement(data); + if (shape) { + setupShapeEventListeners(shape); + canvas.appendChild(shape); + sync.registerShape(shape); + } + } catch (err) { + console.error(`[Canvas] Failed to create remote shape ${data.id} (${data.type}):`, err); + } finally { + isProcessingRemote = false; + } }); // Handle shape deletion from remote @@ -558,317 +589,129 @@ }); } - // Add markdown note button - document.getElementById("add-markdown").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-markdown"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 300; - shape.height = 200; - shape.content = "# New Note\n\nStart typing..."; + // Default dimensions for each shape type + const SHAPE_DEFAULTS = { + "folk-markdown": { width: 300, height: 200 }, + "folk-wrapper": { width: 320, height: 240 }, + "folk-slide": { width: 720, height: 480 }, + "folk-chat": { width: 400, height: 500 }, + "folk-google-item": { width: 280, height: 180 }, + "folk-piano": { width: 800, height: 600 }, + "folk-embed": { width: 480, height: 360 }, + "folk-calendar": { width: 320, height: 380 }, + "folk-map": { width: 500, height: 400 }, + "folk-image-gen": { width: 400, height: 500 }, + "folk-video-gen": { width: 450, height: 550 }, + "folk-prompt": { width: 450, height: 500 }, + "folk-transcription": { width: 400, height: 450 }, + "folk-video-chat": { width: 480, height: 400 }, + "folk-obs-note": { width: 450, height: 500 }, + "folk-workflow-block": { width: 240, height: 180 }, + "folk-itinerary": { width: 320, height: 400 }, + "folk-destination": { width: 280, height: 220 }, + "folk-budget": { width: 300, height: 350 }, + "folk-packing-list": { width: 280, height: 350 }, + "folk-booking": { width: 300, height: 240 }, + }; - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); + // Get the center of the current viewport in canvas coordinates + function getViewportCenter() { + const rect = canvas.getBoundingClientRect(); + const viewCenterX = rect.width / 2; + const viewCenterY = rect.height / 2; + // Reverse the canvas transform to get canvas coordinates + const canvasX = (viewCenterX - panX) / scale; + const canvasY = (viewCenterY - panY) / scale; + // Add jitter so shapes don't stack perfectly + return { + x: canvasX + (Math.random() - 0.5) * 40, + y: canvasY + (Math.random() - 0.5) * 40 + }; + } + + // Create a shape, position it at viewport center, add to canvas, and register for sync + function createAndAddShape(tagName, props = {}) { + const id = `shape-${Date.now()}-${++shapeCounter}`; + const defaults = SHAPE_DEFAULTS[tagName] || { width: 300, height: 200 }; + + const shape = document.createElement(tagName); + shape.id = id; + + const center = getViewportCenter(); + shape.x = center.x - defaults.width / 2; + shape.y = center.y - defaults.height / 2; + shape.width = defaults.width; + shape.height = defaults.height; + + for (const [key, value] of Object.entries(props)) { + shape[key] = value; + } + + try { + setupShapeEventListeners(shape); + canvas.appendChild(shape); + sync.registerShape(shape); + } catch (e) { + console.error(`[Canvas] Failed to create shape ${tagName}:`, e); + shape.remove?.(); + return null; + } + + return shape; + } + + // Toolbar button handlers + document.getElementById("add-markdown").addEventListener("click", () => { + createAndAddShape("folk-markdown", { content: "# New Note\n\nStart typing..." }); }); - // Add wrapper card button document.getElementById("add-wrapper").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; const colors = ["#14b8a6", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6", "#22c55e"]; const icons = ["πŸ“‹", "πŸ’‘", "πŸ“Œ", "πŸ”—", "πŸ“", "⭐"]; - - const shape = document.createElement("folk-wrapper"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 320; - shape.height = 240; - shape.title = "New Card"; - shape.icon = icons[Math.floor(Math.random() * icons.length)]; - shape.primaryColor = colors[Math.floor(Math.random() * colors.length)]; - - // Add some placeholder content inside the wrapper - const content = document.createElement("div"); - content.style.padding = "16px"; - content.style.color = "#374151"; - content.innerHTML = "

Click to edit this card...

"; - shape.appendChild(content); - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); + const shape = createAndAddShape("folk-wrapper", { + title: "New Card", + icon: icons[Math.floor(Math.random() * icons.length)], + primaryColor: colors[Math.floor(Math.random() * colors.length)], + }); + if (shape) { + const content = document.createElement("div"); + content.style.padding = "16px"; + content.style.color = "#374151"; + content.innerHTML = "

Click to edit this card...

"; + shape.appendChild(content); + } }); - // Add slide button document.getElementById("add-slide").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-slide"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 720; - shape.height = 480; - shape.label = `Slide ${shapeCounter}`; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); + createAndAddShape("folk-slide", { label: `Slide ${shapeCounter}` }); }); - // Add chat button document.getElementById("add-chat").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-chat"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 400; - shape.height = 500; - shape.roomId = `room-${id}`; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); + const id = `shape-${Date.now()}-${shapeCounter}`; + createAndAddShape("folk-chat", { roomId: `room-${id}` }); }); - // Add piano button - document.getElementById("add-piano").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-piano"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 800; - shape.height = 600; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); - - // Add embed button - document.getElementById("add-embed").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-embed"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 480; - shape.height = 360; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); - - // Add calendar button - document.getElementById("add-calendar").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-calendar"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 320; - shape.height = 380; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); - - // Add map button - document.getElementById("add-map").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-map"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 500; - shape.height = 400; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); - - // Add image gen button - document.getElementById("add-image-gen").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-image-gen"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 400; - shape.height = 500; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); - - // Add video gen button - document.getElementById("add-video-gen").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-video-gen"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 450; - shape.height = 550; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); - - // Add prompt button - document.getElementById("add-prompt").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-prompt"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 450; - shape.height = 500; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); - - // Add transcription button - document.getElementById("add-transcription").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-transcription"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 400; - shape.height = 450; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); - - // Add video chat button - document.getElementById("add-video-chat").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-video-chat"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 480; - shape.height = 400; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); - - // Add rich note button - document.getElementById("add-obs-note").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-obs-note"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 450; - shape.height = 500; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); - - // Add workflow block button - document.getElementById("add-workflow").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-workflow-block"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 240; - shape.height = 180; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); + document.getElementById("add-piano").addEventListener("click", () => createAndAddShape("folk-piano")); + document.getElementById("add-embed").addEventListener("click", () => createAndAddShape("folk-embed")); + document.getElementById("add-calendar").addEventListener("click", () => createAndAddShape("folk-calendar")); + document.getElementById("add-map").addEventListener("click", () => createAndAddShape("folk-map")); + document.getElementById("add-image-gen").addEventListener("click", () => createAndAddShape("folk-image-gen")); + document.getElementById("add-video-gen").addEventListener("click", () => createAndAddShape("folk-video-gen")); + document.getElementById("add-prompt").addEventListener("click", () => createAndAddShape("folk-prompt")); + document.getElementById("add-transcription").addEventListener("click", () => createAndAddShape("folk-transcription")); + document.getElementById("add-video-chat").addEventListener("click", () => createAndAddShape("folk-video-chat")); + document.getElementById("add-obs-note").addEventListener("click", () => createAndAddShape("folk-obs-note")); + document.getElementById("add-workflow").addEventListener("click", () => createAndAddShape("folk-workflow-block")); + document.getElementById("add-google-item").addEventListener("click", () => { + createAndAddShape("folk-google-item", { service: "drive", title: "New Google Item" }); }); // Trip planning components - document.getElementById("add-itinerary").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-itinerary"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 320; - shape.height = 400; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); - - document.getElementById("add-destination").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-destination"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 280; - shape.height = 220; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); - - document.getElementById("add-budget").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-budget"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 300; - shape.height = 350; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); - - document.getElementById("add-packing-list").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-packing-list"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 280; - shape.height = 350; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); - - document.getElementById("add-booking").addEventListener("click", () => { - const id = `shape-${Date.now()}-${++shapeCounter}`; - const shape = document.createElement("folk-booking"); - shape.id = id; - shape.x = 100 + Math.random() * 200; - shape.y = 100 + Math.random() * 200; - shape.width = 300; - shape.height = 240; - - setupShapeEventListeners(shape); - canvas.appendChild(shape); - sync.registerShape(shape); - }); + document.getElementById("add-itinerary").addEventListener("click", () => createAndAddShape("folk-itinerary")); + document.getElementById("add-destination").addEventListener("click", () => createAndAddShape("folk-destination")); + document.getElementById("add-budget").addEventListener("click", () => createAndAddShape("folk-budget")); + document.getElementById("add-packing-list").addEventListener("click", () => createAndAddShape("folk-packing-list")); + document.getElementById("add-booking").addEventListener("click", () => createAndAddShape("folk-booking")); // Arrow connection mode let connectMode = false; @@ -890,7 +733,7 @@ canvas.addEventListener("click", (e) => { if (!connectMode) return; - const target = e.target.closest("folk-markdown, folk-wrapper"); + const target = e.target.closest(CONNECTABLE_SELECTOR); if (!target || !target.id) return; e.stopPropagation(); @@ -1014,10 +857,12 @@ updateCanvasTransform(); }, { passive: false }); - // Keep-alive ping + // Keep-alive ping to prevent WebSocket idle timeout setInterval(() => { - if (sync.doc) { - // Sync is connected, nothing to do + try { + sync.ping(); + } catch (e) { + console.warn("[Canvas] Keep-alive ping failed:", e); } }, 30000);