mirror of
https://github.com/rio-labs/rio.git
synced 2026-01-23 22:11:45 -06:00
374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
import { ComponentId } from "../../dataModels";
|
|
import { ComponentBase, ComponentState } from "../componentBase";
|
|
import { pixelsPerRem } from "../../app";
|
|
import { NodeInputComponent } from "../nodeInput";
|
|
import { NodeOutputComponent } from "../nodeOutput";
|
|
import {
|
|
AugmentedConnectionState,
|
|
AugmentedNodeState,
|
|
GraphStore,
|
|
NodeState,
|
|
} from "./graphStore";
|
|
import { DraggingConnectionStrategy } from "./draggingConnectionStrategy";
|
|
import { DraggingSelectionStrategy } from "./draggingSelectionStrategy";
|
|
import { DraggingNodesStrategy } from "./draggingNodesStrategy";
|
|
import {
|
|
devel_getComponentByKey,
|
|
getNodeComponentFromElement,
|
|
getNodeFromPort,
|
|
getPortFromCircle,
|
|
makeConnectionElement,
|
|
updateConnectionFromObject,
|
|
} from "./utils";
|
|
import { componentsByElement, componentsById } from "../../componentManagement";
|
|
import { CuttingConnectionStrategy } from "./cuttingConnectionStrategy";
|
|
|
|
export type GraphEditorState = ComponentState & {
|
|
_type_: "GraphEditor-builtin";
|
|
children?: ComponentId[];
|
|
};
|
|
|
|
export class GraphEditorComponent extends ComponentBase {
|
|
declare state: Required<GraphEditorState>;
|
|
|
|
private htmlChild: HTMLElement;
|
|
public svgChild: SVGSVGElement;
|
|
|
|
public selectionRect: HTMLElement;
|
|
|
|
public graphStore: GraphStore = new GraphStore();
|
|
|
|
/// All currently selected nodes, if any. This is intentionally private,
|
|
/// because adding & removing nodes also needs to update their styles
|
|
/// accordingly.
|
|
private selectedNodes: Set<ComponentId> = new Set();
|
|
|
|
/// When clicking & dragging a variety of things can happen based on the
|
|
/// selection, mouse button, position and phase of the moon. This strategy
|
|
/// object is used in lieu of if-else chains.
|
|
private dragStrategy:
|
|
| CuttingConnectionStrategy
|
|
| DraggingConnectionStrategy
|
|
| DraggingSelectionStrategy
|
|
| DraggingNodesStrategy
|
|
| null = null;
|
|
|
|
createElement(): HTMLElement {
|
|
// Create the HTML
|
|
let element = document.createElement("div");
|
|
element.classList.add("rio-graph-editor");
|
|
|
|
this.svgChild = document.createElementNS(
|
|
"http://www.w3.org/2000/svg",
|
|
"svg"
|
|
);
|
|
element.appendChild(this.svgChild);
|
|
|
|
this.htmlChild = document.createElement("div");
|
|
element.appendChild(this.htmlChild);
|
|
|
|
this.selectionRect = document.createElement("div");
|
|
this.selectionRect.classList.add("rio-graph-editor-selection");
|
|
this.selectionRect.style.opacity = "0";
|
|
this.htmlChild.appendChild(this.selectionRect);
|
|
|
|
// Listen for drag events. The exact nature of the drag event is
|
|
// determined by the current drag strategy.
|
|
this.addDragHandler({
|
|
element: element,
|
|
onStart: this._onDragStart.bind(this),
|
|
onMove: this._onDragMove.bind(this),
|
|
onEnd: this._onDragEnd.bind(this),
|
|
capturing: true,
|
|
});
|
|
|
|
return element;
|
|
}
|
|
|
|
updateElement(
|
|
deltaState: GraphEditorState,
|
|
latentComponents: Set<ComponentBase>
|
|
): void {
|
|
super.updateElement(deltaState, latentComponents);
|
|
|
|
// Spawn some children for testing
|
|
if (deltaState.children !== undefined) {
|
|
// Spawn all nodes
|
|
for (let ii = 0; ii < deltaState.children.length; ii++) {
|
|
let childId = deltaState.children[ii];
|
|
|
|
let rawNode: NodeState = {
|
|
id: childId,
|
|
title: `Node ${ii}`,
|
|
left: 10 + ii * 10,
|
|
top: 10 + ii * 10,
|
|
};
|
|
let augmentedNode = this._makeNode(latentComponents, rawNode);
|
|
this.graphStore.addNode(augmentedNode);
|
|
}
|
|
|
|
// Connect some of them
|
|
requestAnimationFrame(() => {
|
|
let fromPortComponent = devel_getComponentByKey("out_1");
|
|
let toPortComponent = devel_getComponentByKey("in_1");
|
|
|
|
let connectionElement = makeConnectionElement();
|
|
this.svgChild.appendChild(connectionElement);
|
|
|
|
let augmentedConn: AugmentedConnectionState = {
|
|
// @ts-ignore
|
|
fromNode: deltaState.children[1],
|
|
fromPort: fromPortComponent.id,
|
|
// @ts-ignore
|
|
toNode: deltaState.children[2],
|
|
toPort: toPortComponent.id,
|
|
element: connectionElement,
|
|
};
|
|
this.graphStore.addConnection(augmentedConn);
|
|
updateConnectionFromObject(this, augmentedConn);
|
|
});
|
|
}
|
|
}
|
|
|
|
_onDragStart(event: PointerEvent): boolean {
|
|
// If a drag strategy is already active, ignore this event
|
|
if (this.dragStrategy !== null) {
|
|
return false;
|
|
}
|
|
|
|
// FIXME: A lot of the strategies below are checking for the left mouse
|
|
// button. This is wrong on touch and pointer devices.
|
|
const isLeftClick = event.button === 0;
|
|
const isHoldingCtrl = event.ctrlKey;
|
|
|
|
let targetElement = event.target as HTMLElement;
|
|
console.assert(
|
|
targetElement !== null,
|
|
"Pointer event has no target element"
|
|
);
|
|
|
|
// Find an applicable strategy
|
|
//
|
|
// Case: Cutting connections
|
|
if (isLeftClick && isHoldingCtrl) {
|
|
// Add a line to the SVG. It provides feedback to the user as to
|
|
// which connections will be cut.
|
|
let lineElement = document.createElementNS(
|
|
"http://www.w3.org/2000/svg",
|
|
"path"
|
|
);
|
|
lineElement.setAttribute("stroke", "var(--rio-global-danger-bg)");
|
|
lineElement.setAttribute("stroke-width", "0.15rem");
|
|
lineElement.setAttribute("fill", "none");
|
|
this.svgChild.appendChild(lineElement);
|
|
|
|
// Store the strategy
|
|
const editorRect = this.element.getBoundingClientRect();
|
|
|
|
this.dragStrategy = new CuttingConnectionStrategy(
|
|
event.clientX - editorRect.left,
|
|
event.clientY - editorRect.top,
|
|
lineElement
|
|
);
|
|
|
|
// Accept the drag
|
|
return true;
|
|
}
|
|
|
|
// Case: New connection from a port
|
|
if (
|
|
isLeftClick &&
|
|
targetElement.classList.contains("rio-graph-editor-port-circle")
|
|
) {
|
|
let portComponent = getPortFromCircle(targetElement);
|
|
|
|
// Add a new connection to the SVG
|
|
let connectionElement = makeConnectionElement();
|
|
this.svgChild.appendChild(connectionElement);
|
|
|
|
// Store the strategy
|
|
this.dragStrategy = new DraggingConnectionStrategy(
|
|
getNodeFromPort(portComponent).id,
|
|
portComponent.id,
|
|
connectionElement
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
// Case: Move around the selected nodes
|
|
if (
|
|
isLeftClick &&
|
|
targetElement.classList.contains("rio-graph-editor-node-header")
|
|
) {
|
|
// Determine which nodes to move
|
|
let targetedComponent = getNodeComponentFromElement(
|
|
targetElement.parentElement!
|
|
);
|
|
let targetedNodeState = this.graphStore.getNodeById(
|
|
targetedComponent.id
|
|
);
|
|
let nodesToMove: AugmentedNodeState[] = [];
|
|
|
|
// If this node is selected, move the entire selection
|
|
if (this.selectedNodes.has(targetedNodeState.id)) {
|
|
for (const node of this.getSelectedNodes()) {
|
|
nodesToMove.push(node);
|
|
}
|
|
}
|
|
// Otherwise make this node the sole selection
|
|
else {
|
|
this.deselectAllNodes();
|
|
this.selectNode(targetedNodeState);
|
|
nodesToMove.push(targetedNodeState);
|
|
}
|
|
|
|
// Make sure all selected nodes are on top
|
|
for (let node of nodesToMove) {
|
|
node.element.style.zIndex = "1";
|
|
}
|
|
|
|
// Store the strategy
|
|
this.dragStrategy = new DraggingNodesStrategy(nodesToMove);
|
|
|
|
// Accept the drag
|
|
return true;
|
|
}
|
|
|
|
// Case: Rectangle selection
|
|
if (isLeftClick && targetElement === this.htmlChild) {
|
|
// Deselect any previously selected nodes
|
|
this.deselectAllNodes();
|
|
|
|
// Store the strategy
|
|
let editorRect = this.element.getBoundingClientRect();
|
|
this.dragStrategy = new DraggingSelectionStrategy(
|
|
event.clientX - editorRect.left,
|
|
event.clientY - editorRect.top
|
|
);
|
|
|
|
// Initialize the selection rectangle
|
|
this.selectionRect.style.opacity = "1";
|
|
this.dragStrategy.updateSelectionRectangle(this, event);
|
|
|
|
// Accept the drag
|
|
return true;
|
|
}
|
|
|
|
// No strategy found
|
|
console.assert(
|
|
this.dragStrategy === null,
|
|
"A drag strategy was found, but the function hasn't returned"
|
|
);
|
|
return false;
|
|
}
|
|
|
|
_onDragMove(event: PointerEvent): void {
|
|
// If no drag strategy is active, there's nothing to do
|
|
if (this.dragStrategy === null) {
|
|
return;
|
|
}
|
|
|
|
// Delegate to the drag strategy
|
|
this.dragStrategy.onDragMove(this, event);
|
|
}
|
|
|
|
_onDragEnd(event: PointerEvent): void {
|
|
// If no drag strategy is active, there's nothing to do
|
|
if (this.dragStrategy === null) {
|
|
return;
|
|
}
|
|
|
|
// Delegate to the drag strategy
|
|
this.dragStrategy.onDragEnd(this, event);
|
|
|
|
// Clear the drag strategy
|
|
this.dragStrategy = null;
|
|
}
|
|
|
|
/// Creates a node element and adds it to the HTML child. Returns the node
|
|
/// state, augmented with the HTML element.
|
|
_makeNode(
|
|
latentComponents: Set<ComponentBase>,
|
|
nodeState: NodeState
|
|
): AugmentedNodeState {
|
|
// Build the node HTML
|
|
const nodeElement = document.createElement("div");
|
|
nodeElement.classList.add(
|
|
"rio-graph-editor-node",
|
|
"rio-switcheroo-neutral"
|
|
);
|
|
nodeElement.style.left = `${nodeState.left}rem`;
|
|
nodeElement.style.top = `${nodeState.top}rem`;
|
|
this.htmlChild.appendChild(nodeElement);
|
|
|
|
// Header
|
|
const header = document.createElement("div");
|
|
header.classList.add("rio-graph-editor-node-header");
|
|
header.innerText = nodeState.title;
|
|
nodeElement.appendChild(header);
|
|
|
|
// Body
|
|
const nodeBody = document.createElement("div");
|
|
nodeBody.classList.add("rio-graph-editor-node-body");
|
|
nodeElement.appendChild(nodeBody);
|
|
|
|
// Content
|
|
this.replaceOnlyChild(latentComponents, nodeState.id, nodeBody);
|
|
|
|
// Build the augmented node state
|
|
let augmentedNode = { ...nodeState } as AugmentedNodeState;
|
|
augmentedNode.element = nodeElement;
|
|
|
|
return augmentedNode;
|
|
}
|
|
|
|
/// Adds the given node to the set of selected nodes and updates its style
|
|
/// appropriately.
|
|
///
|
|
/// Does nothing if the node is already selected.
|
|
public selectNode(node: AugmentedNodeState): void {
|
|
// Add to the set of selected nodes
|
|
this.selectedNodes.add(node.id);
|
|
|
|
// Update the CSS
|
|
node.element.classList.add("rio-graph-editor-selected-node");
|
|
}
|
|
|
|
/// Removes the given node from the set of selected nodes and updates its
|
|
/// style appropriately.
|
|
///
|
|
/// Does nothing if the node is not selected.
|
|
public deselectNode(node: AugmentedNodeState): void {
|
|
// Remove from the set of selected nodes
|
|
this.selectedNodes.delete(node.id);
|
|
|
|
// Update the CSS
|
|
node.element.classList.remove("rio-graph-editor-selected-node");
|
|
}
|
|
|
|
/// Deselects all nodes.
|
|
public deselectAllNodes(): void {
|
|
let selectedNodes = Array.from(this.getSelectedNodes());
|
|
|
|
for (let node of selectedNodes) {
|
|
this.deselectNode(node);
|
|
}
|
|
}
|
|
|
|
/// Returns an iterable over all selected nodes.
|
|
public getSelectedNodes(): Iterable<AugmentedNodeState> {
|
|
let result: AugmentedNodeState[] = [];
|
|
|
|
for (let nodeId of this.selectedNodes) {
|
|
let node = this.graphStore.getNodeById(nodeId);
|
|
|
|
if (node !== undefined) {
|
|
result.push(node);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|