mirror of
https://github.com/rio-labs/rio.git
synced 2026-01-06 05:09:43 -06:00
WIP added initial graph editor
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { AspectRatioContainerComponent } from "./components/aspectRatioContainer";
|
||||
import { ErrorPlaceholderComponent } from "./components/errorPlaceholder";
|
||||
import { ButtonComponent, IconButtonComponent } from "./components/buttons";
|
||||
import { CalendarComponent } from "./components/calendar";
|
||||
import { callRemoteMethodDiscardResponse } from "./rpc";
|
||||
@@ -20,9 +19,11 @@ import { DevToolsConnectorComponent } from "./components/devToolsConnector";
|
||||
import { DialogContainerComponent } from "./components/dialogContainer";
|
||||
import { DrawerComponent } from "./components/drawer";
|
||||
import { DropdownComponent } from "./components/dropdown";
|
||||
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";
|
||||
@@ -41,6 +42,7 @@ import { NodeInputComponent } from "./components/nodeInput";
|
||||
import { NodeOutputComponent } from "./components/nodeOutput";
|
||||
import { OverlayComponent } from "./components/overlay";
|
||||
import { PlotComponent } from "./components/plot";
|
||||
import { PointerEventListenerComponent } from "./components/pointerEventListener";
|
||||
import { PopupComponent } from "./components/popup";
|
||||
import { ProgressBarComponent } from "./components/progressBar";
|
||||
import { ProgressCircleComponent } from "./components/progressCircle";
|
||||
@@ -62,7 +64,6 @@ import { TextComponent } from "./components/text";
|
||||
import { TextInputComponent } from "./components/textInput";
|
||||
import { ThemeContextSwitcherComponent } from "./components/themeContextSwitcher";
|
||||
import { TooltipComponent } from "./components/tooltip";
|
||||
import { PointerEventListenerComponent } from "./components/pointerEventListener";
|
||||
import { WebsiteComponent } from "./components/website";
|
||||
|
||||
const COMPONENT_CLASSES = {
|
||||
@@ -87,6 +88,7 @@ const COMPONENT_CLASSES = {
|
||||
"FilePickerArea-builtin": FilePickerAreaComponent,
|
||||
"FlowContainer-builtin": FlowContainerComponent,
|
||||
"FundamentalRootComponent-builtin": FundamentalRootComponent,
|
||||
"GraphEditor-builtin": GraphEditorComponent,
|
||||
"Grid-builtin": GridComponent,
|
||||
"HeadingListItem-builtin": HeadingListItemComponent,
|
||||
"HighLevelComponent-builtin": HighLevelComponent,
|
||||
|
||||
462
frontend/code/components/graphEditor.ts
Normal file
462
frontend/code/components/graphEditor.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { ColorSet, ComponentId } from "../dataModels";
|
||||
import { ComponentBase, ComponentState } from "./componentBase";
|
||||
import { pixelsPerRem } from "../app";
|
||||
import { componentsById } from "../componentManagement";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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 idsToConnections: Map<number, AugmentedConnectionState[]>;
|
||||
|
||||
constructor() {
|
||||
this.idsToNodes = new Map();
|
||||
this.idsToConnections = 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.idsToConnections.get(conn.fromNode);
|
||||
|
||||
if (fromConnections === undefined) {
|
||||
fromConnections = [];
|
||||
this.idsToConnections.set(conn.fromNode, fromConnections);
|
||||
}
|
||||
|
||||
fromConnections.push(conn);
|
||||
|
||||
// Destination node
|
||||
let toConnections = this.idsToConnections.get(conn.toNode);
|
||||
|
||||
if (toConnections === undefined) {
|
||||
toConnections = [];
|
||||
this.idsToConnections.set(conn.toNode, toConnections);
|
||||
}
|
||||
|
||||
toConnections.push(conn);
|
||||
}
|
||||
|
||||
getConnectionsForNode(nodeId: number): AugmentedConnectionState[] {
|
||||
let connections = this.idsToConnections.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}`);
|
||||
}
|
||||
|
||||
export class GraphEditorComponent extends ComponentBase {
|
||||
declare state: Required<GraphEditorState>;
|
||||
|
||||
private htmlChild: HTMLElement;
|
||||
private svgChild: SVGSVGElement;
|
||||
|
||||
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 latentConnectionStartElement: HTMLElement | null;
|
||||
private latentConnectionEndElement: HTMLElement | null;
|
||||
private latentConnectionPath: SVGPathElement | null;
|
||||
|
||||
// This may be null even if a port is currently being dragged, if the latent
|
||||
// connection is new, rather than one that already existed on the graph.
|
||||
private latentConnection: AugmentedConnectionState | null;
|
||||
|
||||
// True if `updateElement` has been called at least once
|
||||
private isInitialized: boolean = false;
|
||||
|
||||
createElement(): HTMLElement {
|
||||
let element = document.createElement("div");
|
||||
element.classList.add("rio-graph-editor");
|
||||
element.innerHTML = `
|
||||
<svg></svg>
|
||||
<div></div>
|
||||
`;
|
||||
|
||||
this.htmlChild = element.querySelector("div") as HTMLElement;
|
||||
this.svgChild = element.querySelector("svg") as SVGSVGElement;
|
||||
|
||||
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 rawConn: ConnectionState = {
|
||||
fromNode: deltaState.children[1],
|
||||
fromPort: fromPortComponent.id,
|
||||
toNode: deltaState.children[2],
|
||||
toPort: toPortComponent.id,
|
||||
};
|
||||
let augmentedConn = this._makeConnection(rawConn);
|
||||
this.graphStore.addConnection(augmentedConn);
|
||||
});
|
||||
}
|
||||
|
||||
// This component is now initialized
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
_beginDragNode(
|
||||
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;
|
||||
}
|
||||
|
||||
_dragMoveNode(
|
||||
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._updateConnection(conn);
|
||||
}
|
||||
}
|
||||
|
||||
_endDragNode(
|
||||
nodeState: NodeState,
|
||||
nodeElement: HTMLElement,
|
||||
event: PointerEvent
|
||||
): void {
|
||||
// The node no longer needs to be on top
|
||||
nodeElement.style.removeProperty("z-index");
|
||||
}
|
||||
|
||||
_beginDragConnection(
|
||||
isInput: boolean,
|
||||
portElement: HTMLElement,
|
||||
event: MouseEvent
|
||||
): boolean {
|
||||
// Only care about left clicks
|
||||
if (event.button !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create a latent connection
|
||||
this.latentConnectionPath = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"path"
|
||||
) as SVGPathElement;
|
||||
this.latentConnectionPath.setAttribute(
|
||||
"stroke",
|
||||
"var(--rio-local-text-color)"
|
||||
);
|
||||
this.latentConnectionPath.setAttribute("stroke-opacity", "0.5");
|
||||
this.latentConnectionPath.setAttribute("stroke-width", "0.2rem");
|
||||
this.latentConnectionPath.setAttribute("fill", "none");
|
||||
this.latentConnectionPath.setAttribute("stroke-dasharray", "5,5");
|
||||
this.svgChild.appendChild(this.latentConnectionPath);
|
||||
|
||||
if (isInput) {
|
||||
this.latentConnectionStartElement = null;
|
||||
this.latentConnectionEndElement = portElement;
|
||||
} else {
|
||||
this.latentConnectionStartElement = portElement;
|
||||
this.latentConnectionEndElement = null;
|
||||
}
|
||||
|
||||
// If a connection was already connected to this port, it is being
|
||||
// dragged now.
|
||||
this.latentConnection = null;
|
||||
// TODO
|
||||
|
||||
// Accept the drag
|
||||
return true;
|
||||
}
|
||||
|
||||
_dragMoveConnection(event: MouseEvent): void {
|
||||
// Update the latent connection
|
||||
let x1, y1, x4, y4;
|
||||
|
||||
if (this.latentConnectionStartElement !== null) {
|
||||
let startPoint =
|
||||
this.latentConnectionStartElement!.getBoundingClientRect();
|
||||
x1 = startPoint.left + startPoint.width * 0.5;
|
||||
y1 = startPoint.top + startPoint.height * 0.5;
|
||||
|
||||
x4 = event.clientX;
|
||||
y4 = event.clientY;
|
||||
} else {
|
||||
x1 = event.clientX;
|
||||
y1 = event.clientY;
|
||||
|
||||
let endPoint =
|
||||
this.latentConnectionEndElement!.getBoundingClientRect();
|
||||
x4 = endPoint.left + endPoint.width * 0.5;
|
||||
y4 = endPoint.top + endPoint.height * 0.5;
|
||||
}
|
||||
|
||||
this._updateConnectionPath(this.latentConnectionPath!, x1, y1, x4, y4);
|
||||
}
|
||||
|
||||
_endDragConnection(event: MouseEvent): void {
|
||||
// Remove the latent connection
|
||||
this.latentConnectionPath!.remove();
|
||||
|
||||
// Reset state
|
||||
this.latentConnectionStartElement = null;
|
||||
this.latentConnectionEndElement = null;
|
||||
this.latentConnectionPath = null;
|
||||
}
|
||||
|
||||
_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._beginDragNode(nodeState, nodeElement, event),
|
||||
onMove: (event: PointerEvent) =>
|
||||
this._dragMoveNode(nodeState, nodeElement, event),
|
||||
onEnd: (event: PointerEvent) =>
|
||||
this._endDragNode(nodeState, nodeElement, event),
|
||||
});
|
||||
|
||||
// Build the augmented node state
|
||||
let augmentedNode = { ...nodeState } as AugmentedNodeState;
|
||||
augmentedNode.element = nodeElement;
|
||||
|
||||
return augmentedNode;
|
||||
}
|
||||
|
||||
_makeConnection(
|
||||
connectionState: ConnectionState
|
||||
): AugmentedConnectionState {
|
||||
// Create the SVG path. Don't worry about positioning it yet
|
||||
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");
|
||||
this.svgChild.appendChild(svgPath);
|
||||
|
||||
// Augment the connection state
|
||||
let augmentedConn = connectionState as AugmentedConnectionState;
|
||||
augmentedConn.element = svgPath;
|
||||
|
||||
// Update the connection
|
||||
this._updateConnection(augmentedConn);
|
||||
|
||||
return augmentedConn;
|
||||
}
|
||||
|
||||
_updateConnection(connectionState: AugmentedConnectionState): void {
|
||||
// Get the port elements
|
||||
let fromNodeState = this.graphStore.getNodeById(
|
||||
connectionState.fromNode
|
||||
);
|
||||
let toNodeState = this.graphStore.getNodeById(connectionState.toNode);
|
||||
|
||||
console.log(fromNodeState, toNodeState);
|
||||
|
||||
let port1Element = fromNodeState.element.querySelector(
|
||||
".rio-graph-editor-output > *:first-child"
|
||||
) as HTMLElement;
|
||||
|
||||
let port2Element = toNodeState.element.querySelector(
|
||||
".rio-graph-editor-input > *:first-child"
|
||||
) as HTMLElement;
|
||||
|
||||
const box1 = port1Element.getBoundingClientRect();
|
||||
const box2 = port2Element.getBoundingClientRect();
|
||||
|
||||
// Calculate the start and end points
|
||||
const x1 = box1.left + box1.width * 0.5;
|
||||
const y1 = box1.top + box1.height * 0.5;
|
||||
|
||||
const x4 = box2.left + box2.width * 0.5;
|
||||
const y4 = box2.top + box2.height * 0.5;
|
||||
|
||||
// Update the SVG path
|
||||
this._updateConnectionPath(connectionState.element, x1, y1, x4, y4);
|
||||
}
|
||||
|
||||
_updateConnectionPath(
|
||||
connectionElement: SVGPathElement,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x4: number,
|
||||
y4: number
|
||||
): void {
|
||||
// Control the curve's bend
|
||||
let signedDistance = x4 - x1;
|
||||
|
||||
let velocity = Math.sqrt(Math.abs(signedDistance * 20));
|
||||
velocity = Math.max(velocity, 2 * 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,19 +17,22 @@ export class NodeInputComponent extends ComponentBase {
|
||||
|
||||
createElement(): HTMLElement {
|
||||
let element = document.createElement("div");
|
||||
element.classList.add("rio-node-editor-port", "rio-node-editor-input");
|
||||
element.classList.add(
|
||||
"rio-graph-editor-port",
|
||||
"rio-graph-editor-input"
|
||||
);
|
||||
|
||||
element.innerHTML = `
|
||||
<div class="rio-node-editor-port-circle"></div>
|
||||
<div class="rio-node-editor-port-text"></div>
|
||||
<div class="rio-graph-editor-port-circle"></div>
|
||||
<div class="rio-graph-editor-port-text"></div>
|
||||
`;
|
||||
|
||||
this.textElement = element.querySelector(
|
||||
".rio-node-editor-port-text"
|
||||
".rio-graph-editor-port-text"
|
||||
) as HTMLElement;
|
||||
|
||||
this.circleElement = element.querySelector(
|
||||
".rio-node-editor-port-circle"
|
||||
".rio-graph-editor-port-circle"
|
||||
) as HTMLElement;
|
||||
|
||||
return element;
|
||||
|
||||
@@ -17,19 +17,22 @@ export class NodeOutputComponent extends ComponentBase {
|
||||
|
||||
createElement(): HTMLElement {
|
||||
let element = document.createElement("div");
|
||||
element.classList.add("rio-node-editor-port", "rio-node-editor-output");
|
||||
element.classList.add(
|
||||
"rio-graph-editor-port",
|
||||
"rio-graph-editor-output"
|
||||
);
|
||||
|
||||
element.innerHTML = `
|
||||
<div class="rio-node-editor-port-circle"></div>
|
||||
<div class="rio-node-editor-port-text"></div>
|
||||
<div class="rio-graph-editor-port-circle"></div>
|
||||
<div class="rio-graph-editor-port-text"></div>
|
||||
`;
|
||||
|
||||
this.textElement = element.querySelector(
|
||||
".rio-node-editor-port-text"
|
||||
".rio-graph-editor-port-text"
|
||||
) as HTMLElement;
|
||||
|
||||
this.circleElement = element.querySelector(
|
||||
".rio-node-editor-port-circle"
|
||||
".rio-graph-editor-port-circle"
|
||||
) as HTMLElement;
|
||||
|
||||
return element;
|
||||
|
||||
@@ -3951,3 +3951,167 @@ html.picking-component * {
|
||||
.rio-file-picker-area > input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Node Editor
|
||||
$graph-editor-node-padding: 0.7rem;
|
||||
$graph-editor-port-size: 1.4rem;
|
||||
|
||||
.rio-graph-editor > * {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rio-graph-editor::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
z-index: -1;
|
||||
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
var(--rio-local-fg) 12%,
|
||||
transparent 12%
|
||||
);
|
||||
background-size: 1.3rem 1.3rem;
|
||||
|
||||
opacity: 0.15;
|
||||
|
||||
& > * {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.rio-graph-editor-node {
|
||||
pointer-events: auto;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
position: absolute;
|
||||
min-width: 8rem;
|
||||
|
||||
background-color: var(--rio-global-secondary-bg);
|
||||
border-radius: var(--rio-global-corner-radius-small)
|
||||
var(--rio-global-corner-radius-small)
|
||||
var(--rio-global-corner-radius-medium)
|
||||
var(--rio-global-corner-radius-medium);
|
||||
|
||||
box-shadow: 0 0.1rem 0.2rem var(--rio-global-shadow-color);
|
||||
|
||||
transition:
|
||||
background-color 0.1s ease-in-out,
|
||||
box-shadow 0.1s ease-in-out;
|
||||
|
||||
&:has(:nth-child(1):hover) {
|
||||
background-color: var(--rio-global-secondary-bg-active);
|
||||
box-shadow: 0 0.2rem 0.6rem var(--rio-global-shadow-color);
|
||||
}
|
||||
}
|
||||
|
||||
.rio-graph-editor-node-header {
|
||||
user-select: none;
|
||||
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
padding: 0.3rem $graph-editor-node-padding 0.3rem $graph-editor-node-padding;
|
||||
|
||||
cursor: move;
|
||||
|
||||
color: var(--rio-global-secondary-fg);
|
||||
|
||||
// transition: background-color 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.rio-graph-editor-node-header:hover {
|
||||
// background-color: var(--rio-global-secondary-bg-active);
|
||||
}
|
||||
|
||||
.rio-graph-editor-node-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
|
||||
padding: $graph-editor-node-padding;
|
||||
|
||||
border-radius: var(--rio-global-corner-radius-small);
|
||||
background-color: var(--rio-local-bg);
|
||||
}
|
||||
|
||||
.rio-graph-editor-port {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rio-graph-editor-port-circle {
|
||||
position: absolute;
|
||||
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
|
||||
width: $graph-editor-port-size;
|
||||
height: $graph-editor-port-size;
|
||||
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.rio-graph-editor-input {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rio-graph-editor-output {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rio-graph-editor-input > .rio-graph-editor-port-circle {
|
||||
left: 0;
|
||||
transform: translateX(
|
||||
calc(-1 * ($graph-editor-port-size / 2 + $graph-editor-node-padding))
|
||||
);
|
||||
}
|
||||
|
||||
.rio-graph-editor-output > .rio-graph-editor-port-circle {
|
||||
right: 0;
|
||||
transform: translateX(
|
||||
calc($graph-editor-port-size / 2 + $graph-editor-node-padding)
|
||||
);
|
||||
}
|
||||
|
||||
.rio-graph-editor-port-circle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 25%;
|
||||
top: 25%;
|
||||
right: 25%;
|
||||
bottom: 25%;
|
||||
|
||||
border-radius: 50%;
|
||||
background-color: var(--port-color);
|
||||
|
||||
transition:
|
||||
left 0.1s ease-in-out,
|
||||
top 0.1s ease-in-out,
|
||||
right 0.1s ease-in-out,
|
||||
bottom 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.rio-graph-editor-port-circle:hover::after {
|
||||
left: 15%;
|
||||
top: 15%;
|
||||
right: 15%;
|
||||
bottom: 15%;
|
||||
}
|
||||
|
||||
.rio-graph-editor-port-text {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ from .drawer import *
|
||||
from .dropdown import *
|
||||
from .file_picker_area import *
|
||||
from .flow_container import *
|
||||
from .graph_editor import *
|
||||
from .grid import *
|
||||
from .html import *
|
||||
from .icon import *
|
||||
|
||||
126
rio/components/graph_editor.py
Normal file
126
rio/components/graph_editor.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
import rio
|
||||
|
||||
from .fundamental_component import FundamentalComponent
|
||||
|
||||
__all__ = [
|
||||
"GraphEditor",
|
||||
]
|
||||
|
||||
P = t.ParamSpec("P")
|
||||
|
||||
|
||||
@t.final
|
||||
class GraphStore:
|
||||
def __init__(self) -> None:
|
||||
# The graph currently connected to the store. At any point in time there
|
||||
# can only be one graph connected to the store.
|
||||
#
|
||||
# It is the store's responsibility to keep JavaScript and Rio up-to-date
|
||||
# with any changes to the graph.
|
||||
self._graph: GraphEditor | None = None
|
||||
|
||||
# All node instances currently in the graph
|
||||
self._nodes: list[rio.Component] = []
|
||||
|
||||
# All as-of-yet uninstantiated nodes. Since nodes can only be
|
||||
# instantiated while in a `build` function, this list will be used to
|
||||
# instantiate all nodes when the graph is being (re-) built.
|
||||
self._uninstantiated_nodes: list[tuple[t.Callable, tuple, dict]] = []
|
||||
|
||||
def _set_editor(self, editor: GraphEditor) -> list[rio.Component]:
|
||||
"""
|
||||
Connects the graph editor to the store and returns the flat list of
|
||||
children of the graph. This list will be shared between the two and
|
||||
updated in-place as needed.
|
||||
|
||||
If the store was already previously connected to another graph editor,
|
||||
it will disconnect from it first.
|
||||
"""
|
||||
# Already connected to an editor?
|
||||
if self._graph is not None:
|
||||
self._graph.children = []
|
||||
self._graph.session._register_dirty_component(
|
||||
self._graph,
|
||||
include_children_recursively=False,
|
||||
)
|
||||
|
||||
# Connect to the new editor
|
||||
self._graph = editor
|
||||
return self._nodes
|
||||
|
||||
def add_node(
|
||||
self,
|
||||
node_type: t.Callable[P],
|
||||
*args: P.args,
|
||||
**kwargs: P.kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Creates a node of the given type and adds it to the graph.
|
||||
"""
|
||||
# Keep track of the node. It can't be instantiated yet, because Rio
|
||||
# components can only be created inside of `build` functions.
|
||||
self._uninstantiated_nodes.append((node_type, args, kwargs))
|
||||
|
||||
# Tell the graph editor to rebuild
|
||||
if self._graph is not None:
|
||||
self._graph.session._register_dirty_component(
|
||||
self._graph,
|
||||
include_children_recursively=False,
|
||||
)
|
||||
|
||||
|
||||
@t.final
|
||||
class GraphEditor(FundamentalComponent):
|
||||
children: list[rio.Component]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*children: rio.Component,
|
||||
key: str | int | None = None,
|
||||
margin: float | None = None,
|
||||
margin_x: float | None = None,
|
||||
margin_y: float | None = None,
|
||||
margin_left: float | None = None,
|
||||
margin_top: float | None = None,
|
||||
margin_right: float | None = None,
|
||||
margin_bottom: float | None = None,
|
||||
min_width: float = 0,
|
||||
min_height: float = 0,
|
||||
# MAX-SIZE-BRANCH max_width: float | None = None,
|
||||
# MAX-SIZE-BRANCH max_height: float | None = None,
|
||||
grow_x: bool = False,
|
||||
grow_y: bool = False,
|
||||
align_x: float | None = None,
|
||||
align_y: float | None = None,
|
||||
# SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never",
|
||||
# SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never",
|
||||
) -> None:
|
||||
super().__init__(
|
||||
key=key,
|
||||
margin=margin,
|
||||
margin_x=margin_x,
|
||||
margin_y=margin_y,
|
||||
margin_left=margin_left,
|
||||
margin_top=margin_top,
|
||||
margin_right=margin_right,
|
||||
margin_bottom=margin_bottom,
|
||||
min_width=min_width,
|
||||
min_height=min_height,
|
||||
# MAX-SIZE-BRANCH max_width=max_width,
|
||||
# MAX-SIZE-BRANCH max_height=max_height,
|
||||
grow_x=grow_x,
|
||||
grow_y=grow_y,
|
||||
align_x=align_x,
|
||||
align_y=align_y,
|
||||
# SCROLLING-REWORK scroll_x=scroll_x,
|
||||
# SCROLLING-REWORK scroll_y=scroll_y,
|
||||
)
|
||||
|
||||
self.children = list(children)
|
||||
|
||||
|
||||
GraphEditor._unique_id_ = "GraphEditor-builtin"
|
||||
@@ -21,7 +21,7 @@ class _LinearContainer(FundamentalComponent):
|
||||
proportions: t.Literal["homogeneous"] | t.Sequence[float] | None = None
|
||||
|
||||
# Don't let @dataclass generate a constructor
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def add(self, child: rio.Component) -> te.Self:
|
||||
@@ -299,7 +299,7 @@ class Column(_LinearContainer):
|
||||
align_y: float | None = None,
|
||||
# SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never",
|
||||
# SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never",
|
||||
):
|
||||
) -> None:
|
||||
super().__init__(
|
||||
key=key,
|
||||
margin=margin,
|
||||
|
||||
Reference in New Issue
Block a user