WIP: refactor client-side graph editor code

This commit is contained in:
Jakob Pinterits
2024-11-13 20:21:59 +01:00
parent 7943018a79
commit e983e96013
8 changed files with 751 additions and 594 deletions
+1 -1
View File
@@ -22,7 +22,6 @@ import { ErrorPlaceholderComponent } from "./components/errorPlaceholder";
import { FilePickerAreaComponent } from "./components/filePickerArea";
import { FlowComponent as FlowContainerComponent } from "./components/flowContainer";
import { FundamentalRootComponent } from "./components/fundamentalRootComponent";
import { GraphEditorComponent } from "./components/graphEditor";
import { GridComponent } from "./components/grid";
import { HeadingListItemComponent } from "./components/headingListItem";
import { HighLevelComponent as HighLevelComponent } from "./components/highLevelComponent";
@@ -64,6 +63,7 @@ import { TextInputComponent } from "./components/textInput";
import { ThemeContextSwitcherComponent } from "./components/themeContextSwitcher";
import { TooltipComponent } from "./components/tooltip";
import { WebsiteComponent } from "./components/website";
import { GraphEditorComponent } from "./components/graphEditor/graphEditor";
const COMPONENT_CLASSES = {
"Button-builtin": ButtonComponent,
-593
View File
@@ -1,593 +0,0 @@
import { ComponentId } from "../dataModels";
import { ComponentBase, ComponentState } from "./componentBase";
import { pixelsPerRem } from "../app";
import { componentsByElement, componentsById } from "../componentManagement";
import { NodeInputComponent } from "./nodeInput";
import { NodeOutputComponent } from "./nodeOutput";
export type GraphEditorState = ComponentState & {
_type_: "GraphEditor-builtin";
children?: ComponentId[];
};
type NodeState = {
id: ComponentId;
title: string;
left: number;
top: number;
};
type ConnectionState = {
fromNode: ComponentId;
fromPort: ComponentId;
toNode: ComponentId;
toPort: ComponentId;
};
type AugmentedNodeState = NodeState & {
element: HTMLElement;
};
type AugmentedConnectionState = ConnectionState & {
element: SVGPathElement;
};
/// A connection that is only connected to a single port, with the other end
/// currently being dragged by the user.
type LatentConnection = {
fixedNodeId: ComponentId;
fixedPortId: ComponentId;
element: SVGPathElement;
};
class GraphStore {
/// Nodes are identified by their component ID. This maps component IDs to
/// the stored information about the node.
private idsToNodes: Map<number, AugmentedNodeState>;
/// This maps node IDs to connections that are connected to them.
private nodeIdsToConnections: Map<number, AugmentedConnectionState[]>;
constructor() {
this.idsToNodes = new Map();
this.nodeIdsToConnections = new Map();
}
/// Add a node to the store. The node must not already exist in the store.
addNode(node: AugmentedNodeState): void {
console.assert(
!this.idsToNodes.has(node.id),
`Cannot add node to GraphStore because of duplicate ID ${node.id}`
);
this.idsToNodes.set(node.id, node);
}
/// Returns an array of all nodes in the store.
allNodes(): AugmentedNodeState[] {
return Array.from(this.idsToNodes.values());
}
/// Get a node by its ID. Throws an error if the node does not exist.
getNodeById(nodeId: number): AugmentedNodeState {
let node = this.idsToNodes.get(nodeId);
if (node === undefined) {
throw new Error(`NodeEditor has no node with id ${nodeId}`);
}
return node;
}
/// Add a connection to the store. The connection must not already exist in
/// the store.
addConnection(conn: AugmentedConnectionState): void {
// Sanity checks
console.assert(
this.idsToNodes.has(conn.fromNode),
`Cannot add connection to GraphStore because source node ${conn.fromNode} does not exist`
);
console.assert(
this.idsToNodes.has(conn.toNode),
`Cannot add connection to GraphStore because destination node ${conn.toNode} does not exist`
);
// Source node
let fromConnections = this.nodeIdsToConnections.get(conn.fromNode);
if (fromConnections === undefined) {
fromConnections = [];
this.nodeIdsToConnections.set(conn.fromNode, fromConnections);
}
fromConnections.push(conn);
// Destination node
let toConnections = this.nodeIdsToConnections.get(conn.toNode);
if (toConnections === undefined) {
toConnections = [];
this.nodeIdsToConnections.set(conn.toNode, toConnections);
}
toConnections.push(conn);
}
getConnectionsForNode(nodeId: number): AugmentedConnectionState[] {
let connections = this.nodeIdsToConnections.get(nodeId);
if (connections === undefined) {
return [];
}
return connections;
}
}
function devel_getComponentByKey(key: string): ComponentBase {
// Temporary function to get a component by its key
for (let component of Object.values(componentsById)) {
if (component === undefined) {
continue;
}
// @ts-ignore
if (component.state._key_ === key) {
return component;
}
}
throw new Error(`Could not find component with key ${key}`);
}
/// Given a port component, return the node component that the port is part of.
function getNodeForPort(
port: NodeInputComponent | NodeOutputComponent
): ComponentBase {
let nodeElement = port.element.closest(
".rio-graph-editor-node > div > .rio-component"
) as HTMLElement;
let nodeComponent = componentsByElement.get(nodeElement) as ComponentBase;
return nodeComponent;
}
export class GraphEditorComponent extends ComponentBase {
declare state: Required<GraphEditorState>;
private htmlChild: HTMLElement;
private svgChild: SVGSVGElement;
private selectionChild: HTMLElement;
private graphStore: GraphStore = new GraphStore();
// When clicking & dragging a port, a latent connection is created. This
// connection is already connected at one end (either start or end), but the
// change has not yet been committed to the graph store.
private latentConnection: LatentConnection | 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.selectionChild = document.createElement("div");
this.selectionChild.classList.add("rio-graph-editor-selection");
this.htmlChild.appendChild(this.selectionChild);
// Random gradient for testing
const gradient = document.createElementNS(
"http://www.w3.org/2000/svg",
"linearGradient"
);
gradient.setAttribute("id", "connectionGradient");
gradient.setAttribute("x1", "0%");
gradient.setAttribute("y1", "0%");
gradient.setAttribute("x2", "100%");
gradient.setAttribute("y2", "100%");
const stop1 = document.createElementNS(
"http://www.w3.org/2000/svg",
"stop"
);
stop1.setAttribute("offset", "0%");
stop1.setAttribute("stop-color", "red");
gradient.appendChild(stop1);
const stop2 = document.createElementNS(
"http://www.w3.org/2000/svg",
"stop"
);
stop2.setAttribute("offset", "100%");
stop2.setAttribute("stop-color", "blue");
gradient.appendChild(stop2);
const defs = document.createElementNS(
"http://www.w3.org/2000/svg",
"defs"
);
defs.appendChild(gradient);
this.svgChild.appendChild(defs);
// Detect clicks on ports. Just log them for now.
//
// This could of course be handled by the ports themselves, but then
// they'd somehow have to pipe that event to the editor. Instead, just
// detect the event here, and decline it if it's not on a port.
this.addDragHandler({
element: element,
onStart: this._onDragPortStart.bind(this),
onMove: this._onDragPortMove.bind(this),
onEnd: this._onDragPortEnd.bind(this),
capturing: true,
});
return element;
}
updateElement(
deltaState: GraphEditorState,
latentComponents: Set<ComponentBase>
): void {
// 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 augmentedConn: AugmentedConnectionState = {
// @ts-ignore
fromNode: deltaState.children[1],
fromPort: fromPortComponent.id,
// @ts-ignore
toNode: deltaState.children[2],
toPort: toPortComponent.id,
element: this._makeConnectionElement(),
};
this.graphStore.addConnection(augmentedConn);
this._updateConnectionFromObject(augmentedConn);
});
}
}
_onDragPortStart(event: PointerEvent): boolean {
// Only care about left clicks
if (event.button !== 0) {
return false;
}
// Find the port that was clicked
let targetElement = event.target as HTMLElement;
if (!targetElement.classList.contains("rio-graph-editor-port-circle")) {
return false;
}
let portElement = targetElement.parentElement as HTMLElement;
console.assert(
portElement.classList.contains("rio-graph-editor-port"),
"Port element does not have the expected class"
);
let portComponent = componentsByElement.get(portElement) as
| NodeInputComponent
| NodeOutputComponent;
console.assert(
portComponent !== undefined,
"Port element does not have a corresponding component"
);
// Create a latent connection
this.latentConnection = {
fixedNodeId: getNodeForPort(portComponent).id,
fixedPortId: portComponent.id,
element: this._makeConnectionElement(),
};
return true;
}
_onDragPortMove(event: PointerEvent): void {
// Make sure there is actually a latent connection. This may not always
// be guaranteed if there are multiple pointer devices.
if (this.latentConnection === null) {
return;
}
// Update the latent connection
const fixedPortComponent = componentsById[
this.latentConnection.fixedPortId
] as NodeInputComponent | NodeOutputComponent | undefined;
// The element could've been deleted in the meantime
if (fixedPortComponent === undefined) {
return;
}
let [x1, y1] = this._getPortPosition(fixedPortComponent);
let [x2, y2] = [event.clientX, event.clientY];
// If dragging from an input port, flip the coordinates
if (fixedPortComponent instanceof NodeInputComponent) {
[x1, y1, x2, y2] = [x2, y2, x1, y1];
}
// Update the SVG path
this._updateConnectionFromCoordinates(
this.latentConnection.element,
x1,
y1,
x2,
y2
);
}
_onDragPortEnd(event: PointerEvent): void {
// Once again, make sure there is actually a latent connection.
if (this.latentConnection === null) {
return;
}
// Drop the latent connection
let latentConnection = this.latentConnection;
this.latentConnection.element.remove();
this.latentConnection = null;
// Was the pointer released on a port?
let dropElement = event.target as HTMLElement;
if (!dropElement.classList.contains("rio-graph-editor-port-circle")) {
return;
}
let dropPortComponent = componentsByElement.get(
dropElement.parentElement as HTMLElement
);
// Get the fixed port
let fixedPortComponent = componentsById[
latentConnection.fixedPortId
] as NodeInputComponent | NodeOutputComponent;
// Each connection must connect exactly one input and one output
let fromPortComponent: NodeOutputComponent;
let toPortComponent: NodeInputComponent;
if (fixedPortComponent instanceof NodeOutputComponent) {
if (dropPortComponent instanceof NodeOutputComponent) {
return;
}
fromPortComponent = fixedPortComponent;
toPortComponent = dropPortComponent as NodeInputComponent;
} else {
if (dropPortComponent instanceof NodeInputComponent) {
return;
}
fromPortComponent = dropPortComponent as NodeOutputComponent;
toPortComponent = fixedPortComponent;
}
// Create a real connection between the two ports
let augmentedConn: AugmentedConnectionState = {
fromNode: getNodeForPort(fromPortComponent).id,
fromPort: fromPortComponent.id,
toNode: getNodeForPort(toPortComponent).id,
toPort: toPortComponent.id,
element: this._makeConnectionElement(),
};
this.graphStore.addConnection(augmentedConn);
this._updateConnectionFromObject(augmentedConn);
}
_onDragNodeStart(
nodeState: NodeState,
nodeElement: HTMLElement,
event: PointerEvent
): boolean {
// Only care about left clicks
if (event.button !== 0) {
return false;
}
// Make sure this node is on top
nodeElement.style.zIndex = "1";
// Accept the drag
return true;
}
_onDragNodeMove(
nodeState: NodeState,
nodeElement: HTMLElement,
event: PointerEvent
): void {
// Update the node state
nodeState.left += event.movementX / pixelsPerRem;
nodeState.top += event.movementY / pixelsPerRem;
// Move its element
nodeElement.style.left = `${nodeState.left}rem`;
nodeElement.style.top = `${nodeState.top}rem`;
// Update any connections
let connections = this.graphStore.getConnectionsForNode(nodeState.id);
for (let conn of connections) {
this._updateConnectionFromObject(conn);
}
}
_onDragNodeEnd(
nodeState: NodeState,
nodeElement: HTMLElement,
event: PointerEvent
): void {
// The node no longer needs to be on top
nodeElement.style.removeProperty("z-index");
}
_makeNode(
latentComponents: Set<ComponentBase>,
nodeState: NodeState
): AugmentedNodeState {
// Build the node HTML
const nodeElement = document.createElement("div");
nodeElement.dataset.nodeId = nodeState.id.toString();
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);
// Allow dragging the node
// @ts-ignore
this.addDragHandler({
element: header,
onStart: (event: PointerEvent) =>
this._onDragNodeStart(nodeState, nodeElement, event),
onMove: (event: PointerEvent) =>
this._onDragNodeMove(nodeState, nodeElement, event),
onEnd: (event: PointerEvent) =>
this._onDragNodeEnd(nodeState, nodeElement, event),
});
// Build the augmented node state
let augmentedNode = { ...nodeState } as AugmentedNodeState;
augmentedNode.element = nodeElement;
return augmentedNode;
}
/// Creates a SVG path element representing a connection and adds it to the
/// SVG child. Does not position the connection in any way.
_makeConnectionElement(): SVGPathElement {
const svgPath = document.createElementNS(
"http://www.w3.org/2000/svg",
"path"
) as SVGPathElement;
svgPath.setAttribute("stroke", "var(--rio-local-text-color)");
// svgPath.setAttribute("stroke", "url(#connectionGradient)");
svgPath.setAttribute("stroke-opacity", "0.5");
svgPath.setAttribute("stroke-width", "0.2rem");
svgPath.setAttribute("fill", "none");
this.svgChild.appendChild(svgPath);
return svgPath;
}
/// Given a port component, return the coordinates of the port's socket.
_getPortPosition(
portComponent: NodeInputComponent | NodeOutputComponent
): [number, number] {
// Find the circle's HTML element
let circleElement = portComponent.element.querySelector(
".rio-graph-editor-port-circle"
) as HTMLElement;
// Get the circle's bounding box
let circleBox = circleElement.getBoundingClientRect();
// Return the center of the circle
return [
circleBox.left + circleBox.width * 0.5,
circleBox.top + circleBox.height * 0.5,
];
}
_updateConnectionFromObject(
connectionState: AugmentedConnectionState
): void {
// From Port
let fromPortComponent = componentsById[
connectionState.fromPort
] as NodeOutputComponent;
const [x1, y1] = this._getPortPosition(fromPortComponent);
// To Port
let toPortComponent = componentsById[
connectionState.toPort
] as NodeInputComponent;
const [x4, y4] = this._getPortPosition(toPortComponent);
// Update the SVG path
this._updateConnectionFromCoordinates(
connectionState.element,
x1,
y1,
x4,
y4
);
}
_updateConnectionFromCoordinates(
connectionElement: SVGPathElement,
x1: number,
y1: number,
x4: number,
y4: number
): void {
// Control the curve's bend
let signedDistance = Math.abs(x4 - x1);
let velocity = Math.pow(signedDistance * 10, 0.6);
velocity = Math.max(velocity, 3 * pixelsPerRem);
// Calculate the intermediate points
const x2 = x1 + velocity;
const y2 = y1;
const x3 = x4 - velocity;
const y3 = y4;
// Update the SVG path
connectionElement.setAttribute(
"d",
`M${x1} ${y1} C ${x2} ${y2}, ${x3} ${y3}, ${x4} ${y4}`
);
}
}
@@ -0,0 +1,117 @@
import { componentsByElement, componentsById } from "../../componentManagement";
import { ComponentId } from "../../dataModels";
import { GraphEditorComponent } from "./graphEditor";
import { NodeInputComponent } from "../nodeInput";
import { NodeOutputComponent } from "../nodeOutput";
import {
getNodeFromPort,
getPortFromCircle,
getPortPosition,
makeConnectionElement,
updateConnectionFromCoordinates,
updateConnectionFromObject,
} from "./utils";
import { AugmentedConnectionState } from "./graphStore";
/// The user is currently dragging a connection from a port. This means the
/// connection is already connected to one port, with the other end dangling at
/// the pointer.
export class DraggingConnectionStrategy {
fixedNodeId: ComponentId;
fixedPortId: ComponentId;
element: SVGPathElement;
constructor(
fixedNodeId: ComponentId,
fixedPortId: ComponentId,
element: SVGPathElement
) {
this.fixedNodeId = fixedNodeId;
this.fixedPortId = fixedPortId;
this.element = element;
}
onDragMove(this_: GraphEditorComponent, event: PointerEvent): void {
// Update the latent connection
const fixedPortComponent = componentsById[this.fixedPortId] as
| NodeInputComponent
| NodeOutputComponent
| undefined;
// The element could've been deleted in the meantime
if (fixedPortComponent === undefined) {
return;
}
let [x1, y1] = getPortPosition(fixedPortComponent);
let [x2, y2] = [event.clientX, event.clientY];
// If dragging from an input port, flip the coordinates
if (fixedPortComponent instanceof NodeInputComponent) {
[x1, y1, x2, y2] = [x2, y2, x1, y1];
}
// Update the SVG path
updateConnectionFromCoordinates(this.element, x1, y1, x2, y2);
}
onDragEnd(this_: GraphEditorComponent, event: PointerEvent): void {
// Remove the SVG path
this.element.remove();
// Was the pointer released on a port?
let dropElement = event.target as HTMLElement;
if (!dropElement.classList.contains("rio-graph-editor-port-circle")) {
return;
}
let dropPortComponent = getPortFromCircle(dropElement);
// Get the fixed port
let fixedPortComponent = componentsById[this.fixedPortId] as
| NodeInputComponent
| NodeOutputComponent
| undefined;
// The element could've been deleted in the meantime
if (fixedPortComponent === undefined) {
return;
}
// Each connection must connect exactly one input and one output
let fromPortComponent: NodeOutputComponent;
let toPortComponent: NodeInputComponent;
if (fixedPortComponent instanceof NodeOutputComponent) {
if (dropPortComponent instanceof NodeOutputComponent) {
return;
}
fromPortComponent = fixedPortComponent;
toPortComponent = dropPortComponent as NodeInputComponent;
} else {
if (dropPortComponent instanceof NodeInputComponent) {
return;
}
fromPortComponent = dropPortComponent as NodeOutputComponent;
toPortComponent = fixedPortComponent;
}
// Create a real connection between the two ports
let connectionElement = makeConnectionElement();
this_.svgChild.appendChild(connectionElement);
let augmentedConn: AugmentedConnectionState = {
fromNode: getNodeFromPort(fromPortComponent).id,
fromPort: fromPortComponent.id,
toNode: getNodeFromPort(toPortComponent).id,
toPort: toPortComponent.id,
element: connectionElement,
};
this_.graphStore.addConnection(augmentedConn);
updateConnectionFromObject(augmentedConn);
}
}
@@ -0,0 +1,33 @@
/// A connection that is only connected to a single port, with the other end
import { componentsByElement } from "../../componentManagement";
import { ComponentId } from "../../dataModels";
import { GraphEditorComponent } from "./graphEditor";
import { NodeInputComponent } from "../nodeInput";
import { NodeOutputComponent } from "../nodeOutput";
export class DraggingNodesStrategy {
onDragMove(this_: GraphEditorComponent, event: PointerEvent): void {
// Update the node state
nodeState.left += event.movementX / pixelsPerRem;
nodeState.top += event.movementY / pixelsPerRem;
// Move its element
nodeElement.style.left = `${nodeState.left}rem`;
nodeElement.style.top = `${nodeState.top}rem`;
// Update any connections
let connections = this.graphStore.getConnectionsForNode(nodeState.id);
for (let conn of connections) {
this._updateConnectionFromObject(conn);
}
}
onDragEnd(this_: GraphEditorComponent, event: PointerEvent): void {
// The node no longer needs to be on top
nodeElement.style.removeProperty("z-index");
return false;
}
}
@@ -0,0 +1,20 @@
/// A connection that is only connected to a single port, with the other end
import { componentsByElement } from "../../componentManagement";
import { ComponentId } from "../../dataModels";
import { GraphEditorComponent } from "./graphEditor";
import { NodeInputComponent } from "../nodeInput";
import { NodeOutputComponent } from "../nodeOutput";
export class DraggingSelectionStrategy {
startPointX: number;
startPointY: number;
constructor(startPointX: number, startPointY: number) {
this.startPointX = startPointX;
this.startPointY = startPointY;
}
onDragMove(this_: GraphEditorComponent, event: PointerEvent): void {}
onDragEnd(this_: GraphEditorComponent, event: PointerEvent): void {}
}
@@ -0,0 +1,255 @@
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,
getNodeFromPort,
getPortFromCircle,
makeConnectionElement,
updateConnectionFromObject,
} from "./utils";
export type GraphEditorState = ComponentState & {
_type_: "GraphEditor-builtin";
children?: ComponentId[];
};
export class GraphEditorComponent extends ComponentBase {
declare state: Required<GraphEditorState>;
private htmlChild: HTMLElement;
public svgChild: SVGSVGElement;
private selectionChild: HTMLElement;
public graphStore: GraphStore = new GraphStore();
// 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:
| 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.selectionChild = document.createElement("div");
this.selectionChild.classList.add("rio-graph-editor-selection");
this.selectionChild.style.opacity = "0";
this.htmlChild.appendChild(this.selectionChild);
// 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 {
// 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(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.
// Find an applicable strategy
//
// Case: New connection from a port
let targetElement = event.target as HTMLElement;
console.assert(
targetElement !== null,
"Pointer event has no target element"
);
if (
event.button === 0 &&
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 (event.button === 0 && false) {
// Make sure all selected nodes are on top
//
// TODO
//
// nodeElement.style.zIndex = "1";
// Store the strategy
this.dragStrategy = new DraggingNodesStrategy();
// Accept the drag
return true;
}
// Case: Rectangle selection
if (event.button === 0) {
// Store the strategy
this.dragStrategy = new DraggingSelectionStrategy(
event.clientX,
event.clientY
);
// 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.dataset.nodeId = nodeState.id.toString();
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;
}
}
@@ -0,0 +1,172 @@
import { ComponentId } from "../../dataModels";
import { ComponentBase, ComponentState } from "../componentBase";
import { componentsByElement, componentsById } from "../../componentManagement";
import { NodeInputComponent } from "../nodeInput";
import { NodeOutputComponent } from "../nodeOutput";
export type GraphEditorState = ComponentState & {
_type_: "GraphEditor-builtin";
children?: ComponentId[];
};
export type NodeState = {
id: ComponentId;
title: string;
left: number;
top: number;
};
export type ConnectionState = {
fromNode: ComponentId;
fromPort: ComponentId;
toNode: ComponentId;
toPort: ComponentId;
};
export type AugmentedNodeState = NodeState & {
element: HTMLElement;
};
export type AugmentedConnectionState = ConnectionState & {
element: SVGPathElement;
};
export class GraphStore {
/// Nodes are identified by their component ID. This maps component IDs to
/// the stored information about the node.
private idsToNodes: Map<number, AugmentedNodeState>;
/// This maps node IDs to connections that are connected to them.
private nodeIdsToConnections: Map<number, AugmentedConnectionState[]>;
constructor() {
this.idsToNodes = new Map();
this.nodeIdsToConnections = new Map();
}
/// Add a node to the store. The node must not already exist in the store.
addNode(node: AugmentedNodeState): void {
console.assert(
!this.idsToNodes.has(node.id),
`Cannot add node to GraphStore because of duplicate ID ${node.id}`
);
this.idsToNodes.set(node.id, node);
}
/// Returns an array of all nodes in the store.
allNodes(): AugmentedNodeState[] {
return Array.from(this.idsToNodes.values());
}
/// Get a node by its ID. Throws an error if the node does not exist.
getNodeById(nodeId: number): AugmentedNodeState {
let node = this.idsToNodes.get(nodeId);
if (node === undefined) {
throw new Error(`NodeEditor has no node with id ${nodeId}`);
}
return node;
}
/// Add a connection to the store. The connection must not already exist in
/// the store.
addConnection(conn: AugmentedConnectionState): void {
// Sanity checks
console.assert(
this.idsToNodes.has(conn.fromNode),
`Cannot add connection to GraphStore because source node ${conn.fromNode} does not exist`
);
console.assert(
this.idsToNodes.has(conn.toNode),
`Cannot add connection to GraphStore because destination node ${conn.toNode} does not exist`
);
// Source node
let fromConnections = this.nodeIdsToConnections.get(conn.fromNode);
if (fromConnections === undefined) {
fromConnections = [];
this.nodeIdsToConnections.set(conn.fromNode, fromConnections);
}
fromConnections.push(conn);
// Destination node
let toConnections = this.nodeIdsToConnections.get(conn.toNode);
if (toConnections === undefined) {
toConnections = [];
this.nodeIdsToConnections.set(conn.toNode, toConnections);
}
toConnections.push(conn);
}
getConnectionsForNode(nodeId: number): AugmentedConnectionState[] {
let connections = this.nodeIdsToConnections.get(nodeId);
if (connections === undefined) {
return [];
}
return connections;
}
}
function devel_getComponentByKey(key: string): ComponentBase {
// Temporary function to get a component by its key
for (let component of Object.values(componentsById)) {
if (component === undefined) {
continue;
}
// @ts-ignore
if (component.state._key_ === key) {
return component;
}
}
throw new Error(`Could not find component with key ${key}`);
}
/// Given the circle HTML element of a port, walk up the DOM to find the port
/// component that contains it.A
function getPortFromCircle(
circleElement: HTMLElement
): NodeInputComponent | NodeOutputComponent {
let portElement = circleElement.parentElement as HTMLElement;
console.assert(
portElement.classList.contains("rio-graph-editor-port"),
"Port element does not have the expected class"
);
let portComponent = componentsByElement.get(portElement) as ComponentBase;
console.assert(
portComponent !== undefined,
"Port element does not have a corresponding component"
);
console.assert(
portComponent instanceof NodeInputComponent ||
portComponent instanceof NodeOutputComponent,
"Port component is not of the expected type"
);
// @ts-ignore
return portComponent;
}
/// Given a port component, walk up the DOM to find the node component that
/// contains it.
function getNodeFromPort(
port: NodeInputComponent | NodeOutputComponent
): ComponentBase {
let nodeElement = port.element.closest(
".rio-graph-editor-node > div > .rio-component"
) as HTMLElement;
let nodeComponent = componentsByElement.get(nodeElement) as ComponentBase;
return nodeComponent;
}
@@ -0,0 +1,153 @@
import { pixelsPerRem } from "../../app";
import { componentsByElement, componentsById } from "../../componentManagement";
import { ComponentBase } from "../componentBase";
import { NodeInputComponent } from "../nodeInput";
import { NodeOutputComponent } from "../nodeOutput";
import { AugmentedConnectionState } from "./graphStore";
/// Temporary function to get a component by its key
///
/// TODO / FIXME / REMOVEME
export function devel_getComponentByKey(key: string): ComponentBase {
for (let component of Object.values(componentsById)) {
if (component === undefined) {
continue;
}
// @ts-ignore
if (component.state._key_ === key) {
return component;
}
}
throw new Error(`Could not find component with key ${key}`);
}
/// Given the circle HTML element of a port, walk up the DOM to find the port
/// component that contains it.A
export function getPortFromCircle(
circleElement: HTMLElement
): NodeInputComponent | NodeOutputComponent {
let portElement = circleElement.parentElement as HTMLElement;
console.assert(
portElement.classList.contains("rio-graph-editor-port"),
"Port element does not have the expected class"
);
let portComponent = componentsByElement.get(portElement) as ComponentBase;
console.assert(
portComponent !== undefined,
"Port element does not have a corresponding component"
);
console.assert(
portComponent instanceof NodeInputComponent ||
portComponent instanceof NodeOutputComponent,
"Port component is not of the expected type"
);
// @ts-ignore
return portComponent;
}
/// Given a port component, walk up the DOM to find the node component that
/// contains it.
export function getNodeFromPort(
port: NodeInputComponent | NodeOutputComponent
): ComponentBase {
let nodeElement = port.element.closest(
".rio-graph-editor-node > div > .rio-component"
) as HTMLElement;
let nodeComponent = componentsByElement.get(nodeElement) as ComponentBase;
return nodeComponent;
}
/// Creates a SVG path element representing a connection. Does not add it to
/// the DOM.
export function makeConnectionElement(): SVGPathElement {
const svgPath = document.createElementNS(
"http://www.w3.org/2000/svg",
"path"
) as SVGPathElement;
svgPath.setAttribute("stroke", "var(--rio-local-text-color)");
svgPath.setAttribute("stroke-opacity", "0.5");
svgPath.setAttribute("stroke-width", "0.2rem");
svgPath.setAttribute("fill", "none");
return svgPath;
}
/// Given a port component, return the coordinates of the port's socket.
export function getPortPosition(
portComponent: NodeInputComponent | NodeOutputComponent
): [number, number] {
// Find the circle's HTML element
let circleElement = portComponent.element.querySelector(
".rio-graph-editor-port-circle"
) as HTMLElement;
// Get the circle's bounding box
let circleBox = circleElement.getBoundingClientRect();
// Return the center of the circle
return [
circleBox.left + circleBox.width * 0.5,
circleBox.top + circleBox.height * 0.5,
];
}
/// Updates the SVG path of a connection based on the state of the
/// connection. This is a convenience function which determines the start
/// and end points and delegates to the more general function.
export function updateConnectionFromObject(
connectionState: AugmentedConnectionState
): void {
// From Port
let fromPortComponent = componentsById[
connectionState.fromPort
] as NodeOutputComponent;
const [x1, y1] = getPortPosition(fromPortComponent);
// To Port
let toPortComponent = componentsById[
connectionState.toPort
] as NodeInputComponent;
const [x4, y4] = getPortPosition(toPortComponent);
// Update the SVG path
updateConnectionFromCoordinates(connectionState.element, x1, y1, x4, y4);
}
/// Updates the SVG path of a connection based on the coordinates of the
/// start and end points.
export function updateConnectionFromCoordinates(
connectionElement: SVGPathElement,
x1: number,
y1: number,
x4: number,
y4: number
): void {
// Control the curve's bend
let signedDistance = Math.abs(x4 - x1);
let velocity = Math.pow(signedDistance * 10, 0.6);
velocity = Math.max(velocity, 3 * pixelsPerRem);
// Calculate the intermediate points
const x2 = x1 + velocity;
const y2 = y1;
const x3 = x4 - velocity;
const y3 = y4;
// Update the SVG path
connectionElement.setAttribute(
"d",
`M${x1} ${y1} C ${x2} ${y2}, ${x3} ${y3}, ${x4} ${y4}`
);
}