rspace-online/website/canvas.html

838 lines
24 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 {
position: absolute;
}
.connect-mode folk-markdown,
.connect-mode folk-wrapper {
cursor: crosshair;
}
.connect-mode folk-markdown:hover,
.connect-mode folk-wrapper: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-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-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,
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();
// 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;
// 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;
}
isProcessingRemote = true;
const shape = createShapeElement(data);
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
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-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();
});
}
// Add markdown note button
document.getElementById("add-markdown").addEventListener("click", () => {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const shape = document.createElement("folk-markdown");
shape.id = id;
shape.x = 100 + Math.random() * 200;
shape.y = 100 + Math.random() * 200;
shape.width = 300;
shape.height = 200;
shape.content = "# New Note\n\nStart typing...";
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
});
// Add wrapper card button
document.getElementById("add-wrapper").addEventListener("click", () => {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const colors = ["#14b8a6", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6", "#22c55e"];
const icons = ["📋", "💡", "📌", "🔗", "📁", "⭐"];
const shape = document.createElement("folk-wrapper");
shape.id = id;
shape.x = 100 + Math.random() * 200;
shape.y = 100 + Math.random() * 200;
shape.width = 320;
shape.height = 240;
shape.title = "New Card";
shape.icon = icons[Math.floor(Math.random() * icons.length)];
shape.primaryColor = colors[Math.floor(Math.random() * colors.length)];
// Add some placeholder content inside the wrapper
const content = document.createElement("div");
content.style.padding = "16px";
content.style.color = "#374151";
content.innerHTML = "<p>Click to edit this card...</p>";
shape.appendChild(content);
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
});
// Add slide button
document.getElementById("add-slide").addEventListener("click", () => {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const shape = document.createElement("folk-slide");
shape.id = id;
shape.x = 100 + Math.random() * 200;
shape.y = 100 + Math.random() * 200;
shape.width = 720;
shape.height = 480;
shape.label = `Slide ${shapeCounter}`;
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
});
// Add chat button
document.getElementById("add-chat").addEventListener("click", () => {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const shape = document.createElement("folk-chat");
shape.id = id;
shape.x = 100 + Math.random() * 200;
shape.y = 100 + Math.random() * 200;
shape.width = 400;
shape.height = 500;
shape.roomId = `room-${id}`;
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
});
// Add piano button
document.getElementById("add-piano").addEventListener("click", () => {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const shape = document.createElement("folk-piano");
shape.id = id;
shape.x = 100 + Math.random() * 200;
shape.y = 100 + Math.random() * 200;
shape.width = 800;
shape.height = 600;
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
});
// Add embed button
document.getElementById("add-embed").addEventListener("click", () => {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const shape = document.createElement("folk-embed");
shape.id = id;
shape.x = 100 + Math.random() * 200;
shape.y = 100 + Math.random() * 200;
shape.width = 480;
shape.height = 360;
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
});
// Add calendar button
document.getElementById("add-calendar").addEventListener("click", () => {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const shape = document.createElement("folk-calendar");
shape.id = id;
shape.x = 100 + Math.random() * 200;
shape.y = 100 + Math.random() * 200;
shape.width = 320;
shape.height = 380;
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
});
// Add map button
document.getElementById("add-map").addEventListener("click", () => {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const shape = document.createElement("folk-map");
shape.id = id;
shape.x = 100 + Math.random() * 200;
shape.y = 100 + Math.random() * 200;
shape.width = 500;
shape.height = 400;
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
});
// Add image gen button
document.getElementById("add-image-gen").addEventListener("click", () => {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const shape = document.createElement("folk-image-gen");
shape.id = id;
shape.x = 100 + Math.random() * 200;
shape.y = 100 + Math.random() * 200;
shape.width = 400;
shape.height = 500;
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
});
// Add video gen button
document.getElementById("add-video-gen").addEventListener("click", () => {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const shape = document.createElement("folk-video-gen");
shape.id = id;
shape.x = 100 + Math.random() * 200;
shape.y = 100 + Math.random() * 200;
shape.width = 450;
shape.height = 550;
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
});
// Add prompt button
document.getElementById("add-prompt").addEventListener("click", () => {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const shape = document.createElement("folk-prompt");
shape.id = id;
shape.x = 100 + Math.random() * 200;
shape.y = 100 + Math.random() * 200;
shape.width = 450;
shape.height = 500;
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
});
// Add transcription button
document.getElementById("add-transcription").addEventListener("click", () => {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const shape = document.createElement("folk-transcription");
shape.id = id;
shape.x = 100 + Math.random() * 200;
shape.y = 100 + Math.random() * 200;
shape.width = 400;
shape.height = 450;
setupShapeEventListeners(shape);
canvas.appendChild(shape);
sync.registerShape(shape);
});
// 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("folk-markdown, folk-wrapper");
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
setInterval(() => {
if (sync.doc) {
// Sync is connected, nothing to do
}
}, 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>