RountangleEditor.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import * as React from "react";
  2. import {RountangleComponent} from "./RountangleComponent";
  3. import {PrimitiveValue, UUID} from "../../onion/types";
  4. import {GraphType} from "../editable_graph";
  5. import {RountangleAction} from "./RountangleActions";
  6. import {INodeState, IValueState, GraphState} from "../../onion/graph_state";
  7. import {PrimitiveDelta, PrimitiveRegistry} from "../../onion/primitive_delta";
  8. import {assert, assertNever} from "../../util/assert";
  9. import {d3Types} from "../graph";
  10. export interface Rountangle {
  11. readonly name: string;
  12. readonly posX: number;
  13. readonly posY: number;
  14. readonly posZ: number;
  15. readonly width: number;
  16. readonly height: number;
  17. }
  18. export function isRountangle(d3node: d3Types.d3Node<INodeState | IValueState>) {
  19. if (!(d3node.obj.type === "node")) return false;
  20. const nodeState = d3node.obj as unknown as INodeState;
  21. const outgoing = nodeState.getOutgoingEdges();
  22. if (!(outgoing.get('type')?.asTarget() === 'Rountangle')) return false;
  23. if (!(typeof outgoing.get('label')?.asTarget() === "string")) return false;
  24. if (!(typeof outgoing.get('x')?.asTarget() === "number")) return false;
  25. if (!(typeof outgoing.get('y')?.asTarget() === "number")) return false;
  26. if (!(typeof outgoing.get('z-index')?.asTarget() === "number")) return false;
  27. if (!(typeof outgoing.get('width')?.asTarget() === "number")) return false;
  28. if (!(typeof outgoing.get('height')?.asTarget() === "number")) return false;
  29. return true;
  30. }
  31. // Precondition: isRountangle(d3Node)
  32. export function graphStateToRountangle(d3node: d3Types.d3Node<INodeState | IValueState>): [PrimitiveValue, Rountangle] {
  33. if (!isRountangle(d3node)) {
  34. throw new Error(`RountangleParser Cannot parse: ${d3node}`);
  35. }
  36. const nodeState = d3node.obj as INodeState;
  37. const outgoing = nodeState.getOutgoingEdges();
  38. return [nodeState.creation.id.value, {
  39. name: outgoing.get("label")!.asTarget() as string,
  40. posX: outgoing.get("x")!.asTarget() as number,
  41. posY: outgoing.get("y")!.asTarget() as number,
  42. posZ: outgoing.get("z-index")!.asTarget() as number,
  43. width: outgoing.get("width")!.asTarget() as number,
  44. height: outgoing.get("height")!.asTarget() as number,
  45. }]
  46. }
  47. // export function getValueForLabelAsString(node:INodeState, label:string): string {
  48. // const delta = node.getOutgoingEdges().get(label);
  49. // if (delta) {
  50. // const value = delta.target.getTarget();
  51. // if (typeof value === "string") {
  52. // return value;
  53. // }
  54. // else {
  55. // throw new Error(`Outgoing edge "${label}" is not of type string. Type is: ${typeof value}`);
  56. // }
  57. // }
  58. // else {
  59. // throw new Error(`Node ID "${node.creation.id}" has no outgoing edge "${label}"`);
  60. // }
  61. // }
  62. // export function getValueForLabelAsNumber(node:INodeState, label:string): number {
  63. // const target = node.getOutgoingEdges().get(label)!.asTarget();
  64. // if (typeof target === "number") {
  65. // return target;
  66. // }
  67. // throw new Error(`Outgoing edge "${label}" is not of type number. Type is: ${typeof target}`);
  68. // }
  69. export interface RountangleEditorProps {
  70. graph: GraphType;
  71. graphState: GraphState;
  72. generateUUID: () => UUID;
  73. primitiveRegistry: PrimitiveRegistry;
  74. onUserEdit?: (deltas: PrimitiveDelta[], description: string) => void;
  75. }
  76. interface RountangleEditorState {
  77. baseWidth: number;
  78. currentWidth: number;
  79. baseHeight: number;
  80. currentHeight: number;
  81. translateX: number;
  82. translateY: number;
  83. zoom: number;
  84. dragging: boolean;
  85. moved: boolean;
  86. }
  87. export class RountangleEditor extends React.Component<RountangleEditorProps, RountangleEditorState> {
  88. readonly canvasRef: React.RefObject<SVGSVGElement>
  89. private onDispatchListeners: Set<(action: RountangleAction) => void>;
  90. constructor(props) {
  91. super(props);
  92. this.canvasRef = React.createRef<SVGSVGElement>();
  93. this.onDispatchListeners = new Set();
  94. this.state = {
  95. baseWidth: 350,
  96. baseHeight: 350,
  97. currentHeight: 350,
  98. currentWidth: 350,
  99. translateX: 0,
  100. translateY: 0,
  101. zoom: 1.0,
  102. dragging: false,
  103. moved: false
  104. }
  105. }
  106. componentDidMount() {
  107. this.addOnDispatchListener(_ => this.forceUpdate());
  108. // IMPORTANT: notice the `passive: false` option
  109. this.canvasRef.current?.addEventListener('wheel', this.onWheel, { passive: false });
  110. }
  111. componentWillUnmount() {
  112. this.canvasRef.current?.removeEventListener('wheel', this.onWheel);
  113. }
  114. // this function gets a list of key-value-pairs and returns a compositeDelta that represents the creation of the
  115. // appropriate nodes and edges in the graph state.
  116. createValueDeltas(nodeState: INodeState, values: [string,PrimitiveValue][]): PrimitiveDelta[] {
  117. const deltas: PrimitiveDelta[] = [];
  118. for (const [edgeLabel, value] of values) {
  119. deltas.push(...nodeState.getDeltasForSetEdge(this.props.primitiveRegistry, edgeLabel, value));
  120. }
  121. return deltas;
  122. }
  123. dispatch = (action: RountangleAction) => {
  124. const deltas: PrimitiveDelta[] = [];
  125. let uuid: UUID;
  126. let nodeState: INodeState | undefined;
  127. switch (action.tag) {
  128. case "createRountangle":
  129. uuid = new UUID(action.id);
  130. const createRountangleNodeDelta = this.props.primitiveRegistry.newNodeCreation(uuid);
  131. deltas.push(createRountangleNodeDelta);
  132. const edgeSpec: [string,PrimitiveValue][] = [
  133. ["type","Rountangle"],
  134. ["label",action.name],
  135. ["x",action.posX],
  136. ["y",action.posY],
  137. ["z-index",action.posZ],
  138. ["width",action.width],
  139. ["height",action.height],
  140. ];
  141. deltas.push(... edgeSpec.map(([edgeLabel, value]) =>
  142. this.props.primitiveRegistry.newEdgeCreation(createRountangleNodeDelta, edgeLabel, value)));
  143. break;
  144. case 'moveRountangle':
  145. // get nodeState from clicked node
  146. nodeState = this.props.graphState.nodes.get(action.id);
  147. if (nodeState !== undefined) {
  148. deltas.push(...this.createValueDeltas(nodeState, [["x", action.newPosX], ["y", action.newPosY]]));
  149. }
  150. break;
  151. case 'deleteRountangle':
  152. // get nodeState from clicked node
  153. nodeState = this.props.graphState.nodes.get(action.id);
  154. if (nodeState !== undefined) {
  155. deltas.push(...nodeState.getDeltasForDelete(this.props.primitiveRegistry));
  156. }
  157. break;
  158. case 'resizeRountangle':
  159. nodeState = this.props.graphState.nodes.get(action.id);
  160. if (nodeState !== undefined) {
  161. deltas.push(...this.createValueDeltas(nodeState, [["width",action.width],["height",action.height]]));
  162. }
  163. break;
  164. case 'renameRountangle':
  165. nodeState = this.props.graphState.nodes.get(action.id);
  166. if (nodeState !== undefined) {
  167. deltas.push(...this.createValueDeltas(nodeState, [["label",action.newName]]));
  168. }
  169. break;
  170. case 'changeRountangleZ':
  171. // this.setState( (state,_) => ({
  172. // ...state,
  173. // [action.id]: {...state[action.id], posZ: action.newPosZ}
  174. // }));
  175. // TODO
  176. break;
  177. default: assertNever(action);
  178. }
  179. if (deltas.length > 0) {
  180. this.props.onUserEdit?.(deltas, action.tag);
  181. }
  182. this.onDispatchListeners.forEach(listener => listener(action));
  183. }
  184. addOnDispatchListener(newListener: (action: RountangleAction) => void) {
  185. this.onDispatchListeners.add(newListener);
  186. }
  187. onPointerDown = (event: React.PointerEvent<SVGSVGElement>) => {
  188. // only left mouse button
  189. if (event.button !== 0) return;
  190. event.stopPropagation();
  191. event.preventDefault();
  192. event.currentTarget.setPointerCapture(event.pointerId);
  193. if (!event.altKey) {
  194. this.setState({dragging: true, moved: false});
  195. }
  196. }
  197. onPointerMove = (event: React.PointerEvent<SVGSVGElement>) => {
  198. if (!this.state.dragging) return;
  199. if (event.movementY !== 0 ||event.movementX !== 0) {
  200. this.setState({
  201. translateX: this.state.translateX - event.movementX * this.state.zoom,
  202. translateY: this.state.translateY - event.movementY * this.state.zoom,
  203. moved: true
  204. });
  205. }
  206. event.stopPropagation();
  207. event.preventDefault();
  208. }
  209. private clickToSVGPos = (x:number, y: number): DOMPoint | undefined => {
  210. // point transformation adapted from https://stackoverflow.com/a/70595400
  211. if (!this.canvasRef.current) return undefined;
  212. const screenCTM = this.canvasRef.current.getScreenCTM();
  213. if (screenCTM === null) return undefined;
  214. const refPoint = new DOMPoint(x, y);
  215. return refPoint.matrixTransform(screenCTM.inverse());
  216. }
  217. onPointerUp = (event: React.PointerEvent<SVGSVGElement>) => {
  218. event.stopPropagation();
  219. event.preventDefault();
  220. event.currentTarget.releasePointerCapture(event.pointerId);
  221. this.setState({
  222. dragging: false,
  223. moved: false
  224. });
  225. // add new state on left mouse button and ALT-Key pressed
  226. if (event.button === 0 && event.altKey) {
  227. const cursorPoint = this.clickToSVGPos(event.clientX, event.clientY);
  228. if (cursorPoint) {
  229. const newRountangleName = prompt('Name', 'New Rountangle');
  230. if (newRountangleName) {
  231. this.dispatch({
  232. tag: 'createRountangle',
  233. id: this.props.generateUUID().value.toString(),
  234. posX: cursorPoint.x,
  235. posY: cursorPoint.y,
  236. posZ: 10,
  237. width: 100,
  238. height: 66,
  239. name: newRountangleName});
  240. }
  241. }
  242. }
  243. }
  244. onWheel = (event: WheelEvent) => {
  245. event.preventDefault();
  246. event.stopPropagation();
  247. if (event.deltaY === 0) return;
  248. if(!this.canvasRef.current) return;
  249. const newZoom = event.deltaY > 0 ? Math.min(this.state.zoom * 1.1, 3.0) : Math.max(this.state.zoom * 0.9, 0.1);
  250. this.setState({
  251. zoom: newZoom,
  252. translateX: this.state.translateX + (this.state.baseWidth * this.state.zoom - this.state.baseWidth * newZoom)/2,
  253. translateY: this.state.translateY + (this.state.baseHeight * this.state.zoom - this.state.baseHeight * newZoom)/2,
  254. currentWidth: this.state.baseWidth * newZoom,
  255. currentHeight: this.state.baseHeight * newZoom
  256. })
  257. }
  258. render() {
  259. return(
  260. <svg
  261. className={`re-background canvas ${this.state.dragging ? 'dragging' : ''}`}
  262. onPointerDown={this.onPointerDown}
  263. onPointerMove={this.onPointerMove}
  264. onPointerUp={this.onPointerUp}
  265. width='350px'
  266. height='100%'
  267. viewBox={`${this.state.translateX} ${this.state.translateY} ${this.state.currentWidth} ${this.state.currentHeight}`}
  268. ref={this.canvasRef}
  269. >
  270. {
  271. Array.from(this.props.graph.nodes)
  272. .filter(isRountangle)
  273. .map(graphStateToRountangle)
  274. .map(node => {
  275. const [id, rountangle] = node;
  276. return <RountangleComponent
  277. key={id.toString()}
  278. id={id}
  279. name={rountangle.name}
  280. posX={rountangle.posX}
  281. posY={rountangle.posY}
  282. posZ={rountangle.posZ}
  283. width={rountangle.width}
  284. height={rountangle.height}
  285. zoom={this.state.zoom}
  286. dispatch={this.dispatch}
  287. />
  288. })
  289. }
  290. </svg>
  291. )
  292. }
  293. }