import * as React from "react"; import {RountangleComponent} from "./RountangleComponent"; import {PrimitiveValue, UUID} from "../../onion/types"; import {GraphType} from "../editable_graph"; import {RountangleAction} from "./RountangleActions"; import {INodeState, IValueState, GraphState} from "../../onion/graph_state"; import {PrimitiveDelta, PrimitiveRegistry} from "../../onion/primitive_delta"; import {assert, assertNever} from "../../util/assert"; import {d3Types} from "../graph"; export interface Rountangle { readonly name: string; readonly posX: number; readonly posY: number; readonly posZ: number; readonly width: number; readonly height: number; } export function isRountangle(d3node: d3Types.d3Node) { if (!(d3node.obj.type === "node")) return false; const nodeState = d3node.obj as unknown as INodeState; const outgoing = nodeState.getOutgoingEdges(); if (!(outgoing.get('type')?.asTarget() === 'Rountangle')) return false; if (!(typeof outgoing.get('label')?.asTarget() === "string")) return false; if (!(typeof outgoing.get('x')?.asTarget() === "number")) return false; if (!(typeof outgoing.get('y')?.asTarget() === "number")) return false; if (!(typeof outgoing.get('z-index')?.asTarget() === "number")) return false; if (!(typeof outgoing.get('width')?.asTarget() === "number")) return false; if (!(typeof outgoing.get('height')?.asTarget() === "number")) return false; return true; } // Precondition: isRountangle(d3Node) export function graphStateToRountangle(d3node: d3Types.d3Node): [PrimitiveValue, Rountangle] { if (!isRountangle(d3node)) { throw new Error(`RountangleParser Cannot parse: ${d3node}`); } const nodeState = d3node.obj as INodeState; const outgoing = nodeState.getOutgoingEdges(); return [nodeState.creation.id.value, { name: outgoing.get("label")!.asTarget() as string, posX: outgoing.get("x")!.asTarget() as number, posY: outgoing.get("y")!.asTarget() as number, posZ: outgoing.get("z-index")!.asTarget() as number, width: outgoing.get("width")!.asTarget() as number, height: outgoing.get("height")!.asTarget() as number, }] } // export function getValueForLabelAsString(node:INodeState, label:string): string { // const delta = node.getOutgoingEdges().get(label); // if (delta) { // const value = delta.target.getTarget(); // if (typeof value === "string") { // return value; // } // else { // throw new Error(`Outgoing edge "${label}" is not of type string. Type is: ${typeof value}`); // } // } // else { // throw new Error(`Node ID "${node.creation.id}" has no outgoing edge "${label}"`); // } // } // export function getValueForLabelAsNumber(node:INodeState, label:string): number { // const target = node.getOutgoingEdges().get(label)!.asTarget(); // if (typeof target === "number") { // return target; // } // throw new Error(`Outgoing edge "${label}" is not of type number. Type is: ${typeof target}`); // } export interface RountangleEditorProps { graph: GraphType; graphState: GraphState; generateUUID: () => UUID; primitiveRegistry: PrimitiveRegistry; onUserEdit?: (deltas: PrimitiveDelta[], description: string) => void; } interface RountangleEditorState { baseWidth: number; currentWidth: number; baseHeight: number; currentHeight: number; translateX: number; translateY: number; zoom: number; dragging: boolean; moved: boolean; } export class RountangleEditor extends React.Component { readonly canvasRef: React.RefObject private onDispatchListeners: Set<(action: RountangleAction) => void>; constructor(props) { super(props); this.canvasRef = React.createRef(); this.onDispatchListeners = new Set(); this.state = { baseWidth: 350, baseHeight: 350, currentHeight: 350, currentWidth: 350, translateX: 0, translateY: 0, zoom: 1.0, dragging: false, moved: false } } componentDidMount() { this.addOnDispatchListener(_ => this.forceUpdate()); // IMPORTANT: notice the `passive: false` option this.canvasRef.current?.addEventListener('wheel', this.onWheel, { passive: false }); } componentWillUnmount() { this.canvasRef.current?.removeEventListener('wheel', this.onWheel); } // this function gets a list of key-value-pairs and returns a compositeDelta that represents the creation of the // appropriate nodes and edges in the graph state. createValueDeltas(nodeState: INodeState, values: [string,PrimitiveValue][]): PrimitiveDelta[] { const deltas: PrimitiveDelta[] = []; for (const [edgeLabel, value] of values) { deltas.push(...nodeState.getDeltasForSetEdge(this.props.primitiveRegistry, edgeLabel, value)); } return deltas; } dispatch = (action: RountangleAction) => { const deltas: PrimitiveDelta[] = []; let uuid: UUID; let nodeState: INodeState | undefined; switch (action.tag) { case "createRountangle": uuid = new UUID(action.id); const createRountangleNodeDelta = this.props.primitiveRegistry.newNodeCreation(uuid); deltas.push(createRountangleNodeDelta); const edgeSpec: [string,PrimitiveValue][] = [ ["type","Rountangle"], ["label",action.name], ["x",action.posX], ["y",action.posY], ["z-index",action.posZ], ["width",action.width], ["height",action.height], ]; deltas.push(... edgeSpec.map(([edgeLabel, value]) => this.props.primitiveRegistry.newEdgeCreation(createRountangleNodeDelta, edgeLabel, value))); break; case 'moveRountangle': // get nodeState from clicked node nodeState = this.props.graphState.nodes.get(action.id); if (nodeState !== undefined) { deltas.push(...this.createValueDeltas(nodeState, [["x", action.newPosX], ["y", action.newPosY]])); } break; case 'deleteRountangle': // get nodeState from clicked node nodeState = this.props.graphState.nodes.get(action.id); if (nodeState !== undefined) { deltas.push(...nodeState.getDeltasForDelete(this.props.primitiveRegistry)); } break; case 'resizeRountangle': nodeState = this.props.graphState.nodes.get(action.id); if (nodeState !== undefined) { deltas.push(...this.createValueDeltas(nodeState, [["width",action.width],["height",action.height]])); } break; case 'renameRountangle': nodeState = this.props.graphState.nodes.get(action.id); if (nodeState !== undefined) { deltas.push(...this.createValueDeltas(nodeState, [["label",action.newName]])); } break; case 'changeRountangleZ': // this.setState( (state,_) => ({ // ...state, // [action.id]: {...state[action.id], posZ: action.newPosZ} // })); // TODO break; default: assertNever(action); } if (deltas.length > 0) { this.props.onUserEdit?.(deltas, action.tag); } this.onDispatchListeners.forEach(listener => listener(action)); } addOnDispatchListener(newListener: (action: RountangleAction) => void) { this.onDispatchListeners.add(newListener); } onPointerDown = (event: React.PointerEvent) => { // only left mouse button if (event.button !== 0) return; event.stopPropagation(); event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); if (!event.altKey) { this.setState({dragging: true, moved: false}); } } onPointerMove = (event: React.PointerEvent) => { if (!this.state.dragging) return; if (event.movementY !== 0 ||event.movementX !== 0) { this.setState({ translateX: this.state.translateX - event.movementX * this.state.zoom, translateY: this.state.translateY - event.movementY * this.state.zoom, moved: true }); } event.stopPropagation(); event.preventDefault(); } private clickToSVGPos = (x:number, y: number): DOMPoint | undefined => { // point transformation adapted from https://stackoverflow.com/a/70595400 if (!this.canvasRef.current) return undefined; const screenCTM = this.canvasRef.current.getScreenCTM(); if (screenCTM === null) return undefined; const refPoint = new DOMPoint(x, y); return refPoint.matrixTransform(screenCTM.inverse()); } onPointerUp = (event: React.PointerEvent) => { event.stopPropagation(); event.preventDefault(); event.currentTarget.releasePointerCapture(event.pointerId); this.setState({ dragging: false, moved: false }); // add new state on left mouse button and ALT-Key pressed if (event.button === 0 && event.altKey) { const cursorPoint = this.clickToSVGPos(event.clientX, event.clientY); if (cursorPoint) { const newRountangleName = prompt('Name', 'New Rountangle'); if (newRountangleName) { this.dispatch({ tag: 'createRountangle', id: this.props.generateUUID().value.toString(), posX: cursorPoint.x, posY: cursorPoint.y, posZ: 10, width: 100, height: 66, name: newRountangleName}); } } } } onWheel = (event: WheelEvent) => { event.preventDefault(); event.stopPropagation(); if (event.deltaY === 0) return; if(!this.canvasRef.current) return; const newZoom = event.deltaY > 0 ? Math.min(this.state.zoom * 1.1, 3.0) : Math.max(this.state.zoom * 0.9, 0.1); this.setState({ zoom: newZoom, translateX: this.state.translateX + (this.state.baseWidth * this.state.zoom - this.state.baseWidth * newZoom)/2, translateY: this.state.translateY + (this.state.baseHeight * this.state.zoom - this.state.baseHeight * newZoom)/2, currentWidth: this.state.baseWidth * newZoom, currentHeight: this.state.baseHeight * newZoom }) } render() { return( { Array.from(this.props.graph.nodes) .filter(isRountangle) .map(graphStateToRountangle) .map(node => { const [id, rountangle] = node; return }) } ) } }