2129 lines
67 KiB
HTML
2129 lines
67 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||
<title>rSpace Canvas</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
html,
|
||
body {
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
}
|
||
|
||
#toolbar {
|
||
position: fixed;
|
||
top: 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>
|