UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

245 lines (244 loc) 9.35 kB
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" })]; } }