UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

232 lines (231 loc) 10.2 kB
import { EventedComponent } from "../../components/canvas/EventedComponent/EventedComponent"; import { ESchedulerPriority } from "../../lib"; import { getXY, isMetaKeyEvent, isTrackpadWheelEvent } from "../../utils/functions"; import { clamp } from "../../utils/functions/clamp"; import { dragListener } from "../../utils/functions/dragListener"; import { EVENTS } from "../../utils/types/events"; import { schedule } from "../../utils/utils/schedule"; export class Camera extends EventedComponent { constructor(props, parent) { super(props, parent); this.handleCameraStateChange = (event) => { const state = event.detail; const isAutoPanningEnabled = state.autoPanningEnabled; if (isAutoPanningEnabled) { this.startAutoPanning(); } else { this.stopAutoPanning(); } }; this.handleClick = () => { this.context.graph.api.unsetSelection(); }; this.handleMouseMoveForAutoPan = (event) => { this.lastMouseEvent = event; }; this.handleMouseDownEvent = (event) => { if (!this.context.graph.rootStore.settings.getConfigFlag("canDragCamera") || !(event instanceof MouseEvent)) { return; } if (!isMetaKeyEvent(event)) { // Camera drag doesn't need graph sync since it IS the camera dragListener(this.ownerDocument, { graph: this.context.graph, autopanning: false, dragCursor: "grabbing" }) .on(EVENTS.DRAG_START, (event) => this.onDragStart(event)) .on(EVENTS.DRAG_UPDATE, (event) => this.onDragUpdate(event)) .on(EVENTS.DRAG_END, () => this.onDragEnd()); } }; this.handleWheelEvent = (event) => { if (!this.context.graph.rootStore.settings.getConfigFlag("canZoomCamera")) { return; } event.stopPropagation(); event.preventDefault(); const isTrackpad = isTrackpadWheelEvent(event); const isTrackpadMove = isTrackpad && !isMetaKeyEvent(event); // Trackpad swipe gesture - always moves camera if (isTrackpadMove) { this.handleTrackpadMove(event); return; } // Mouse wheel behavior - check configuration if (!isTrackpad && !isMetaKeyEvent(event)) { const mouseWheelBehavior = this.context.constants.camera.MOUSE_WHEEL_BEHAVIOR; if (mouseWheelBehavior === "scroll") { this.handleTrackpadMove(event); return; } } // Default: zoom behavior (trackpad pinch or mouse wheel with "zoom" mode) this.handleWheelZoom(event); }; this.camera = this.context.camera; this.ownerDocument = this.context.ownerDocument; this.addWheelListener(); this.addEventListener("click", this.handleClick); this.addEventListener("mousedown", this.handleMouseDownEvent); // Subscribe to auto-panning state changes this.context.graph.on("camera-change", this.handleCameraStateChange); } setRoot() { this.setContext({ root: this.props.root, }); this.addWheelListener(this.props.root); } addWheelListener(root = this.props.root) { root?.addEventListener("wheel", this.handleWheelEvent, { passive: false }); } propsChanged(nextProps) { if (this.props.root !== nextProps.root) { this.props.root?.removeEventListener("wheel", this.handleWheelEvent); this.addWheelListener(nextProps.root); } super.propsChanged(nextProps); } unmount() { super.unmount(); this.stopAutoPanning(); this.props.root?.removeEventListener("wheel", this.handleWheelEvent); this.removeEventListener("mousedown", this.handleMouseDownEvent); this.context.graph.off("camera-change", this.handleCameraStateChange); } startAutoPanning() { if (this.removeAutoPanScheduler) { return; // Already running } // Add mousemove listener to track cursor position this.props.root?.addEventListener("mousemove", this.handleMouseMoveForAutoPan); // Start scheduler to check and perform auto-panning at ~60fps this.removeAutoPanScheduler = schedule(() => { this.performAutoPan(); }, { priority: ESchedulerPriority.HIGH, frameInterval: 1, // Execute every frame }); } stopAutoPanning() { if (this.removeAutoPanScheduler) { this.removeAutoPanScheduler(); this.removeAutoPanScheduler = undefined; } this.props.root?.removeEventListener("mousemove", this.handleMouseMoveForAutoPan); this.lastMouseEvent = undefined; } performAutoPan() { if (!this.lastMouseEvent || !this.props.root) { return; } const rect = this.props.root.getBoundingClientRect(); const mouseX = this.lastMouseEvent.clientX - rect.left; const mouseY = this.lastMouseEvent.clientY - rect.top; const cameraState = this.camera.getCameraState(); const insets = cameraState.viewportInsets; // Get auto-panning constants from graph config const AUTO_PAN_THRESHOLD = this.context.constants.camera.AUTO_PAN_THRESHOLD; const AUTO_PAN_SPEED = this.context.constants.camera.AUTO_PAN_SPEED; // Calculate effective viewport bounds (accounting for insets) const viewportLeft = insets.left; const viewportRight = cameraState.width - insets.right; const viewportTop = insets.top; const viewportBottom = cameraState.height - insets.bottom; let deltaX = 0; let deltaY = 0; // Check left edge - move camera right to show more content on the left if (mouseX < viewportLeft + AUTO_PAN_THRESHOLD && mouseX >= viewportLeft) { const ratio = 1 - (mouseX - viewportLeft) / AUTO_PAN_THRESHOLD; deltaX = AUTO_PAN_SPEED * ratio; } // Check right edge - move camera left to show more content on the right else if (mouseX > viewportRight - AUTO_PAN_THRESHOLD && mouseX <= viewportRight) { const ratio = 1 - (viewportRight - mouseX) / AUTO_PAN_THRESHOLD; deltaX = -AUTO_PAN_SPEED * ratio; } // Check top edge - move camera down to show more content on the top if (mouseY < viewportTop + AUTO_PAN_THRESHOLD && mouseY >= viewportTop) { const ratio = 1 - (mouseY - viewportTop) / AUTO_PAN_THRESHOLD; deltaY = AUTO_PAN_SPEED * ratio; } // Check bottom edge - move camera up to show more content on the bottom else if (mouseY > viewportBottom - AUTO_PAN_THRESHOLD && mouseY <= viewportBottom) { const ratio = 1 - (viewportBottom - mouseY) / AUTO_PAN_THRESHOLD; deltaY = -AUTO_PAN_SPEED * ratio; } // Apply camera movement if needed if (deltaX !== 0 || deltaY !== 0) { this.camera.move(deltaX, deltaY); } } onDragStart(event) { this.lastDragEvent = event; } onDragUpdate(event) { if (!this.lastDragEvent) { return; } this.camera.move(event.pageX - this.lastDragEvent.pageX, event.pageY - this.lastDragEvent.pageY); this.lastDragEvent = event; } onDragEnd() { this.lastDragEvent = undefined; } /** * Handles trackpad swipe gestures for camera movement */ handleTrackpadMove(event) { const hasWrongHorizontalScroll = event.shiftKey && Math.abs(event.deltaY) > 0.001; this.moveWithEdges(hasWrongHorizontalScroll ? -event.deltaY : -event.deltaX, hasWrongHorizontalScroll ? -event.deltaX : -event.deltaY); } /** * Handles zoom behavior for both trackpad pinch and mouse wheel */ handleWheelZoom(event) { if (!event.deltaY) { return; } const xy = getXY(this.context.canvas, event); /** * Speed of wheel/trackpad pinch * * The zoom event from the trackpad pass the deltaY as a floating number, which can be less than +1/-1. * If the delta is less than 1, it causes the zoom speed to slow down. * Therefore, we have to round the value of deltaY to 1 if it is less than or equal to 1. */ const pinchSpeed = Math.sign(event.deltaY) * clamp(Math.abs(event.deltaY), 1, 20); const dScale = this.context.constants.camera.STEP * this.context.constants.camera.SPEED * pinchSpeed; const cameraScale = this.camera.getCameraScale(); // Smooth scale. The closer you get, the higher the speed const smoothDScale = dScale * cameraScale; this.camera.zoom(xy[0], xy[1], cameraScale - smoothDScale); } moveWithEdges(deltaX, deltaY) { const uR = this.context.graph.api.getUsableRect(); const cameraState = this.camera.getCameraState(); const gapX = cameraState.relativeWidth; const gapY = cameraState.relativeHeight; const moveToRight = deltaX > 0; const moveToLeft = deltaX < 0; const moveToTop = deltaY > 0; const moveToBottop = deltaY < 0; if (moveToRight && uR.x - gapX > cameraState.relativeX * -1) { deltaX = 0; } // left if (moveToLeft && uR.x + uR.width + gapX < cameraState.relativeX * -1 + cameraState.relativeWidth) { deltaX = 0; } // right if (moveToTop && uR.y - gapY > cameraState.relativeY * -1) { deltaY = 0; } // top if (moveToBottop && uR.y + uR.height + gapY < cameraState.relativeY * -1 + cameraState.relativeHeight) { deltaY = 0; } // bottom this.camera.move(deltaX, deltaY); } render() { this.context.layer.resetTransform(); } updateChildren() { return this.props.children; } }