tscircuit/ti

ti tscircuit library

Version
1.0.195
License
unset
Stars
0

system-block-ui/src/editor/create-system-block-editor.tsx

import {
  applyEdgeChanges,
  applyNodeChanges,
  Background,
  BackgroundVariant,
  type Connection,
  Controls,
  type EdgeChange,
  type FinalConnectionState,
  type NodeChange,
  ReactFlow,
  type ReactFlowInstance,
} from "@xyflow/react";
import { useSyncExternalStore } from "react";
import { createRoot, type Root } from "react-dom/client";

import "@xyflow/react/dist/style.css";
import "./react-flow-editor.css";

import {
  assertResolvedConnectionPortsAvailable,
  getUnavailableConnectionPorts,
  tryResolveConnection,
} from "../model/resolve-connection";
import {
  type BlockInstance,
  type ConnectionKind,
  type ConnectionKindInput,
  ConnectionResolutionError,
  type LogicalConnection,
  type ResolvedConnection,
  type SubcircuitDefinition,
} from "../model/types";
import { SemanticEdge } from "./SemanticEdge";
import { SystemBlockNodeView } from "./SystemBlockNodeView";
import {
  type ConnectBlocksOptions,
  type CreateSystemBlockEditorOptions,
  createSystemBlockConnection,
  createSystemBlockNode,
  type GraphChangeListener,
  normalizeConnectionKind,
  type RejectedConnection,
  type ReplaceBlockDefinitionResult,
  type SystemBlockConnection,
  type SystemBlockGraphSnapshot,
  type SystemBlockInitialGraph,
  type SystemBlockNode,
  type SystemBlockPosition,
  type SystemBlockRenderState,
} from "./types";

const NODE_TYPES = { systemBlock: SystemBlockNodeView };
const EDGE_TYPES = { semantic: SemanticEdge };

let fallbackId = 0;

function createId(prefix: string): string {
  if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
    return `${prefix}_${crypto.randomUUID()}`;
  }
  fallbackId += 1;
  return `${prefix}_${Date.now().toString(36)}_${fallbackId.toString(36)}`;
}

function cloneBlock(block: BlockInstance): BlockInstance {
  return {
    ...block,
    position: block.position ? { ...block.position } : undefined,
  };
}

function cloneResolved(resolved: ResolvedConnection): ResolvedConnection {
  return {
    ...resolved,
    traces: resolved.traces.map((trace) => ({ ...trace })),
  };
}

function missingBlockError(id: string): ConnectionResolutionError {
  return new ConnectionResolutionError(
    "UNKNOWN_BLOCK",
    `No system block with id "${id}" exists in the editor.`,
    { blockId: id },
  );
}

function parseHandle(
  handle: string | null,
  expectedSide: "input" | "output",
): ConnectionKind | undefined {
  const match = handle?.match(/^(input|output):(power|data)$/);
  if (!match || match[1] !== expectedSide) return undefined;
  return match[2] as ConnectionKind;
}

type ResolutionAttempt =
  | {
      ok: true;
      source: SystemBlockNode;
      target: SystemBlockNode;
      logical: LogicalConnection;
      resolved: ResolvedConnection;
    }
  | { ok: false; rejection: RejectedConnection };

function SystemBlockFlow({
  controller,
}: {
  controller: SystemBlockEditorController;
}) {
  const state = useSyncExternalStore(
    controller.subscribeRender,
    controller.getRenderState,
    controller.getRenderState,
  );

  return (
    <ReactFlow<SystemBlockNode, SystemBlockConnection>
      className="system-block-flow"
      colorMode="light"
      connectionLineStyle={{ stroke: "#7b8794", strokeWidth: 2.5 }}
      connectionRadius={28}
      deleteKeyCode={["Backspace", "Delete"]}
      edges={state.edges}
      edgesReconnectable={false}
      edgeTypes={EDGE_TYPES}
      elevateEdgesOnSelect
      fitViewOptions={{ maxZoom: 1.2, padding: 0.18 }}
      isValidConnection={controller.isValidConnection}
      minZoom={0.3}
      nodeTypes={NODE_TYPES}
      nodes={state.nodes}
      nodesConnectable
      onConnect={controller.handleConnect}
      onConnectEnd={controller.handleConnectEnd}
      onEdgesChange={controller.handleEdgesChange}
      onInit={controller.attachFlowInstance}
      onNodesChange={controller.handleNodesChange}
      onNodeClick={(event, node) => {
        const target = event.target;
        if (
          !(target instanceof Element) ||
          !target.closest(".system-block-card-action")
        )
          return;
        controller.requestBlockSelection(node.id);
      }}
      panOnScroll
      proOptions={{ hideAttribution: false }}
      selectionOnDrag
      snapGrid={[18, 18]}
      snapToGrid
    >
      <Background
        color="#c8ced7"
        gap={18}
        id="system-block-grid"
        size={1.15}
        variant={BackgroundVariant.Dots}
      />
      <Controls
        fitViewOptions={{ maxZoom: 1.2, padding: 0.18 }}
        position="bottom-left"
        showInteractive={false}
      />
    </ReactFlow>
  );
}

/** Imperative adapter preserving the original App-facing editor contract. */
export class SystemBlockEditorController {
  private readonly definitions = new Map<string, SubcircuitDefinition>();
  private readonly graphListeners = new Set<GraphChangeListener>();
  private readonly renderListeners = new Set<() => void>();
  private readonly root: Root;
  private readonly ready: Promise<void>;
  private resolveReady!: () => void;
  private flowInstance?: ReactFlowInstance<
    SystemBlockNode,
    SystemBlockConnection
  >;
  private nodes: SystemBlockNode[] = [];
  private edges: SystemBlockConnection[] = [];
  private renderState: SystemBlockRenderState = {
    revision: 0,
    nodes: [],
    edges: [],
  };
  private destroyed = false;
  private graphNotificationQueued = false;
  private notificationsSuspended = 0;
  private lastConnectionRejection?: RejectedConnection;

  private constructor(
    container: HTMLElement,
    private readonly options: CreateSystemBlockEditorOptions,
  ) {
    for (const definition of options.catalog ?? []) {
      this.definitions.set(definition.id, definition);
    }
    if (options.onGraphChange) {
      this.graphListeners.add(options.onGraphChange);
    }

    this.ready = new Promise<void>((resolve) => {
      this.resolveReady = resolve;
    });
    this.root = createRoot(container);
    this.root.render(<SystemBlockFlow controller={this} />);
  }

  static async create(
    container: HTMLElement,
    options: CreateSystemBlockEditorOptions = {},
  ): Promise<SystemBlockEditorController> {
    const controller = new SystemBlockEditorController(container, options);
    await controller.ready;
    if (options.initialGraph) {
      await controller.loadInitialGraph(options.initialGraph);
    } else {
      controller.scheduleGraphChange();
    }
    return controller;
  }

  readonly subscribeRender = (listener: () => void): (() => void) => {
    this.renderListeners.add(listener);
    return () => this.renderListeners.delete(listener);
  };

  readonly getRenderState = (): SystemBlockRenderState => this.renderState;

  readonly attachFlowInstance = (
    instance: ReactFlowInstance<SystemBlockNode, SystemBlockConnection>,
  ): void => {
    this.flowInstance = instance;
    this.resolveReady();
  };

  readonly handleNodesChange = (
    changes: NodeChange<SystemBlockNode>[],
  ): void => {
    if (this.destroyed || changes.length === 0) return;
    const previousById = new Map(this.nodes.map((node) => [node.id, node]));
    const removed = new Set(
      changes
        .filter((change) => change.type === "remove")
        .map((change) => change.id),
    );

    this.nodes = applyNodeChanges(changes, this.nodes).map((node) => {
      const previous = previousById.get(node.id);
      if (
        previous &&
        (previous.position.x !== node.position.x ||
          previous.position.y !== node.position.y)
      ) {
        return {
          ...node,
          data: {
            ...node.data,
            block: {
              ...node.data.block,
              position: { ...node.position },
            },
          },
        };
      }
      return node;
    });

    if (removed.size > 0) {
      this.edges = this.edges.filter(
        (edge) => !removed.has(edge.source) && !removed.has(edge.target),
      );
    }
    const graphChanged =
      removed.size > 0 ||
      changes.some(
        (change) => change.type === "position" && change.dragging !== true,
      );
    this.publishRender(graphChanged);
  };

  readonly handleEdgesChange = (
    changes: EdgeChange<SystemBlockConnection>[],
  ): void => {
    if (this.destroyed || changes.length === 0) return;
    this.edges = applyEdgeChanges(changes, this.edges);
    this.publishRender(changes.some((change) => change.type === "remove"));
  };

  readonly isValidConnection = (
    connection: SystemBlockConnection | Connection,
  ): boolean => {
    const attempt = this.resolveFlowConnection(connection);
    this.lastConnectionRejection = attempt.ok ? undefined : attempt.rejection;
    return attempt.ok;
  };

  readonly handleConnect = (connection: Connection): void => {
    const sourceKind = parseHandle(connection.sourceHandle, "output");
    if (!sourceKind) return;
    this.lastConnectionRejection = undefined;
    void this.connect({
      id: createId("connection"),
      fromBlockId: connection.source,
      toBlockId: connection.target,
      kind: sourceKind,
    }).catch(() => {
      // connect() reports semantic failures through onConnectionRejected.
      // React Flow owns this event callback, so keep its promise rejection local.
    });
  };

  readonly handleConnectEnd = (
    _event: MouseEvent | TouchEvent,
    state: FinalConnectionState,
  ): void => {
    if (state.isValid !== true && this.lastConnectionRejection) {
      this.reject(this.lastConnectionRejection);
    }
    this.lastConnectionRejection = undefined;
  };

  readonly requestBlockSelection = (blockId: string): void => {
    if (this.destroyed) return;
    this.options.onBlockSelectionRequested?.(blockId);
  };

  async addBlock(
    definition: SubcircuitDefinition,
    position: SystemBlockPosition = { x: 0, y: 0 },
    instance: Partial<BlockInstance> = {},
  ): Promise<SystemBlockNode> {
    this.assertAlive();
    this.definitions.set(definition.id, definition);
    const block: BlockInstance = {
      ...instance,
      id: instance.id ?? createId("block"),
      definitionId: definition.id,
      position: { ...position },
    };
    if (this.nodes.some((node) => node.id === block.id)) {
      throw new Error(`A system block with id "${block.id}" already exists.`);
    }

    const node = createSystemBlockNode(block, definition);
    this.nodes = [...this.nodes, node];
    this.publishRender(true);
    return node;
  }

  clientToCanvas(point: SystemBlockPosition): SystemBlockPosition {
    this.assertAlive();
    if (!this.flowInstance) {
      throw new Error("The system block canvas is not ready.");
    }
    return this.flowInstance.screenToFlowPosition(point, {
      snapGrid: [18, 18],
      snapToGrid: true,
    });
  }

  addBlockAtClientPoint(
    definition: SubcircuitDefinition,
    clientPoint: SystemBlockPosition,
    instance: Partial<BlockInstance> = {},
  ): Promise<SystemBlockNode> {
    return this.addBlock(
      definition,
      this.clientToCanvas(clientPoint),
      instance,
    );
  }

  async connect(logical: LogicalConnection): Promise<SystemBlockConnection> {
    this.assertAlive();
    const attempt = this.resolveLogicalConnection(logical);
    if (!attempt.ok) {
      this.reject(attempt.rejection);
      throw attempt.rejection.error;
    }
    if (this.edges.some((edge) => edge.id === logical.id)) {
      throw new Error(`A connection with id "${logical.id}" already exists.`);
    }

    const edge = createSystemBlockConnection(
      attempt.source,
      attempt.target,
      attempt.logical,
      attempt.resolved,
    );
    this.edges = [...this.edges, edge];
    this.publishRender(true);
    return edge;
  }

  connectBlocks(
    fromBlockId: string,
    toBlockId: string,
    kind: ConnectionKindInput,
    options: ConnectBlocksOptions = {},
  ): Promise<SystemBlockConnection> {
    return this.connect({
      id: options.id ?? createId("connection"),
      fromBlockId,
      toBlockId,
      kind,
      protocol: options.protocol,
    });
  }

  async removeBlock(id: string): Promise<boolean> {
    this.assertAlive();
    if (!this.nodes.some((node) => node.id === id)) return false;
    this.nodes = this.nodes.filter((node) => node.id !== id);
    this.edges = this.edges.filter(
      (edge) => edge.source !== id && edge.target !== id,
    );
    this.publishRender(true);
    return true;
  }

  async removeConnection(id: string): Promise<boolean> {
    this.assertAlive();
    if (!this.edges.some((edge) => edge.id === id)) return false;
    this.edges = this.edges.filter((edge) => edge.id !== id);
    this.publishRender(true);
    return true;
  }

  /** Replace one placed subcircuit while keeping every compatible connection. */
  replaceBlockDefinition(
    blockId: string,
    definition: SubcircuitDefinition,
  ): ReplaceBlockDefinitionResult {
    this.assertAlive();
    if (definition.canInstantiate === false) {
      throw new Error(
        definition.warning ?? "This subcircuit cannot be instantiated.",
      );
    }

    const current = this.nodes.find((node) => node.id === blockId);
    if (!current) throw missingBlockError(blockId);

    this.definitions.set(definition.id, definition);
    const nextBlock: BlockInstance = {
      ...current.data.block,
      definitionId: definition.id,
      position: { ...current.position },
    };
    const replacement = {
      ...createSystemBlockNode(nextBlock, definition),
      selected: current.selected,
    };
    const nextNodes = this.nodes.map((node) =>
      node.id === blockId ? replacement : node,
    );
    const nextEdges: SystemBlockConnection[] = [];
    const removedConnections: LogicalConnection[] = [];

    for (const edge of this.edges) {
      const attempt = this.resolveLogicalConnection(
        edge.data.logical,
        nextNodes,
        nextEdges,
      );
      if (!attempt.ok) {
        removedConnections.push({ ...edge.data.logical });
        continue;
      }
      nextEdges.push(
        createSystemBlockConnection(
          attempt.source,
          attempt.target,
          attempt.logical,
          attempt.resolved,
        ),
      );
    }

    this.nodes = nextNodes;
    this.edges = nextEdges;
    this.publishRender(true);

    return {
      block: cloneBlock(nextBlock),
      removedConnections,
      retainedConnectionCount: nextEdges.length,
    };
  }

  async clear(): Promise<boolean> {
    this.assertAlive();
    this.nodes = [];
    this.edges = [];
    this.publishRender(true);
    return true;
  }

  /** Atomically replace the graph and automatically resolve every edge. */
  async loadInitialGraph(graph: SystemBlockInitialGraph): Promise<void> {
    this.assertAlive();
    const nextNodes: SystemBlockNode[] = [];
    const seenIds = new Set<string>();
    for (const block of graph.blocks) {
      if (seenIds.has(block.id)) {
        throw new Error(`Duplicate system block id "${block.id}".`);
      }
      seenIds.add(block.id);
      const definition = this.definitions.get(block.definitionId);
      if (!definition) {
        throw new ConnectionResolutionError(
          "UNKNOWN_SUBCIRCUIT",
          `No catalog definition exists for "${block.definitionId}".`,
          { blockId: block.id, definitionId: block.definitionId },
        );
      }
      nextNodes.push(createSystemBlockNode(block, definition));
    }

    const nextEdges: SystemBlockConnection[] = [];
    for (const logical of graph.connections) {
      const attempt = this.resolveLogicalConnection(
        logical,
        nextNodes,
        nextEdges,
      );
      if (!attempt.ok) throw attempt.rejection.error;
      nextEdges.push(
        createSystemBlockConnection(
          attempt.source,
          attempt.target,
          attempt.logical,
          attempt.resolved,
        ),
      );
    }

    this.notificationsSuspended += 1;
    try {
      this.nodes = nextNodes;
      this.edges = nextEdges;
      this.publishRender(false);
    } finally {
      this.notificationsSuspended -= 1;
      this.scheduleGraphChange();
    }
  }

  getSnapshot(): SystemBlockGraphSnapshot {
    return {
      blocks: this.nodes.map((node) =>
        cloneBlock({
          ...node.data.block,
          position: { ...node.position },
        }),
      ),
      connections: this.edges.map((edge) => ({ ...edge.data.logical })),
      resolvedConnections: this.edges.map((edge) =>
        cloneResolved(edge.data.resolved),
      ),
    };
  }

  subscribe(listener: GraphChangeListener, emitCurrent = true): () => void {
    this.assertAlive();
    this.graphListeners.add(listener);
    if (emitCurrent) listener(this.getSnapshot());
    return () => this.graphListeners.delete(listener);
  }

  async zoomToFit(): Promise<void> {
    this.assertAlive();
    if (this.nodes.length === 0 || !this.flowInstance) return;
    // Controlled nodes reach React Flow's measured-node store on the next
    // frame. Waiting here keeps reset -> fit deterministic for App callers.
    await new Promise<void>((resolve) =>
      requestAnimationFrame(() => resolve()),
    );
    await this.flowInstance.fitView({
      duration: 280,
      maxZoom: 1.2,
      padding: 0.18,
    });
  }

  destroy(): void {
    if (this.destroyed) return;
    this.destroyed = true;
    this.graphListeners.clear();
    this.renderListeners.clear();
    this.flowInstance = undefined;
    this.root.unmount();
  }

  private resolveFlowConnection(
    connection: SystemBlockConnection | Connection,
  ): ResolutionAttempt {
    const sourceKind = parseHandle(connection.sourceHandle ?? null, "output");
    const targetKind = parseHandle(connection.targetHandle ?? null, "input");
    if (!sourceKind || sourceKind !== targetKind) {
      return this.failedResolution(
        new ConnectionResolutionError(
          "NO_COMPATIBLE_PORTS",
          "Connect Power to Power or Data to Data.",
          { sourceKind, targetKind },
        ),
      );
    }

    return this.resolveLogicalConnection({
      id: createId("connection-preview"),
      fromBlockId: connection.source,
      toBlockId: connection.target,
      kind: sourceKind,
    });
  }

  private resolveLogicalConnection(
    logical: LogicalConnection,
    nodes: readonly SystemBlockNode[] = this.nodes,
    edges: readonly SystemBlockConnection[] = this.edges,
  ): ResolutionAttempt {
    const source = nodes.find((node) => node.id === logical.fromBlockId);
    const target = nodes.find((node) => node.id === logical.toBlockId);
    if (!source) {
      return this.failedResolution(
        missingBlockError(logical.fromBlockId),
        logical,
      );
    }
    if (!target) {
      return this.failedResolution(
        missingBlockError(logical.toBlockId),
        logical,
      );
    }

    const kind = normalizeConnectionKind(logical.kind);
    const sourceInterface = source.data.interfaces.find(
      (item) => item.kind === kind && item.outputPorts.length > 0,
    );
    const targetInterface = target.data.interfaces.find(
      (item) => item.kind === kind && item.inputPorts.length > 0,
    );
    if (!sourceInterface || !targetInterface) {
      return this.failedResolution(
        new ConnectionResolutionError(
          "NO_COMPATIBLE_PORTS",
          `${source.data.definition.title} cannot provide ${kind} to ${target.data.definition.title}.`,
          { fromBlockId: source.id, toBlockId: target.id, kind },
        ),
        logical,
      );
    }

    const blocks = nodes.map((node) => ({
      ...node.data.block,
      position: { ...node.position },
    }));
    const catalog = [...this.definitions.values()];
    const existingResolved = edges.map((edge) => edge.data.resolved);
    const result = tryResolveConnection({
      connectionId: logical.id,
      kind,
      protocol: logical.protocol,
      from: { block: source.data.block, definition: source.data.definition },
      to: { block: target.data.block, definition: target.data.definition },
      unavailablePorts: getUnavailableConnectionPorts(
        existingResolved,
        blocks,
        catalog,
      ),
    });
    if (!result.ok) return this.failedResolution(result.error, logical);

    const duplicate = edges.find(
      (edge) =>
        edge.source === source.id &&
        edge.target === target.id &&
        edge.data.resolved.fromPortId === result.value.fromPortId &&
        edge.data.resolved.toPortId === result.value.toPortId,
    );
    if (duplicate) {
      return this.failedResolution(
        new ConnectionResolutionError(
          "NO_COMPATIBLE_PORTS",
          "Those resolved interfaces are already connected.",
          { existingConnectionId: duplicate.id },
        ),
        logical,
      );
    }

    try {
      assertResolvedConnectionPortsAvailable(
        result.value,
        existingResolved,
        blocks,
        catalog,
      );
    } catch (error) {
      if (error instanceof ConnectionResolutionError) {
        return this.failedResolution(error, logical);
      }
      throw error;
    }

    return {
      ok: true,
      source,
      target,
      logical: { ...logical, kind },
      resolved: result.value,
    };
  }

  private failedResolution(
    error: ConnectionResolutionError,
    connection?: LogicalConnection,
  ): ResolutionAttempt {
    return { ok: false, rejection: { error, connection } };
  }

  private reject(rejection: RejectedConnection): void {
    this.options.onConnectionRejected?.(rejection);
  }

  private publishRender(graphChanged: boolean): void {
    this.renderState = {
      revision: this.renderState.revision + 1,
      nodes: this.nodes,
      edges: this.edges,
    };
    for (const listener of this.renderListeners) listener();
    if (graphChanged) this.scheduleGraphChange();
  }

  private scheduleGraphChange(): void {
    if (
      this.destroyed ||
      this.notificationsSuspended > 0 ||
      this.graphNotificationQueued
    ) {
      return;
    }
    this.graphNotificationQueued = true;
    queueMicrotask(() => {
      this.graphNotificationQueued = false;
      if (this.destroyed || this.notificationsSuspended > 0) return;
      const snapshot = this.getSnapshot();
      for (const listener of this.graphListeners) listener(snapshot);
    });
  }

  private assertAlive(): void {
    if (this.destroyed) {
      throw new Error("The system block editor was destroyed.");
    }
  }
}

export function createSystemBlockEditor(
  container: HTMLElement,
  options: CreateSystemBlockEditorOptions = {},
): Promise<SystemBlockEditorController> {
  return SystemBlockEditorController.create(container, options);
}