fix: overhaul canvas shape creation, connections, and sync

- Fix CSS position:absolute missing for 5 trip planning shapes
- Expand arrow connection mode to all 21 shape types (was only 2)
- Center new shapes in viewport instead of clustering top-left
- Extract createAndAddShape() utility, eliminating ~270 lines of duplication
- Add missing Google Item toolbar button
- Add error handling on remote shape creation (try-catch-finally)
- Implement actual WebSocket keep-alive ping (was a no-op)
- Use shape.toJSON() in sync layer to capture all shape properties (was only 3 types)
- Add index signature to ShapeData for arbitrary shape-specific properties

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-15 08:27:48 -07:00
parent eedc6b1b4a
commit 38636862d8
2 changed files with 179 additions and 340 deletions

View File

@ -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;

View File

@ -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 @@
<button id="add-chat" title="Add Chat">💬 Chat</button>
<button id="add-piano" title="Add Piano">🎹 Piano</button>
<button id="add-embed" title="Add Web Embed">🔗 Embed</button>
<button id="add-google-item" title="Add Google Item">📎 Google</button>
<button id="add-calendar" title="Add Calendar">📅 Calendar</button>
<button id="add-map" title="Add Map">🗺️ Map</button>
<button id="add-image-gen" title="AI Image Generation">🎨 Image</button>
@ -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 = "<p>Click to edit this card...</p>";
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 = "<p>Click to edit this card...</p>";
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);