@gravity-ui/graph
Version:
Modern graph editor component
233 lines (232 loc) • 8.8 kB
JavaScript
import { HitBox } from "../../../services/HitTest";
import { getXY } from "../../../utils/functions";
import { EventedComponent } from "../EventedComponent/EventedComponent";
export class GraphComponent extends EventedComponent {
getEntityId() {
throw new Error("GraphComponent.getEntityId() is not implemented");
}
/**
* Returns whether this component can be dragged.
* Override in subclasses to enable drag behavior.
* Components that return true will participate in drag operations managed by DragService.
*
* @returns true if the component is draggable, false otherwise
*/
isDraggable() {
return false;
}
/**
* Called when a drag operation starts on this component.
* Override in subclasses to handle drag start logic.
*
* @param _context - The drag context containing coordinates and participating components
*/
handleDragStart(_context) {
// Default implementation does nothing
}
/**
* Called on each frame during a drag operation.
* Override in subclasses to update component position.
*
* @param _diff - The diff containing coordinate changes (deltaX/deltaY for incremental, diffX/diffY for absolute)
* @param _context - The drag context containing coordinates and participating components
*/
handleDrag(_diff, _context) {
// Default implementation does nothing
}
/**
* Called when a drag operation ends.
* Override in subclasses to finalize drag state.
*
* @param _context - The drag context containing final coordinates and participating components
*/
handleDragEnd(_context) {
// Default implementation does nothing
}
get affectsUsableRect() {
return this.props.affectsUsableRect ?? this.context.affectsUsableRect ?? true;
}
constructor(props, parent) {
super(props, parent);
this.unsubscribe = [];
this.ports = new Map();
this.mounted = false;
// Determine affectsUsableRect value: explicit prop > parent context > default (true)
this.hitBox = new HitBox(this, this.context.graph.hitTest);
const affectsUsableRect = props.affectsUsableRect ?? this.context.affectsUsableRect ?? true;
this.setProps({ affectsUsableRect });
this.setContext({ affectsUsableRect });
}
createPort(id) {
const port = this.context.graph.rootStore.connectionsList.claimPort(id, this);
this.ports.set(id, port);
return port;
}
getPort(id) {
if (!this.ports.has(id)) {
return this.createPort(id);
}
return this.ports.get(id);
}
setAffectsUsableRect(affectsUsableRect) {
this.setProps({ affectsUsableRect });
this.setContext({ affectsUsableRect });
}
propsChanged(_nextProps) {
if (this.affectsUsableRect !== _nextProps.affectsUsableRect) {
this.hitBox.setAffectsUsableRect(_nextProps.affectsUsableRect);
this.setContext({ affectsUsableRect: _nextProps.affectsUsableRect });
}
super.propsChanged(_nextProps);
}
contextChanged(_nextContext) {
// If affectsUsableRect changed in context and there's no explicit prop override
if (this.firstRender ||
(this.context.affectsUsableRect !== _nextContext.affectsUsableRect && this.props.affectsUsableRect === undefined)) {
this.hitBox.setAffectsUsableRect(_nextContext.affectsUsableRect);
}
super.contextChanged(_nextContext);
}
onChange(cb) {
return this.addEventListener("graph-component-change", () => {
cb(this);
});
}
checkData() {
if (super.checkData()) {
this.dispatchEvent(new Event("graph-component-change"));
return true;
}
return false;
}
onDrag({ onDragStart, onDragUpdate, onDrop, isDraggable, autopanning, dragCursor, }) {
let startCoords;
let prevCoords;
return this.addEventListener("mousedown", (event) => {
if (!isDraggable?.(event)) {
return;
}
event.stopPropagation();
this.context.graph.dragService.startDrag({
onStart: (event) => {
if (onDragStart?.(event) === false) {
return;
}
const xy = getXY(this.context.canvas, event);
startCoords = this.context.camera.applyToPoint(xy[0], xy[1]);
prevCoords = startCoords;
},
onUpdate: (event) => {
if (!startCoords?.length)
return;
const [canvasX, canvasY] = getXY(this.context.canvas, event);
const currentCoords = this.context.camera.applyToPoint(canvasX, canvasY);
// Absolute diff from drag start
const diffX = currentCoords[0] - startCoords[0];
const diffY = currentCoords[1] - startCoords[1];
// Incremental diff from previous frame
const deltaX = currentCoords[0] - prevCoords[0];
const deltaY = currentCoords[1] - prevCoords[1];
onDragUpdate?.({ startCoords, prevCoords, currentCoords, diffX, diffY, deltaX, deltaY }, event);
prevCoords = currentCoords;
},
onEnd: (event) => {
startCoords = undefined;
prevCoords = undefined;
onDrop?.(event);
},
}, {
component: this,
autopanning: autopanning ?? true,
cursor: dragCursor ?? "grabbing",
});
});
}
isMounted() {
return this.mounted;
}
willMount() {
super.willMount();
this.mounted = true;
}
/**
* Subscribes to a graph event and automatically unsubscribes on component unmount.
*
* This is a convenience wrapper around this.context.graph.on that also registers the
* returned unsubscribe function in the internal unsubscribe list, ensuring proper cleanup.
*
* @param eventName - Graph event name to subscribe to
* @param handler - Event handler callback
* @param options - Additional AddEventListener options
* @returns Unsubscribe function
*/
onGraphEvent(eventName, handler, options) {
const unsubscribe = this.context.graph.on(eventName, handler, options);
this.unsubscribe.push(unsubscribe);
return unsubscribe;
}
/**
* Subscribes to a DOM event on the graph root element and automatically unsubscribes on unmount.
*
* @param eventName - DOM event name to subscribe to
* @param handler - Event handler callback
* @param options - Additional AddEventListener options
* @returns Unsubscribe function
*/
onRootEvent(eventName, handler, options) {
const root = this.context.root;
if (!root) {
throw new Error("Attempt to add event listener to non-existent root element");
}
const listener = typeof handler === "function"
? handler
: handler;
root.addEventListener(eventName, listener, options);
const unsubscribe = () => {
root.removeEventListener(eventName, listener, options);
};
this.unsubscribe.push(unsubscribe);
return unsubscribe;
}
subscribeSignal(signal, cb) {
this.unsubscribe.push(signal.subscribe(cb));
}
onUnmounted(cb) {
return this.addEventListener("graph-component-unmounted", cb);
}
unmount() {
super.unmount();
this.unsubscribe.forEach((cb) => cb());
this.ports.forEach((port) => {
this.context.graph.rootStore.connectionsList.releasePort(port.id, this);
});
this.ports.clear();
this.destroyHitBox();
this.mounted = false;
this.dispatchEvent(new CustomEvent("graph-component-unmounted", { detail: { component: this } }));
}
setHitBox(minX, minY, maxX, maxY, force) {
this.hitBox.update(minX, minY, maxX, maxY, force);
}
willIterate() {
super.willIterate();
if (!this.firstIterate) {
this.shouldRender = this.isVisible();
}
}
isVisible() {
return this.context.camera.isRectVisible(...this.getHitBox());
}
getHitBox() {
return this.hitBox.getRect();
}
removeHitBox() {
this.hitBox.remove();
}
destroyHitBox() {
this.hitBox.destroy();
}
onHitBox(_) {
return this.isIterated();
}
}