938 lines
31 KiB
HTML
938 lines
31 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: 16px;
|
|
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: 16px;
|
|
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;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
#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 */
|
|
}
|
|
|
|
/* 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 {
|
|
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) {
|
|
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):hover {
|
|
outline: 2px dashed #3b82f6;
|
|
outline-offset: 4px;
|
|
}
|
|
|
|
.connect-source {
|
|
outline: 3px solid #22c55e !important;
|
|
outline-offset: 4px !important;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="community-info">
|
|
<h2 id="community-name">Loading...</h2>
|
|
<p id="community-slug"></p>
|
|
</div>
|
|
|
|
<div id="toolbar">
|
|
<button id="add-markdown" title="Add Markdown Note">📝 Note</button>
|
|
<button id="add-wrapper" title="Add Wrapped Card">🗂️ Card</button>
|
|
<button id="add-slide" title="Add Slide">🎞️ Slide</button>
|
|
<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>
|
|
<button id="add-video-gen" title="AI Video Generation">🎬 Video</button>
|
|
<button id="add-prompt" title="AI Chat/Prompt">🤖 AI</button>
|
|
<button id="add-transcription" title="Audio Transcription">🎤 Transcribe</button>
|
|
<button id="add-video-chat" title="Video Call">📹 Call</button>
|
|
<button id="add-obs-note" title="Rich Note">📓 Rich Note</button>
|
|
<button id="add-workflow" title="Workflow Block">⚙️ Workflow</button>
|
|
<button id="add-itinerary" title="Trip Itinerary">🗓️ Itinerary</button>
|
|
<button id="add-destination" title="Trip Destination">📍 Destination</button>
|
|
<button id="add-budget" title="Trip Budget">💰 Budget</button>
|
|
<button id="add-packing-list" title="Packing List">🎒 Packing</button>
|
|
<button id="add-booking" title="Trip Booking">✈️ Booking</button>
|
|
<button id="add-token" title="Create Token">🪙 Token</button>
|
|
<button id="add-arrow" title="Connect Shapes">↗️ Connect</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="status" class="disconnected">
|
|
<span class="indicator"></span>
|
|
<span id="status-text">Connecting...</span>
|
|
</div>
|
|
|
|
<div id="canvas"></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,
|
|
CommunitySync,
|
|
PresenceManager,
|
|
generatePeerId
|
|
} from "@lib";
|
|
|
|
// 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();
|
|
|
|
// Get community info from URL
|
|
const hostname = window.location.hostname;
|
|
const subdomain = hostname.split(".")[0];
|
|
const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1";
|
|
const 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 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"
|
|
].join(", ");
|
|
|
|
// Initialize CommunitySync
|
|
const sync = new CommunitySync(communitySlug);
|
|
|
|
// 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", () => {
|
|
status.className = "disconnected";
|
|
statusText.textContent = "Reconnecting...";
|
|
});
|
|
|
|
sync.addEventListener("synced", (e) => {
|
|
console.log("[Canvas] Initial sync complete:", e.detail.shapes);
|
|
});
|
|
|
|
// Handle shape creation from remote
|
|
sync.addEventListener("create-shape", (e) => {
|
|
const data = e.detail;
|
|
|
|
// Check if shape already exists
|
|
if (document.getElementById(data.id)) {
|
|
return;
|
|
}
|
|
|
|
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
|
|
sync.addEventListener("shape-deleted", (e) => {
|
|
const { shapeId, shape } = e.detail;
|
|
if (shape && shape.parentNode) {
|
|
shape.remove();
|
|
}
|
|
});
|
|
|
|
// Create a shape element from data
|
|
function createShapeElement(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-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 },
|
|
};
|
|
|
|
// 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..." });
|
|
});
|
|
|
|
document.getElementById("add-wrapper").addEventListener("click", () => {
|
|
const colors = ["#14b8a6", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6", "#22c55e"];
|
|
const icons = ["📋", "💡", "📌", "🔗", "📁", "⭐"];
|
|
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);
|
|
}
|
|
});
|
|
|
|
document.getElementById("add-slide").addEventListener("click", () => {
|
|
createAndAddShape("folk-slide", { label: `Slide ${shapeCounter}` });
|
|
});
|
|
|
|
document.getElementById("add-chat").addEventListener("click", () => {
|
|
const id = `shape-${Date.now()}-${shapeCounter}`;
|
|
createAndAddShape("folk-chat", { roomId: `room-${id}` });
|
|
});
|
|
|
|
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", () => 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"));
|
|
|
|
// Token creation - creates a mint + ledger pair with connecting arrow
|
|
document.getElementById("add-token").addEventListener("click", () => {
|
|
const mint = createAndAddShape("folk-token-mint", {
|
|
tokenName: "New Token",
|
|
tokenSymbol: "TKN",
|
|
totalSupply: 1000,
|
|
tokenColor: "#8b5cf6",
|
|
tokenIcon: "🪙",
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
if (mint) {
|
|
const ledger = createAndAddShape("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";
|
|
canvas.appendChild(arrow);
|
|
sync.registerShape(arrow);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Arrow connection mode
|
|
let connectMode = false;
|
|
let connectSource = null;
|
|
const addArrowBtn = document.getElementById("add-arrow");
|
|
|
|
addArrowBtn.addEventListener("click", () => {
|
|
connectMode = !connectMode;
|
|
addArrowBtn.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)];
|
|
|
|
canvas.appendChild(arrow);
|
|
sync.registerShape(arrow);
|
|
|
|
// Reset connection mode
|
|
connectSource.classList.remove("connect-source");
|
|
connectSource = null;
|
|
connectMode = false;
|
|
addArrowBtn.classList.remove("active");
|
|
canvas.classList.remove("connect-mode");
|
|
}
|
|
});
|
|
|
|
// Zoom and pan controls
|
|
let scale = 1;
|
|
let panX = 0;
|
|
let panY = 0;
|
|
const minScale = 0.25;
|
|
const maxScale = 4;
|
|
|
|
function updateCanvasTransform() {
|
|
canvas.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
|
|
canvas.style.transformOrigin = "center center";
|
|
}
|
|
|
|
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();
|
|
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 });
|
|
|
|
// 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}`;
|
|
sync.connect(wsUrl);
|
|
|
|
// Debug: expose sync for console inspection
|
|
window.sync = sync;
|
|
</script>
|
|
</body>
|
|
</html>
|