472 lines
12 KiB
TypeScript
472 lines
12 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 } 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">×</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;
|
||
}
|
||
}
|