rspace-online/website/canvas.html

1538 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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;
touch-action: none; /* Prevent browser gestures, handle manually */
}
#canvas-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transform-origin: 0 0;
overflow: visible;
}
/* 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 menu toggle (hidden on desktop) */
#mobile-menu {
display: none;
}
#mobile-zoom {
display: none;
}
@media (max-width: 768px) {
/* FAB toggle button */
#mobile-menu {
display: flex;
position: fixed;
bottom: 24px;
right: 16px;
width: 56px;
height: 56px;
border: none;
border-radius: 50%;
background: #14b8a6;
color: white;
font-size: 28px;
align-items: center;
justify-content: center;
z-index: 1002;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
cursor: pointer;
touch-action: manipulation;
}
/* Always-visible zoom strip */
#mobile-zoom {
display: flex;
position: fixed;
bottom: 24px;
left: 16px;
gap: 4px;
z-index: 1002;
}
#mobile-zoom button {
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
font-size: 16px;
cursor: pointer;
touch-action: manipulation;
}
/* Hide desktop toolbar, show as grid overlay when toggled */
#toolbar {
display: none;
position: fixed;
top: 72px;
left: 8px;
right: 8px;
transform: none;
flex-wrap: wrap;
max-height: calc(100vh - 160px);
overflow-y: auto;
gap: 6px;
padding: 12px;
border-radius: 16px;
z-index: 1001;
}
#toolbar.mobile-open {
display: flex;
}
#toolbar button {
flex: 0 0 calc(33.33% - 4px);
padding: 10px 4px;
font-size: 12px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Hide zoom/reset from toolbar grid (they're in #mobile-zoom) */
#toolbar #zoom-in,
#toolbar #zoom-out,
#toolbar #reset-view {
display: none;
}
#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>
<button id="mobile-menu" title="Tools"></button>
<div id="mobile-zoom">
<button id="mz-out" title="Zoom Out"></button>
<button id="mz-in" title="Zoom In">+</button>
<button id="mz-reset" title="Reset View"></button>
</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();
});
// Mobile toolbar toggle
const mobileMenuBtn = document.getElementById("mobile-menu");
const toolbarEl = document.getElementById("toolbar");
mobileMenuBtn.addEventListener("click", () => {
const isOpen = toolbarEl.classList.toggle("mobile-open");
mobileMenuBtn.textContent = isOpen ? "✕" : "✚";
});
// Auto-close toolbar after tapping a shape-creation button on mobile
toolbarEl.addEventListener("click", (e) => {
if (window.innerWidth > 768) return;
const btn = e.target.closest("button");
if (!btn) return;
// Keep open for connect, memory, zoom controls
const keepOpen = ["new-arrow", "toggle-memory", "zoom-in", "zoom-out", "reset-view"];
if (!keepOpen.includes(btn.id)) {
toolbarEl.classList.remove("mobile-open");
mobileMenuBtn.textContent = "✚";
}
});
// Mobile zoom controls (separate from toolbar)
document.getElementById("mz-in").addEventListener("click", () => {
scale = Math.min(scale * 1.25, maxScale);
updateCanvasTransform();
});
document.getElementById("mz-out").addEventListener("click", () => {
scale = Math.max(scale / 1.25, minScale);
updateCanvasTransform();
});
document.getElementById("mz-reset").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>