UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

233 lines (232 loc) 8.8 kB
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(); } }