rspace-online/lib/folk-applet.ts

472 lines
12 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 } 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 indicators on edges */
.port-indicator {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #0f172a;
cursor: crosshair;
z-index: 2;
transition: transform 0.15s;
}
.port-indicator:hover {
transform: scale(1.4);
}
.port-indicator.input {
left: -6px;
}
.port-indicator.output {
right: -6px;
}
.port-label {
position: absolute;
font-size: 9px;
color: var(--rs-text-muted, #94a3b8);
white-space: nowrap;
pointer-events: none;
}
.port-label.input {
left: 10px;
}
.port-label.output {
right: 10px;
text-align: right;
}
/* 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[] = [];
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);
}
/** 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 },
}));
return root;
}
#renderPorts(): void {
// Remove existing port indicators
this.#wrapper.querySelectorAll(".port-indicator, .port-label").forEach(el => el.remove());
const inputs = this.getInputPorts();
const outputs = this.getOutputPorts();
// Input ports on left edge
inputs.forEach((port, i) => {
const yPct = ((i + 1) / (inputs.length + 1)) * 100;
const color = dataTypeColor(port.type);
const dot = document.createElement("div");
dot.className = "port-indicator input";
dot.style.top = `${yPct}%`;
dot.style.backgroundColor = color;
dot.dataset.portName = port.name;
dot.dataset.portDir = "input";
dot.title = `${port.name} (${port.type})`;
const label = document.createElement("span");
label.className = "port-label input";
label.style.top = `${yPct}%`;
label.style.transform = "translateY(-50%)";
label.textContent = port.name;
this.#wrapper.appendChild(dot);
this.#wrapper.appendChild(label);
});
// Output ports on right edge
outputs.forEach((port, i) => {
const yPct = ((i + 1) / (outputs.length + 1)) * 100;
const color = dataTypeColor(port.type);
const dot = document.createElement("div");
dot.className = "port-indicator output";
dot.style.top = `${yPct}%`;
dot.style.backgroundColor = color;
dot.dataset.portName = port.name;
dot.dataset.portDir = "output";
dot.title = `${port.name} (${port.type})`;
const label = document.createElement("span");
label.className = "port-label output";
label.style.top = `${yPct}%`;
label.style.transform = "translateY(-50%)";
label.textContent = port.name;
this.#wrapper.appendChild(dot);
this.#wrapper.appendChild(label);
});
}
#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;
}
}