@gravity-ui/graph
Version:
Modern graph editor component
252 lines (251 loc) • 9.91 kB
JavaScript
import { isNativeGraphEventName } from "../../../../graphEvents";
import { Layer } from "../../../../services/Layer";
import { Camera } from "../../../../services/camera/Camera";
import { getEventDelta } from "../../../../utils/functions";
import { Blocks } from "../../blocks/Blocks";
import { BlockConnection } from "../../connections/BlockConnection";
import { BlockConnections } from "../../connections/BlockConnections";
import { DrawBelow, DrawOver } from "./helpers";
const rootBubblingEventTypes = new Set([
"mousedown",
"touchstart",
"mouseup",
"touchend",
"click",
"dblclick",
"contextmenu",
]);
const rootCapturingEventTypes = new Set(["mousedown", "touchstart", "mouseup", "touchend"]);
export class GraphLayer extends Layer {
constructor(props) {
super({
canvas: {
zIndex: 2,
classNames: ["no-user-select"],
transformByCameraPosition: true,
},
// HTML element creation is now separated into framework-specific layers
...props,
});
this.pointerPressed = false;
this.handleMouseDownEvent = (event) => {
return this.onRootPointerStart(event);
};
const canvas = this.getCanvas();
this.setContext({
canvas: canvas,
ctx: canvas.getContext("2d"),
root: this.props.root,
camera: this.props.camera,
ownerDocument: canvas.ownerDocument,
constants: this.props.graph.graphConstants,
colors: this.props.graph.graphColors,
graph: this.props.graph,
});
if (this.context.root) {
this.attachListeners();
}
this.camera = this.props.camera;
this.performRender = this.performRender.bind(this);
}
afterInit() {
this.setContext({
root: this.root,
});
this.attachListeners();
// Subscribe to graph events here instead of in the constructor
this.onGraphEvent("camera-change", this.performRender);
this.context.graph.rootStore.blocksList.$blocks.subscribe(() => {
this.performRender();
});
this.context.graph.rootStore.connectionsList.$connections.subscribe(() => {
this.performRender();
});
super.afterInit();
}
/**
* Attaches DOM event listeners to the root element.
* All event listeners are registered with the rootOn wrapper method to ensure they are properly cleaned up
* when the layer is unmounted. This eliminates the need for manual event listener removal.
*/
attachListeners() {
if (!this.root)
return;
rootBubblingEventTypes.forEach((type) => this.onRootEvent(type, this));
rootCapturingEventTypes.forEach((type) => this.onRootEvent(type, this, { capture: true }));
this.onRootEvent("mousemove", this);
}
/*
* Capture element for future events
* When element is captured, it will be used as target for future events
* until releaseCapture is called
* @param component - element to capture
*/
captureEvents(component) {
this.capturedTargetComponent = component;
}
releaseCapture() {
this.capturedTargetComponent = undefined;
}
handleEvent(e) {
if (e.type === "mousemove") {
this.updateTargetComponent(e);
this.onRootPointerMove(e);
return;
}
if (e.eventPhase === Event.CAPTURING_PHASE && rootCapturingEventTypes.has(e.type)) {
this.tryEmulateClick(e);
return;
}
if (e.eventPhase === Event.BUBBLING_PHASE && rootBubblingEventTypes.has(e.type)) {
switch (e.type) {
case "mousedown":
case "touchstart": {
this.updateTargetComponent(e, true);
this.handleMouseDownEvent(e);
break;
}
case "mouseup":
case "touchend": {
this.onRootPointerEnd(e);
break;
}
case "click":
case "dblclick": {
this.tryEmulateClick(e);
break;
}
}
}
}
dispatchNativeEvent(type, event, targetComponent) {
const graphEvent = this.props.graph.emit(type, {
target: targetComponent,
pointerPressed: this.pointerPressed,
sourceEvent: event,
});
if (graphEvent.defaultPrevented) {
event.preventDefault();
return false;
}
return true;
}
applyEventToTargetComponent(event, target = this.targetComponent) {
if (isNativeGraphEventName(event.type)) {
if (!this.dispatchNativeEvent(event.type, event, target)) {
return;
}
}
if (!target || typeof target.dispatchEvent !== "function")
return;
target.dispatchEvent(event);
}
updateTargetComponent(event, force = false) {
// Check is event is too close to previous event
// In case when previous event is too close to current event, we don't need to update target component
// This is useful to prevent flickering when user is moving mouse fast
if (!force && this.eventByTargetComponent && getEventDelta(event, this.eventByTargetComponent) < 3)
return;
this.eventByTargetComponent = event;
if (this.capturedTargetComponent) {
this.targetComponent = this.capturedTargetComponent;
return;
}
this.prevTargetComponent = this.targetComponent;
const point = this.context.graph.getPointInCameraSpace(event);
this.targetComponent = this.context.graph.getElementOverPoint(point) || this.$.camera;
}
onRootPointerMove(event) {
if (this.targetComponent !== this.prevTargetComponent) {
if (this.targetComponent?.cursor) {
this.root.style.cursor = this.targetComponent?.cursor;
}
else {
this.root.style.removeProperty("cursor");
}
this.applyEventToTargetComponent(new CustomEvent("mouseleave", {
bubbles: false,
detail: {
target: this.prevTargetComponent,
sourceEvent: event,
pointerPressed: this.pointerPressed,
},
}), this.prevTargetComponent);
this.applyEventToTargetComponent(new CustomEvent("mouseout", {
bubbles: true,
detail: {
target: this.prevTargetComponent,
sourceEvent: event,
pointerPressed: this.pointerPressed,
},
}), this.prevTargetComponent);
this.applyEventToTargetComponent(new CustomEvent("mouseenter", {
bubbles: false,
detail: {
target: this.targetComponent,
sourceEvent: event,
pointerPressed: this.pointerPressed,
},
}), this.targetComponent);
this.applyEventToTargetComponent(new CustomEvent("mouseover", {
bubbles: true,
detail: {
target: this.targetComponent,
sourceEvent: event,
pointerPressed: this.pointerPressed,
},
}), this.targetComponent);
}
}
onRootPointerStart(event) {
if (event.button === 2 /* Mouse right button */) {
/*
* used contextmenu event to prevent native menu
* see .onContextMenuEvent method
* */
return;
}
this.pointerPressed = true;
// Proxy event for components
this.applyEventToTargetComponent(event);
}
onRootPointerEnd(event) {
if (event.button === 2 /* Mouse right button */) {
/*
* used contextmenu event to prevent native menu
* see .onContextMenuEvent method
* */
return;
}
this.pointerPressed = false;
// Proxy event for components
this.applyEventToTargetComponent(event);
}
tryEmulateClick(event, target = this.targetComponent) {
if ((event.type === "mousedown" || event.type === "touchstart") && target !== undefined) {
this.canEmulateClick = false;
this.pointerStartTarget = target;
this.pointerStartEvent = event;
}
if ((event.type === "mouseup" || event.type === "touchend") &&
(this.pointerStartTarget === target ||
// connections can be very close to each other
(this.pointerStartTarget instanceof BlockConnection && target instanceof BlockConnection)) &&
// pointerStartEvent can be undefined if mousedown/touchstart event happened over dialog backdrop
this.pointerStartEvent &&
getEventDelta(this.pointerStartEvent, event) < 3) {
this.canEmulateClick = true;
}
if (this.canEmulateClick && (event.type === "click" || event.type === "dblclick")) {
this.applyEventToTargetComponent(new MouseEvent(event.type, event), this.pointerStartTarget);
}
}
updateChildren() {
const cameraProps = {
/* Blocks must be initialized before connections as connections need Block instances to access their geometry */
children: [DrawOver.create(), Blocks.create(), DrawBelow.create(), BlockConnections.create()],
root: this.root,
};
return [Camera.create(cameraProps, { ref: "camera" })];
}
}