UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

387 lines (386 loc) 13.7 kB
import { batch, signal } from "@preact/signals-core"; import merge from "lodash/merge"; import { PublicGraphApi } from "./api/PublicGraphApi"; import { BelowLayer } from "./components/canvas/layers/belowLayer/BelowLayer"; import { CursorLayer } from "./components/canvas/layers/cursorLayer"; import { GraphLayer } from "./components/canvas/layers/graphLayer/GraphLayer"; import { SelectionLayer } from "./components/canvas/layers/selectionLayer/SelectionLayer"; import { initGraphColors, initGraphConstants } from "./graphConfig"; import { scheduler } from "./lib/Scheduler"; import { HitTest } from "./services/HitTest"; import { Layers } from "./services/LayersService"; import { CameraService } from "./services/camera/CameraService"; import { RootStore } from "./store"; import { getXY } from "./utils/functions"; import { clearTextCache } from "./utils/renderers/text"; import { Point, isTRect } from "./utils/types/shapes"; export var GraphState; (function (GraphState) { GraphState[GraphState["INIT"] = 0] = "INIT"; GraphState[GraphState["ATTACHED"] = 1] = "ATTACHED"; GraphState[GraphState["READY"] = 2] = "READY"; })(GraphState || (GraphState = {})); export class Graph { getGraphCanvas() { return this.graphLayer.getCanvas(); } get graphColors() { return this.$graphColors.value; } get graphConstants() { return this.$graphConstants.value; } get blocks() { return this.rootStore.blocksList; } get connections() { return this.rootStore.connectionsList; } get selectionService() { return this.rootStore.selectionService; } constructor(config, rootEl, graphColors, graphConstants) { this.scheduler = scheduler; this.cameraService = new CameraService(this); this.layers = new Layers(); this.api = new PublicGraphApi(this); this.eventEmitter = new EventTarget(); this.rootStore = new RootStore(this); this.hitTest = new HitTest(this); this.$graphColors = signal(initGraphColors); this.$graphConstants = signal(initGraphConstants); this.state = GraphState.INIT; this.startRequested = false; this.onUpdateSize = (event) => { this.cameraService.set(event); }; this.belowLayer = this.addLayer(BelowLayer, {}); this.graphLayer = this.addLayer(GraphLayer, {}); this.selectionLayer = this.addLayer(SelectionLayer, {}); this.cursorLayer = this.addLayer(CursorLayer, {}); this.selectionLayer.hide(); this.graphLayer.hide(); this.belowLayer.hide(); if (rootEl) { this.attach(rootEl); } if (graphColors) { this.setColors(graphColors); } if (graphConstants) { this.setConstants(graphConstants); } this.setupGraph(config); } getGraphLayer() { return this.graphLayer; } setColors(colors) { this.$graphColors.value = merge({}, initGraphColors, colors); this.emit("colors-changed", { colors: this.graphColors }); } setConstants(constants) { this.$graphConstants.value = merge({}, initGraphConstants, constants); this.emit("constants-changed", { constants: this.graphConstants }); } /** * Zoom to center of camera * @param zoomConfig - zoom config * @param zoomConfig.x if set - zoom to x coordinate, else - zoom to center * @param zoomConfig.y if set - zoom to y coordinate, else - zoom to center * @param zoomConfig.scale camera scale * * @returns {undefined} * */ zoom(zoomConfig) { const { width, height } = this.cameraService.getCameraState(); this.cameraService.zoom(zoomConfig.x || width / 2, zoomConfig.y || height / 2, zoomConfig.scale); } zoomTo(target, config) { if (target === "center") { this.api.zoomToViewPort(config); return; } if (isTRect(target)) { this.api.zoomToRect(target, config); return; } this.api.zoomToBlocks(target, config); } getElementsOverPoint(point, filter) { const items = this.hitTest.testPoint(point, this.layers.getDPR()); if (filter && items.length > 0) { return items.filter((item) => filter.some((Component) => item instanceof Component)); } return items; } getElementOverPoint(point, filter) { return this.getElementsOverPoint(point, filter)?.[0]; } /** * Returns the current viewport rectangle in camera space, expanded by threshold. * @returns {TRect} Viewport rect in camera-relative coordinates */ getViewportRect() { const CAMERA_VIEWPORT_TRESHOLD = this.graphConstants.system.CAMERA_VIEWPORT_TRESHOLD; const rel = this.cameraService.getRelativeViewportRect(); // full viewport, ignores insets const x = -rel.x - rel.width * CAMERA_VIEWPORT_TRESHOLD; const y = -rel.y - rel.height * CAMERA_VIEWPORT_TRESHOLD; const width = -rel.x + rel.width * (1 + CAMERA_VIEWPORT_TRESHOLD) - x; const height = -rel.y + rel.height * (1 + CAMERA_VIEWPORT_TRESHOLD) - y; return { x, y, width, height }; } getElementsInViewport(filter) { const viewportRect = this.getViewportRect(); return this.getElementsOverRect(viewportRect, filter); } getElementsOverRect(rect, filter) { const items = this.hitTest.testBox({ minX: rect.x, minY: rect.y, maxX: rect.x + rect.width, maxY: rect.y + rect.height, }); if (filter.length && items.length > 0) { return items.filter((item) => filter.some((Component) => item instanceof Component)); } return items; } getPointInCameraSpace(event) { const xy = getXY(this.graphLayer.getCanvas(), event); const applied = this.cameraService.applyToPoint(xy[0], xy[1]); return new Point(applied[0], applied[1], { x: xy[0], y: xy[1] }); } updateEntities({ blocks, connections, }) { batch(() => { if (blocks?.length) { this.rootStore.blocksList.updateBlocks(blocks); } if (connections?.length) { this.rootStore.connectionsList.updateConnections(connections); } }); } setEntities({ blocks, connections, }) { batch(() => { this.rootStore.blocksList.setBlocks(blocks || []); this.rootStore.connectionsList.setConnections(connections || []); }); } on(type, cb, options) { this.eventEmitter.addEventListener(type, cb, options); return () => this.off(type, cb); } off(type, cb) { this.eventEmitter.removeEventListener(type, cb); } /* * Emit Graph's events */ emit(eventName, detail) { const event = new CustomEvent(eventName, { detail, bubbles: false, cancelable: true, }); this.eventEmitter.dispatchEvent(event); return event; } /* * Emit Graph's event and execute default action if it is not prevented */ executеDefaultEventAction(eventName, detail, defaultCb) { const event = this.emit(eventName, detail); if (!event.defaultPrevented) { defaultCb(); } } addLayer(layerCtor, props) { // TODO: These types are too complicated, try to simplify them return this.layers.createLayer(layerCtor, { ...props, camera: this.cameraService, graph: this, }); } detachLayer(layer) { this.layers.detachLayer(layer); } setupGraph(config = {}) { this.config = config; this.rootStore.configurationName = config.configurationName; this.setEntities({ blocks: config.blocks, connections: config.connections, }); if (config.settings) { this.updateSettings(config.settings); } if (config.layers) { config.layers.forEach(([layer, params]) => { this.addLayer(layer, params); }); } } updateSettings(settings) { this.rootStore.settings.setupSettings(settings); } updateSize() { this.layers.updateSize(); } attach(rootEl) { if (this.state === GraphState.READY) { return; } rootEl[Symbol.for("graph")] = this; this.layers.attach(rootEl); const { width: rootWidth, height: rootHeight } = this.layers.getRootSize(); this.cameraService.set({ width: rootWidth, height: rootHeight }); this.setGraphState(GraphState.ATTACHED); if (this.startRequested) { this.startRequested = false; this.start(); } } start(rootEl = this.layers.$root) { if (this.state !== GraphState.ATTACHED) { this.startRequested = true; return; } if (this.state >= GraphState.READY) { throw new Error("Graph already started"); } if (rootEl) { this.attach(rootEl); } this.layers.on("update-size", this.onUpdateSize); this.layers.start(); this.scheduler.start(); this.setGraphState(GraphState.READY); this.runAfterGraphReady(() => { this.selectionLayer.show(); this.graphLayer.show(); this.belowLayer.show(); this.cursorLayer.show(); }); } /** * Graph is ready when the hitboxes are stable. * In order to initialize hitboxes we need to start scheduler and wait untils every component registered in hitTest service * Immediatelly after registering startign a rendering process. * @param cb - Callback to run after graph is ready */ runAfterGraphReady(cb) { this.hitTest.waitUsableRectUpdate(cb); } stop(full = false) { this.layers.detach(full); clearTextCache(); this.scheduler.stop(); this.setGraphState(this.layers.$root ? GraphState.ATTACHED : GraphState.INIT); } setGraphState(state) { if (this.state === state) { return; } this.state = state; this.emit("state-change", { state: this.state }); } clear() { this.layers.detach(); clearTextCache(); } detach() { this.stop(true); } unmount() { this.detach(); this.layers.off("update-size", this.onUpdateSize); this.setGraphState(GraphState.INIT); this.hitTest.clear(); this.layers.unmount(); clearTextCache(); this.rootStore.reset(); this.scheduler.stop(); } /** * Locks the cursor to a specific type, disabling automatic cursor changes. * * When the cursor is locked, it will remain fixed to the specified type * and will not change automatically based on component interactions until * unlockCursor() is called. This is useful during drag operations, loading * states, or other situations where you want to override the default * interactive cursor behavior. * * @param cursor - The cursor type to lock to * * @example * ```typescript * // Lock to loading cursor during async operation * graph.lockCursor("wait"); * * // Lock to grabbing cursor during drag operation * graph.lockCursor("grabbing"); * * // Lock to copy cursor for duplication operations * graph.lockCursor("copy"); * ``` * * @see {@link CursorLayer.lockCursor} for more details * @see {@link unlockCursor} to return to automatic behavior */ lockCursor(cursor) { this.cursorLayer.lockCursor(cursor); } /** * Unlocks the cursor and returns to automatic cursor management. * * The cursor will immediately update to reflect the current state * based on the component under the mouse (if any). This provides * smooth transitions when ending drag operations or async tasks. * * @example * ```typescript * // After completing a drag operation * graph.unlockCursor(); // Will show appropriate cursor for current hover state * * // After finishing an async operation * await someAsyncTask(); * graph.unlockCursor(); // Returns to interactive cursor behavior * ``` * * @see {@link CursorLayer.unlockCursor} for more details * @see {@link lockCursor} to override automatic behavior */ unlockCursor() { this.cursorLayer.unlockCursor(); } /** * Returns the CursorLayer instance for advanced cursor management. * * Use this method when you need direct access to cursor layer functionality * beyond the basic setCursor/unsetCursor API, such as checking the current * mode or getting the component under the cursor. * * @returns The CursorLayer instance * * @example * ```typescript * const cursorLayer = graph.getCursorLayer(); * * // Check current mode * if (cursorLayer.isManual()) { * console.log("Manual cursor:", cursorLayer.getManualCursor()); * } * * // Get component under cursor for debugging * const target = cursorLayer.getCurrentTarget(); * console.log("Hovering over:", target?.constructor.name); * ``` * * @see {@link CursorLayer} for available methods and properties */ getCursorLayer() { return this.cursorLayer; } }