497 lines
13 KiB
TypeScript
497 lines
13 KiB
TypeScript
/**
|
||
* 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">×</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;
|
||
}
|
||
}
|