rspace-online/website/canvas.html

1419 lines
45 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>rSpace Canvas</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
#toolbar {
position: fixed;
top: 72px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
padding: 8px 16px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
#toolbar button {
padding: 8px 16px;
border: none;
border-radius: 8px;
background: #f1f5f9;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
#toolbar button:hover {
background: #e2e8f0;
}
#toolbar button.active {
background: #14b8a6;
color: white;
}
#community-info {
position: fixed;
top: 72px;
left: 16px;
padding: 8px 16px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
#community-info h2 {
font-size: 14px;
color: #0f172a;
}
#community-info p {
font-size: 12px;
color: #64748b;
}
#status {
position: fixed;
bottom: 16px;
right: 16px;
padding: 8px 16px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
font-size: 12px;
color: #64748b;
z-index: 1000;
display: flex;
align-items: center;
gap: 8px;
}
#status .indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: #64748b;
}
#status.connected .indicator {
background: #22c55e;
}
#status.disconnected .indicator {
background: #ef4444;
}
#status.syncing .indicator {
background: #f59e0b;
animation: pulse 1s infinite;
}
#status.offline .indicator {
background: #f59e0b;
}
#status.offline-empty .indicator {
background: #94a3b8;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Memory layer panel */
#memory-panel {
position: fixed;
top: 72px;
right: 16px;
width: 300px;
max-height: calc(100vh - 120px);
background: white;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18);
z-index: 1001;
display: none;
overflow: hidden;
}
#memory-panel.open {
display: flex;
flex-direction: column;
}
#memory-panel-header {
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
#memory-panel-header h3 {
font-size: 14px;
color: #0f172a;
margin: 0;
}
#memory-panel-header .count {
font-size: 12px;
color: #94a3b8;
background: #f1f5f9;
padding: 2px 8px;
border-radius: 10px;
}
#memory-list {
overflow-y: auto;
flex: 1;
padding: 8px;
}
#memory-list:empty::after {
content: "Nothing forgotten yet";
display: block;
text-align: center;
padding: 24px 16px;
color: #94a3b8;
font-size: 13px;
}
.memory-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
}
.memory-item:hover {
background: #f1f5f9;
}
.memory-item .icon {
font-size: 18px;
width: 28px;
text-align: center;
flex-shrink: 0;
}
.memory-item .info {
flex: 1;
min-width: 0;
}
.memory-item .info .name {
font-size: 13px;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.memory-item .info .meta {
font-size: 11px;
color: #94a3b8;
}
.memory-item .remember-btn {
padding: 4px 10px;
border: none;
border-radius: 6px;
background: #14b8a6;
color: white;
font-size: 12px;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s;
}
.memory-item .remember-btn:hover {
background: #0d9488;
}
#canvas {
width: 100%;
height: 100%;
background: linear-gradient(#f1f5f9 1px, transparent 1px),
linear-gradient(90deg, #f1f5f9 1px, transparent 1px);
background-size: 20px 20px;
background-position: -1px -1px;
position: relative;
overflow: hidden;
touch-action: none; /* Prevent browser gestures, handle manually */
}
#canvas-content {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}
/* Touch-friendly resize handles */
@media (pointer: coarse) {
folk-shape::part(resize-top-left),
folk-shape::part(resize-top-right),
folk-shape::part(resize-bottom-left),
folk-shape::part(resize-bottom-right) {
width: 24px !important;
height: 24px !important;
}
folk-shape::part(rotation-top-left),
folk-shape::part(rotation-top-right),
folk-shape::part(rotation-bottom-left),
folk-shape::part(rotation-bottom-right) {
width: 32px !important;
height: 32px !important;
}
}
folk-markdown,
folk-wrapper,
folk-arrow,
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,
folk-token-mint,
folk-token-ledger,
folk-choice-vote,
folk-choice-rank,
folk-choice-spider,
folk-social-post {
position: absolute;
}
.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, folk-token-mint, folk-token-ledger,
folk-choice-vote, folk-choice-rank, folk-choice-spider,
folk-social-post) {
cursor: crosshair;
}
.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, folk-token-mint, folk-token-ledger,
folk-choice-vote, folk-choice-rank, folk-choice-spider,
folk-social-post):hover {
outline: 2px dashed #3b82f6;
outline-offset: 4px;
}
.connect-source {
outline: 3px solid #22c55e !important;
outline-offset: 4px !important;
}
/* Mobile toolbar: icon-only scrollable strip */
@media (max-width: 768px) {
#toolbar {
max-width: calc(100vw - 32px);
overflow-x: auto;
scrollbar-width: none;
gap: 4px;
padding: 6px 8px;
touch-action: pan-x;
}
#toolbar::-webkit-scrollbar {
display: none;
}
#toolbar button {
max-width: 36px;
min-width: 36px;
padding: 8px;
overflow: hidden;
white-space: nowrap;
}
#community-info {
display: none;
}
#memory-panel {
max-width: calc(100vw - 32px);
}
}
</style>
</head>
<body>
<div id="community-info">
<h2 id="community-name">Loading...</h2>
<p id="community-slug"></p>
</div>
<div id="toolbar">
<button id="new-markdown" title="New Note">📝 Note</button>
<button id="new-wrapper" title="New Card">🗂️ Card</button>
<button id="new-slide" title="New Slide">🎞️ Slide</button>
<button id="new-chat" title="New Chat">💬 Chat</button>
<button id="new-piano" title="New Piano">🎹 Piano</button>
<button id="new-embed" title="New Embed">🔗 Embed</button>
<button id="new-google-item" title="New Google Item">📎 Google</button>
<button id="new-calendar" title="New Calendar">📅 Calendar</button>
<button id="new-map" title="New Map">🗺️ Map</button>
<button id="new-image-gen" title="New AI Image">🎨 Image</button>
<button id="new-video-gen" title="New AI Video">🎬 Video</button>
<button id="new-prompt" title="New AI Chat">🤖 AI</button>
<button id="new-transcription" title="New Transcription">🎤 Transcribe</button>
<button id="new-video-chat" title="New Video Call">📹 Call</button>
<button id="new-obs-note" title="New Rich Note">📓 Rich Note</button>
<button id="new-workflow" title="New Workflow">⚙️ Workflow</button>
<button id="new-itinerary" title="New Itinerary">🗓️ Itinerary</button>
<button id="new-destination" title="New Destination">📍 Destination</button>
<button id="new-budget" title="New Budget">💰 Budget</button>
<button id="new-packing-list" title="New Packing List">🎒 Packing</button>
<button id="new-booking" title="New Booking">✈️ Booking</button>
<button id="new-token" title="New Token">🪙 Token</button>
<button id="new-choice-vote" title="New Poll">☑ Poll</button>
<button id="new-choice-rank" title="New Ranking">📊 Rank</button>
<button id="new-choice-spider" title="New Scoring">🕸 Spider</button>
<button id="new-social-post" title="New Post">📱 Post</button>
<button id="new-arrow" title="Connect Shapes">↗️ Connect</button>
<button id="toggle-memory" title="Forgotten shapes">💭 Memory</button>
<button id="zoom-in" title="Zoom In">+</button>
<button id="zoom-out" title="Zoom Out">-</button>
<button id="reset-view" title="Reset View">Reset</button>
</div>
<div id="memory-panel">
<div id="memory-panel-header">
<h3>💭 Memory</h3>
<span class="count" id="memory-count">0</span>
</div>
<div id="memory-list"></div>
</div>
<div id="status" class="disconnected">
<span class="indicator"></span>
<span id="status-text">Connecting...</span>
</div>
<div id="canvas"><div id="canvas-content"></div></div>
<script type="module">
import {
FolkShape,
FolkMarkdown,
FolkWrapper,
FolkArrow,
FolkSlide,
FolkChat,
FolkGoogleItem,
FolkPiano,
FolkEmbed,
FolkCalendar,
FolkMap,
FolkImageGen,
FolkVideoGen,
FolkPrompt,
FolkTranscription,
FolkVideoChat,
FolkObsNote,
FolkWorkflowBlock,
FolkItinerary,
FolkDestination,
FolkBudget,
FolkPackingList,
FolkBooking,
FolkTokenMint,
FolkTokenLedger,
FolkChoiceVote,
FolkChoiceRank,
FolkChoiceSpider,
FolkSocialPost,
CommunitySync,
PresenceManager,
generatePeerId,
OfflineStore
} from "@lib";
import { mountHeader } from "@lib/rspace-header";
// Mount the header (light theme for canvas)
mountHeader({ theme: "light", showBrand: true });
// Register service worker for offline support
if ("serviceWorker" in navigator && window.location.hostname !== "localhost") {
navigator.serviceWorker.register("/sw.js").then((reg) => {
console.log("[Canvas] Service worker registered, scope:", reg.scope);
}).catch((err) => {
console.warn("[Canvas] Service worker registration failed:", err);
});
}
// Register custom elements
FolkShape.define();
FolkMarkdown.define();
FolkWrapper.define();
FolkArrow.define();
FolkSlide.define();
FolkChat.define();
FolkGoogleItem.define();
FolkPiano.define();
FolkEmbed.define();
FolkCalendar.define();
FolkMap.define();
FolkImageGen.define();
FolkVideoGen.define();
FolkPrompt.define();
FolkTranscription.define();
FolkVideoChat.define();
FolkObsNote.define();
FolkWorkflowBlock.define();
FolkItinerary.define();
FolkDestination.define();
FolkBudget.define();
FolkPackingList.define();
FolkBooking.define();
FolkTokenMint.define();
FolkTokenLedger.define();
FolkChoiceVote.define();
FolkChoiceRank.define();
FolkChoiceSpider.define();
FolkSocialPost.define();
// Get community info from URL
// Supports path-based slugs: cca.rspace.online/campaign/demo → slug "campaign-demo"
const hostname = window.location.hostname;
const subdomain = hostname.split(".")[0];
const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1";
const urlParams = new URLSearchParams(window.location.search);
const pathSegments = window.location.pathname.split("/").filter(Boolean);
const ignorePaths = ["canvas", "settings", "api"];
const cleanSegments = pathSegments.filter(s => !ignorePaths.includes(s));
let communitySlug = urlParams.get("space");
if (!communitySlug && cleanSegments.length > 0) {
communitySlug = cleanSegments.join("-");
} else if (!communitySlug) {
communitySlug = isLocalhost ? "demo" : subdomain;
}
// Update UI
document.getElementById("community-name").textContent = communitySlug;
document.getElementById("community-slug").textContent = `${communitySlug}.rspace.online`;
const canvas = document.getElementById("canvas");
const canvasContent = document.getElementById("canvas-content");
const status = document.getElementById("status");
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",
"folk-token-mint", "folk-token-ledger",
"folk-choice-vote", "folk-choice-rank", "folk-choice-spider",
"folk-social-post"
].join(", ");
// Initialize offline store and CommunitySync
const offlineStore = new OfflineStore();
await offlineStore.open();
const sync = new CommunitySync(communitySlug, offlineStore);
// Try to load from cache immediately (shows content before WebSocket connects)
const hadCache = await sync.initFromCache();
if (hadCache) {
status.className = "offline";
statusText.textContent = "Offline (cached)";
}
// Initialize Presence for real-time cursors
const peerId = generatePeerId();
const storedUsername = localStorage.getItem("rspace-username") || `User ${peerId.slice(0, 4)}`;
const presence = new PresenceManager(canvas, peerId, storedUsername);
// Track selected shape for presence sharing
let selectedShapeId = null;
// Throttle cursor updates (send at most every 50ms)
let lastCursorUpdate = 0;
const CURSOR_THROTTLE = 50;
canvas.addEventListener("mousemove", (e) => {
const now = Date.now();
if (now - lastCursorUpdate < CURSOR_THROTTLE) return;
lastCursorUpdate = now;
// Get cursor position relative to canvas
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Send presence update via sync
sync.sendPresence(presence.getLocalPresence({ x, y }, selectedShapeId));
});
// Handle touch for cursor position on mobile
canvas.addEventListener("touchmove", (e) => {
if (e.touches.length !== 1) return;
const now = Date.now();
if (now - lastCursorUpdate < CURSOR_THROTTLE) return;
lastCursorUpdate = now;
const rect = canvas.getBoundingClientRect();
const x = e.touches[0].clientX - rect.left;
const y = e.touches[0].clientY - rect.top;
sync.sendPresence(presence.getLocalPresence({ x, y }, selectedShapeId));
}, { passive: true });
// Handle presence updates from other users
sync.addEventListener("presence", (e) => {
presence.updatePresence(e.detail);
});
// Track if we're processing remote changes to avoid feedback loops
let isProcessingRemote = false;
// Handle connection status
sync.addEventListener("connected", () => {
status.className = "connected";
statusText.textContent = "Connected";
});
sync.addEventListener("disconnected", () => {
if (navigator.onLine) {
status.className = "disconnected";
statusText.textContent = "Reconnecting...";
} else {
status.className = "offline";
statusText.textContent = "Offline (changes saved locally)";
}
});
sync.addEventListener("synced", (e) => {
console.log("[Canvas] Initial sync complete:", e.detail.shapes);
});
// FUN: New — handle new shape from remote sync
sync.addEventListener("new-shape", (e) => {
const data = e.detail;
// Check if shape already exists
if (document.getElementById(data.id)) {
return;
}
try {
isProcessingRemote = true;
const shape = newShapeElement(data);
if (shape) {
setupShapeEventListeners(shape);
canvasContent.appendChild(shape);
sync.registerShape(shape);
}
} catch (err) {
console.error(`[Canvas] Failed to create remote shape ${data.id} (${data.type}):`, err);
} finally {
isProcessingRemote = false;
}
});
// FUN: Forget — handle shape removal from remote sync
sync.addEventListener("shape-removed", (e) => {
const { shapeId, shape } = e.detail;
if (shape && shape.parentNode) {
shape.remove();
}
});
// Create a shape element from data
function newShapeElement(data) {
let shape;
switch (data.type) {
case "folk-arrow":
shape = document.createElement("folk-arrow");
if (data.sourceId) shape.sourceId = data.sourceId;
if (data.targetId) shape.targetId = data.targetId;
if (data.color) shape.color = data.color;
if (data.strokeWidth) shape.strokeWidth = data.strokeWidth;
shape.id = data.id;
return shape; // Arrows don't have position/size
case "folk-wrapper":
shape = document.createElement("folk-wrapper");
if (data.title) shape.title = data.title;
if (data.icon) shape.icon = data.icon;
if (data.primaryColor) shape.primaryColor = data.primaryColor;
if (data.isMinimized) shape.isMinimized = data.isMinimized;
if (data.isPinned) shape.isPinned = data.isPinned;
if (data.tags) shape.tags = data.tags;
break;
case "folk-slide":
shape = document.createElement("folk-slide");
if (data.label) shape.label = data.label;
break;
case "folk-chat":
shape = document.createElement("folk-chat");
if (data.roomId) shape.roomId = data.roomId;
break;
case "folk-google-item":
shape = document.createElement("folk-google-item");
if (data.itemId) shape.itemId = data.itemId;
if (data.service) shape.service = data.service;
if (data.title) shape.title = data.title;
if (data.preview) shape.preview = data.preview;
if (data.date) shape.date = data.date;
if (data.thumbnailUrl) shape.thumbnailUrl = data.thumbnailUrl;
if (data.visibility) shape.visibility = data.visibility;
break;
case "folk-piano":
shape = document.createElement("folk-piano");
if (data.isMinimized) shape.isMinimized = data.isMinimized;
break;
case "folk-embed":
shape = document.createElement("folk-embed");
if (data.url) shape.url = data.url;
break;
case "folk-calendar":
shape = document.createElement("folk-calendar");
if (data.selectedDate) shape.selectedDate = new Date(data.selectedDate);
if (data.events) {
shape.events = data.events.map(e => ({
...e,
date: new Date(e.date)
}));
}
break;
case "folk-map":
shape = document.createElement("folk-map");
if (data.center) shape.center = data.center;
if (data.zoom) shape.zoom = data.zoom;
// Note: markers would need to be handled separately
break;
case "folk-image-gen":
shape = document.createElement("folk-image-gen");
// Images history would need to be restored from data.images
break;
case "folk-video-gen":
shape = document.createElement("folk-video-gen");
// Videos history would need to be restored from data.videos
break;
case "folk-prompt":
shape = document.createElement("folk-prompt");
// Messages history would need to be restored from data.messages
break;
case "folk-transcription":
shape = document.createElement("folk-transcription");
// Transcript would need to be restored from data.segments
break;
case "folk-video-chat":
shape = document.createElement("folk-video-chat");
if (data.roomId) shape.roomId = data.roomId;
break;
case "folk-obs-note":
shape = document.createElement("folk-obs-note");
if (data.title) shape.title = data.title;
if (data.content) shape.content = data.content;
break;
case "folk-workflow-block":
shape = document.createElement("folk-workflow-block");
if (data.blockType) shape.blockType = data.blockType;
if (data.label) shape.label = data.label;
if (data.inputs) shape.inputs = data.inputs;
if (data.outputs) shape.outputs = data.outputs;
break;
case "folk-itinerary":
shape = document.createElement("folk-itinerary");
if (data.tripTitle) shape.tripTitle = data.tripTitle;
if (data.items) shape.items = data.items;
break;
case "folk-destination":
shape = document.createElement("folk-destination");
if (data.destName) shape.destName = data.destName;
if (data.country) shape.country = data.country;
if (data.lat != null) shape.lat = data.lat;
if (data.lng != null) shape.lng = data.lng;
if (data.arrivalDate) shape.arrivalDate = data.arrivalDate;
if (data.departureDate) shape.departureDate = data.departureDate;
if (data.notes) shape.notes = data.notes;
break;
case "folk-budget":
shape = document.createElement("folk-budget");
if (data.budgetTotal != null) shape.budgetTotal = data.budgetTotal;
if (data.currency) shape.currency = data.currency;
if (data.expenses) shape.expenses = data.expenses;
break;
case "folk-packing-list":
shape = document.createElement("folk-packing-list");
if (data.items) shape.items = data.items;
break;
case "folk-booking":
shape = document.createElement("folk-booking");
if (data.bookingType) shape.bookingType = data.bookingType;
if (data.provider) shape.provider = data.provider;
if (data.confirmationNumber) shape.confirmationNumber = data.confirmationNumber;
if (data.details) shape.details = data.details;
if (data.cost != null) shape.cost = data.cost;
if (data.currency) shape.currency = data.currency;
if (data.startDate) shape.startDate = data.startDate;
if (data.endDate) shape.endDate = data.endDate;
if (data.bookingStatus) shape.bookingStatus = data.bookingStatus;
break;
case "folk-token-mint":
shape = document.createElement("folk-token-mint");
if (data.tokenName) shape.tokenName = data.tokenName;
if (data.tokenSymbol) shape.tokenSymbol = data.tokenSymbol;
if (data.description) shape.description = data.description;
if (data.totalSupply != null) shape.totalSupply = data.totalSupply;
if (data.issuedSupply != null) shape.issuedSupply = data.issuedSupply;
if (data.tokenColor) shape.tokenColor = data.tokenColor;
if (data.tokenIcon) shape.tokenIcon = data.tokenIcon;
if (data.createdBy) shape.createdBy = data.createdBy;
if (data.createdAt) shape.createdAt = data.createdAt;
break;
case "folk-token-ledger":
shape = document.createElement("folk-token-ledger");
if (data.mintId) shape.mintId = data.mintId;
if (data.entries) shape.entries = data.entries;
break;
case "folk-choice-vote":
shape = document.createElement("folk-choice-vote");
if (data.title) shape.title = data.title;
if (data.options) shape.options = data.options;
if (data.mode) shape.mode = data.mode;
if (data.budget != null) shape.budget = data.budget;
if (data.votes) shape.votes = data.votes;
break;
case "folk-choice-rank":
shape = document.createElement("folk-choice-rank");
if (data.title) shape.title = data.title;
if (data.options) shape.options = data.options;
if (data.rankings) shape.rankings = data.rankings;
break;
case "folk-choice-spider":
shape = document.createElement("folk-choice-spider");
if (data.title) shape.title = data.title;
if (data.options) shape.options = data.options;
if (data.criteria) shape.criteria = data.criteria;
if (data.scores) shape.scores = data.scores;
break;
case "folk-social-post":
shape = document.createElement("folk-social-post");
if (data.platform) shape.platform = data.platform;
if (data.postType) shape.postType = data.postType;
if (data.content) shape.content = data.content;
if (data.mediaUrl) shape.mediaUrl = data.mediaUrl;
if (data.mediaType) shape.mediaType = data.mediaType;
if (data.scheduledAt) shape.scheduledAt = data.scheduledAt;
if (data.status) shape.status = data.status;
if (data.hashtags) shape.hashtags = data.hashtags;
if (data.stepNumber) shape.stepNumber = data.stepNumber;
break;
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.rotation) shape.rotation = data.rotation;
return shape;
}
// 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
}
});
// 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();
});
}
// 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 },
"folk-token-mint": { width: 320, height: 280 },
"folk-token-ledger": { width: 380, height: 400 },
"folk-choice-vote": { width: 360, height: 400 },
"folk-choice-rank": { width: 380, height: 480 },
"folk-choice-spider": { width: 440, height: 540 },
"folk-social-post": { width: 300, height: 380 },
};
// 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 newShape(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);
canvasContent.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("new-markdown").addEventListener("click", () => {
newShape("folk-markdown", { content: "# New Note\n\nStart typing..." });
});
document.getElementById("new-wrapper").addEventListener("click", () => {
const colors = ["#14b8a6", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6", "#22c55e"];
const icons = ["📋", "💡", "📌", "🔗", "📁", "⭐"];
const shape = newShape("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);
}
});
document.getElementById("new-slide").addEventListener("click", () => {
newShape("folk-slide", { label: `Slide ${shapeCounter}` });
});
document.getElementById("new-chat").addEventListener("click", () => {
const id = `shape-${Date.now()}-${shapeCounter}`;
newShape("folk-chat", { roomId: `room-${id}` });
});
document.getElementById("new-piano").addEventListener("click", () => newShape("folk-piano"));
document.getElementById("new-embed").addEventListener("click", () => newShape("folk-embed"));
document.getElementById("new-calendar").addEventListener("click", () => newShape("folk-calendar"));
document.getElementById("new-map").addEventListener("click", () => newShape("folk-map"));
document.getElementById("new-image-gen").addEventListener("click", () => newShape("folk-image-gen"));
document.getElementById("new-video-gen").addEventListener("click", () => newShape("folk-video-gen"));
document.getElementById("new-prompt").addEventListener("click", () => newShape("folk-prompt"));
document.getElementById("new-transcription").addEventListener("click", () => newShape("folk-transcription"));
document.getElementById("new-video-chat").addEventListener("click", () => newShape("folk-video-chat"));
document.getElementById("new-obs-note").addEventListener("click", () => newShape("folk-obs-note"));
document.getElementById("new-workflow").addEventListener("click", () => newShape("folk-workflow-block"));
document.getElementById("new-google-item").addEventListener("click", () => {
newShape("folk-google-item", { service: "drive", title: "New Google Item" });
});
// Trip planning components
document.getElementById("new-itinerary").addEventListener("click", () => newShape("folk-itinerary"));
document.getElementById("new-destination").addEventListener("click", () => newShape("folk-destination"));
document.getElementById("new-budget").addEventListener("click", () => newShape("folk-budget"));
document.getElementById("new-packing-list").addEventListener("click", () => newShape("folk-packing-list"));
document.getElementById("new-booking").addEventListener("click", () => newShape("folk-booking"));
// Token creation - creates a mint + ledger pair with connecting arrow
document.getElementById("new-token").addEventListener("click", () => {
const mint = newShape("folk-token-mint", {
tokenName: "New Token",
tokenSymbol: "TKN",
totalSupply: 1000,
tokenColor: "#8b5cf6",
tokenIcon: "🪙",
createdAt: new Date().toISOString(),
});
if (mint) {
const ledger = newShape("folk-token-ledger", {
mintId: mint.id,
entries: [],
});
if (ledger) {
// Position ledger to the right of mint
ledger.x = mint.x + mint.width + 60;
ledger.y = mint.y;
// Connect with an arrow
const arrowId = `arrow-${Date.now()}-${++shapeCounter}`;
const arrow = document.createElement("folk-arrow");
arrow.id = arrowId;
arrow.sourceId = mint.id;
arrow.targetId = ledger.id;
arrow.color = "#8b5cf6";
canvasContent.appendChild(arrow);
sync.registerShape(arrow);
}
}
});
// Decision/choice components
document.getElementById("new-choice-vote").addEventListener("click", () => {
newShape("folk-choice-vote", {
title: "Quick Poll",
options: [
{ id: "opt-1", label: "Option A", color: "#3b82f6" },
{ id: "opt-2", label: "Option B", color: "#22c55e" },
{ id: "opt-3", label: "Option C", color: "#f59e0b" },
],
mode: "plurality",
budget: 100,
votes: [],
});
});
document.getElementById("new-choice-rank").addEventListener("click", () => {
newShape("folk-choice-rank", {
title: "Rank These",
options: [
{ id: "opt-1", label: "Option A" },
{ id: "opt-2", label: "Option B" },
{ id: "opt-3", label: "Option C" },
],
rankings: [],
});
});
document.getElementById("new-choice-spider").addEventListener("click", () => {
newShape("folk-choice-spider", {
title: "Evaluate Options",
options: [
{ id: "opt-1", label: "Option A" },
{ id: "opt-2", label: "Option B" },
],
criteria: [
{ id: "crit-1", label: "Quality", weight: 1 },
{ id: "crit-2", label: "Cost", weight: 1 },
{ id: "crit-3", label: "Speed", weight: 1 },
{ id: "crit-4", label: "Risk", weight: 1 },
],
scores: [],
});
});
// Social media post
document.getElementById("new-social-post").addEventListener("click", () => {
newShape("folk-social-post", {
platform: "x",
postType: "text",
content: "Write your post content here...",
status: "draft",
hashtags: [],
});
});
// Arrow connection mode
let connectMode = false;
let connectSource = null;
const newArrowBtn = document.getElementById("new-arrow");
newArrowBtn.addEventListener("click", () => {
connectMode = !connectMode;
newArrowBtn.classList.toggle("active", connectMode);
canvas.classList.toggle("connect-mode", connectMode);
if (!connectMode && connectSource) {
connectSource.classList.remove("connect-source");
connectSource = null;
}
});
// Handle shape clicks for connection mode
canvas.addEventListener("click", (e) => {
if (!connectMode) return;
const target = e.target.closest(CONNECTABLE_SELECTOR);
if (!target || !target.id) return;
e.stopPropagation();
if (!connectSource) {
// First click - select source
connectSource = target;
target.classList.add("connect-source");
} else if (target !== connectSource) {
// Second click - create arrow
const arrowId = `arrow-${Date.now()}-${++shapeCounter}`;
const colors = ["#374151", "#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6"];
const arrow = document.createElement("folk-arrow");
arrow.id = arrowId;
arrow.sourceId = connectSource.id;
arrow.targetId = target.id;
arrow.color = colors[Math.floor(Math.random() * colors.length)];
canvasContent.appendChild(arrow);
sync.registerShape(arrow);
// Reset connection mode
connectSource.classList.remove("connect-source");
connectSource = null;
connectMode = false;
newArrowBtn.classList.remove("active");
canvas.classList.remove("connect-mode");
}
});
// Memory panel — browse and remember forgotten shapes
const memoryPanel = document.getElementById("memory-panel");
const memoryList = document.getElementById("memory-list");
const memoryCount = document.getElementById("memory-count");
const toggleMemoryBtn = document.getElementById("toggle-memory");
const SHAPE_ICONS = {
"folk-markdown": "📝", "folk-wrapper": "🗂️", "folk-slide": "🎞️",
"folk-chat": "💬", "folk-piano": "🎹", "folk-embed": "🔗",
"folk-google-item": "📎", "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": "✈️",
"folk-token-mint": "🪙", "folk-token-ledger": "📒",
"folk-choice-vote": "☑", "folk-choice-rank": "📊",
"folk-choice-spider": "🕸", "folk-social-post": "📱",
"folk-arrow": "↗️",
};
function getShapeLabel(data) {
return data.title || data.content?.slice(0, 40) || data.tokenName || data.label || data.type || "Shape";
}
function renderMemoryPanel() {
const forgotten = sync.getForgottenShapes();
memoryCount.textContent = forgotten.length;
memoryList.innerHTML = "";
for (const shape of forgotten) {
const item = document.createElement("div");
item.className = "memory-item";
const ago = shape.forgottenAt
? timeAgo(shape.forgottenAt)
: "";
item.innerHTML = `
<span class="icon">${SHAPE_ICONS[shape.type] || "📦"}</span>
<div class="info">
<div class="name">${escapeHtml(getShapeLabel(shape))}</div>
<div class="meta">${shape.type}${ago ? " · " + ago : ""}</div>
</div>
<button class="remember-btn">Remember</button>
`;
item.querySelector(".remember-btn").addEventListener("click", (e) => {
e.stopPropagation();
sync.rememberShape(shape.id);
renderMemoryPanel();
});
memoryList.appendChild(item);
}
}
function timeAgo(ts) {
const diff = Date.now() - ts;
if (diff < 60000) return "just now";
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return `${Math.floor(diff / 86400000)}d ago`;
}
function escapeHtml(str) {
const d = document.createElement("div");
d.textContent = str;
return d.innerHTML;
}
toggleMemoryBtn.addEventListener("click", () => {
const isOpen = memoryPanel.classList.toggle("open");
toggleMemoryBtn.classList.toggle("active", isOpen);
if (isOpen) renderMemoryPanel();
});
// Refresh panel when shapes are forgotten/remembered via remote sync
sync.addEventListener("shape-forgotten", () => {
if (memoryPanel.classList.contains("open")) renderMemoryPanel();
});
sync.addEventListener("synced", () => {
if (memoryPanel.classList.contains("open")) renderMemoryPanel();
});
// Zoom and pan controls
let scale = 1;
let panX = 0;
let panY = 0;
const minScale = 0.05;
const maxScale = 20;
function updateCanvasTransform() {
// Transform only the content layer — canvas viewport stays fixed
canvasContent.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
// Adjust grid to track pan/zoom so it appears infinite
const gridSize = 20 * scale;
canvas.style.backgroundSize = `${gridSize}px ${gridSize}px`;
canvas.style.backgroundPosition = `${panX - 1}px ${panY - 1}px`;
}
document.getElementById("zoom-in").addEventListener("click", () => {
scale = Math.min(scale * 1.25, maxScale);
updateCanvasTransform();
});
document.getElementById("zoom-out").addEventListener("click", () => {
scale = Math.max(scale / 1.25, minScale);
updateCanvasTransform();
});
document.getElementById("reset-view").addEventListener("click", () => {
scale = 1;
panX = 0;
panY = 0;
updateCanvasTransform();
});
// Touch gesture handling for pinch-to-zoom and two-finger pan
let initialDistance = 0;
let initialScale = 1;
let lastTouchCenter = null;
function getTouchDistance(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function getTouchCenter(touches) {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2,
};
}
canvas.addEventListener("touchstart", (e) => {
if (e.touches.length === 2) {
e.preventDefault();
// Cancel any single-finger pan to avoid conflict
isPanning = false;
panPointerId = null;
canvas.style.cursor = "";
initialDistance = getTouchDistance(e.touches);
initialScale = scale;
lastTouchCenter = getTouchCenter(e.touches);
}
}, { passive: false });
canvas.addEventListener("touchmove", (e) => {
if (e.touches.length === 2) {
e.preventDefault();
// Pinch-to-zoom
const currentDistance = getTouchDistance(e.touches);
const scaleChange = currentDistance / initialDistance;
scale = Math.min(Math.max(initialScale * scaleChange, minScale), maxScale);
// Two-finger pan
const currentCenter = getTouchCenter(e.touches);
if (lastTouchCenter) {
panX += currentCenter.x - lastTouchCenter.x;
panY += currentCenter.y - lastTouchCenter.y;
}
lastTouchCenter = currentCenter;
updateCanvasTransform();
}
}, { passive: false });
canvas.addEventListener("touchend", (e) => {
if (e.touches.length < 2) {
initialDistance = 0;
lastTouchCenter = null;
}
});
// Mouse wheel zoom
canvas.addEventListener("wheel", (e) => {
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
scale = Math.min(Math.max(scale * zoomFactor, minScale), maxScale);
updateCanvasTransform();
}, { passive: false });
// Single-finger canvas pan (pointer events on empty background)
let isPanning = false;
let panPointerId = null;
let panStartX = 0;
let panStartY = 0;
canvas.addEventListener("pointerdown", (e) => {
if (e.target !== canvas && e.target !== canvasContent) return;
if (connectMode) return;
isPanning = true;
panPointerId = e.pointerId;
panStartX = e.clientX;
panStartY = e.clientY;
canvas.setPointerCapture(e.pointerId);
canvas.style.cursor = "grabbing";
});
canvas.addEventListener("pointermove", (e) => {
if (!isPanning || e.pointerId !== panPointerId) return;
const dx = e.clientX - panStartX;
const dy = e.clientY - panStartY;
panX += dx;
panY += dy;
panStartX = e.clientX;
panStartY = e.clientY;
updateCanvasTransform();
});
canvas.addEventListener("pointerup", (e) => {
if (e.pointerId !== panPointerId) return;
isPanning = false;
panPointerId = null;
canvas.style.cursor = "";
});
canvas.addEventListener("pointercancel", (e) => {
if (e.pointerId !== panPointerId) return;
isPanning = false;
panPointerId = null;
canvas.style.cursor = "";
});
// Keep-alive ping to prevent WebSocket idle timeout
setInterval(() => {
try {
sync.ping();
} catch (e) {
console.warn("[Canvas] Keep-alive ping failed:", e);
}
}, 30000);
// Connect to server
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws/${communitySlug}`;
// Handle browser online/offline events
window.addEventListener("online", () => {
console.log("[Canvas] Browser went online, reconnecting...");
status.className = "syncing";
statusText.textContent = "Reconnecting...";
sync.connect(wsUrl);
});
window.addEventListener("offline", () => {
console.log("[Canvas] Browser went offline");
status.className = "offline";
statusText.textContent = "Offline (changes saved locally)";
});
// Handle offline-loaded event
sync.addEventListener("offline-loaded", () => {
console.log("[Canvas] Loaded from offline cache");
});
// Save state before page unload
window.addEventListener("beforeunload", () => {
sync.saveBeforeUnload();
});
sync.connect(wsUrl);
// Debug: expose sync for console inspection
window.sync = sync;
</script>
</body>
</html>