@gravity-ui/graph
Version:
Modern graph editor component
245 lines (244 loc) • 9.35 kB
JavaScript
import { isGraphEvent, 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";
const rootBubblingEventTypes = new Set([
"mousedown",
"touchstart",
"mouseup",
"touchend",
"click",
"dblclick",
"contextmenu",
]);
export class GraphLayer extends Layer {
constructor(props) {
super({
canvas: {
zIndex: 2,
classNames: ["no-user-select", "no-pointer-events"],
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 as keyof HTMLElementEventMap, 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;
}
switch (e.type) {
case "mousedown":
case "touchstart": {
this.updateTargetComponent(e, true);
this.tryEmulateClick(e);
this.handleMouseDownEvent(e);
break;
}
case "mouseup":
case "touchend": {
this.tryEmulateClick(e);
this.onRootPointerEnd(e);
break;
}
case "click":
case "dblclick": {
this.tryEmulateClick(e);
break;
}
}
return;
}
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;
}
if (isGraphEvent(graphEvent) && graphEvent.graphEventPropagationStopped) {
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) {
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 = {
children: [BlockConnections.create(), Blocks.create()],
root: this.root,
};
return [Camera.create(cameraProps, { ref: "camera" })];
}
}