123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- 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<INodeState | IValueState>) {
- 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<INodeState | IValueState>): [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<RountangleEditorProps, RountangleEditorState> {
- readonly canvasRef: React.RefObject<SVGSVGElement>
- private onDispatchListeners: Set<(action: RountangleAction) => void>;
- constructor(props) {
- super(props);
- this.canvasRef = React.createRef<SVGSVGElement>();
- 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<SVGSVGElement>) => {
- // 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<SVGSVGElement>) => {
- 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<SVGSVGElement>) => {
- 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(
- <svg
- className={`re-background canvas ${this.state.dragging ? 'dragging' : ''}`}
- onPointerDown={this.onPointerDown}
- onPointerMove={this.onPointerMove}
- onPointerUp={this.onPointerUp}
- width='350px'
- height='100%'
- viewBox={`${this.state.translateX} ${this.state.translateY} ${this.state.currentWidth} ${this.state.currentHeight}`}
- ref={this.canvasRef}
- >
- {
- Array.from(this.props.graph.nodes)
- .filter(isRountangle)
- .map(graphStateToRountangle)
- .map(node => {
- const [id, rountangle] = node;
- return <RountangleComponent
- key={id.toString()}
- id={id}
- name={rountangle.name}
- posX={rountangle.posX}
- posY={rountangle.posY}
- posZ={rountangle.posZ}
- width={rountangle.width}
- height={rountangle.height}
- zoom={this.state.zoom}
- dispatch={this.dispatch}
- />
- })
- }
- </svg>
- )
- }
- }
|