rspace-online/website/canvas.html

2129 lines
67 KiB
HTML
Raw Permalink 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: 108px; /* header(56) + tab-row(36) + gap(16) */
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
/* Dropdown group container */
.toolbar-group {
position: relative;
}
.toolbar-group-toggle {
padding: 7px 12px;
border: none;
border-radius: 8px;
background: #f1f5f9;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
white-space: nowrap;
}
.toolbar-group-toggle:hover {
background: #e2e8f0;
}
.toolbar-group.open > .toolbar-group-toggle {
background: #14b8a6;
color: white;
}
.toolbar-dropdown {
display: none;
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
min-width: 160px;
background: white;
border-radius: 10px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
padding: 6px;
z-index: 1001;
}
.toolbar-group.open > .toolbar-dropdown {
display: flex;
flex-direction: column;
gap: 2px;
}
.toolbar-dropdown button {
padding: 8px 12px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 13px;
text-align: left;
white-space: nowrap;
transition: background 0.15s;
}
.toolbar-dropdown button:hover {
background: #f1f5f9;
}
/* Separator between sections */
.toolbar-sep {
width: 1px;
height: 24px;
background: #e2e8f0;
margin: 0 2px;
flex-shrink: 0;
}
/* Direct toolbar buttons (Connect, Memory, Zoom) */
#toolbar > button {
padding: 7px 12px;
border: none;
border-radius: 8px;
background: #f1f5f9;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
white-space: nowrap;
}
#toolbar > button:hover {
background: #e2e8f0;
}
#toolbar > button.active {
background: #14b8a6;
color: white;
}
/* Collapse/expand toggle */
#toolbar-collapse {
padding: 7px 8px !important;
background: transparent !important;
font-size: 16px !important;
line-height: 1;
opacity: 0.6;
transition: opacity 0.2s;
}
#toolbar-collapse:hover {
opacity: 1;
background: #f1f5f9 !important;
}
#toolbar.collapsed .toolbar-group,
#toolbar.collapsed .toolbar-sep,
#toolbar.collapsed > button:not(#toolbar-collapse) {
display: none;
}
#toolbar.collapsed {
padding: 6px;
}
#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,
folk-rapp {
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, folk-rapp) {
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, folk-rapp):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 column 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;
}
/* On mobile, flatten groups into a grid */
#toolbar .toolbar-group {
width: 100%;
}
#toolbar .toolbar-group-toggle {
width: 100%;
text-align: left;
padding: 10px 12px;
font-size: 13px;
}
#toolbar .toolbar-dropdown {
position: static;
transform: none;
box-shadow: none;
padding: 4px 0 4px 12px;
min-width: 0;
}
#toolbar .toolbar-dropdown button {
padding: 10px 12px;
font-size: 13px;
}
#toolbar > button {
width: 100%;
text-align: left;
padding: 10px 12px;
}
#toolbar .toolbar-sep {
width: 100%;
height: 1px;
margin: 4px 0;
}
/* Hide zoom/reset from toolbar (they're in #mobile-zoom) */
#toolbar #zoom-in,
#toolbar #zoom-out,
#toolbar #reset-view,
#toolbar #toolbar-collapse {
display: none;
}
#community-info {
display: none;
}
#memory-panel {
max-width: calc(100vw - 32px);
}
}
</style>
<link rel="stylesheet" href="/shell.css">
</head>
<body data-theme="light">
<header class="rstack-header" data-theme="light">
<div class="rstack-header__left">
<rstack-app-switcher current="canvas"></rstack-app-switcher>
<rstack-space-switcher current="" name=""></rstack-space-switcher>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
</div>
<div class="rstack-header__right">
<rstack-identity></rstack-identity>
</div>
</header>
<div class="rstack-tab-row" data-theme="light">
<rstack-tab-bar space="" active="" view-mode="flat"></rstack-tab-bar>
</div>
<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="toolbar-collapse" title="Minimize toolbar"></button>
<div class="toolbar-group">
<button class="toolbar-group-toggle">📝 Create</button>
<div class="toolbar-dropdown">
<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-obs-note" title="New Rich Note">📓 Rich Note</button>
<button id="new-chat" title="New Chat">💬 Chat</button>
</div>
</div>
<div class="toolbar-group">
<button class="toolbar-group-toggle">🎨 Media</button>
<div class="toolbar-dropdown">
<button id="new-image-gen" title="New AI Image">🎨 AI Image</button>
<button id="new-video-gen" title="New AI Video">🎬 AI Video</button>
<button id="new-transcription" title="New Transcription">🎤 Transcribe</button>
<button id="new-video-chat" title="New Video Call">📹 Video Call</button>
<button id="new-piano" title="New Piano">🎹 Piano</button>
</div>
</div>
<div class="toolbar-group">
<button class="toolbar-group-toggle">🔗 Embed</button>
<div class="toolbar-dropdown">
<button id="new-embed" title="New Embed">🔗 Web 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-social-post" title="New Post">📱 Social Post</button>
</div>
</div>
<div class="toolbar-group">
<button class="toolbar-group-toggle">🤖 AI</button>
<div class="toolbar-dropdown">
<button id="new-prompt" title="New AI Chat">🤖 AI Chat</button>
<button id="new-workflow" title="New Workflow">⚙️ Workflow</button>
</div>
</div>
<div class="toolbar-group">
<button class="toolbar-group-toggle">✈️ Travel</button>
<div class="toolbar-dropdown">
<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 List</button>
<button id="new-booking" title="New Booking">✈️ Booking</button>
</div>
</div>
<div class="toolbar-group">
<button class="toolbar-group-toggle">📊 Decide</button>
<div class="toolbar-dropdown">
<button id="new-choice-vote" title="New Poll">☑ Poll</button>
<button id="new-choice-rank" title="New Ranking">📊 Ranking</button>
<button id="new-choice-spider" title="New Scoring">🕸 Scoring</button>
<button id="new-token" title="New Token">🪙 Token</button>
</div>
</div>
<div class="toolbar-group">
<button class="toolbar-group-toggle">📱 rApps</button>
<div class="toolbar-dropdown">
<button id="embed-notes" title="Embed rNotes">📝 rNotes</button>
<button id="embed-photos" title="Embed rPhotos">📸 rPhotos</button>
<button id="embed-books" title="Embed rBooks">📚 rBooks</button>
<button id="embed-pubs" title="Embed rPubs">📖 rPubs</button>
<button id="embed-files" title="Embed rFiles">📁 rFiles</button>
<button id="embed-work" title="Embed rWork">📋 rWork</button>
<button id="embed-forum" title="Embed rForum">💬 rForum</button>
<button id="embed-inbox" title="Embed rInbox">📧 rInbox</button>
<button id="embed-tube" title="Embed rTube">🎬 rTube</button>
<button id="embed-funds" title="Embed rFunds">🌊 rFunds</button>
<button id="embed-wallet" title="Embed rWallet">💰 rWallet</button>
<button id="embed-vote" title="Embed rVote">🗳️ rVote</button>
<button id="embed-cart" title="Embed rCart">🛒 rCart</button>
<button id="embed-data" title="Embed rData">📊 rData</button>
<button id="embed-network" title="Embed rNetwork">🌍 rNetwork</button>
<button id="embed-splat" title="Embed rSplat">🔮 rSplat</button>
<button id="embed-swag" title="Embed rSwag">🎨 rSwag</button>
</div>
</div>
<span class="toolbar-sep"></span>
<button id="new-arrow" title="Connect rSpaces">↗️ Connect</button>
<button id="new-feed" title="New Feed from another layer">🔄 Feed</button>
<button id="toggle-memory" title="Forgotten rSpaces">💭 Memory</button>
<span class="toolbar-sep"></span>
<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,
FolkCanvas,
FolkRApp,
FolkFeed,
CommunitySync,
PresenceManager,
generatePeerId,
OfflineStore
} from "@lib";
import { RStackIdentity } from "@shared/components/rstack-identity";
import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher";
import { RStackSpaceSwitcher } from "@shared/components/rstack-space-switcher";
import { RStackTabBar } from "@shared/components/rstack-tab-bar";
import { rspaceNavUrl } from "@shared/url-helpers";
// Register shell header components
RStackIdentity.define();
RStackAppSwitcher.define();
RStackSpaceSwitcher.define();
RStackTabBar.define();
// Load module list for app switcher
fetch("/api/modules").then(r => r.json()).then(data => {
document.querySelector("rstack-app-switcher")?.setModules(data.modules || []);
}).catch(() => {});
// ── Tab bar / Layer system initialization ──
const tabBar = document.querySelector("rstack-tab-bar");
if (tabBar) {
const canvasDefaultLayer = {
id: "layer-canvas",
moduleId: "rspace",
label: "rSpace",
order: 0,
color: "",
visible: true,
createdAt: Date.now(),
};
tabBar.setLayers([canvasDefaultLayer]);
tabBar.setAttribute("active", canvasDefaultLayer.id);
// Tab switching: navigate to the selected module's page
tabBar.addEventListener("layer-switch", (e) => {
const { moduleId } = e.detail;
if (moduleId === "rspace") return; // already on canvas
window.location.href = rspaceNavUrl(
document.querySelector("rstack-space-switcher")?.getAttribute("current") || "demo",
moduleId
);
});
// Adding a new tab: navigate to that module
tabBar.addEventListener("layer-add", (e) => {
const { moduleId } = e.detail;
window.location.href = rspaceNavUrl(
document.querySelector("rstack-space-switcher")?.getAttribute("current") || "demo",
moduleId
);
});
// Closing a tab
tabBar.addEventListener("layer-close", (e) => {
tabBar.removeLayer(e.detail.layerId);
});
// View mode toggle
tabBar.addEventListener("view-toggle", (e) => {
document.dispatchEvent(new CustomEvent("layer-view-mode", { detail: { mode: e.detail.mode } }));
});
// Expose for CommunitySync wiring
window.__rspaceTabBar = tabBar;
}
// 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();
FolkCanvas.define();
FolkRApp.define();
FolkFeed.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 = ["rspace", "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`;
// Update shell header space-switcher with resolved slug
const spaceSwitcher = document.querySelector("rstack-space-switcher");
if (spaceSwitcher) {
spaceSwitcher.setAttribute("current", communitySlug);
spaceSwitcher.setAttribute("name", communitySlug);
}
// Update tab bar with resolved space slug
if (tabBar) {
tabBar.setAttribute("space", communitySlug);
}
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",
"folk-rapp",
"folk-feed"
].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)";
}
// Notify the shell tab bar that CommunitySync is ready
document.dispatchEvent(new CustomEvent("community-sync-ready", {
detail: { sync, communitySlug }
}));
// Wire tab bar to CommunitySync for layer persistence
if (tabBar && sync) {
const canvasDefaultLayer = {
id: "layer-canvas",
moduleId: "rspace",
label: "rSpace",
order: 0,
color: "",
visible: true,
createdAt: Date.now(),
};
// Load persisted layers from Automerge
const layers = sync.getLayers?.() || [];
if (layers.length > 0) {
tabBar.setLayers(layers);
const activeId = sync.doc?.activeLayerId;
if (activeId) tabBar.setAttribute("active", activeId);
if (sync.getFlows) tabBar.setFlows(sync.getFlows());
} else {
// First visit: persist the canvas layer
sync.addLayer?.(canvasDefaultLayer);
sync.setActiveLayer?.(canvasDefaultLayer.id);
}
// Persist layer switch
tabBar.addEventListener("layer-switch", (e) => {
sync.setActiveLayer?.(e.detail.layerId);
});
// Persist new layer
tabBar.addEventListener("layer-add", (e) => {
const { moduleId } = e.detail;
sync.addLayer?.({
id: "layer-" + moduleId,
moduleId,
label: moduleId,
order: (sync.getLayers?.() || []).length,
color: "",
visible: true,
createdAt: Date.now(),
});
});
// Persist layer close
tabBar.addEventListener("layer-close", (e) => {
sync.removeLayer?.(e.detail.layerId);
});
// Persist layer reorder
tabBar.addEventListener("layer-reorder", (e) => {
const { layerId, newIndex } = e.detail;
sync.updateLayer?.(layerId, { order: newIndex });
const allLayers = sync.getLayers?.() || [];
allLayers.forEach((l, i) => {
if (l.order !== i) sync.updateLayer?.(l.id, { order: i });
});
});
// Flow creation from stack view
tabBar.addEventListener("flow-create", (e) => {
sync.addFlow?.(e.detail.flow);
});
// Flow removal from stack view
tabBar.addEventListener("flow-remove", (e) => {
sync.removeFlow?.(e.detail.flowId);
});
// View mode persistence
tabBar.addEventListener("view-toggle", (e) => {
sync.setLayerViewMode?.(e.detail.mode);
});
// Sync remote layer/flow changes back to tab bar
sync.addEventListener("change", () => {
const updatedLayers = sync.getLayers?.() || [];
if (updatedLayers.length > 0) {
tabBar.setLayers(updatedLayers);
if (sync.getFlows) tabBar.setFlows(sync.getFlows());
const activeId = sync.doc?.activeLayerId;
if (activeId) tabBar.setAttribute("active", activeId);
const viewMode = sync.doc?.layerViewMode;
if (viewMode) tabBar.setAttribute("view-mode", viewMode);
}
});
}
// 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-canvas":
shape = document.createElement("folk-canvas");
shape.parentSlug = communitySlug; // pass parent context for nest-from
if (data.sourceSlug) shape.sourceSlug = data.sourceSlug;
if (data.sourceDID) shape.sourceDID = data.sourceDID;
if (data.permissions) shape.permissions = data.permissions;
if (data.collapsed != null) shape.collapsed = data.collapsed;
if (data.label) shape.label = data.label;
break;
case "folk-rapp":
shape = document.createElement("folk-rapp");
if (data.moduleId) shape.moduleId = data.moduleId;
if (data.spaceSlug) shape.spaceSlug = data.spaceSlug;
break;
case "folk-feed":
shape = document.createElement("folk-feed");
if (data.sourceLayer) shape.sourceLayer = data.sourceLayer;
if (data.sourceModule) shape.sourceModule = data.sourceModule;
if (data.feedId) shape.feedId = data.feedId;
if (data.flowKind) shape.flowKind = data.flowKind;
if (data.feedFilter) shape.feedFilter = data.feedFilter;
if (data.maxItems) shape.maxItems = data.maxItems;
if (data.refreshInterval) shape.refreshInterval = data.refreshInterval;
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 },
"folk-canvas": { width: 600, height: 400 },
"folk-rapp": { width: 500, height: 400 },
"folk-feed": { width: 280, height: 360 },
};
// 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;
return { x: canvasX, y: canvasY };
}
// Check if two rectangles overlap (with padding gap)
function rectsOverlap(a, b, gap = 20) {
return !(a.x + a.width + gap <= b.x ||
b.x + b.width + gap <= a.x ||
a.y + a.height + gap <= b.y ||
b.y + b.height + gap <= a.y);
}
// Collect bounding boxes of all visible shapes on the canvas
function getExistingShapeRects() {
return [...canvasContent.children]
.filter(el => el.tagName && el.tagName.includes('-') &&
!el.tagName.toLowerCase().includes('arrow') &&
typeof el.x === 'number' && typeof el.width === 'number' &&
el.width > 0)
.map(el => ({ x: el.x, y: el.y, width: el.width, height: el.height }));
}
// Find a free position near the viewport center that doesn't overlap existing shapes
function findFreePosition(width, height) {
const center = getViewportCenter();
const candidateX = center.x - width / 2;
const candidateY = center.y - height / 2;
const gap = 20;
const existing = getExistingShapeRects();
if (existing.length === 0) {
return { x: candidateX, y: candidateY };
}
const candidate = { x: candidateX, y: candidateY, width, height };
const hasOverlap = (rect) => existing.some(e => rectsOverlap(rect, e, gap));
if (!hasOverlap(candidate)) {
return { x: candidateX, y: candidateY };
}
// Spiral search: try right, below, left, above with increasing steps
const stepX = width + gap;
const stepY = height + gap;
for (let ring = 1; ring <= 20; ring++) {
const offsets = [
{ x: ring * stepX, y: 0 }, // right
{ x: 0, y: ring * stepY }, // below
{ x: -ring * stepX, y: 0 }, // left
{ x: 0, y: -ring * stepY }, // above
{ x: ring * stepX, y: ring * stepY }, // bottom-right
{ x: -ring * stepX, y: ring * stepY }, // bottom-left
{ x: ring * stepX, y: -ring * stepY }, // top-right
{ x: -ring * stepX, y: -ring * stepY }, // top-left
];
for (const off of offsets) {
const test = { x: candidateX + off.x, y: candidateY + off.y, width, height };
if (!hasOverlap(test)) {
return { x: test.x, y: test.y };
}
}
}
// Fallback: place with jitter if everything is occupied
return {
x: candidateX + (Math.random() - 0.5) * 100,
y: candidateY + (Math.random() - 0.5) * 100
};
}
// Create a shape, position it without overlapping others, 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 pos = findFreePosition(defaults.width, defaults.height);
shape.x = pos.x;
shape.y = pos.y;
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: [],
});
});
// rApp embed buttons — embed any module as a folk-rapp shape on the canvas
const rAppModules = [
{ btnId: "embed-notes", moduleId: "rnotes" },
{ btnId: "embed-photos", moduleId: "rphotos" },
{ btnId: "embed-books", moduleId: "rbooks" },
{ btnId: "embed-pubs", moduleId: "rpubs" },
{ btnId: "embed-files", moduleId: "rfiles" },
{ btnId: "embed-work", moduleId: "rwork" },
{ btnId: "embed-forum", moduleId: "rforum" },
{ btnId: "embed-inbox", moduleId: "rinbox" },
{ btnId: "embed-tube", moduleId: "rtube" },
{ btnId: "embed-funds", moduleId: "rfunds" },
{ btnId: "embed-wallet", moduleId: "rwallet" },
{ btnId: "embed-vote", moduleId: "rvote" },
{ btnId: "embed-cart", moduleId: "rcart" },
{ btnId: "embed-data", moduleId: "rdata" },
{ btnId: "embed-network", moduleId: "rnetwork" },
{ btnId: "embed-splat", moduleId: "rsplat" },
{ btnId: "embed-swag", moduleId: "rswag" },
];
for (const app of rAppModules) {
const btn = document.getElementById(app.btnId);
if (btn) {
btn.addEventListener("click", () => {
newShape("folk-rapp", { moduleId: app.moduleId, spaceSlug: communitySlug });
});
}
}
// Feed shape — pull live data from another layer/module
document.getElementById("new-feed").addEventListener("click", () => {
// Prompt for source module (simple for now — will get a proper UI)
const modules = ["notes", "funds", "vote", "choices", "wallet", "data", "work", "network", "trips"];
const sourceModule = prompt("Feed from which rApp?\n\n" + modules.join(", "), "notes");
if (!sourceModule || !modules.includes(sourceModule)) return;
// Pick flow kind based on module defaults
const moduleFlowKinds = {
funds: "economic", wallet: "economic", trips: "economic",
vote: "governance", choices: "governance",
network: "trust",
data: "attention",
notes: "data", work: "data",
};
const flowKind = moduleFlowKinds[sourceModule] || "data";
const shape = newShape("folk-feed", {
sourceModule,
sourceLayer: "layer-" + sourceModule,
feedId: "",
flowKind,
maxItems: 10,
refreshInterval: 30000,
});
// Auto-register a LayerFlow in Automerge if layers exist
if (shape && sync.getLayers) {
const layers = sync.getLayers();
const currentLayer = layers.find(l => l.moduleId === "rspace") || layers[0];
const sourceLayer = layers.find(l => l.moduleId === sourceModule);
if (currentLayer && sourceLayer) {
const flowId = `flow-auto-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
sync.addFlow({
id: flowId,
kind: flowKind,
sourceLayerId: sourceLayer.id,
targetLayerId: currentLayer.id,
targetShapeId: shape.id,
label: sourceModule + " feed",
strength: 0.5,
active: true,
});
}
// Also ensure source module has a layer (add if missing)
if (!sourceLayer) {
sync.addLayer({
id: "layer-" + sourceModule,
moduleId: sourceModule,
label: sourceModule,
order: layers.length,
color: "",
visible: true,
createdAt: Date.now(),
});
}
}
});
// 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-rapp": "📱", "folk-feed": "🔄", "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, group toggles, collapse
const keepOpen = ["new-arrow", "toggle-memory", "zoom-in", "zoom-out", "reset-view", "toolbar-collapse"];
if (btn.classList.contains("toolbar-group-toggle")) return;
if (!keepOpen.includes(btn.id)) {
toolbarEl.classList.remove("mobile-open");
mobileMenuBtn.textContent = "✚";
}
});
// Dropdown group toggles
toolbarEl.querySelectorAll(".toolbar-group-toggle").forEach(toggle => {
toggle.addEventListener("click", (e) => {
e.stopPropagation();
const group = toggle.closest(".toolbar-group");
const wasOpen = group.classList.contains("open");
// Close all other groups
toolbarEl.querySelectorAll(".toolbar-group.open").forEach(g => g.classList.remove("open"));
// Toggle this one
if (!wasOpen) group.classList.add("open");
});
});
// Close dropdowns when clicking a tool inside one
toolbarEl.querySelectorAll(".toolbar-dropdown button").forEach(btn => {
btn.addEventListener("click", () => {
toolbarEl.querySelectorAll(".toolbar-group.open").forEach(g => g.classList.remove("open"));
});
});
// Close dropdowns when clicking outside
document.addEventListener("click", (e) => {
if (!e.target.closest("#toolbar")) {
toolbarEl.querySelectorAll(".toolbar-group.open").forEach(g => g.classList.remove("open"));
}
});
// Collapse/expand toolbar
const collapseBtn = document.getElementById("toolbar-collapse");
collapseBtn.addEventListener("click", () => {
const isCollapsed = toolbarEl.classList.toggle("collapsed");
collapseBtn.textContent = isCollapsed ? "▶" : "◀";
collapseBtn.title = isCollapsed ? "Expand toolbar" : "Minimize toolbar";
});
// 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 two-finger pan
let lastTouchCenter = null;
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 = "";
lastTouchCenter = getTouchCenter(e.touches);
}
}, { passive: false });
canvas.addEventListener("touchmove", (e) => {
if (e.touches.length === 2) {
e.preventDefault();
// Two-finger pan (no zoom)
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) {
lastTouchCenter = null;
}
});
// Mouse wheel / trackpad: pan (two-finger scroll = pan, Ctrl+wheel = zoom)
canvas.addEventListener("wheel", (e) => {
e.preventDefault();
if (e.ctrlKey) {
// Ctrl+wheel (or trackpad pinch) = zoom
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
scale = Math.min(Math.max(scale * zoomFactor, minScale), maxScale);
} else {
// Regular wheel/two-finger scroll = pan
panX -= e.deltaX;
panY -= e.deltaY;
}
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>