import React from "react"; import { action, computed, observable, runInAction, makeObservable } from "mobx"; import { observer } from "mobx-react"; import classNames from "classnames"; import { Point, pointDistance, Rect } from "eez-studio-shared/geometry"; import { Draggable } from "eez-studio-ui/draggable"; import { IPanel } from "project-editor/store"; import { ProjectContext } from "project-editor/project/context"; import { Flow } from "project-editor/flow/flow"; import { ConnectionLine } from "project-editor/flow/connection-line"; import type { IFlowContext } from "project-editor/flow/flow-interfaces"; import { Svg } from "project-editor/flow/editor/render"; import { ConnectionLineShape } from "project-editor/flow/connection-line/ConnectionLineComponent"; import { IMouseHandler, PanMouseHandler } from "project-editor/flow/editor/mouse-handler"; import { ProjectEditor } from "project-editor/project-editor-interface"; import { getProjectStore } from "project-editor/store"; import { getObjectIdFromPoint } from "project-editor/flow/editor/bounding-rects"; import { TreeObjectAdapter } from "project-editor/core/objectAdapter"; import type { IDocument, IViewState, IEditorOptions, IResizeHandler, IDataContext } from "project-editor/flow/flow-interfaces"; import { Transform } from "project-editor/flow/editor/transform"; import { Component, Widget } from "project-editor/flow/component"; import { guid } from "eez-studio-shared/guid"; import { ChangedFlowObjects, ChangeOperations } from "./state"; //////////////////////////////////////////////////////////////////////////////// export const DEFAULT_SCALE = 0.75; const CONF_DOUBLE_CLICK_TIME = 350; // ms const CONF_DOUBLE_CLICK_DISTANCE = 5; // px //////////////////////////////////////////////////////////////////////////////// export const FlowViewer = observer( class FlowViewer extends React.Component<{ title: string; legend?: boolean; flow: Flow; transform: Transform; changedObjects: ChangedFlowObjects; selectedObject: Component | ConnectionLine | undefined; }> implements IPanel { static contextType = ProjectContext; declare context: React.ContextType; divRef = React.createRef(); constructor(props: any) { super(props); makeObservable(this, { flowContext: computed }); } get flowContext() { const flowContext = new FlowContext( this.props.flow, this.props.transform ); return flowContext; } componentDidMount() { this.context.navigationStore.mountPanel(this); } componentDidCatch(error: any, info: any) { console.error(error, info); } componentWillUnmount() { this.context.navigationStore.unmountPanel(this); } // interface IPanel implementation get selectedObject() { return undefined; } get selectedObjects() { return []; } onFocus = () => { this.context.navigationStore.setSelectedPanel(this); }; getDragComponent(event: React.DragEvent) { return undefined; } onDoubleClick = () => { this.flowContext.viewState.resetTransform(); }; static getDerivedStateFromError(error: any) { return { hasError: true }; } render() { const flow = this.props.flow; return (
{this.flowContext.document?.flow.object === flow && ( <> { // render widget components
{flow.renderWidgetComponents( this.flowContext )}
} { // render connection lines } { // render action components
{flow.renderActionComponents( this.flowContext )}
} { // mark changed objects
{this.props.changedObjects.map( changedObject => { if ( changedObject.object instanceof ProjectEditor.ComponentClass ) { const component = changedObject.object; return (
); } return null; } )}
} )}
{this.props.title}
{this.props.legend === true && (
ADDED REMOVED UPDATED
)}
); } } ); //////////////////////////////////////////////////////////////////////////////// export const Canvas = observer( class Canvas extends React.Component<{ children?: React.ReactNode; flowContext: IFlowContext; pageRect?: Rect; }> { div: HTMLDivElement; resizeObserver: ResizeObserver; deltaY = 0; buttonsAtDown: number; lastMouseUpPosition: Point; lastMouseUpTime: number | undefined; draggable = new Draggable(this); constructor(props: any) { super(props); makeObservable(this, { _mouseHandler: observable, onDragStart: action.bound, onDragEnd: action.bound }); this.resizeObserver = new ResizeObserver( this.resizeObserverCallback ); } _mouseHandler: IMouseHandler | undefined; get mouseHandler() { return this._mouseHandler; } set mouseHandler(value: IMouseHandler | undefined) { runInAction(() => { this._mouseHandler = value; }); } _setOverflowTimeout: any; resizeObserverCallback = () => { if ($(this.div).is(":visible")) { const transform = this.props.flowContext.viewState.transform; let clientRect = this.div.getBoundingClientRect(); if ( clientRect.left !== transform.clientRect.left || clientRect.top !== transform.clientRect.top || (clientRect.width && clientRect.width !== transform.clientRect.width) || (clientRect.height && clientRect.height !== transform.clientRect.height) ) { runInAction(() => { transform.clientRect = clientRect; }); } } }; componentDidMount() { this.draggable.attach(this.div); this.div.addEventListener("wheel", this.onWheel, { passive: false }); if (this.div) { this.resizeObserver.observe(this.div); } } componentDidUpdate() { this.resizeObserverCallback(); } componentWillUnmount() { this.draggable.attach(null); this.div.removeEventListener("wheel", this.onWheel); if (this.div) { this.resizeObserver.unobserve(this.div); } if (this._setOverflowTimeout) { clearTimeout(this._setOverflowTimeout); this._setOverflowTimeout = undefined; } } onWheel = (event: WheelEvent) => { if (event.buttons === 4 || this.props.flowContext.frontFace) { // do nothing if mouse wheel is pressed, i.e. pan will be activated in onMouseDown return; } if (event.ctrlKey) { this.deltaY += event.deltaY; if (Math.abs(this.deltaY) > 10) { const transform = this.props.flowContext.viewState.transform; let scale: number; if (this.deltaY < 0) { scale = transform.nextScale; } else { scale = transform.previousScale; } this.deltaY = 0; var point = transform.clientToOffsetPoint({ x: event.clientX, y: event.clientY }); let x = point.x - transform.clientRect.width / 2; let y = point.y - transform.clientRect.height / 2; let tx = x - ((x - transform.translate.x) * scale) / transform.scale; let ty = y - ((y - transform.translate.y) * scale) / transform.scale; runInAction(() => { this.props.flowContext.viewState.transform.scale = scale; this.props.flowContext.viewState.transform.translate = { x: tx, y: ty }; }); } } else { runInAction(() => { const transform = this.props.flowContext.viewState.transform; this.props.flowContext.viewState.transform.translate = { x: transform.translate.x - (event.shiftKey ? event.deltaY : event.deltaX), y: transform.translate.y - (event.shiftKey ? event.deltaX : event.deltaY) }; }); } event.preventDefault(); event.stopPropagation(); }; onDragStart(event: PointerEvent) { this.buttonsAtDown = event.buttons; if (this.mouseHandler) { this.mouseHandler.up(this.props.flowContext, true); this.mouseHandler = undefined; } if (event.buttons && event.buttons !== 1) { this.mouseHandler = new PanMouseHandler(); } if (this.mouseHandler) { this.mouseHandler.lastPointerEvent = { clientX: event.clientX, clientY: event.clientY, movementX: event.movementX ?? 0, movementY: event.movementY ?? 0, ctrlKey: event.ctrlKey, shiftKey: event.shiftKey, timeStamp: event.timeStamp }; this.mouseHandler.down(this.props.flowContext, event); } } onDragMove = (event: PointerEvent) => { if (this.mouseHandler) { this.mouseHandler.lastPointerEvent = { clientX: event.clientX, clientY: event.clientY, movementX: event.movementX ? event.movementX : this.mouseHandler.lastPointerEvent ? this.mouseHandler.lastPointerEvent.movementX : 0, movementY: event.movementY ? event.movementY : this.mouseHandler.lastPointerEvent ? this.mouseHandler.lastPointerEvent.movementY : 0, ctrlKey: event.ctrlKey, shiftKey: event.shiftKey, timeStamp: event.timeStamp }; this.mouseHandler.move(this.props.flowContext, event); } }; onDragEnd(event: PointerEvent, cancel: boolean) { if (this.mouseHandler) { this.mouseHandler.up(this.props.flowContext, cancel); this.mouseHandler = undefined; } let time = new Date().getTime(); if (this.buttonsAtDown === 1) { let distance = pointDistance( { x: event.clientX, y: event.clientY }, { x: this.draggable.xDragStart, y: this.draggable.yDragStart } ); if (distance <= CONF_DOUBLE_CLICK_DISTANCE) { if (this.lastMouseUpTime !== undefined) { let distance = pointDistance( { x: event.clientX, y: event.clientY }, this.lastMouseUpPosition ); if ( time - this.lastMouseUpTime <= CONF_DOUBLE_CLICK_TIME && distance <= CONF_DOUBLE_CLICK_DISTANCE ) { // double click this.props.flowContext.viewState.resetTransform(); } } this.lastMouseUpTime = time; this.lastMouseUpPosition = { x: event.clientX, y: event.clientY }; } else { this.lastMouseUpTime = undefined; } } else { this.lastMouseUpTime = undefined; } } render() { let style: React.CSSProperties = {}; const transform = this.props.flowContext.viewState.transform; const xt = Math.round( transform.translate.x + transform.clientRect.width / 2 ); const yt = Math.round( transform.translate.y + transform.clientRect.height / 2 ); const scale = transform.scale; if ( transform.clientRect.width <= 1 || transform.clientRect.height <= 1 ) { style.visibility = "hidden"; } return (
(this.div = ref!)} style={style}>
{this.props.children}
); } } ); //////////////////////////////////////////////////////////////////////////////// const colors = { added: "green", removed: "red", updated: "#999" }; function getChangeOperationFilter(operation: ChangeOperations) { return { color: colors[operation] }; } const AllConnectionLines = observer( ({ flowContext, changedObjects, selectedObject }: { flowContext: IFlowContext; changedObjects: ChangedFlowObjects; selectedObject: Component | ConnectionLine | undefined; }) => { return ( {flowContext.document.nonSelectedConnectionLines.map( connectionLineAdapter => { const changedObject = changedObjects.find( changedObject => changedObject.object == connectionLineAdapter.object ); return ( ); } )} ); } ); //////////////////////////////////////////////////////////////////////////////// class FlowContext implements IFlowContext { containerId = guid(); document: IDocument; viewState: ViewState = new ViewState(this); editorOptions: IEditorOptions = {}; _dataContext: IDataContext; constructor(public flow: Flow, public transform: Transform) { this.document = new FlowDocument( new FlowTreeObjectAdapter(this.flow), this ); this.editorOptions = {}; makeObservable(this, { flowState: computed, resetTransform: action }); } resetTransform() { this.transform.translate = { x: 0, y: 0 }; this.transform.scale = DEFAULT_SCALE; } get projectStore() { return this.document.projectStore; } get flowState() { return undefined; } get dataContext() { return this._dataContext || this.document.projectStore.dataContext; } get frontFace() { return false; } overrideDataContext(dataContextOverridesObject: any): IFlowContext { if (!dataContextOverridesObject) { return this; } return Object.assign(new FlowContext(this.flow, this.transform), this, { _dataContext: this.dataContext.createWithDefaultValueOverrides( dataContextOverridesObject ) }); } overrideFlowState(component: Component): IFlowContext { return this; } } //////////////////////////////////////////////////////////////////////////////// class FlowTreeObjectAdapter extends TreeObjectAdapter { constructor(private flow: Flow) { super(flow); } get children() { return [ ...this.flow.components.map(child => this.transformer(child)), ...this.flow.connectionLines.map(child => this.transformer(child)) ]; } } //////////////////////////////////////////////////////////////////////////////// class FlowDocument implements IDocument { constructor( public flow: TreeObjectAdapter, private flowContext: FlowContext ) { makeObservable(this, { connectionLines: computed, projectStore: computed }); } get connectionLines() { return (this.flow.children as TreeObjectAdapter[]).filter( editorObject => editorObject.object instanceof ProjectEditor.ConnectionLineClass ); } get selectedConnectionLines() { return []; } get nonSelectedConnectionLines() { return this.connectionLines; } findObjectById(id: string) { return this.flow.getObjectAdapter(id); } findObjectParent(object: TreeObjectAdapter) { return this.flow.getParent(object); } objectFromPoint(point: Point): | { id: string; connectionInput?: string; connectionOutput?: string; } | undefined { return getObjectIdFromPoint(this, this.flowContext.viewState, point); } getObjectsInsideRect(rect: Rect) { return []; } createContextMenu(objects: TreeObjectAdapter[]) { return undefined; } duplicateSelection() {} pasteSelection() {} get projectStore() { return getProjectStore(this.flow.object); } onDragStart(): void {} onDragEnd(): void {} connectionExists( sourceObjectId: string, connectionOutput: string, targetObjectId: string, connectionInput: string ): boolean { return false; } connect( sourceObjectId: string, connectionOutput: string, targetObjectId: string, connectionInput: string ) {} connectToNewTarget( sourceObjectId: string, connectionOutput: string, atPoint: Point ) {} connectToNewSource( targetObjectId: string, connectionInput: string, atPoint: Point ) {} } //////////////////////////////////////////////////////////////////////////////// class ViewState implements IViewState { get transform() { return this.flowContext.transform; } set transform(transform: Transform) { this.flowContext.transform = transform; } dxMouseDrag: number | undefined; dyMouseDrag: number | undefined; constructor(public flowContext: FlowContext) { makeObservable(this, { dxMouseDrag: observable, dyMouseDrag: observable }); } get projectStore() { return this.flowContext.projectStore; } get document() { return this.flowContext.document; } get containerId() { return this.flowContext.containerId; } resetTransform() { this.flowContext.resetTransform(); } getResizeHandlers(): IResizeHandler[] | undefined { return undefined; } get selectedObjects() { return []; } get connectionLine() { return undefined; } get sourceComponent() { return undefined; } get targetComponent() { return undefined; } isObjectSelected(object: TreeObjectAdapter): boolean { return false; } isObjectIdSelected(id: string): boolean { return false; } selectObject(object: TreeObjectAdapter) {} selectObjects(objects: TreeObjectAdapter[]) {} deselectAllObjects(): void {} moveSelection( where: | "left" | "up" | "right" | "down" | "home-x" | "end-x" | "home-y" | "end-y" ) {} }