rspace-online/lib/folk-applet.ts

497 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* folk-applet — Generic rApplet shape for the canvas.
*
* Compact mode (default): 300×200 card with module-provided HTML body + port indicators.
* Expanded mode: 600×400 with applet-circuit-canvas sub-graph or iframe fallback.
*
* Persisted fields: moduleId, appletId, instanceConfig, mode.
* Live data arrives via updateLiveData() — no direct module imports.
*/
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { dataTypeColor } from "./data-types";
import type { PortDescriptor } from "./data-types";
import type { AppletDefinition, AppletLiveData, AppletContext } from "../shared/applet-types";
// ── Applet registry (populated by modules at init) ──
const appletDefs = new Map<string, AppletDefinition>();
/** Register an applet definition. Key = "moduleId:appletId". */
export function registerAppletDef(moduleId: string, def: AppletDefinition): void {
appletDefs.set(`${moduleId}:${def.id}`, def);
}
/** Look up a registered applet definition. */
export function getAppletDef(moduleId: string, appletId: string): AppletDefinition | undefined {
return appletDefs.get(`${moduleId}:${appletId}`);
}
/** List all registered applet definitions. */
export function listAppletDefs(): Array<{ moduleId: string; def: AppletDefinition }> {
const result: Array<{ moduleId: string; def: AppletDefinition }> = [];
for (const [key, def] of appletDefs) {
const moduleId = key.split(":")[0];
result.push({ moduleId, def });
}
return result;
}
// ── Styles ──
const COMPACT_W = 300;
const COMPACT_H = 200;
const EXPANDED_W = 600;
const EXPANDED_H = 400;
const styles = css`
:host {
background: var(--rs-bg-surface, #1e293b);
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
overflow: visible;
}
.applet-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
color: white;
font-size: 12px;
font-weight: 600;
cursor: move;
border-radius: 10px 10px 0 0;
min-height: 32px;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions {
display: flex;
gap: 2px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
line-height: 1;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.body {
flex: 1;
padding: 12px;
overflow: hidden;
font-size: 12px;
color: var(--rs-text-primary, #e2e8f0);
border-radius: 0 0 10px 10px;
}
.body-empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--rs-text-muted, #64748b);
font-style: italic;
}
/* Port chips on edges */
.port-chip {
position: absolute;
display: flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
border-radius: 8px;
border: 1.5px solid;
background: var(--rs-bg-surface, #1e293b);
font-size: 9px;
color: var(--rs-text-muted, #94a3b8);
white-space: nowrap;
cursor: crosshair;
z-index: 2;
transform: translateY(-50%);
transition: filter 0.15s;
}
.port-chip:hover {
filter: brightness(1.3);
}
.port-chip.input {
left: -2px;
}
.port-chip.output {
right: -2px;
flex-direction: row-reverse;
}
.chip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
/* Expanded mode circuit container */
.circuit-container {
flex: 1;
border-radius: 0 0 10px 10px;
overflow: hidden;
}
.circuit-container applet-circuit-canvas {
width: 100%;
height: 100%;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-applet": FolkApplet;
}
}
export class FolkApplet extends FolkShape {
static override tagName = "folk-applet";
// Dynamic port descriptors set from the applet definition
static override portDescriptors: PortDescriptor[] = [];
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n");
const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#moduleId = "";
#appletId = "";
#mode: "compact" | "expanded" = "compact";
#instanceConfig: Record<string, unknown> = {};
#liveData: AppletLiveData | null = null;
// DOM refs
#bodyEl!: HTMLElement;
#wrapper!: HTMLElement;
// Instance-level port descriptors (override static)
#instancePorts: PortDescriptor[] = [];
// Live data polling timer
#liveDataTimer: ReturnType<typeof setInterval> | null = null;
get moduleId() { return this.#moduleId; }
set moduleId(v: string) {
this.#moduleId = v;
this.#syncDefPorts();
}
get appletId() { return this.#appletId; }
set appletId(v: string) {
this.#appletId = v;
this.#syncDefPorts();
}
get mode() { return this.#mode; }
set mode(v: "compact" | "expanded") {
if (this.#mode === v) return;
this.#mode = v;
this.#updateMode();
}
get instanceConfig() { return this.#instanceConfig; }
set instanceConfig(v: Record<string, unknown>) { this.#instanceConfig = v; }
/** Sync port descriptors from the applet definition. */
#syncDefPorts(): void {
const def = getAppletDef(this.#moduleId, this.#appletId);
if (def) {
this.#instancePorts = def.ports;
}
}
/** Override: use instance ports instead of static. */
override getInputPorts(): PortDescriptor[] {
return this.#instancePorts.filter(p => p.direction === "input");
}
override getOutputPorts(): PortDescriptor[] {
return this.#instancePorts.filter(p => p.direction === "output");
}
override getPort(name: string): PortDescriptor | undefined {
return this.#instancePorts.find(p => p.name === name);
}
/** Bridge FolkArrow piping → applet def's onInputReceived. */
override setPortValue(name: string, value: unknown): void {
super.setPortValue(name, value);
const port = this.getPort(name);
if (port?.direction !== "input") return;
const def = getAppletDef(this.#moduleId, this.#appletId);
if (!def?.onInputReceived) return;
const ctx: AppletContext = {
space: (this.closest("[space]") as any)?.getAttribute("space") || "",
shapeId: this.id,
emitOutput: (portName, val) => super.setPortValue(portName, val),
};
def.onInputReceived(name, value, ctx);
this.#renderBody();
}
/** Update live data and re-render compact body. */
updateLiveData(snapshot: Record<string, unknown>): void {
this.#liveData = {
space: (this.closest("[space]") as any)?.getAttribute("space") || "",
moduleId: this.#moduleId,
appletId: this.#appletId,
snapshot,
outputValues: {},
};
this.#renderBody();
}
override createRenderRoot() {
const root = super.createRenderRoot();
this.#syncDefPorts();
this.initPorts();
const def = getAppletDef(this.#moduleId, this.#appletId);
const accentColor = def?.accentColor || "#475569";
const icon = def?.icon || "📦";
const label = def?.label || this.#appletId;
this.#wrapper = document.createElement("div");
this.#wrapper.className = "applet-wrapper";
this.#wrapper.innerHTML = html`
<div class="header" data-drag style="background: ${accentColor}">
<span class="header-title">${icon} ${label}</span>
<span class="header-actions">
<button class="expand-btn" title="Toggle expanded">⊞</button>
<button class="close-btn" title="Close">&times;</button>
</span>
</div>
<div class="body body-empty">Loading...</div>
`;
const slot = root.querySelector("slot");
const container = slot?.parentElement as HTMLElement;
if (container) container.replaceWith(this.#wrapper);
this.#bodyEl = this.#wrapper.querySelector(".body") as HTMLElement;
// Wire events
this.#wrapper.querySelector(".expand-btn")!.addEventListener("click", (e) => {
e.stopPropagation();
this.mode = this.#mode === "compact" ? "expanded" : "compact";
this.dispatchEvent(new CustomEvent("content-change"));
});
this.#wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Render port indicators
this.#renderPorts();
// Render initial body
this.#renderBody();
// Notify canvas we want live data
this.dispatchEvent(new CustomEvent("applet-subscribe", {
bubbles: true,
detail: { moduleId: this.#moduleId, appletId: this.#appletId, shapeId: this.id },
}));
// Start self-fetch polling if the applet defines fetchLiveData
this.#startLiveDataPolling();
return root;
}
disconnectedCallback() {
if (this.#liveDataTimer) {
clearInterval(this.#liveDataTimer);
this.#liveDataTimer = null;
}
}
#startLiveDataPolling(): void {
const def = getAppletDef(this.#moduleId, this.#appletId);
if (!def?.fetchLiveData) return;
const space = (this.closest("[space]") as any)?.getAttribute("space") || "";
const doFetch = () => {
def.fetchLiveData!(space).then(snapshot => {
this.updateLiveData(snapshot);
}).catch(() => {});
};
// Fetch immediately, then every 30s
doFetch();
this.#liveDataTimer = setInterval(doFetch, 30_000);
}
#renderPorts(): void {
this.#wrapper.querySelectorAll(".port-chip").forEach(el => el.remove());
const renderChips = (ports: PortDescriptor[], dir: "input" | "output") => {
ports.forEach((port, i) => {
const yPct = ((i + 1) / (ports.length + 1)) * 100;
const color = dataTypeColor(port.type);
const chip = document.createElement("div");
chip.className = `port-chip ${dir}`;
chip.style.top = `${yPct}%`;
chip.style.borderColor = color;
chip.dataset.portName = port.name;
chip.dataset.portDir = dir;
chip.title = `${port.name} (${port.type})`;
const dot = document.createElement("span");
dot.className = "chip-dot";
dot.style.background = color;
const label = document.createTextNode(port.name);
chip.appendChild(dot);
chip.appendChild(label);
this.#wrapper.appendChild(chip);
});
};
renderChips(this.getInputPorts(), "input");
renderChips(this.getOutputPorts(), "output");
}
#renderBody(): void {
if (!this.#bodyEl) return;
const def = getAppletDef(this.#moduleId, this.#appletId);
if (!def) {
this.#bodyEl.className = "body body-empty";
this.#bodyEl.textContent = `Unknown applet: ${this.#moduleId}:${this.#appletId}`;
return;
}
if (this.#mode === "expanded" && def.getCircuit) {
this.#renderExpanded(def);
return;
}
// Compact mode — module-provided HTML
const data: AppletLiveData = this.#liveData || {
space: "",
moduleId: this.#moduleId,
appletId: this.#appletId,
snapshot: {},
outputValues: {},
};
try {
const bodyHtml = def.renderCompact(data);
this.#bodyEl.className = "body";
this.#bodyEl.innerHTML = bodyHtml;
} catch (err) {
this.#bodyEl.className = "body body-empty";
this.#bodyEl.textContent = `Render error: ${err}`;
}
}
#renderExpanded(def: AppletDefinition): void {
if (!def.getCircuit) return;
const space = (this.closest("[space]") as any)?.getAttribute("space") || "";
const { nodes, edges } = def.getCircuit(space);
this.#bodyEl.className = "body circuit-container";
this.#bodyEl.innerHTML = "";
const canvas = document.createElement("applet-circuit-canvas") as any;
canvas.nodes = nodes;
canvas.edges = edges;
this.#bodyEl.appendChild(canvas);
}
#updateMode(): void {
if (!this.#wrapper) return;
if (this.#mode === "expanded") {
this.width = EXPANDED_W;
this.height = EXPANDED_H;
} else {
this.width = COMPACT_W;
this.height = COMPACT_H;
}
this.#renderBody();
// Update expand button icon
const btn = this.#wrapper.querySelector(".expand-btn");
if (btn) btn.textContent = this.#mode === "expanded" ? "⊟" : "⊞";
}
// ── Serialization ──
override toJSON() {
return {
...super.toJSON(),
type: "folk-applet",
moduleId: this.#moduleId,
appletId: this.#appletId,
mode: this.#mode,
instanceConfig: this.#instanceConfig,
};
}
static override fromData(data: Record<string, any>): FolkApplet {
const shape = FolkShape.fromData.call(this, data) as FolkApplet;
if (data.moduleId) shape.moduleId = data.moduleId;
if (data.appletId) shape.appletId = data.appletId;
if (data.mode) shape.mode = data.mode;
if (data.instanceConfig) shape.instanceConfig = data.instanceConfig;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.moduleId !== undefined && data.moduleId !== this.#moduleId) this.moduleId = data.moduleId;
if (data.appletId !== undefined && data.appletId !== this.#appletId) this.appletId = data.appletId;
if (data.mode !== undefined && data.mode !== this.#mode) this.mode = data.mode;
if (data.instanceConfig !== undefined) this.instanceConfig = data.instanceConfig;
}
}