graph editor progress

This commit is contained in:
Jakob Pinterits
2024-11-13 22:39:07 +01:00
parent e983e96013
commit c71bc29e2d
6 changed files with 208 additions and 50 deletions
@@ -31,7 +31,7 @@ export class DraggingConnectionStrategy {
this.element = element;
}
onDragMove(this_: GraphEditorComponent, event: PointerEvent): void {
onDragMove(ge: GraphEditorComponent, event: PointerEvent): void {
// Update the latent connection
const fixedPortComponent = componentsById[this.fixedPortId] as
| NodeInputComponent
@@ -55,7 +55,7 @@ export class DraggingConnectionStrategy {
updateConnectionFromCoordinates(this.element, x1, y1, x2, y2);
}
onDragEnd(this_: GraphEditorComponent, event: PointerEvent): void {
onDragEnd(ge: GraphEditorComponent, event: PointerEvent): void {
// Remove the SVG path
this.element.remove();
@@ -101,7 +101,7 @@ export class DraggingConnectionStrategy {
// Create a real connection between the two ports
let connectionElement = makeConnectionElement();
this_.svgChild.appendChild(connectionElement);
ge.svgChild.appendChild(connectionElement);
let augmentedConn: AugmentedConnectionState = {
fromNode: getNodeFromPort(fromPortComponent).id,
@@ -111,7 +111,7 @@ export class DraggingConnectionStrategy {
element: connectionElement,
};
this_.graphStore.addConnection(augmentedConn);
ge.graphStore.addConnection(augmentedConn);
updateConnectionFromObject(augmentedConn);
}
}
@@ -1,33 +1,38 @@
/// 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";
import { pixelsPerRem } from "../../app";
import { updateConnectionFromObject } from "./utils";
/// The user is moving around all selected nodes
export class DraggingNodesStrategy {
onDragMove(this_: GraphEditorComponent, event: PointerEvent): void {
// Update the node state
nodeState.left += event.movementX / pixelsPerRem;
nodeState.top += event.movementY / pixelsPerRem;
onDragMove(ge: GraphEditorComponent, event: PointerEvent): void {
// Move all selected nodes
let moveX = event.movementX / pixelsPerRem;
let moveY = event.movementY / pixelsPerRem;
// Move its element
nodeElement.style.left = `${nodeState.left}rem`;
nodeElement.style.top = `${nodeState.top}rem`;
for (let nodeState of ge.getSelectedNodes()) {
// Update the stored position
nodeState.left += moveX;
nodeState.top += moveY;
// Move the HTML element
nodeState.element.style.left = `${nodeState.left}rem`;
nodeState.element.style.top = `${nodeState.top}rem`;
}
// Update any connections
let connections = this.graphStore.getConnectionsForNode(nodeState.id);
for (let conn of connections) {
this._updateConnectionFromObject(conn);
for (let nodeState of ge.getSelectedNodes()) {
for (let connection of ge.graphStore.getConnectionsForNode(
nodeState.id
)) {
updateConnectionFromObject(connection);
}
}
}
onDragEnd(this_: GraphEditorComponent, event: PointerEvent): void {
// The node no longer needs to be on top
nodeElement.style.removeProperty("z-index");
return false;
onDragEnd(ge: GraphEditorComponent, event: PointerEvent): void {
// The nodes no longer needs to be on top
for (let nodeState of ge.getSelectedNodes()) {
nodeState.element.style.removeProperty("z-index");
}
}
}
@@ -1,11 +1,6 @@
/// 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";
/// The user is selecting nodes by dragging a rectangle
export class DraggingSelectionStrategy {
startPointX: number;
startPointY: number;
@@ -15,6 +10,70 @@ export class DraggingSelectionStrategy {
this.startPointY = startPointY;
}
onDragMove(this_: GraphEditorComponent, event: PointerEvent): void {}
onDragEnd(this_: GraphEditorComponent, event: PointerEvent): void {}
/// Returns the [left, top, width, height] of the selection rectangle,
/// taking care that width and height are not negative.
getSelectedRectangle(
event: PointerEvent
): [number, number, number, number] {
let rectLeft = this.startPointX;
let rectTop = this.startPointY;
let rectWidth = event.clientX - this.startPointX;
let rectHeight = event.clientY - this.startPointY;
// Avoid negative width and height
if (rectWidth < 0) {
rectLeft += rectWidth;
rectWidth = -rectWidth;
}
if (rectHeight < 0) {
rectTop += rectHeight;
rectHeight = -rectHeight;
}
// Done
return [rectLeft, rectTop, rectWidth, rectHeight];
}
onDragMove(ge: GraphEditorComponent, event: PointerEvent): void {
// Get the new selection rectangle
let [rectLeft, rectTop, rectWidth, rectHeight] =
this.getSelectedRectangle(event);
// Apply the new values
ge.selectionRect.style.left = `${rectLeft}px`;
ge.selectionRect.style.top = `${rectTop}px`;
ge.selectionRect.style.width = `${rectWidth}px`;
ge.selectionRect.style.height = `${rectHeight}px`;
}
onDragEnd(ge: GraphEditorComponent, event: PointerEvent): void {
// Hide the selection rectangle
ge.selectionRect.style.opacity = "0";
// Get the new selection rectangle
let [rectLeft, rectTop, rectWidth, rectHeight] =
this.getSelectedRectangle(event);
let rectRight = rectLeft + rectWidth;
let rectBottom = rectTop + rectHeight;
// Select all nodes that fall within the selection rectangle
for (let node of ge.graphStore.allNodes()) {
let nodeElement = node.element;
// Get the bounding box of the node
let nodeBox = nodeElement.getBoundingClientRect();
// Check if the node is within the selection rectangle
if (
nodeBox.left >= rectLeft &&
nodeBox.right <= rectRight &&
nodeBox.top >= rectTop &&
nodeBox.bottom <= rectBottom
) {
ge.selectNode(node);
}
}
}
}
@@ -14,11 +14,13 @@ import { DraggingSelectionStrategy } from "./draggingSelectionStrategy";
import { DraggingNodesStrategy } from "./draggingNodesStrategy";
import {
devel_getComponentByKey,
getNodeComponentFromElement,
getNodeFromPort,
getPortFromCircle,
makeConnectionElement,
updateConnectionFromObject,
} from "./utils";
import { componentsByElement, componentsById } from "../../componentManagement";
export type GraphEditorState = ComponentState & {
_type_: "GraphEditor-builtin";
@@ -31,13 +33,18 @@ export class GraphEditorComponent extends ComponentBase {
private htmlChild: HTMLElement;
public svgChild: SVGSVGElement;
private selectionChild: HTMLElement;
public selectionRect: 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.
/// 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:
| DraggingConnectionStrategy
| DraggingSelectionStrategy
@@ -58,10 +65,10 @@ export class GraphEditorComponent extends ComponentBase {
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);
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.
@@ -158,12 +165,25 @@ export class GraphEditorComponent extends ComponentBase {
}
// Case: Move around the selected nodes
if (event.button === 0 && false) {
if (
event.button === 0 &&
targetElement.classList.contains("rio-graph-editor-node-header")
) {
// Make sure this node is selected
let nodeComponent = getNodeComponentFromElement(
targetElement.parentElement!
);
let nodeState = this.graphStore.getNodeById(nodeComponent.id);
this.selectNode(nodeState);
// Make sure all selected nodes are on top
//
// TODO
//
// nodeElement.style.zIndex = "1";
for (let nodeId of this.selectedNodes) {
let node = componentsById[nodeId];
if (node !== undefined) {
node.element.style.zIndex = "1";
}
}
// Store the strategy
this.dragStrategy = new DraggingNodesStrategy();
@@ -173,7 +193,19 @@ export class GraphEditorComponent extends ComponentBase {
}
// Case: Rectangle selection
if (event.button === 0) {
if (event.button === 0 && targetElement === this.htmlChild) {
// Deselect any previously selected nodes
for (let node of this.getSelectedNodes()) {
this.deselectNode(node);
}
// Initialize the selection rectangle
this.selectionRect.style.opacity = "1";
this.selectionRect.style.left = `${event.clientX}px`;
this.selectionRect.style.top = `${event.clientY}px`;
this.selectionRect.style.width = "0";
this.selectionRect.style.height = "0";
// Store the strategy
this.dragStrategy = new DraggingSelectionStrategy(
event.clientX,
@@ -223,7 +255,6 @@ export class GraphEditorComponent extends ComponentBase {
): 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"
@@ -252,4 +283,43 @@ export class GraphEditorComponent extends ComponentBase {
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");
}
/// 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;
}
}
@@ -64,6 +64,25 @@ export function getNodeFromPort(
return nodeComponent;
}
/// Given the HTML element of a node, return the node's component by walking
/// the DOM.
export function getNodeComponentFromElement(
nodeElement: HTMLElement
): ComponentBase {
console.assert(
nodeElement.classList.contains("rio-graph-editor-node"),
"Node element does not have the expected class"
);
// The node body contains a Rio component. That component's ID is also the
// node's ID.
let componentElement = nodeElement.querySelector(
".rio-component"
) as HTMLElement;
return componentsByElement.get(componentElement) as ComponentBase;
}
/// Creates a SVG path element representing a connection. Does not add it to
/// the DOM.
export function makeConnectionElement(): SVGPathElement {