import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; const styles = css` :host { background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); min-width: 200px; min-height: 120px; } :host([data-state="running"]) { box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5); } :host([data-state="success"]) { box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.5); } :host([data-state="error"]) { box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.5); } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #f8fafc; border-radius: 12px 12px 0 0; border-bottom: 1px solid #e2e8f0; cursor: move; } .header-left { display: flex; align-items: center; gap: 8px; } .block-icon { width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; } .block-icon.trigger { background: #dbeafe; } .block-icon.action { background: #dcfce7; } .block-icon.condition { background: #fef3c7; } .block-icon.output { background: #f3e8ff; } .block-label { font-size: 13px; font-weight: 600; color: #1e293b; } .header-actions button { background: transparent; border: none; cursor: pointer; padding: 2px; color: #64748b; font-size: 14px; } .header-actions button:hover { color: #1e293b; } .content { padding: 12px; min-height: 60px; } .ports { display: flex; flex-direction: column; gap: 8px; } .port-row { display: flex; align-items: center; gap: 8px; font-size: 12px; } .port-row.input { justify-content: flex-start; } .port-row.output { justify-content: flex-end; } .port-handle { width: 12px; height: 12px; border-radius: 50%; border: 2px solid; cursor: pointer; transition: transform 0.2s; } .port-handle:hover { transform: scale(1.2); } .port-handle.string { border-color: #3b82f6; background: #dbeafe; } .port-handle.number { border-color: #10b981; background: #d1fae5; } .port-handle.boolean { border-color: #f59e0b; background: #fef3c7; } .port-handle.any { border-color: #6b7280; background: #f3f4f6; } .port-handle.trigger { border-color: #ef4444; background: #fee2e2; } .port-label { color: #64748b; } .port-value { color: #1e293b; font-family: "Monaco", "Consolas", monospace; background: #f1f5f9; padding: 2px 6px; border-radius: 4px; max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .config-area { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e2e8f0; } .config-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; } .config-field label { font-size: 11px; font-weight: 500; color: #64748b; } .config-field input, .config-field select { padding: 6px 8px; border: 1px solid #e2e8f0; border-radius: 4px; font-size: 12px; outline: none; } .config-field input:focus, .config-field select:focus { border-color: #3b82f6; } .status-bar { display: flex; align-items: center; justify-content: space-between; padding: 6px 12px; background: #f8fafc; border-radius: 0 0 12px 12px; border-top: 1px solid #e2e8f0; font-size: 11px; } .status-indicator { display: flex; align-items: center; gap: 4px; } .status-dot { width: 6px; height: 6px; border-radius: 50%; } .status-dot.idle { background: #6b7280; } .status-dot.running { background: #3b82f6; animation: pulse 1s infinite; } .status-dot.success { background: #22c55e; } .status-dot.error { background: #ef4444; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .run-btn { padding: 4px 8px; background: #3b82f6; color: white; border: none; border-radius: 4px; font-size: 10px; font-weight: 500; cursor: pointer; } .run-btn:hover { background: #2563eb; } `; export type PortType = "string" | "number" | "boolean" | "any" | "trigger"; export interface Port { name: string; type: PortType; value?: unknown; } export type BlockType = "trigger" | "action" | "condition" | "output"; export type BlockState = "idle" | "running" | "success" | "error"; declare global { interface HTMLElementTagNameMap { "folk-workflow-block": FolkWorkflowBlock; } } export class FolkWorkflowBlock extends FolkShape { static override tagName = "folk-workflow-block"; 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; } #blockType: BlockType = "action"; #label = "Block"; #icon = "\u{2699}"; #state: BlockState = "idle"; #inputs: Port[] = []; #outputs: Port[] = []; #config: Record = {}; #contentEl: HTMLElement | null = null; #statusDot: HTMLElement | null = null; #statusText: HTMLElement | null = null; get blockType() { return this.#blockType; } set blockType(value: BlockType) { this.#blockType = value; this.#updateIcon(); } get label() { return this.#label; } set label(value: string) { this.#label = value; } get state() { return this.#state; } set state(value: BlockState) { this.#state = value; this.setAttribute("data-state", value); this.#updateStatus(); } get inputs(): Port[] { return this.#inputs; } set inputs(value: Port[]) { this.#inputs = value; this.#renderPorts(); } get outputs(): Port[] { return this.#outputs; } set outputs(value: Port[]) { this.#outputs = value; this.#renderPorts(); } override createRenderRoot() { const root = super.createRenderRoot(); // Parse attributes const typeAttr = this.getAttribute("block-type") as BlockType; if (typeAttr) this.#blockType = typeAttr; const labelAttr = this.getAttribute("label"); if (labelAttr) this.#label = labelAttr; this.#updateIcon(); const wrapper = document.createElement("div"); wrapper.innerHTML = html`
${this.#icon} ${this.#escapeHtml(this.#label)}
Idle
`; // Replace the container div (slot's parent) with our wrapper const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) { containerDiv.replaceWith(wrapper); } this.#contentEl = wrapper.querySelector(".content"); this.#statusDot = wrapper.querySelector(".status-dot"); this.#statusText = wrapper.querySelector(".status-text"); const runBtn = wrapper.querySelector(".run-btn") as HTMLButtonElement; const settingsBtn = wrapper.querySelector(".settings-btn") as HTMLButtonElement; const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; // Run button runBtn.addEventListener("click", (e) => { e.stopPropagation(); this.#execute(); }); // Settings button settingsBtn.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("open-settings")); }); // Close button closeBtn.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); // Initialize with default ports based on type this.#initDefaultPorts(); this.#renderPorts(); return root; } #updateIcon() { switch (this.#blockType) { case "trigger": this.#icon = "\u26A1"; break; case "action": this.#icon = "\u2699"; break; case "condition": this.#icon = "\u2753"; break; case "output": this.#icon = "\u{1F4E4}"; break; } } #initDefaultPorts() { switch (this.#blockType) { case "trigger": this.#outputs = [{ name: "trigger", type: "trigger" }]; break; case "action": this.#inputs = [ { name: "trigger", type: "trigger" }, { name: "data", type: "any" }, ]; this.#outputs = [ { name: "done", type: "trigger" }, { name: "result", type: "any" }, ]; break; case "condition": this.#inputs = [ { name: "trigger", type: "trigger" }, { name: "value", type: "any" }, ]; this.#outputs = [ { name: "true", type: "trigger" }, { name: "false", type: "trigger" }, ]; break; case "output": this.#inputs = [ { name: "trigger", type: "trigger" }, { name: "data", type: "any" }, ]; break; } } #renderPorts() { const portsEl = this.#contentEl?.querySelector(".ports"); if (!portsEl) return; let html = ""; // Input ports for (const port of this.#inputs) { html += `
${this.#escapeHtml(port.name)} ${port.value !== undefined ? `${this.#formatValue(port.value)}` : ""}
`; } // Output ports for (const port of this.#outputs) { html += `
${port.value !== undefined ? `${this.#formatValue(port.value)}` : ""} ${this.#escapeHtml(port.name)}
`; } portsEl.innerHTML = html; // Add click handlers for ports portsEl.querySelectorAll(".port-handle").forEach((handle) => { handle.addEventListener("click", (e) => { e.stopPropagation(); const portName = (handle as HTMLElement).dataset.port; const portType = (handle as HTMLElement).dataset.type; const direction = (handle.closest(".port-row") as HTMLElement)?.dataset.direction; this.dispatchEvent( new CustomEvent("port-click", { detail: { port: portName, type: portType, direction, blockId: this.id }, }) ); }); }); } #formatValue(value: unknown): string { if (typeof value === "string") { return value.length > 12 ? `${value.slice(0, 12)}...` : value; } if (typeof value === "boolean") { return value ? "true" : "false"; } if (typeof value === "number") { return String(value); } if (value === null) { return "null"; } if (value === undefined) { return "undefined"; } return JSON.stringify(value).slice(0, 12); } #updateStatus() { if (this.#statusDot) { this.#statusDot.className = `status-dot ${this.#state}`; } if (this.#statusText) { const labels: Record = { idle: "Idle", running: "Running...", success: "Success", error: "Error", }; this.#statusText.textContent = labels[this.#state]; } } async #execute() { this.state = "running"; try { // Simulate execution await new Promise((resolve) => setTimeout(resolve, 1000)); // Dispatch execution event this.dispatchEvent( new CustomEvent("execute", { detail: { blockId: this.id, inputs: this.#inputs, config: this.#config, }, }) ); this.state = "success"; // Reset to idle after delay setTimeout(() => { this.state = "idle"; }, 2000); } catch (error) { this.state = "error"; console.error("Block execution failed:", error); } } setInputValue(portName: string, value: unknown) { const port = this.#inputs.find((p) => p.name === portName); if (port) { port.value = value; this.#renderPorts(); } } setOutputValue(portName: string, value: unknown) { const port = this.#outputs.find((p) => p.name === portName); if (port) { port.value = value; this.#renderPorts(); this.dispatchEvent( new CustomEvent("output-change", { detail: { port: portName, value, blockId: this.id }, }) ); } } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } override toJSON() { return { ...super.toJSON(), type: "folk-workflow-block", blockType: this.blockType, label: this.label, inputs: this.inputs, outputs: this.outputs, config: this.#config, }; } }