@gravity-ui/graph
Version:
Modern graph editor component
266 lines (265 loc) • 12.1 kB
JavaScript
import { EventedComponent } from "../../components/canvas/EventedComponent/EventedComponent";
import { GraphComponent } from "../../components/canvas/GraphComponent";
import { isGraphEvent } from "../../graphEvents";
import { ESchedulerPriority } from "../../lib";
import { getXY, isMetaKeyEvent } from "../../utils/functions";
import { clamp } from "../../utils/functions/clamp";
import { dragListener } from "../../utils/functions/dragListener";
import { EWheelIntent, isPinchZoomGesture } from "../../utils/functions/wheelIntent";
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 = (state) => {
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) => {
const nativeEvent = event.detail.sourceEvent;
if (!(nativeEvent instanceof MouseEvent)) {
return;
}
if (!this.context.graph.rootStore.settings.getConfigFlag("canDragCamera")) {
return;
}
if (isGraphEvent(event) && event.isDefaultPrevented()) {
return;
}
if (isMetaKeyEvent(nativeEvent)) {
return;
}
if (nativeEvent.button === 1) {
// Middle button: preventDefault stops DragService (see graph.emit guard)
// and suppresses the browser's native middle-click autoscroll.
event.preventDefault();
}
else if (event.detail.target !== this && this.isTargetDraggable(event.detail.target)) {
return;
}
// Left button on empty canvas — suppress native text selection before pan drag.
event.preventDefault();
// 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());
};
/**
* Transitional wheel entry point: resolves pan vs zoom intent, then dispatches to
* {@link handlePan} / {@link handleZoom}. Intent classification lives in
* {@link GraphEditorSettings.wheelIntentFromEvent} today; later an input layer will
* emit semantic graph events (`camera:pan`, `camera:zoom`, …) and Camera will subscribe.
*/
this.handleWheelEvent = (event) => {
if (!this.context.graph.rootStore.settings.getConfigFlag("canZoomCamera")) {
return;
}
event.stopPropagation();
event.preventDefault();
const intent = this.context.graph.rootStore.settings.wheelIntentFromEvent(event, {
mouseWheelBehavior: this.context.constants.camera.MOUSE_WHEEL_BEHAVIOR,
wheelInputDevice: this.context.constants.camera.WHEEL_INPUT_DEVICE,
});
if (intent === EWheelIntent.Pan) {
this.handlePan(event);
return;
}
const acceleration = isPinchZoomGesture(event) ? this.context.constants.camera.PINCH_ZOOM_SPEED : 1;
this.handleZoom(event, acceleration);
};
this.camera = this.context.camera;
this.ownerDocument = this.context.ownerDocument;
this.addWheelListener();
this.addEventListener("click", this.handleClick);
this.context.graph.on("mousedown", this.handleMouseDownEvent);
this.unsubscribeCamera = this.context.graph.$camera.subscribe(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.context.graph.off("mousedown", this.handleMouseDownEvent);
this.unsubscribeCamera?.();
}
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);
}
}
/**
* DragService handles draggable graph components; camera pan is deferred in that case.
*/
isTargetDraggable(target) {
return target instanceof GraphComponent && target.isDraggable();
}
onDragStart(event) {
this.lastDragEvent = event;
}
onDragUpdate(event) {
if (!this.lastDragEvent) {
return;
}
// No PAN_SPEED multiplier here: pointer drag tracks the cursor 1:1, otherwise
// the canvas slides out from under the pointer. PAN_SPEED only scales
// wheel-delta-driven pan gestures (see handlePan).
this.camera.move(event.pageX - this.lastDragEvent.pageX, event.pageY - this.lastDragEvent.pageY);
this.lastDragEvent = event;
}
onDragEnd() {
this.lastDragEvent = undefined;
}
/**
* Applies a pan delta from wheel input.
*
* Transitional: invoked directly from {@link handleWheelEvent}. Will become a handler
* for a semantic `camera:pan` graph event once the input event bus replaces raw DOM subscriptions.
*/
handlePan(event) {
const hasWrongHorizontalScroll = event.shiftKey && Math.abs(event.deltaY) > 0.001;
const panSpeed = this.context.constants.camera.PAN_SPEED;
this.moveWithEdges((hasWrongHorizontalScroll ? -event.deltaY : -event.deltaX) * panSpeed, (hasWrongHorizontalScroll ? -event.deltaX : -event.deltaY) * panSpeed);
}
/**
* Applies a zoom delta from wheel input.
*
* Transitional: invoked directly from {@link handleWheelEvent}. Will become a handler
* for a semantic `camera:zoom` graph event once the input event bus replaces raw DOM subscriptions.
*/
handleZoom(event, acceleration = 1) {
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 * acceleration;
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;
}
}