Merge branch 'dev'
This commit is contained in:
commit
59f2be356b
|
|
@ -40,6 +40,28 @@ export const FLOW_LABELS: Record<FlowKind, string> = {
|
|||
custom: "Custom",
|
||||
};
|
||||
|
||||
// ── Feed definition ──
|
||||
|
||||
/**
|
||||
* Feed definition — describes a data feed that a module can expose to other layers.
|
||||
* Feeds are the connective tissue between layers: they let data, economic value,
|
||||
* trust signals, etc. flow between rApps.
|
||||
*/
|
||||
export interface FeedDefinition {
|
||||
/** Feed identifier (unique within the module) */
|
||||
id: string;
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
/** What kind of flow this feed carries */
|
||||
kind: FlowKind;
|
||||
/** Description of what this feed provides */
|
||||
description: string;
|
||||
/** Shape types this feed emits (e.g. "folk-note", "folk-token-mint") */
|
||||
emits?: string[];
|
||||
/** Whether this feed supports filtering */
|
||||
filterable?: boolean;
|
||||
}
|
||||
|
||||
// ── Layer definition ──
|
||||
|
||||
export interface Layer {
|
||||
|
|
|
|||
|
|
@ -301,6 +301,22 @@ export const booksModule: RSpaceModule = {
|
|||
description: "Community PDF library with flipbook reader",
|
||||
routes,
|
||||
standaloneDomain: "rbooks.online",
|
||||
feeds: [
|
||||
{
|
||||
id: "reading-list",
|
||||
name: "Reading List",
|
||||
kind: "data",
|
||||
description: "Books in the community library — titles, authors, tags",
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
id: "annotations",
|
||||
name: "Annotations",
|
||||
kind: "data",
|
||||
description: "Reader highlights, bookmarks, and notes on books",
|
||||
},
|
||||
],
|
||||
acceptsFeeds: ["data", "resource"],
|
||||
|
||||
async onSpaceCreate(spaceSlug: string) {
|
||||
// Books are global, not space-scoped (for now). No-op.
|
||||
|
|
|
|||
|
|
@ -394,4 +394,20 @@ export const calModule: RSpaceModule = {
|
|||
description: "Temporal coordination calendar with lunar, solar, and seasonal systems",
|
||||
routes,
|
||||
standaloneDomain: "rcal.online",
|
||||
feeds: [
|
||||
{
|
||||
id: "events",
|
||||
name: "Events",
|
||||
kind: "data",
|
||||
description: "Calendar events with times, locations, and virtual meeting links",
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
id: "availability",
|
||||
name: "Availability",
|
||||
kind: "data",
|
||||
description: "Free/busy windows and scheduling availability across calendars",
|
||||
},
|
||||
],
|
||||
acceptsFeeds: ["data", "governance"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -460,4 +460,21 @@ export const cartModule: RSpaceModule = {
|
|||
description: "Cosmolocal print-on-demand shop",
|
||||
routes,
|
||||
standaloneDomain: "rcart.online",
|
||||
feeds: [
|
||||
{
|
||||
id: "orders",
|
||||
name: "Orders",
|
||||
kind: "economic",
|
||||
description: "Order stream with pricing, fulfillment status, and revenue splits",
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
id: "catalog",
|
||||
name: "Catalog",
|
||||
kind: "data",
|
||||
description: "Active catalog listings with product details and pricing",
|
||||
filterable: true,
|
||||
},
|
||||
],
|
||||
acceptsFeeds: ["economic", "data"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -385,4 +385,20 @@ export const filesModule: RSpaceModule = {
|
|||
description: "File sharing, share links, and memory cards",
|
||||
routes,
|
||||
standaloneDomain: "rfiles.online",
|
||||
feeds: [
|
||||
{
|
||||
id: "file-activity",
|
||||
name: "File Activity",
|
||||
kind: "data",
|
||||
description: "Upload, download, and share events across the file library",
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
id: "shared-files",
|
||||
name: "Shared Files",
|
||||
kind: "resource",
|
||||
description: "Files available via public share links",
|
||||
},
|
||||
],
|
||||
acceptsFeeds: ["data", "resource"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -176,4 +176,20 @@ export const forumModule: RSpaceModule = {
|
|||
description: "Deploy and manage Discourse forums",
|
||||
routes,
|
||||
standaloneDomain: "rforum.online",
|
||||
feeds: [
|
||||
{
|
||||
id: "threads",
|
||||
name: "Threads",
|
||||
kind: "data",
|
||||
description: "Discussion threads and topics from provisioned Discourse instances",
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
id: "activity",
|
||||
name: "Activity",
|
||||
kind: "attention",
|
||||
description: "Forum engagement — new posts, replies, and participation metrics",
|
||||
},
|
||||
],
|
||||
acceptsFeeds: ["data", "governance"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -600,4 +600,20 @@ export const inboxModule: RSpaceModule = {
|
|||
description: "Collaborative email with multisig approval",
|
||||
routes,
|
||||
standaloneDomain: "rinbox.online",
|
||||
feeds: [
|
||||
{
|
||||
id: "messages",
|
||||
name: "Messages",
|
||||
kind: "data",
|
||||
description: "Email threads and messages across shared mailboxes",
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
name: "Notifications",
|
||||
kind: "attention",
|
||||
description: "New mail alerts, approval requests, and mention notifications",
|
||||
},
|
||||
],
|
||||
acceptsFeeds: ["data"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -168,4 +168,20 @@ export const mapsModule: RSpaceModule = {
|
|||
description: "Real-time collaborative location sharing and indoor/outdoor maps",
|
||||
routes,
|
||||
standaloneDomain: "rmaps.online",
|
||||
feeds: [
|
||||
{
|
||||
id: "locations",
|
||||
name: "Locations",
|
||||
kind: "data",
|
||||
description: "Shared location pins, points of interest, and room positions",
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
id: "routes",
|
||||
name: "Routes",
|
||||
kind: "data",
|
||||
description: "Calculated routes and navigation paths between locations",
|
||||
},
|
||||
],
|
||||
acceptsFeeds: ["data"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -342,4 +342,20 @@ export const pubsModule: RSpaceModule = {
|
|||
description: "Drop in a document, get a pocket book",
|
||||
routes,
|
||||
standaloneDomain: "rpubs.online",
|
||||
feeds: [
|
||||
{
|
||||
id: "publications",
|
||||
name: "Publications",
|
||||
kind: "data",
|
||||
description: "Print-ready artifacts generated from markdown documents",
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
id: "citations",
|
||||
name: "Citations",
|
||||
kind: "data",
|
||||
description: "Citation and reference data extracted from documents",
|
||||
},
|
||||
],
|
||||
acceptsFeeds: ["data"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -247,4 +247,20 @@ export const swagModule: RSpaceModule = {
|
|||
description: "Design print-ready swag: stickers, posters, tees",
|
||||
routes,
|
||||
standaloneDomain: "rswag.online",
|
||||
feeds: [
|
||||
{
|
||||
id: "designs",
|
||||
name: "Designs",
|
||||
kind: "resource",
|
||||
description: "Print-ready design artifacts for stickers, posters, and tees",
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
id: "merch-orders",
|
||||
name: "Merch Orders",
|
||||
kind: "economic",
|
||||
description: "Merchandise order events and fulfillment tracking",
|
||||
},
|
||||
],
|
||||
acceptsFeeds: ["economic", "resource"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -210,4 +210,20 @@ export const tubeModule: RSpaceModule = {
|
|||
description: "Community video hosting & live streaming",
|
||||
routes,
|
||||
standaloneDomain: "rtube.online",
|
||||
feeds: [
|
||||
{
|
||||
id: "videos",
|
||||
name: "Videos",
|
||||
kind: "data",
|
||||
description: "Video library — uploaded and live-streamed content",
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
id: "watch-activity",
|
||||
name: "Watch Activity",
|
||||
kind: "attention",
|
||||
description: "View counts, watch time, and streaming engagement metrics",
|
||||
},
|
||||
],
|
||||
acceptsFeeds: ["data", "resource"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ const MI_MODEL = process.env.MI_MODEL || "llama3.2";
|
|||
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
|
||||
|
||||
app.post("/api/mi/ask", async (c) => {
|
||||
const { query, messages = [], space, module: currentModule } = await c.req.json();
|
||||
const { query, messages = [], space, module: currentModule, context = {} } = await c.req.json();
|
||||
if (!query) return c.json({ error: "Query required" }, 400);
|
||||
|
||||
// Build rApp context for the system prompt
|
||||
|
|
@ -142,22 +142,36 @@ app.post("/api/mi/ask", async (c) => {
|
|||
.map((m) => `- **${m.name}** (${m.id}): ${m.icon} ${m.description}`)
|
||||
.join("\n");
|
||||
|
||||
const systemPrompt = `You are mi, the intelligent assistant for rSpace — a self-hosted, community-run platform.
|
||||
// Build extended context section from client-provided context
|
||||
let contextSection = `- Space: ${space || "none selected"}\n- Active rApp: ${currentModule || "none"}`;
|
||||
if (context.pageTitle) contextSection += `\n- Page: ${context.pageTitle}`;
|
||||
if (context.activeTab) contextSection += `\n- Active tab: ${context.activeTab}`;
|
||||
if (context.openShapes?.length) {
|
||||
const shapeSummary = context.openShapes
|
||||
.slice(0, 10)
|
||||
.map((s: any) => ` - ${s.type}${s.title ? `: ${s.title}` : ""}${s.snippet ? ` (${s.snippet})` : ""}`)
|
||||
.join("\n");
|
||||
contextSection += `\n- Open shapes on canvas:\n${shapeSummary}`;
|
||||
}
|
||||
|
||||
const systemPrompt = `You are mi (mycelial intelligence), the intelligent assistant for rSpace — a self-hosted, community-run platform.
|
||||
You help users navigate, understand, and get the most out of the platform's apps (rApps).
|
||||
You understand the full context of what the user has open and can guide them through setup and usage.
|
||||
|
||||
## Available rApps
|
||||
${moduleList}
|
||||
|
||||
## Current Context
|
||||
- Space: ${space || "none selected"}
|
||||
- Active rApp: ${currentModule || "none"}
|
||||
${contextSection}
|
||||
|
||||
## Guidelines
|
||||
- Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail.
|
||||
- When suggesting actions, reference specific rApps by name and explain how they connect.
|
||||
- You can suggest navigating to /:space/:moduleId paths.
|
||||
- If the user has shapes open on their canvas, you can reference them and suggest connections.
|
||||
- Help with setup: guide users through creating spaces, adding content, configuring rApps.
|
||||
- If you don't know something specific about the user's data, say so honestly.
|
||||
- Use a warm, knowledgeable tone. You're a guide, not a search engine.`;
|
||||
- Use a warm, knowledgeable tone. You're a mycelial guide, connecting knowledge across the platform.`;
|
||||
|
||||
// Build conversation for Ollama
|
||||
const ollamaMessages = [
|
||||
|
|
|
|||
|
|
@ -34,14 +34,14 @@ export class RStackMi extends HTMLElement {
|
|||
<div class="mi-bar" id="mi-bar">
|
||||
<span class="mi-icon">✧</span>
|
||||
<input class="mi-input" id="mi-input" type="text"
|
||||
placeholder="Ask mi anything..." autocomplete="off" />
|
||||
placeholder="Ask mi anything — setup, navigation, what's possible..." autocomplete="off" />
|
||||
</div>
|
||||
<div class="mi-panel" id="mi-panel">
|
||||
<div class="mi-messages" id="mi-messages">
|
||||
<div class="mi-welcome">
|
||||
<span class="mi-welcome-icon">✧</span>
|
||||
<p>Hi, I'm <strong>mi</strong> — your guide to rSpace.</p>
|
||||
<p class="mi-welcome-sub">Ask me about any rApp, how to find things, or what you can do here.</p>
|
||||
<p>Hi, I'm <strong>mi</strong> — your mycelial intelligence guide.</p>
|
||||
<p class="mi-welcome-sub">I see everything you have open and can help with setup, navigation, connecting rApps, or anything else.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -81,6 +81,42 @@ export class RStackMi extends HTMLElement {
|
|||
bar.addEventListener("click", (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
/** Gather page context: open shapes, active module, tabs, etc. */
|
||||
#gatherContext(): Record<string, any> {
|
||||
const ctx: Record<string, any> = {};
|
||||
|
||||
// Current space and module
|
||||
ctx.space = document.querySelector("rstack-space-switcher")?.getAttribute("current") || "";
|
||||
ctx.module = document.querySelector("rstack-app-switcher")?.getAttribute("current") || "";
|
||||
|
||||
// Open shapes on canvas (if any)
|
||||
const canvasContent = document.getElementById("canvas-content");
|
||||
if (canvasContent) {
|
||||
const shapes = [...canvasContent.children]
|
||||
.filter((el) => el.tagName?.includes("-") && el.id)
|
||||
.map((el: any) => ({
|
||||
type: el.tagName.toLowerCase(),
|
||||
id: el.id,
|
||||
...(el.content ? { snippet: el.content.slice(0, 60) } : {}),
|
||||
...(el.title ? { title: el.title } : {}),
|
||||
}))
|
||||
.slice(0, 20);
|
||||
if (shapes.length) ctx.openShapes = shapes;
|
||||
}
|
||||
|
||||
// Active tab/layer info
|
||||
const tabBar = document.querySelector("rstack-tab-bar");
|
||||
if (tabBar) {
|
||||
const active = tabBar.getAttribute("active") || "";
|
||||
ctx.activeTab = active;
|
||||
}
|
||||
|
||||
// Page title
|
||||
ctx.pageTitle = document.title;
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
async #ask(query: string) {
|
||||
const panel = this.#shadow.getElementById("mi-panel")!;
|
||||
const messagesEl = this.#shadow.getElementById("mi-messages")!;
|
||||
|
|
@ -103,6 +139,7 @@ export class RStackMi extends HTMLElement {
|
|||
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const context = this.#gatherContext();
|
||||
const res = await fetch("/api/mi/ask", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -112,8 +149,9 @@ export class RStackMi extends HTMLElement {
|
|||
body: JSON.stringify({
|
||||
query,
|
||||
messages: this.#messages.slice(0, -1).slice(-10),
|
||||
space: document.querySelector("rstack-space-switcher")?.getAttribute("current") || "",
|
||||
module: document.querySelector("rstack-app-switcher")?.getAttribute("current") || "",
|
||||
space: context.space,
|
||||
module: context.module,
|
||||
context,
|
||||
}),
|
||||
signal: this.#abortController.signal,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
* flow-select — fired when a flow is clicked in stack view { detail: { flowId } }
|
||||
*/
|
||||
|
||||
import type { Layer, LayerFlow, FlowKind } from "../../lib/layer-types";
|
||||
import type { Layer, LayerFlow, FlowKind, FeedDefinition } from "../../lib/layer-types";
|
||||
import { FLOW_COLORS, FLOW_LABELS } from "../../lib/layer-types";
|
||||
|
||||
// Badge info for tab display
|
||||
|
|
@ -78,6 +78,8 @@ export interface TabBarModule {
|
|||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
feeds?: FeedDefinition[];
|
||||
acceptsFeeds?: FlowKind[];
|
||||
}
|
||||
|
||||
export class RStackTabBar extends HTMLElement {
|
||||
|
|
@ -93,6 +95,15 @@ export class RStackTabBar extends HTMLElement {
|
|||
#flowDialogOpen = false;
|
||||
#flowDialogSourceId = "";
|
||||
#flowDialogTargetId = "";
|
||||
// 3D scene state
|
||||
#sceneRotX = 55;
|
||||
#sceneRotZ = -15;
|
||||
#scenePerspective = 1200;
|
||||
#orbitDragging = false;
|
||||
#orbitLastX = 0;
|
||||
#orbitLastY = 0;
|
||||
#simSpeed = 1;
|
||||
#simPlaying = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -172,6 +183,57 @@ export class RStackTabBar extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Feed compatibility helpers ──
|
||||
|
||||
/** Get the set of FlowKinds a module can output (from its feeds) */
|
||||
#getModuleOutputKinds(moduleId: string): Set<FlowKind> {
|
||||
const mod = this.#modules.find(m => m.id === moduleId);
|
||||
if (!mod?.feeds) return new Set();
|
||||
return new Set(mod.feeds.map(f => f.kind));
|
||||
}
|
||||
|
||||
/** Get the set of FlowKinds a module can accept as input */
|
||||
#getModuleInputKinds(moduleId: string): Set<FlowKind> {
|
||||
const mod = this.#modules.find(m => m.id === moduleId);
|
||||
if (!mod?.acceptsFeeds) return new Set();
|
||||
return new Set(mod.acceptsFeeds);
|
||||
}
|
||||
|
||||
/** Get compatible FlowKinds between two layers (intersection of source outputs and target accepts) */
|
||||
#getCompatibleKinds(srcLayerId: string, tgtLayerId: string): Set<FlowKind> {
|
||||
const srcLayer = this.#layers.find(l => l.id === srcLayerId);
|
||||
const tgtLayer = this.#layers.find(l => l.id === tgtLayerId);
|
||||
if (!srcLayer || !tgtLayer) return new Set();
|
||||
|
||||
const srcOutputs = this.#getModuleOutputKinds(srcLayer.moduleId);
|
||||
const tgtInputs = this.#getModuleInputKinds(tgtLayer.moduleId);
|
||||
|
||||
// If either module has no feed data, return empty (fallback allows all in dialog)
|
||||
if (srcOutputs.size === 0 && tgtInputs.size === 0) return new Set();
|
||||
|
||||
const compatible = new Set<FlowKind>();
|
||||
for (const kind of srcOutputs) {
|
||||
if (tgtInputs.has(kind)) compatible.add(kind);
|
||||
}
|
||||
return compatible;
|
||||
}
|
||||
|
||||
/** Get feeds that have no matching outgoing flow (contained within the layer) */
|
||||
#getContainedFeeds(layerId: string): FeedDefinition[] {
|
||||
const layer = this.#layers.find(l => l.id === layerId);
|
||||
if (!layer) return [];
|
||||
const mod = this.#modules.find(m => m.id === layer.moduleId);
|
||||
if (!mod?.feeds) return [];
|
||||
|
||||
const outgoingKinds = new Set(
|
||||
this.#flows
|
||||
.filter(f => f.sourceLayerId === layerId && f.active)
|
||||
.map(f => f.kind)
|
||||
);
|
||||
|
||||
return mod.feeds.filter(f => !outgoingKinds.has(f.kind));
|
||||
}
|
||||
|
||||
// ── Render ──
|
||||
|
||||
#render() {
|
||||
|
|
@ -280,108 +342,115 @@ export class RStackTabBar extends HTMLElement {
|
|||
const layerCount = this.#layers.length;
|
||||
if (layerCount === 0) return "";
|
||||
|
||||
const layerHeight = 60;
|
||||
const layerGap = 40;
|
||||
const totalHeight = layerCount * layerHeight + (layerCount - 1) * layerGap + 80;
|
||||
const width = 600;
|
||||
const layerSpacing = 80;
|
||||
const animDuration = 2 / this.#simSpeed;
|
||||
|
||||
// Build layer rects and flow arcs
|
||||
let layersSvg = "";
|
||||
let flowsSvg = "";
|
||||
const layerPositions = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||
// Build layer planes
|
||||
let layersHtml = "";
|
||||
const layerZMap = new Map<string, number>();
|
||||
|
||||
this.#layers.forEach((layer, i) => {
|
||||
const y = 40 + i * (layerHeight + layerGap);
|
||||
const x = 40;
|
||||
const w = width - 80;
|
||||
const z = i * layerSpacing;
|
||||
layerZMap.set(layer.id, z);
|
||||
const badge = MODULE_BADGES[layer.moduleId];
|
||||
const color = layer.color || badge?.color || "#94a3b8";
|
||||
|
||||
layerPositions.set(layer.id, { x, y, w, h: layerHeight });
|
||||
|
||||
const isActive = layer.id === this.active;
|
||||
layersSvg += `
|
||||
<g class="stack-layer ${isActive ? "stack-layer--active" : ""}"
|
||||
data-layer-id="${layer.id}">
|
||||
<rect x="${x}" y="${y}" width="${w}" height="${layerHeight}"
|
||||
rx="8" fill="${color}20" stroke="${color}" stroke-width="${isActive ? 2 : 1}"
|
||||
style="cursor:pointer" />
|
||||
<text x="${x + 14}" y="${y + layerHeight / 2 + 5}"
|
||||
fill="${color}" font-size="13" font-weight="600">${layer.label}</text>
|
||||
<text x="${x + w - 14}" y="${y + layerHeight / 2 + 5}"
|
||||
fill="${color}80" font-size="11" text-anchor="end">${badge?.badge || layer.moduleId}</text>
|
||||
</g>
|
||||
const containedFeeds = this.#getContainedFeeds(layer.id);
|
||||
|
||||
// Feed port indicators — output kinds (right side) and input kinds (left side)
|
||||
const outputKinds = this.#getModuleOutputKinds(layer.moduleId);
|
||||
const inputKinds = this.#getModuleInputKinds(layer.moduleId);
|
||||
|
||||
const outPorts = [...outputKinds].map(k =>
|
||||
`<span class="feed-port feed-port--out" style="background:${FLOW_COLORS[k]}" title="${FLOW_LABELS[k]} out"></span>`
|
||||
).join("");
|
||||
const inPorts = [...inputKinds].map(k =>
|
||||
`<span class="feed-port feed-port--in" style="background:${FLOW_COLORS[k]}" title="${FLOW_LABELS[k]} in"></span>`
|
||||
).join("");
|
||||
|
||||
const containedHtml = containedFeeds.length > 0 ? `
|
||||
<div class="layer-contained">
|
||||
${containedFeeds.map(f => `
|
||||
<span class="contained-feed" style="--feed-color:${FLOW_COLORS[f.kind]}">
|
||||
<span class="contained-lock">\uD83D\uDD12</span>
|
||||
${f.name}
|
||||
</span>
|
||||
`).join("")}
|
||||
</div>
|
||||
` : "";
|
||||
|
||||
layersHtml += `
|
||||
<div class="layer-plane ${isActive ? "layer-plane--active" : ""}"
|
||||
data-layer-id="${layer.id}"
|
||||
style="--layer-color:${color}; transform: translateZ(${z}px);">
|
||||
<div class="layer-header">
|
||||
<div class="layer-ports layer-ports--in">${inPorts}</div>
|
||||
<span class="layer-badge" style="background:${color}">${badge?.badge || layer.moduleId.slice(0, 2)}</span>
|
||||
<span class="layer-name">${layer.label}</span>
|
||||
<div class="layer-ports layer-ports--out">${outPorts}</div>
|
||||
</div>
|
||||
${containedHtml}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
// Draw flows as curved arcs on the right side
|
||||
// Build flow particles
|
||||
let particlesHtml = "";
|
||||
for (const flow of this.#flows) {
|
||||
const src = layerPositions.get(flow.sourceLayerId);
|
||||
const tgt = layerPositions.get(flow.targetLayerId);
|
||||
if (!src || !tgt) continue;
|
||||
if (!flow.active) continue;
|
||||
const srcZ = layerZMap.get(flow.sourceLayerId);
|
||||
const tgtZ = layerZMap.get(flow.targetLayerId);
|
||||
if (srcZ === undefined || tgtZ === undefined) continue;
|
||||
|
||||
const srcY = src.y + src.h / 2;
|
||||
const tgtY = tgt.y + tgt.h / 2;
|
||||
const rightX = src.x + src.w;
|
||||
const arcOut = 30 + flow.strength * 40;
|
||||
const color = flow.color || FLOW_COLORS[flow.kind] || "#94a3b8";
|
||||
const strokeWidth = 1.5 + flow.strength * 3;
|
||||
const particleCount = Math.max(2, Math.round(flow.strength * 6));
|
||||
|
||||
// Going down or up — arc curves right
|
||||
const direction = tgtY > srcY ? 1 : -1;
|
||||
const midY = (srcY + tgtY) / 2;
|
||||
|
||||
flowsSvg += `
|
||||
<g class="stack-flow" data-flow-id="${flow.id}">
|
||||
<path d="M ${rightX} ${srcY}
|
||||
C ${rightX + arcOut} ${srcY},
|
||||
${rightX + arcOut} ${tgtY},
|
||||
${rightX} ${tgtY}"
|
||||
fill="none" stroke="${color}" stroke-width="${strokeWidth}"
|
||||
stroke-dasharray="${flow.active ? "none" : "4 4"}"
|
||||
opacity="${flow.active ? 0.8 : 0.4}"
|
||||
style="cursor:pointer" />
|
||||
<circle cx="${rightX}" cy="${srcY}" r="3" fill="${color}" />
|
||||
<circle cx="${rightX}" cy="${tgtY}" r="3" fill="${color}" />
|
||||
${flow.label ? `
|
||||
<text x="${rightX + arcOut + 6}" y="${midY + 4}"
|
||||
fill="${color}" font-size="10" opacity="0.7">${flow.label}</text>
|
||||
` : ""}
|
||||
<!-- Arrow head -->
|
||||
<polygon points="${rightX},${tgtY} ${rightX + 7},${tgtY - 4 * direction} ${rightX + 7},${tgtY + 4 * direction}"
|
||||
fill="${color}" opacity="${flow.active ? 0.8 : 0.4}" />
|
||||
</g>
|
||||
`;
|
||||
for (let p = 0; p < particleCount; p++) {
|
||||
const delay = (p / particleCount) * animDuration;
|
||||
particlesHtml += `
|
||||
<div class="flow-particle"
|
||||
data-flow-id="${flow.id}"
|
||||
style="--src-z:${srcZ}px; --tgt-z:${tgtZ}px; --color:${color};
|
||||
--duration:${animDuration}s; --delay:${delay}s;
|
||||
animation-play-state:${this.#simPlaying ? "running" : "paused"};"></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Flow kind legend
|
||||
// Flow legend
|
||||
const activeKinds = new Set(this.#flows.map(f => f.kind));
|
||||
let legendSvg = "";
|
||||
let legendX = 50;
|
||||
for (const kind of activeKinds) {
|
||||
const color = FLOW_COLORS[kind];
|
||||
legendSvg += `
|
||||
<circle cx="${legendX}" cy="${totalHeight - 16}" r="4" fill="${color}" />
|
||||
<text x="${legendX + 8}" y="${totalHeight - 12}" fill="${color}" font-size="10">${FLOW_LABELS[kind]}</text>
|
||||
`;
|
||||
legendX += FLOW_LABELS[kind].length * 7 + 24;
|
||||
}
|
||||
const legendHtml = [...activeKinds].map(k => `
|
||||
<span class="legend-item">
|
||||
<span class="legend-dot" style="background:${FLOW_COLORS[k]}"></span>
|
||||
${FLOW_LABELS[k]}
|
||||
</span>
|
||||
`).join("");
|
||||
|
||||
// Flow creation hint
|
||||
const hintSvg = this.#layers.length >= 2 ? `
|
||||
<text x="${width / 2}" y="${totalHeight - 2}" fill="#475569" font-size="9" text-anchor="middle">
|
||||
Drag from one layer to another to create a flow
|
||||
</text>
|
||||
` : "";
|
||||
// Time scrubber
|
||||
const scrubberHtml = `
|
||||
<div class="time-scrubber">
|
||||
<button class="scrubber-playpause" id="scrubber-playpause" title="${this.#simPlaying ? "Pause" : "Play"}">
|
||||
${this.#simPlaying ? "\u23F8" : "\u25B6"}
|
||||
</button>
|
||||
<input type="range" class="scrubber-range" id="scrubber-range"
|
||||
min="0.1" max="5" step="0.1" value="${this.#simSpeed}" />
|
||||
<span class="scrubber-label" id="scrubber-label">${this.#simSpeed.toFixed(1)}x</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="stack-view">
|
||||
<svg width="100%" height="${totalHeight}" viewBox="0 0 ${width} ${totalHeight}">
|
||||
${flowsSvg}
|
||||
${layersSvg}
|
||||
${legendSvg}
|
||||
${hintSvg}
|
||||
</svg>
|
||||
<div class="stack-view-3d" id="stack-3d"
|
||||
style="perspective:${this.#scenePerspective}px;">
|
||||
<div class="stack-scene" id="stack-scene"
|
||||
style="transform: rotateX(${this.#sceneRotX}deg) rotateZ(${this.#sceneRotZ}deg);">
|
||||
${layersHtml}
|
||||
${particlesHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stack-legend">${legendHtml}</div>
|
||||
${scrubberHtml}
|
||||
${this.#layers.length >= 2 ? `<div class="stack-hint">Drag between layers to create a flow \u00b7 Drag empty space to orbit</div>` : ""}
|
||||
${this.#flowDialogOpen ? this.#renderFlowDialog() : ""}
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -398,24 +467,44 @@ export class RStackTabBar extends HTMLElement {
|
|||
const tgtBadge = MODULE_BADGES[tgtLayer.moduleId];
|
||||
|
||||
const kinds: FlowKind[] = ["economic", "trust", "data", "attention", "governance", "resource"];
|
||||
const compatible = this.#getCompatibleKinds(this.#flowDialogSourceId, this.#flowDialogTargetId);
|
||||
const hasModuleData = this.#modules.some(m => m.id === srcLayer.moduleId && m.feeds) ||
|
||||
this.#modules.some(m => m.id === tgtLayer.moduleId && m.acceptsFeeds);
|
||||
|
||||
// Count source feeds per kind for badge display
|
||||
const srcMod = this.#modules.find(m => m.id === srcLayer.moduleId);
|
||||
const feedCountByKind = new Map<FlowKind, number>();
|
||||
if (srcMod?.feeds) {
|
||||
for (const f of srcMod.feeds) {
|
||||
feedCountByKind.set(f.kind, (feedCountByKind.get(f.kind) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="flow-dialog" id="flow-dialog">
|
||||
<div class="flow-dialog-header">New Flow</div>
|
||||
<div class="flow-dialog-route">
|
||||
<span class="flow-dialog-badge" style="background:${srcBadge?.color || "#94a3b8"}">${srcBadge?.badge || srcLayer.moduleId}</span>
|
||||
<span class="flow-dialog-arrow">→</span>
|
||||
<span class="flow-dialog-arrow">\u2192</span>
|
||||
<span class="flow-dialog-badge" style="background:${tgtBadge?.color || "#94a3b8"}">${tgtBadge?.badge || tgtLayer.moduleId}</span>
|
||||
</div>
|
||||
<div class="flow-dialog-field">
|
||||
<label class="flow-dialog-label">Flow Type</label>
|
||||
<div class="flow-kind-grid" id="flow-kind-grid">
|
||||
${kinds.map(k => `
|
||||
<button class="flow-kind-btn" data-kind="${k}" style="--kind-color:${FLOW_COLORS[k]}">
|
||||
${kinds.map(k => {
|
||||
const isCompatible = !hasModuleData || compatible.has(k);
|
||||
const feedCount = feedCountByKind.get(k) || 0;
|
||||
return `
|
||||
<button class="flow-kind-btn ${!isCompatible ? "disabled" : ""}"
|
||||
data-kind="${k}"
|
||||
style="--kind-color:${FLOW_COLORS[k]}"
|
||||
${!isCompatible ? 'disabled aria-disabled="true"' : ""}>
|
||||
<span class="flow-kind-dot" style="background:${FLOW_COLORS[k]}"></span>
|
||||
${FLOW_LABELS[k]}
|
||||
${isCompatible && feedCount > 0 ? `<span class="flow-kind-count">${feedCount}</span>` : ""}
|
||||
</button>
|
||||
`).join("")}
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-dialog-field">
|
||||
|
|
@ -556,13 +645,13 @@ export class RStackTabBar extends HTMLElement {
|
|||
this.#render();
|
||||
});
|
||||
|
||||
// Stack view layer clicks + drag-to-connect
|
||||
this.#shadow.querySelectorAll<SVGGElement>(".stack-layer").forEach(g => {
|
||||
const layerId = g.dataset.layerId!;
|
||||
// 3D Stack view: layer clicks + drag-to-connect
|
||||
this.#shadow.querySelectorAll<HTMLElement>(".layer-plane").forEach(plane => {
|
||||
const layerId = plane.dataset.layerId!;
|
||||
|
||||
// Click to switch layer
|
||||
g.addEventListener("click", () => {
|
||||
if (this.#flowDragSource) return; // ignore if mid-drag
|
||||
plane.addEventListener("click", (e) => {
|
||||
if (this.#flowDragSource || this.#orbitDragging) return;
|
||||
const layer = this.#layers.find(l => l.id === layerId);
|
||||
if (layer) {
|
||||
this.active = layerId;
|
||||
|
|
@ -574,48 +663,87 @@ export class RStackTabBar extends HTMLElement {
|
|||
});
|
||||
|
||||
// Drag-to-connect: mousedown starts a flow drag
|
||||
g.addEventListener("mousedown", (e) => {
|
||||
plane.addEventListener("mousedown", (e) => {
|
||||
if ((e as MouseEvent).button !== 0) return;
|
||||
e.stopPropagation(); // prevent orbit
|
||||
this.#flowDragSource = layerId;
|
||||
g.classList.add("flow-drag-source");
|
||||
plane.classList.add("flow-drag-source");
|
||||
});
|
||||
|
||||
g.addEventListener("mouseenter", () => {
|
||||
plane.addEventListener("mouseenter", () => {
|
||||
if (this.#flowDragSource && this.#flowDragSource !== layerId) {
|
||||
this.#flowDragTarget = layerId;
|
||||
g.classList.add("flow-drag-target");
|
||||
plane.classList.add("flow-drag-target");
|
||||
}
|
||||
});
|
||||
|
||||
g.addEventListener("mouseleave", () => {
|
||||
plane.addEventListener("mouseleave", () => {
|
||||
if (this.#flowDragTarget === layerId) {
|
||||
this.#flowDragTarget = null;
|
||||
g.classList.remove("flow-drag-target");
|
||||
plane.classList.remove("flow-drag-target");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Global mouseup completes the flow drag
|
||||
const svgEl = this.#shadow.querySelector<SVGElement>(".stack-view svg");
|
||||
if (svgEl) {
|
||||
svgEl.addEventListener("mouseup", () => {
|
||||
// 3D scene: orbit controls (drag on empty space to rotate)
|
||||
const sceneContainer = this.#shadow.getElementById("stack-3d");
|
||||
if (sceneContainer) {
|
||||
sceneContainer.addEventListener("mousedown", (e) => {
|
||||
// Only orbit on left-click on empty space (not on layer planes)
|
||||
if ((e as MouseEvent).button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest(".layer-plane")) return;
|
||||
this.#orbitDragging = true;
|
||||
this.#orbitLastX = (e as MouseEvent).clientX;
|
||||
this.#orbitLastY = (e as MouseEvent).clientY;
|
||||
sceneContainer.style.cursor = "grabbing";
|
||||
});
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (this.#orbitDragging) {
|
||||
const dx = e.clientX - this.#orbitLastX;
|
||||
const dy = e.clientY - this.#orbitLastY;
|
||||
this.#sceneRotZ += dx * 0.3;
|
||||
this.#sceneRotX = Math.max(10, Math.min(80, this.#sceneRotX - dy * 0.3));
|
||||
this.#orbitLastX = e.clientX;
|
||||
this.#orbitLastY = e.clientY;
|
||||
const scene = this.#shadow.getElementById("stack-scene");
|
||||
if (scene) scene.style.transform = `rotateX(${this.#sceneRotX}deg) rotateZ(${this.#sceneRotZ}deg)`;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
if (this.#orbitDragging) {
|
||||
this.#orbitDragging = false;
|
||||
sceneContainer.style.cursor = "";
|
||||
}
|
||||
// Complete flow drag
|
||||
if (this.#flowDragSource && this.#flowDragTarget) {
|
||||
this.#openFlowDialog(this.#flowDragSource, this.#flowDragTarget);
|
||||
}
|
||||
// Reset drag state
|
||||
this.#flowDragSource = null;
|
||||
this.#flowDragTarget = null;
|
||||
this.#shadow.querySelectorAll(".flow-drag-source, .flow-drag-target").forEach(el => {
|
||||
el.classList.remove("flow-drag-source", "flow-drag-target");
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Attach to document for drag continuity
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
// Scroll to zoom
|
||||
sceneContainer.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
this.#scenePerspective = Math.max(400, Math.min(3000, this.#scenePerspective + (e as WheelEvent).deltaY * 2));
|
||||
sceneContainer.style.perspective = `${this.#scenePerspective}px`;
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// Stack view flow clicks — select or delete
|
||||
this.#shadow.querySelectorAll<SVGGElement>(".stack-flow").forEach(g => {
|
||||
g.addEventListener("click", (e) => {
|
||||
// Flow particle clicks — select flow
|
||||
this.#shadow.querySelectorAll<HTMLElement>(".flow-particle").forEach(p => {
|
||||
p.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const flowId = g.dataset.flowId!;
|
||||
const flowId = p.dataset.flowId!;
|
||||
this.dispatchEvent(new CustomEvent("flow-select", {
|
||||
detail: { flowId },
|
||||
bubbles: true,
|
||||
|
|
@ -623,9 +751,9 @@ export class RStackTabBar extends HTMLElement {
|
|||
});
|
||||
|
||||
// Right-click to delete flow
|
||||
g.addEventListener("contextmenu", (e) => {
|
||||
p.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const flowId = g.dataset.flowId!;
|
||||
const flowId = p.dataset.flowId!;
|
||||
const flow = this.#flows.find(f => f.id === flowId);
|
||||
if (flow && confirm(`Remove ${FLOW_LABELS[flow.kind]} flow${flow.label ? `: ${flow.label}` : ""}?`)) {
|
||||
this.dispatchEvent(new CustomEvent("flow-remove", {
|
||||
|
|
@ -636,11 +764,39 @@ export class RStackTabBar extends HTMLElement {
|
|||
});
|
||||
});
|
||||
|
||||
// Time scrubber controls
|
||||
const scrubberRange = this.#shadow.getElementById("scrubber-range") as HTMLInputElement | null;
|
||||
const scrubberLabel = this.#shadow.getElementById("scrubber-label");
|
||||
const scrubberPlaypause = this.#shadow.getElementById("scrubber-playpause");
|
||||
|
||||
scrubberRange?.addEventListener("input", () => {
|
||||
this.#simSpeed = parseFloat(scrubberRange.value);
|
||||
if (scrubberLabel) scrubberLabel.textContent = `${this.#simSpeed.toFixed(1)}x`;
|
||||
// Update particle durations without full re-render
|
||||
const dur = 2 / this.#simSpeed;
|
||||
this.#shadow.querySelectorAll<HTMLElement>(".flow-particle").forEach(p => {
|
||||
p.style.setProperty("--duration", `${dur}s`);
|
||||
});
|
||||
});
|
||||
|
||||
scrubberPlaypause?.addEventListener("click", () => {
|
||||
this.#simPlaying = !this.#simPlaying;
|
||||
scrubberPlaypause.textContent = this.#simPlaying ? "\u23F8" : "\u25B6";
|
||||
scrubberPlaypause.title = this.#simPlaying ? "Pause" : "Play";
|
||||
const state = this.#simPlaying ? "running" : "paused";
|
||||
this.#shadow.querySelectorAll<HTMLElement>(".flow-particle").forEach(p => {
|
||||
p.style.animationPlayState = state;
|
||||
});
|
||||
});
|
||||
|
||||
// Flow dialog events
|
||||
if (this.#flowDialogOpen) {
|
||||
let selectedKind: FlowKind = "data";
|
||||
// Default to first compatible kind, or "data" as fallback
|
||||
const compatible = this.#getCompatibleKinds(this.#flowDialogSourceId, this.#flowDialogTargetId);
|
||||
const defaultKind: FlowKind = compatible.size > 0 ? [...compatible][0] : "data";
|
||||
let selectedKind: FlowKind = defaultKind;
|
||||
|
||||
this.#shadow.querySelectorAll<HTMLElement>(".flow-kind-btn").forEach(btn => {
|
||||
this.#shadow.querySelectorAll<HTMLElement>(".flow-kind-btn:not(.disabled)").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
this.#shadow.querySelectorAll(".flow-kind-btn").forEach(b => b.classList.remove("selected"));
|
||||
btn.classList.add("selected");
|
||||
|
|
@ -648,9 +804,11 @@ export class RStackTabBar extends HTMLElement {
|
|||
});
|
||||
});
|
||||
|
||||
// Select "data" by default
|
||||
const defaultBtn = this.#shadow.querySelector('.flow-kind-btn[data-kind="data"]');
|
||||
// Select default kind
|
||||
const defaultBtn = this.#shadow.querySelector(`.flow-kind-btn[data-kind="${defaultKind}"]:not(.disabled)`) ||
|
||||
this.#shadow.querySelector('.flow-kind-btn:not(.disabled)');
|
||||
defaultBtn?.classList.add("selected");
|
||||
if (defaultBtn) selectedKind = (defaultBtn as HTMLElement).dataset.kind as FlowKind;
|
||||
|
||||
this.#shadow.getElementById("flow-cancel")?.addEventListener("click", () => {
|
||||
this.#closeFlowDialog();
|
||||
|
|
@ -1131,6 +1289,27 @@ const STYLES = `
|
|||
background: color-mix(in srgb, var(--kind-color) 10%, transparent);
|
||||
}
|
||||
|
||||
.flow-kind-btn.disabled {
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.flow-kind-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
background: var(--kind-color);
|
||||
color: #0f172a;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.flow-kind-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
|
|
|||
|
|
@ -1,25 +1,6 @@
|
|||
import { Hono } from "hono";
|
||||
import type { FlowKind } from "../lib/layer-types";
|
||||
|
||||
/**
|
||||
* Feed definition — describes a data feed that a module can expose to other layers.
|
||||
* Feeds are the connective tissue between layers: they let data, economic value,
|
||||
* trust signals, etc. flow between rApps.
|
||||
*/
|
||||
export interface FeedDefinition {
|
||||
/** Feed identifier (unique within the module) */
|
||||
id: string;
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
/** What kind of flow this feed carries */
|
||||
kind: FlowKind;
|
||||
/** Description of what this feed provides */
|
||||
description: string;
|
||||
/** Shape types this feed emits (e.g. "folk-note", "folk-token-mint") */
|
||||
emits?: string[];
|
||||
/** Whether this feed supports filtering */
|
||||
filterable?: boolean;
|
||||
}
|
||||
export type { FeedDefinition } from "../lib/layer-types";
|
||||
|
||||
/**
|
||||
* The contract every rSpace module must implement.
|
||||
|
|
|
|||
|
|
@ -23,16 +23,20 @@
|
|||
#toolbar {
|
||||
position: fixed;
|
||||
top: 108px; /* header(56) + tab-row(36) + gap(16) */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
left: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
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 */
|
||||
|
|
@ -41,7 +45,7 @@
|
|||
}
|
||||
|
||||
.toolbar-group-toggle {
|
||||
padding: 7px 12px;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
|
|
@ -49,6 +53,7 @@
|
|||
font-size: 13px;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.toolbar-group-toggle:hover {
|
||||
|
|
@ -63,9 +68,8 @@
|
|||
.toolbar-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 0;
|
||||
left: calc(100% + 6px);
|
||||
min-width: 160px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
|
|
@ -96,18 +100,23 @@
|
|||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.toolbar-dropdown button.active {
|
||||
background: #14b8a6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Separator between sections */
|
||||
.toolbar-sep {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: #e2e8f0;
|
||||
margin: 0 2px;
|
||||
margin: 2px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Direct toolbar buttons (Connect, Memory, Zoom) */
|
||||
/* Direct toolbar buttons (Connect, Memory, etc.) */
|
||||
#toolbar > button {
|
||||
padding: 7px 12px;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
|
|
@ -115,6 +124,7 @@
|
|||
font-size: 13px;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#toolbar > button:hover {
|
||||
|
|
@ -149,6 +159,7 @@
|
|||
|
||||
#toolbar.collapsed {
|
||||
padding: 6px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
#community-info {
|
||||
|
|
@ -502,7 +513,6 @@
|
|||
top: 72px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
transform: none;
|
||||
flex-wrap: wrap;
|
||||
max-height: calc(100vh - 160px);
|
||||
overflow-y: auto;
|
||||
|
|
@ -516,7 +526,7 @@
|
|||
display: flex;
|
||||
}
|
||||
|
||||
/* On mobile, flatten groups into a grid */
|
||||
/* On mobile, flatten groups */
|
||||
#toolbar .toolbar-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -530,7 +540,6 @@
|
|||
|
||||
#toolbar .toolbar-dropdown {
|
||||
position: static;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
padding: 4px 0 4px 12px;
|
||||
min-width: 0;
|
||||
|
|
@ -553,10 +562,7 @@
|
|||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Hide zoom/reset from toolbar (they're in #mobile-zoom) */
|
||||
#toolbar #zoom-in,
|
||||
#toolbar #zoom-out,
|
||||
#toolbar #reset-view,
|
||||
/* Hide collapse on mobile */
|
||||
#toolbar #toolbar-collapse {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -601,7 +607,19 @@
|
|||
</div>
|
||||
|
||||
<div id="toolbar">
|
||||
<button id="toolbar-collapse" title="Minimize toolbar">◀</button>
|
||||
<button id="toolbar-collapse" title="Minimize toolbar">▲</button>
|
||||
|
||||
<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>
|
||||
|
|
@ -706,9 +724,14 @@
|
|||
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<div id="memory-panel">
|
||||
|
|
@ -1844,6 +1867,166 @@
|
|||
}
|
||||
});
|
||||
|
||||
// ── 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;
|
||||
wbPreviewEl = null;
|
||||
wbCurrentPath = [];
|
||||
});
|
||||
|
||||
// Eraser: click on existing SVG strokes to delete them
|
||||
wbOverlay.addEventListener("click", (e) => {
|
||||
if (wbTool !== "eraser") return;
|
||||
const hit = e.target;
|
||||
if (hit && hit !== wbOverlay && wbOverlay.contains(hit)) {
|
||||
hit.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Memory panel — browse and remember forgotten shapes
|
||||
const memoryPanel = document.getElementById("memory-panel");
|
||||
const memoryList = document.getElementById("memory-list");
|
||||
|
|
@ -1861,6 +2044,8 @@
|
|||
"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": "↗️",
|
||||
};
|
||||
|
||||
|
|
@ -1976,8 +2161,9 @@
|
|||
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"];
|
||||
// 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");
|
||||
|
|
@ -2016,7 +2202,7 @@
|
|||
const collapseBtn = document.getElementById("toolbar-collapse");
|
||||
collapseBtn.addEventListener("click", () => {
|
||||
const isCollapsed = toolbarEl.classList.toggle("collapsed");
|
||||
collapseBtn.textContent = isCollapsed ? "▶" : "◀";
|
||||
collapseBtn.textContent = isCollapsed ? "▼" : "▲";
|
||||
collapseBtn.title = isCollapsed ? "Expand toolbar" : "Minimize toolbar";
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue