UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

389 lines (388 loc) 16.4 kB
import { Component } from "../lib/Component"; import { ESchedulerPriority } from "../lib/Scheduler"; import { debounce } from "../utils/utils/schedule"; import "./Layer.css"; const HIDDEN_CLASS_NAME = "layer-hidden"; export class Layer extends Component { /** * A wrapper for this.props.graph.on that automatically includes the AbortController signal. * The method is named onGraphEvent to indicate it's specifically for graph events. * This simplifies event subscription and ensures proper cleanup when the layer is unmounted. * * IMPORTANT: Always use this method in the afterInit() method, NOT in the constructor. * This ensures that event subscriptions are properly set up when the layer is reattached. * When a layer is unmounted, the AbortController is aborted and a new one is created. * When the layer is reattached, afterInit() is called again, which sets up new subscriptions * with the new AbortController. * * @param eventName - The name of the event to subscribe to * @param handler - The event handler function * @param options - Additional options (optional) * @returns The result of graph.on call (an unsubscribe function) */ onGraphEvent(eventName, handler, options) { return this.props.graph.on(eventName, handler, { ...options, signal: this.eventAbortController.signal, }); } hide() { this.canvas?.classList.add(HIDDEN_CLASS_NAME); this.html?.classList.add(HIDDEN_CLASS_NAME); } isHidden() { return this.canvas?.classList.contains(HIDDEN_CLASS_NAME) || this.html?.classList.contains(HIDDEN_CLASS_NAME); } show() { this.canvas?.classList.remove(HIDDEN_CLASS_NAME); this.html?.classList.remove(HIDDEN_CLASS_NAME); } /** * A wrapper for HTMLElement.addEventListener that automatically includes the AbortController signal. * This method is for adding event listeners to the HTML element of the layer. * It simplifies event subscription and ensures proper cleanup when the layer is unmounted. * * IMPORTANT: Always use this method in the afterInit() method, NOT in the constructor. * This ensures that event subscriptions are properly set up when the layer is reattached. * When a layer is unmounted, the AbortController is aborted and a new one is created. * When the layer is reattached, afterInit() is called again, which sets up new subscriptions * with the new AbortController. * * @param eventName - The name of the DOM event to subscribe to * @param handler - The event handler function * @param options - Additional options (optional) */ onHtmlEvent(eventName, handler, options) { if (!this.html) { throw new Error("Attempt to add event listener to non-existent HTML element"); } this.html.addEventListener(eventName, handler, { ...options, signal: this.eventAbortController.signal, }); } /** * A wrapper for HTMLCanvasElement.addEventListener that automatically includes the AbortController signal. * This method is for adding event listeners to the canvas element of the layer. * It simplifies event subscription and ensures proper cleanup when the layer is unmounted. * * IMPORTANT: Always use this method in the afterInit() method, NOT in the constructor. * This ensures that event subscriptions are properly set up when the layer is reattached. * When a layer is unmounted, the AbortController is aborted and a new one is created. * When the layer is reattached, afterInit() is called again, which sets up new subscriptions * with the new AbortController. * * @param eventName - The name of the DOM event to subscribe to * @param handler - The event handler function * @param options - Additional options (optional) */ onCanvasEvent(eventName, handler, options) { if (!this.canvas) { throw new Error("Attempt to add event listener to non-existent canvas element"); } this.canvas.addEventListener(eventName, handler, { ...options, signal: this.eventAbortController.signal, }); } /** * A wrapper for HTMLElement.addEventListener that automatically includes the AbortController signal. * This method is for adding event listeners to the root element of the layer. * It simplifies event subscription and ensures proper cleanup when the layer is unmounted. * * IMPORTANT: Always use this method in the afterInit() method, NOT in the constructor. * This ensures that event subscriptions are properly set up when the layer is reattached. * When a layer is unmounted, the AbortController is aborted and a new one is created. * When the layer is reattached, afterInit() is called again, which sets up new subscriptions * with the new AbortController. * * @param eventName - The name of the DOM event to subscribe to * @param handler - The event handler function * @param options - Additional options (optional) */ onRootEvent(eventName, handler, options) { if (!this.root) { throw new Error("Attempt to add event listener to non-existent root element"); } this.root.addEventListener(eventName, handler, { ...options, signal: this.eventAbortController.signal, }); } /** * Subscribes to a signal (with .subscribe) and automatically unsubscribes when the layer's AbortController is aborted. * * Usage: * this.onSignal(signal, handler) * * @template S - Signal type (must have .subscribe method) * @template T - Value type of the signal * @param signal - Signal with .subscribe method (returns unsubscribe function) * @param handler - Handler function to call on signal change * @returns The unsubscribe function (called automatically on abort) */ onSignal(signal, handler) { const unsubscribe = signal.subscribe(handler); const abortHandler = () => { unsubscribe(); this.eventAbortController.signal.removeEventListener("abort", abortHandler); }; this.eventAbortController.signal.addEventListener("abort", abortHandler); return unsubscribe; } constructor(props, parent) { super(props, parent); this.attached = false; /** * Indicates whether the HTML layer is currently active based on camera scale. * When false, the HTML layer is hidden and won't receive updates. */ this.htmlActive = true; this.sizeTouched = false; this.updateSize = () => { this.sizeTouched = true; this.performRender(); }; this.stopCameraMoving = debounce(() => { this.html?.classList.remove("layer-with-camera-moving"); this.moving = false; }, { priority: ESchedulerPriority.LOW, frameTimeout: 150 }); this.moving = false; this.eventAbortController = new AbortController(); this.setContext({ graph: this.props.graph, camera: props.camera, colors: this.props.graph.$graphColors.value, constants: this.props.graph.$graphConstants.value, layer: this, }); this.init(); } /** * Called after initialization and when the layer is reattached. * This is the proper place to set up event subscriptions using onGraphEvent(). * * When a layer is unmounted, the AbortController is aborted and a new one is created. * When the layer is reattached, this method is called again, which sets up new subscriptions * with the new AbortController. * * All derived Layer classes should call super.afterInit() at the end of their afterInit method. */ afterInit() { this.setContext({ colors: this.props.graph.$graphColors.value, constants: this.props.graph.$graphConstants.value, }); // Subscribe to graph events here instead of in the constructor // This ensures that subscriptions are properly set up when the layer is reattached this.onGraphEvent("colors-changed", (event) => { this.setContext({ colors: event.detail.colors, }); }); this.onGraphEvent("constants-changed", (event) => { this.setContext({ constants: event.detail.constants, }); }); this.onSignal(this.props.graph.$camera, (camera) => this.handleCommittedCameraChange(camera)); this.onSignal(this.props.graph.layers.rootSize, this.updateSize); this.shouldRenderChildren = true; this.shouldUpdateChildren = true; // Initialize htmlActive state based on current camera scale if (this.html && this.props.html?.activationScale !== undefined) { const cameraState = this.context.camera.getCameraState(); this.htmlActive = cameraState.scale >= this.props.html.activationScale; if (!this.htmlActive) { this.onHtmlActiveChange(false); } } this.handleCommittedCameraChange(this.context.camera.getCameraState()); this.updateSize(); } scheduleCameraChange(camera) { if (this.html && this.htmlActive) { if (!this.moving) { this.html.classList.add("layer-with-camera-moving"); this.moving = true; } this.html.style.transform = `matrix(${camera.scale}, 0, 0, ${camera.scale}, ${camera.x}, ${camera.y})`; this.stopCameraMoving(); } } handleCommittedCameraChange(camera) { this.applyCameraTransform(camera); this.onCameraChange(camera); } applyCameraTransform(camera) { // Check if HTML layer should be active based on activationScale if (this.html && this.props.html?.activationScale !== undefined) { const shouldBeActive = camera.scale >= this.props.html.activationScale; if (shouldBeActive !== this.htmlActive) { this.htmlActive = shouldBeActive; this.onHtmlActiveChange(shouldBeActive); } } if (this.props.html?.transformByCameraPosition && this.html && this.htmlActive) { this.scheduleCameraChange(camera); } if (this.props.canvas?.transformByCameraPosition) { this.performRender(); } } /** * Called when committed camera state changes (`graph.$camera` signal). * Override in derived layers to react to pan, zoom, and resize. * * Built-in HTML/canvas transforms (`transformByCameraPosition`, `activationScale`) * are applied before this hook via `applyCameraTransform`. * * @param camera - Committed camera state (same as `graph.$camera.value`) */ onCameraChange(_camera) { // Override in subclasses } /** * Called when the HTML layer's active state changes based on camera scale. * Override this method to implement custom behavior when the layer activates/deactivates. * * @param active - Whether the HTML layer is now active */ onHtmlActiveChange(active) { if (active) { this.html.classList.remove("layer-hidden"); } else { this.html.classList.add("layer-hidden"); } } /** * Returns whether the HTML layer is currently active. * The layer is inactive when camera scale is below the activationScale threshold. */ isHtmlActive() { return this.htmlActive; } init() { this.attached = false; if (this.props.canvas) { if (this.canvas) { throw new Error("Attempt to recreate a canvas"); } this.canvas = this.createCanvas(this.props.canvas); } if (this.props.html) { if (this.html) { throw new Error("Attempt to recreate an html"); } this.html = this.createHTML(this.props.html); } } unmountLayer() { if (this.canvas) { const cameraState = this.context.camera.getCameraState(); const context = this.canvas.getContext("2d"); context.setTransform(1, 0, 0, 1, 0, 0); context.clearRect(0, 0, cameraState.width, cameraState.height); context.setTransform(cameraState.scale, 0, 0, cameraState.scale, cameraState.x, cameraState.y); } this.canvas?.parentNode?.removeChild(this.canvas); this.html?.parentNode?.removeChild(this.html); // Abort all event listeners (both graph.on and DOM addEventListener) this.eventAbortController.abort(); // Create a new controller for potential reattachment // This ensures that if the layer is reattached, new event listeners can be registered this.eventAbortController = new AbortController(); this.attached = false; } unmount() { this.stopCameraMoving.cancel(); this.unmountLayer(); super.unmount(); } getCanvas() { return this.canvas; } getHTML() { return this.html; } attachLayer(root) { if (this.attached) { return; } if (this.root) { this.unmountLayer(); } this.root = root; if (this.canvas) { root.appendChild(this.canvas); } if (this.html) { root.appendChild(this.html); } this.attached = true; this.afterInit(); } detachLayer() { this.unmountLayer(); this.root = undefined; } createCanvas(params) { const canvas = document.createElement("canvas"); canvas.classList.add("layer", "layer-canvas"); if (Array.isArray(params.classNames)) canvas.classList.add(...params.classNames); canvas.style.zIndex = `${Number(params.zIndex)}`; this.setContext({ graphCanvas: canvas, ctx: canvas.getContext("2d", { desynchronized: params.desynchronized ?? false, willReadFrequently: params.willReadFrequently ?? false, alpha: params.alpha ?? true, }), }); return canvas; } createHTML(params) { const div = document.createElement("div"); div.classList.add("layer", "layer-html"); if (Array.isArray(params.classNames)) div.classList.add(...params.classNames); div.style.zIndex = `${Number(params.zIndex)}`; if (params.transformByCameraPosition) { div.classList.add("layer-with-camera"); } return div; } getDRP() { const respectPixelRatio = this.props.canvas?.respectPixelRatio ?? true; return respectPixelRatio ? this.context.graph.layers.rootSize.value.dpr : 1; } applyTransform(x, y, scale, respectPixelRatio = this.props.canvas?.respectPixelRatio) { const ctx = this.context.ctx; const dpr = respectPixelRatio ? this.getDRP() : 1; ctx.setTransform(scale * dpr, 0, 0, scale * dpr, x * dpr, y * dpr); } updateCanvasSize() { const { width, height, dpr } = this.context.graph.layers.getRootSize(); this.canvas.width = width * dpr; this.canvas.height = height * dpr; } resetTransform() { if (this.sizeTouched) { this.sizeTouched = false; this.updateCanvasSize(); } const cameraState = this.props.canvas?.transformByCameraPosition ? this.context.camera.getCameraState() : null; // Reset transform and clear the canvas this.context.ctx.setTransform(1, 0, 0, 1, 0, 0); // Use canvas dimensions directly, as they should already factor in DPR if respectPixelRatio is true this.context.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.applyTransform(cameraState?.x ?? 0, cameraState?.y ?? 0, cameraState?.scale ?? 1, true); } render() { if (this.canvas) { this.resetTransform(); } } }