rspace-online/lib/folk-workflow-block.ts

582 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.

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 = "⚙";
#state: BlockState = "idle";
#inputs: Port[] = [];
#outputs: Port[] = [];
#config: Record<string, unknown> = {};
#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`
<div class="header">
<div class="header-left">
<span class="block-icon ${this.#blockType}">${this.#icon}</span>
<span class="block-label">${this.#escapeHtml(this.#label)}</span>
</div>
<div class="header-actions">
<button class="settings-btn" title="Settings">⚙</button>
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<div class="ports"></div>
</div>
<div class="status-bar">
<div class="status-indicator">
<span class="status-dot idle"></span>
<span class="status-text">Idle</span>
</div>
<button class="run-btn">▶ Run</button>
</div>
`;
// 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 = "⚡";
break;
case "action":
this.#icon = "⚙";
break;
case "condition":
this.#icon = "❓";
break;
case "output":
this.#icon = "📤";
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 += `
<div class="port-row input" data-port="${port.name}" data-direction="input">
<span class="port-handle ${port.type}" data-port="${port.name}" data-type="${port.type}"></span>
<span class="port-label">${this.#escapeHtml(port.name)}</span>
${port.value !== undefined ? `<span class="port-value">${this.#formatValue(port.value)}</span>` : ""}
</div>
`;
}
// Output ports
for (const port of this.#outputs) {
html += `
<div class="port-row output" data-port="${port.name}" data-direction="output">
${port.value !== undefined ? `<span class="port-value">${this.#formatValue(port.value)}</span>` : ""}
<span class="port-label">${this.#escapeHtml(port.name)}</span>
<span class="port-handle ${port.type}" data-port="${port.name}" data-type="${port.type}"></span>
</div>
`;
}
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<BlockState, string> = {
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,
};
}
}