/** * 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(); /** 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 = {}; #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) { 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): 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`
${icon} ${label}
Loading...
`; 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): 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): 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; } }