@gravity-ui/graph
Version:
Modern graph editor component
308 lines (307 loc) • 13 kB
JavaScript
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();
}
}
}