UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

308 lines (307 loc) 13 kB
import { Component } from "../lib/Component"; import "./Layer.css"; 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"); this.html?.classList.add("hidden"); } isHidden() { return this.canvas?.classList.contains("hidden") || this.html?.classList.contains("hidden"); } show() { this.canvas?.classList.remove("hidden"); this.html?.classList.remove("hidden"); } /** * 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; this.sizeTouched = false; this.updateSize = () => { this.sizeTouched = true; this.performRender(); }; 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.onGraphEvent("camera-change", (event) => this.onCameraChange(event.detail)); this.onSignal(this.props.graph.layers.rootSize, this.updateSize); this.shouldRenderChildren = true; this.shouldUpdateChildren = true; this.onCameraChange(this.context.camera.getCameraState()); this.updateSize(); } onCameraChange(camera) { if (this.props.html?.transformByCameraPosition && this.html) { this.html.style.transform = `matrix(${camera.scale}, 0, 0, ${camera.scale}, ${camera.x}, ${camera.y})`; } if (this.props.canvas?.transformByCameraPosition) { this.performRender(); } } 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.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"), }); 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.getDPR() : 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(); } } }