rspace-online/website/canvas.html

2663 lines
86 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>rSpace Canvas</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
#toolbar {
position: fixed;
top: 108px; /* header(56) + tab-row(36) + gap(16) */
left: 12px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
padding: 8px 6px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 1000;
max-height: calc(100vh - 130px);
overflow-y: auto;
overflow-x: visible;
scrollbar-width: thin;
}
/* Dropdown group container */
.toolbar-group {
position: relative;
}
.toolbar-group-toggle {
padding: 7px 10px;
border: none;
border-radius: 8px;
background: #f1f5f9;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
white-space: nowrap;
text-align: left;
}
.toolbar-group-toggle:hover {
background: #e2e8f0;
}
.toolbar-group.open > .toolbar-group-toggle {
background: #14b8a6;
color: white;
}
.toolbar-dropdown {
display: none;
position: absolute;
top: 0;
left: calc(100% + 6px);
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;
}
.toolbar-dropdown button.active {
background: #14b8a6;
color: white;
}
/* Popout panel — renders group tools to the right of toolbar */
#toolbar-panel {
position: fixed;
top: 108px;
left: calc(68px + 12px + 8px);
min-width: 180px;
max-height: calc(100vh - 130px);
background: white;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
z-index: 1001;
display: none;
flex-direction: column;
}
#toolbar-panel.panel-open {
display: flex;
}
#toolbar-panel-header {
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
}
#toolbar-panel-body {
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
#toolbar-panel-body 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-panel-body button:hover {
background: #f1f5f9;
}
#toolbar-panel-body button.active {
background: #14b8a6;
color: white;
}
/* Hide inline dropdowns — all rendering goes through the panel */
.toolbar-group.open > .toolbar-dropdown {
display: none !important;
}
/* Separator between sections */
.toolbar-sep {
width: 100%;
height: 1px;
background: #e2e8f0;
margin: 2px 0;
flex-shrink: 0;
}
/* Direct toolbar buttons (Connect, Memory, etc.) */
#toolbar > button {
padding: 7px 10px;
border: none;
border-radius: 8px;
background: #f1f5f9;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
white-space: nowrap;
text-align: left;
}
#toolbar > button:hover {
background: #e2e8f0;
}
#toolbar > button.active {
background: #14b8a6;
color: white;
}
/* Collapse/expand toggle — small pill at bottom of toolbar */
#toolbar-collapse {
padding: 4px 0 !important;
background: transparent !important;
font-size: 11px !important;
line-height: 1;
opacity: 0.4;
transition: opacity 0.2s;
text-align: center;
letter-spacing: 2px;
color: #94a3b8;
order: 999; /* always last */
margin-top: auto;
}
#toolbar-collapse:hover {
opacity: 1;
background: #f1f5f9 !important;
color: #0f172a;
}
#toolbar.collapsed .toolbar-group,
#toolbar.collapsed .toolbar-sep,
#toolbar.collapsed > button:not(#toolbar-collapse) {
display: none;
}
#toolbar.collapsed {
padding: 6px;
overflow: visible;
}
#toolbar.collapsed #toolbar-collapse {
opacity: 0.7;
font-size: 14px !important;
padding: 4px 6px !important;
}
#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-splat,
folk-blender,
folk-drawfast,
folk-freecad,
folk-kicad,
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-splat, folk-blender, folk-drawfast,
folk-freecad, folk-kicad, 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-splat, folk-blender, folk-drawfast,
folk-freecad, folk-kicad, 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;
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 */
#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;
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 collapse on mobile */
#toolbar #toolbar-collapse {
display: none;
}
#community-info {
display: none;
}
#memory-panel {
max-width: calc(100vw - 32px);
}
/* Mobile: panel slides up from bottom as sheet */
#toolbar-panel {
top: auto;
bottom: 90px;
left: 8px;
right: 8px;
border-radius: 16px;
max-height: 50vh;
}
}
</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="quick-add" title="Add rApp" style="background:#14b8a6;color:white;font-size:18px;font-weight:700;padding:6px 10px;border:none;border-radius:8px;cursor:pointer;text-align:center;line-height:1;">+</button>
<span class="toolbar-sep"></span>
<div class="toolbar-group">
<button class="toolbar-group-toggle">✏️ Draw</button>
<div class="toolbar-dropdown">
<button id="wb-pencil" title="Freehand Pencil">✏️ Pencil</button>
<button id="wb-sticky" title="Sticky Note">📌 Sticky Note</button>
<button id="wb-rect" title="Rectangle">▢ Rectangle</button>
<button id="wb-circle" title="Circle">○ Circle</button>
<button id="wb-line" title="Line"> Line</button>
<button id="wb-eraser" title="Eraser">🧹 Eraser</button>
</div>
</div>
<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">🔮 Creative</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-splat" title="New 3D Splat">🔮 3D Splat</button>
<button id="new-blender" title="New 3D Blender">🧊 3D Blender</button>
<button id="new-drawfast" title="New Drawing">✏️ Drawfast</button>
<button id="new-freecad" title="New FreeCAD">📐 FreeCAD</button>
<button id="new-kicad" title="New KiCAD PCB">🔌 KiCAD PCB</button>
</div>
</div>
<div class="toolbar-group">
<button class="toolbar-group-toggle">🎨 Media</button>
<div class="toolbar-dropdown">
<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-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>
<div class="toolbar-group">
<button class="toolbar-group-toggle">🔍 Zoom</button>
<div class="toolbar-dropdown">
<button id="zoom-in" title="Zoom In">+ Zoom In</button>
<button id="zoom-out" title="Zoom Out"> Zoom Out</button>
<button id="reset-view" title="Reset View">⟳ Reset View</button>
</div>
</div>
<button id="toolbar-collapse" title="Minimize toolbar">···</button>
</div>
<div id="toolbar-panel">
<div id="toolbar-panel-header"></div>
<div id="toolbar-panel-body"></div>
</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,
FolkSplat,
FolkBlender,
FolkDrawfast,
FolkFreeCAD,
FolkKiCAD,
FolkCanvas,
FolkRApp,
FolkFeed,
CommunitySync,
PresenceManager,
generatePeerId,
OfflineStore,
MiCanvasBridge,
installSelectionTransforms
} 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();
FolkSplat.define();
FolkBlender.define();
FolkDrawfast.define();
FolkFreeCAD.define();
FolkKiCAD.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-splat", "folk-blender", "folk-drawfast",
"folk-freecad", "folk-kicad",
"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;
}
// Check if wb-svg already exists in the overlay
if (data.type === "wb-svg" && wbOverlay.querySelector(`[data-wb-id="${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();
}
// Also remove wb-svg elements from the overlay
const wbEl = wbOverlay?.querySelector(`[data-wb-id="${shapeId}"]`);
if (wbEl) wbEl.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-splat":
shape = document.createElement("folk-splat");
if (data.splatUrl) shape.splatUrl = data.splatUrl;
break;
case "folk-blender":
shape = document.createElement("folk-blender");
break;
case "folk-drawfast":
shape = document.createElement("folk-drawfast");
break;
case "folk-freecad":
shape = document.createElement("folk-freecad");
break;
case "folk-kicad":
shape = document.createElement("folk-kicad");
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;
shape.spaceSlug = data.spaceSlug || communitySlug;
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 "wb-svg":
// Whiteboard SVG drawing — recreate in SVG overlay, not as a folk-shape
if (data.svgMarkup) {
const temp = document.createElementNS("http://www.w3.org/2000/svg", "g");
temp.innerHTML = data.svgMarkup;
const svgEl = temp.firstElementChild;
if (svgEl) {
svgEl.setAttribute("data-wb-id", data.id);
wbOverlay.appendChild(svgEl);
}
}
return null; // Not a folk-shape element
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
}
});
// Track selection for MI bridge
shape.addEventListener("pointerdown", () => {
selectedShapeId = shape.id;
__miCanvasBridge.setSelection([shape.id]);
});
// 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-splat": { width: 480, height: 420 },
"folk-blender": { width: 420, height: 520 },
"folk-drawfast": { width: 500, height: 480 },
"folk-freecad": { width: 400, height: 480 },
"folk-kicad": { width: 420, height: 500 },
"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 that doesn't overlap existing shapes.
// If preferX/preferY are provided, use that as the anchor; otherwise use viewport center.
function findFreePosition(width, height, preferX, preferY) {
let candidateX, candidateY;
if (preferX !== undefined && preferY !== undefined) {
candidateX = preferX - width / 2;
candidateY = preferY - height / 2;
} else {
const center = getViewportCenter();
candidateX = center.x - width / 2;
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
};
}
// ── Pending tool state for click-to-place ──
let pendingTool = null; // { tagName, props }
let ghostEl = null;
function setPendingTool(tagName, props = {}) {
pendingTool = { tagName, props };
canvas.style.cursor = "crosshair";
// Create ghost outline
if (ghostEl) ghostEl.remove();
const defaults = SHAPE_DEFAULTS[tagName] || { width: 300, height: 200 };
ghostEl = document.createElement("div");
ghostEl.style.cssText = `
position: fixed; pointer-events: none; z-index: 9999;
width: ${defaults.width * scale}px; height: ${defaults.height * scale}px;
border: 2px dashed #14b8a6; border-radius: 8px;
background: rgba(20, 184, 166, 0.06);
transform: translate(-50%, -50%);
transition: width 0.1s, height 0.1s;
`;
document.body.appendChild(ghostEl);
}
function clearPendingTool() {
pendingTool = null;
canvas.style.cursor = "";
if (ghostEl) { ghostEl.remove(); ghostEl = null; }
}
// Track ghost position
document.addEventListener("mousemove", (e) => {
if (ghostEl) {
ghostEl.style.left = e.clientX + "px";
ghostEl.style.top = e.clientY + "px";
}
});
// ESC clears pending tool
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && pendingTool) {
clearPendingTool();
}
});
// Create a shape, position it without overlapping others, add to canvas, and register for sync
// atPosition: optional { x, y } in canvas coordinates to place near
function newShape(tagName, props = {}, atPosition) {
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 = atPosition
? findFreePosition(defaults.width, defaults.height, atPosition.x, atPosition.y)
: 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;
}
// ── MI Canvas Bridge + Selection Transforms ──
const __miCanvasBridge = new MiCanvasBridge(canvasContent);
window.__miCanvasBridge = __miCanvasBridge;
window.__canvasApi = { newShape, findFreePosition, SHAPE_DEFAULTS, setupShapeEventListeners, sync, canvasContent };
installSelectionTransforms();
// Toolbar button handlers — set pending tool for click-to-place
document.getElementById("new-markdown").addEventListener("click", () => {
setPendingTool("folk-markdown", { content: "# New Note\n\nStart typing..." });
});
document.getElementById("new-wrapper").addEventListener("click", () => {
const colors = ["#14b8a6", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6", "#22c55e"];
const icons = ["📋", "💡", "📌", "🔗", "📁", "⭐"];
setPendingTool("folk-wrapper", {
title: "New Card",
icon: icons[Math.floor(Math.random() * icons.length)],
primaryColor: colors[Math.floor(Math.random() * colors.length)],
__postCreate: (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", () => {
setPendingTool("folk-slide", { label: `Slide ${shapeCounter}` });
});
document.getElementById("new-chat").addEventListener("click", () => {
const id = `shape-${Date.now()}-${shapeCounter}`;
setPendingTool("folk-chat", { roomId: `room-${id}` });
});
document.getElementById("new-piano").addEventListener("click", () => setPendingTool("folk-piano"));
document.getElementById("new-embed").addEventListener("click", () => setPendingTool("folk-embed"));
document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar"));
document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map"));
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));
document.getElementById("new-video-gen").addEventListener("click", () => setPendingTool("folk-video-gen"));
document.getElementById("new-prompt").addEventListener("click", () => setPendingTool("folk-prompt"));
document.getElementById("new-transcription").addEventListener("click", () => setPendingTool("folk-transcription"));
document.getElementById("new-video-chat").addEventListener("click", () => setPendingTool("folk-video-chat"));
document.getElementById("new-obs-note").addEventListener("click", () => setPendingTool("folk-obs-note"));
document.getElementById("new-workflow").addEventListener("click", () => setPendingTool("folk-workflow-block"));
document.getElementById("new-splat").addEventListener("click", () => setPendingTool("folk-splat"));
document.getElementById("new-blender").addEventListener("click", () => setPendingTool("folk-blender"));
document.getElementById("new-drawfast").addEventListener("click", () => setPendingTool("folk-drawfast"));
document.getElementById("new-freecad").addEventListener("click", () => setPendingTool("folk-freecad"));
document.getElementById("new-kicad").addEventListener("click", () => setPendingTool("folk-kicad"));
document.getElementById("new-google-item").addEventListener("click", () => {
setPendingTool("folk-google-item", { service: "drive", title: "New Google Item" });
});
// Trip planning components
document.getElementById("new-itinerary").addEventListener("click", () => setPendingTool("folk-itinerary"));
document.getElementById("new-destination").addEventListener("click", () => setPendingTool("folk-destination"));
document.getElementById("new-budget").addEventListener("click", () => setPendingTool("folk-budget"));
document.getElementById("new-packing-list").addEventListener("click", () => setPendingTool("folk-packing-list"));
document.getElementById("new-booking").addEventListener("click", () => setPendingTool("folk-booking"));
// Token creation - creates a mint + ledger pair with connecting arrow
document.getElementById("new-token").addEventListener("click", () => {
setPendingTool("folk-token-mint", {
tokenName: "New Token",
tokenSymbol: "TKN",
totalSupply: 1000,
tokenColor: "#8b5cf6",
tokenIcon: "🪙",
createdAt: new Date().toISOString(),
__postCreate: (mint) => {
const ledger = newShape("folk-token-ledger", {
mintId: mint.id,
entries: [],
}, { x: mint.x + mint.width + 60 + 150, y: mint.y + mint.height / 2 });
if (ledger) {
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", () => {
setPendingTool("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", () => {
setPendingTool("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", () => {
setPendingTool("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", () => {
setPendingTool("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-swag", moduleId: "rswag" },
];
for (const app of rAppModules) {
const btn = document.getElementById(app.btnId);
if (btn) {
btn.addEventListener("click", () => {
setPendingTool("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");
}
});
// ── Whiteboard drawing tools ──
let wbTool = null; // "pencil" | "sticky" | "rect" | "circle" | "line" | "eraser" | null
let wbDrawing = false;
let wbStartX = 0, wbStartY = 0;
let wbCurrentPath = [];
let wbPreviewEl = null;
const wbColor = "#1e293b";
const wbStrokeWidth = 3;
// SVG overlay for whiteboard drawing
const wbOverlay = document.createElementNS("http://www.w3.org/2000/svg", "svg");
wbOverlay.id = "wb-overlay";
wbOverlay.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5;overflow:visible;";
canvasContent.appendChild(wbOverlay);
function setWbTool(tool) {
const prev = wbTool;
wbTool = wbTool === tool ? null : tool;
// Update button active states
document.querySelectorAll("[id^='wb-']").forEach(b => b.classList.remove("active"));
if (wbTool) {
document.getElementById("wb-" + wbTool)?.classList.add("active");
canvas.style.cursor = wbTool === "eraser" ? "crosshair" : "crosshair";
} else {
canvas.style.cursor = "";
}
// Disable shape interaction when whiteboard tool is active
canvasContent.style.pointerEvents = wbTool ? "none" : "";
wbOverlay.style.pointerEvents = wbTool ? "all" : "none";
}
document.getElementById("wb-pencil")?.addEventListener("click", () => setWbTool("pencil"));
document.getElementById("wb-sticky")?.addEventListener("click", () => {
// Create a sticky note as a markdown shape with yellow background
setWbTool(null);
const shape = newShape("folk-markdown", {
content: "# Sticky Note\n\nClick to edit..."
});
if (shape) {
shape.width = 200;
shape.height = 200;
shape.style.background = "#fef08a";
shape.style.borderRadius = "4px";
shape.style.boxShadow = "2px 2px 8px rgba(0,0,0,0.15)";
}
});
document.getElementById("wb-rect")?.addEventListener("click", () => setWbTool("rect"));
document.getElementById("wb-circle")?.addEventListener("click", () => setWbTool("circle"));
document.getElementById("wb-line")?.addEventListener("click", () => setWbTool("line"));
document.getElementById("wb-eraser")?.addEventListener("click", () => setWbTool("eraser"));
// Whiteboard pointer handlers on the SVG overlay
wbOverlay.addEventListener("pointerdown", (e) => {
if (!wbTool || wbTool === "eraser") {
if (wbTool === "eraser") {
// Find and remove the nearest SVG element under cursor
const hit = document.elementFromPoint(e.clientX, e.clientY);
if (hit && hit !== wbOverlay && wbOverlay.contains(hit)) {
hit.remove();
}
}
return;
}
e.preventDefault();
e.stopPropagation();
wbDrawing = true;
const rect = canvasContent.getBoundingClientRect();
wbStartX = (e.clientX - rect.left) / scale;
wbStartY = (e.clientY - rect.top) / scale;
if (wbTool === "pencil") {
wbCurrentPath = [{ x: wbStartX, y: wbStartY }];
wbPreviewEl = document.createElementNS("http://www.w3.org/2000/svg", "path");
wbPreviewEl.setAttribute("fill", "none");
wbPreviewEl.setAttribute("stroke", wbColor);
wbPreviewEl.setAttribute("stroke-width", wbStrokeWidth);
wbPreviewEl.setAttribute("stroke-linecap", "round");
wbPreviewEl.setAttribute("stroke-linejoin", "round");
wbOverlay.appendChild(wbPreviewEl);
} else if (wbTool === "rect") {
wbPreviewEl = document.createElementNS("http://www.w3.org/2000/svg", "rect");
wbPreviewEl.setAttribute("fill", "none");
wbPreviewEl.setAttribute("stroke", wbColor);
wbPreviewEl.setAttribute("stroke-width", wbStrokeWidth);
wbOverlay.appendChild(wbPreviewEl);
} else if (wbTool === "circle") {
wbPreviewEl = document.createElementNS("http://www.w3.org/2000/svg", "ellipse");
wbPreviewEl.setAttribute("fill", "none");
wbPreviewEl.setAttribute("stroke", wbColor);
wbPreviewEl.setAttribute("stroke-width", wbStrokeWidth);
wbOverlay.appendChild(wbPreviewEl);
} else if (wbTool === "line") {
wbPreviewEl = document.createElementNS("http://www.w3.org/2000/svg", "line");
wbPreviewEl.setAttribute("stroke", wbColor);
wbPreviewEl.setAttribute("stroke-width", wbStrokeWidth);
wbPreviewEl.setAttribute("stroke-linecap", "round");
wbPreviewEl.setAttribute("x1", wbStartX);
wbPreviewEl.setAttribute("y1", wbStartY);
wbPreviewEl.setAttribute("x2", wbStartX);
wbPreviewEl.setAttribute("y2", wbStartY);
wbOverlay.appendChild(wbPreviewEl);
}
wbOverlay.setPointerCapture(e.pointerId);
});
wbOverlay.addEventListener("pointermove", (e) => {
if (!wbDrawing || !wbPreviewEl) return;
const rect = canvasContent.getBoundingClientRect();
const cx = (e.clientX - rect.left) / scale;
const cy = (e.clientY - rect.top) / scale;
if (wbTool === "pencil") {
wbCurrentPath.push({ x: cx, y: cy });
const d = wbCurrentPath.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
wbPreviewEl.setAttribute("d", d);
} else if (wbTool === "rect") {
const x = Math.min(wbStartX, cx);
const y = Math.min(wbStartY, cy);
const w = Math.abs(cx - wbStartX);
const h = Math.abs(cy - wbStartY);
wbPreviewEl.setAttribute("x", x);
wbPreviewEl.setAttribute("y", y);
wbPreviewEl.setAttribute("width", w);
wbPreviewEl.setAttribute("height", h);
} else if (wbTool === "circle") {
const cxe = (wbStartX + cx) / 2;
const cye = (wbStartY + cy) / 2;
const rx = Math.abs(cx - wbStartX) / 2;
const ry = Math.abs(cy - wbStartY) / 2;
wbPreviewEl.setAttribute("cx", cxe);
wbPreviewEl.setAttribute("cy", cye);
wbPreviewEl.setAttribute("rx", rx);
wbPreviewEl.setAttribute("ry", ry);
} else if (wbTool === "line") {
wbPreviewEl.setAttribute("x2", cx);
wbPreviewEl.setAttribute("y2", cy);
}
});
wbOverlay.addEventListener("pointerup", (e) => {
if (!wbDrawing) return;
wbDrawing = false;
// Persist the completed SVG element to Automerge
if (wbPreviewEl) {
const wbId = `wb-${Date.now()}-${++shapeCounter}`;
wbPreviewEl.setAttribute("data-wb-id", wbId);
const svgMarkup = wbPreviewEl.outerHTML;
sync.addShapeData({
type: "wb-svg",
id: wbId,
svgMarkup,
x: 0, y: 0, width: 0, height: 0, rotation: 0,
});
}
wbPreviewEl = null;
wbCurrentPath = [];
});
// Eraser: click on existing SVG strokes to delete them + remove from Automerge
wbOverlay.addEventListener("click", (e) => {
if (wbTool !== "eraser") return;
const hit = e.target;
if (hit && hit !== wbOverlay && wbOverlay.contains(hit)) {
const wbId = hit.getAttribute("data-wb-id");
if (wbId) {
sync.deleteShape(wbId);
}
hit.remove();
}
});
// 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-splat": "🔮", "folk-blender": "🧊", "folk-drawfast": "✏️",
"folk-freecad": "📐", "folk-kicad": "🔌",
"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`;
// Keep MI bridge in sync
__miCanvasBridge.setViewport(panX, panY, scale);
}
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", () => {
// On mobile, first tap opens rApps group in the popout panel
const rAppsGroup = toolbarEl.querySelector(".toolbar-group:has(#embed-notes)")
|| [...toolbarEl.querySelectorAll(".toolbar-group")].find(g =>
g.querySelector(".toolbar-group-toggle")?.textContent.includes("rApps"));
if (rAppsGroup && !toolbarEl.classList.contains("mobile-open")) {
toolbarEl.classList.add("mobile-open");
mobileMenuBtn.textContent = "✕";
// Auto-open the rApps panel
setTimeout(() => {
if (typeof openToolbarPanel === "function") openToolbarPanel(rAppsGroup);
}, 50);
} else {
const isOpen = toolbarEl.classList.toggle("mobile-open");
mobileMenuBtn.textContent = isOpen ? "✕" : "✚";
if (!isOpen && typeof closeToolbarPanel === "function") closeToolbarPanel();
}
});
// 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, group toggles, collapse, whiteboard tools
const keepOpen = ["new-arrow", "toggle-memory", "zoom-in", "zoom-out", "reset-view", "toolbar-collapse",
"wb-pencil", "wb-sticky", "wb-rect", "wb-circle", "wb-line", "wb-eraser"];
if (btn.classList.contains("toolbar-group-toggle")) return;
if (!keepOpen.includes(btn.id)) {
toolbarEl.classList.remove("mobile-open");
mobileMenuBtn.textContent = "✚";
}
});
// Popout panel references
const toolbarPanel = document.getElementById("toolbar-panel");
const toolbarPanelHeader = document.getElementById("toolbar-panel-header");
const toolbarPanelBody = document.getElementById("toolbar-panel-body");
let activeToolbarGroup = null;
function openToolbarPanel(group) {
const toggle = group.querySelector(".toolbar-group-toggle");
const dropdown = group.querySelector(".toolbar-dropdown");
if (!dropdown) return;
// Set header text from the toggle button
toolbarPanelHeader.textContent = toggle.textContent.trim();
// Clone dropdown buttons into the panel body
toolbarPanelBody.innerHTML = "";
for (const btn of dropdown.querySelectorAll("button")) {
const clone = btn.cloneNode(true);
// Forward click to the original button
clone.addEventListener("click", (e) => {
e.stopPropagation();
btn.click();
// Close panel after tool is selected (unless it's a whiteboard toggle)
const keepOpen = ["wb-pencil", "wb-rect", "wb-circle", "wb-line", "wb-eraser"];
if (!keepOpen.includes(btn.id)) {
closeToolbarPanel();
}
});
toolbarPanelBody.appendChild(clone);
}
// Mark group as active
toolbarEl.querySelectorAll(".toolbar-group").forEach(g => g.classList.remove("open"));
group.classList.add("open");
activeToolbarGroup = group;
toolbarPanel.classList.add("panel-open");
}
function closeToolbarPanel() {
toolbarPanel.classList.remove("panel-open");
toolbarEl.querySelectorAll(".toolbar-group").forEach(g => g.classList.remove("open"));
activeToolbarGroup = null;
}
// Dropdown group toggles → popout panel
toolbarEl.querySelectorAll(".toolbar-group-toggle").forEach(toggle => {
toggle.addEventListener("click", (e) => {
e.stopPropagation();
const group = toggle.closest(".toolbar-group");
if (activeToolbarGroup === group) {
closeToolbarPanel();
} else {
openToolbarPanel(group);
}
});
});
// Close panel when clicking outside toolbar + panel
document.addEventListener("click", (e) => {
if (!e.target.closest("#toolbar") && !e.target.closest("#toolbar-panel")) {
closeToolbarPanel();
}
});
// Desktop quick-add button → opens the rApps popout panel
document.getElementById("quick-add")?.addEventListener("click", (e) => {
e.stopPropagation();
const rAppsGroup = toolbarEl.querySelector(".toolbar-group:has(#embed-notes)")
|| [...toolbarEl.querySelectorAll(".toolbar-group")].find(g =>
g.querySelector(".toolbar-group-toggle")?.textContent.includes("rApps"));
if (rAppsGroup) {
if (activeToolbarGroup === rAppsGroup) {
closeToolbarPanel();
} else {
openToolbarPanel(rAppsGroup);
}
}
});
// 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;
// Click-to-place: if a pending tool is set, place it at the click position
if (pendingTool) {
e.preventDefault();
e.stopPropagation();
const rect = canvasContent.getBoundingClientRect();
const canvasX = (e.clientX - rect.left) / scale;
const canvasY = (e.clientY - rect.top) / scale;
const { tagName, props } = pendingTool;
const postCreate = props.__postCreate;
const cleanProps = { ...props };
delete cleanProps.__postCreate;
const shape = newShape(tagName, cleanProps, { x: canvasX, y: canvasY });
if (shape && postCreate) postCreate(shape);
clearPendingTool();
return;
}
// Clicking canvas background clears MI selection and exits any editing shape
selectedShapeId = null;
__miCanvasBridge.setSelection([]);
// Exit edit mode on any currently-editing shape
canvasContent.querySelectorAll("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-workflow-block").forEach(el => {
if (el.exitEditMode) el.exitEditMode();
});
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 = "";
});
// Double-click on empty canvas background → quick-draw (pencil) mode
canvas.addEventListener("dblclick", (e) => {
if (e.target === canvas || e.target === canvasContent) {
setWbTool("pencil");
}
});
// 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>