@gravity-ui/graph
Version:
Modern graph editor component
113 lines (112 loc) • 5.43 kB
JavaScript
import React, { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react";
import { ESchedulerPriority } from "../lib/Scheduler";
import { useComputedSignal, useSchedulerDebounce, useSignalEffect } from "./hooks";
import { useBlockState } from "./hooks/useBlockState";
import { applyBlockContainerLayout } from "./utils/applyBlockContainerLayout";
import { cn } from "./utils/cn";
import "./Block.css";
/**
* Base block component for render HTML block via react
*
* Creates a container for the graph block, correctly places it on the canvas, updates the geometry and z-index when the state changes
*
* By default will be hide canvas block automatically when react will render the block.
* But if you want to manage canvas block visibility manually, you can set `autoHideCanvas` prop to `false`, and pass `canvasVisible` prop to control it.
*
*
* @example
* ```jsx
* <GraphBlock graph={graph} block={block}>
* <div>This is a HTML block with name {block.name}</div>
* <button onClick={() => console.log("`Click me` button clicked")}>Click me</button>
* </GraphBlock>
* ```
*
*/
function GraphBlockInner({ graph, block, children, className, containerClassName, autoHideCanvas = true, canvasVisible }, ref) {
const containerRef = useRef(null);
useImperativeHandle(ref, () => containerRef.current);
const lastStateRef = useRef({ x: 0, y: 0, width: 0, height: 0, zIndex: 0 });
const state = useBlockState(graph, block);
const stopMoving = useSchedulerDebounce((container) => {
container.classList.remove("graph-block-container-moving");
}, { priority: ESchedulerPriority.LOW, frameTimeout: 150 });
const viewState = useComputedSignal(() => state?.$viewComponent.value, [state]);
const [interactive, setInteractive] = useState(viewState?.isInteractive() ?? false);
/**
* By reason of scheduler, canvas layer get the camera state before react will initialize the block
* so if canvas block will hide before react will render the block,
* user will see empty space for a moment, and after react blocks will be initialized,user will see the html-block.
* In order to prevent that flickering, we need to hide the canvas block **only** after react render the block.
*/
useEffect(() => {
if (!autoHideCanvas) {
if (canvasVisible !== undefined) {
// Manual canvas visibility: use setRenderDelegated so the canvas block stays
// in the hit-test tree (unlike setHiddenBlock which removes the hitbox). Needed
// when HTML/React owns the pixels but interactions should still target the block.
viewState?.setRenderDelegated(!canvasVisible);
}
}
else {
// Auto: suppress canvas rendering once React has mounted the block
viewState?.setRenderDelegated(true);
}
return () => viewState?.setRenderDelegated(false);
}, [viewState, canvasVisible]);
/**
* Synchronously set initial block geometry before the browser paints
* to prevent blocks from flashing at position (0, 0) when mounting.
*/
useLayoutEffect(() => {
const geometry = state?.$geometry.value;
const container = containerRef.current;
if (!container || !geometry || !viewState) {
return;
}
applyBlockContainerLayout(container, geometry, viewState, lastStateRef.current);
}, [state, viewState]);
/**
* Update the block geometry and z-index when the state changes.
*/
useSignalEffect(() => {
const geometry = state?.$geometry.value;
const container = containerRef.current;
if (!container || !geometry || !viewState) {
return;
}
const { hasPositionChange } = applyBlockContainerLayout(container, geometry, viewState, lastStateRef.current);
if (hasPositionChange) {
if (!container.classList.contains("graph-block-container-moving")) {
container.classList.add("graph-block-container-moving");
}
stopMoving(container);
}
}, [containerRef, lastStateRef, state, viewState]);
/**
* Update the interactive state when the view state changes.
* Interactive state is defined by props.interactive
* So to handle update this props we use onChange callback from view state.
*/
useEffect(() => {
if (!viewState)
return;
setInteractive(viewState.isInteractive());
// eslint-disable-next-line consistent-return
return viewState.onChange(() => {
setInteractive(viewState.isInteractive());
});
}, [viewState]);
const containerClassNames = useMemo(() => {
return cn("graph-block-container", containerClassName, !interactive ? "graph-block-container-non-interactive" : "");
}, [containerClassName, interactive]);
const wrapperClassNames = useMemo(() => {
return cn("graph-block-wrapper", className, state?.$selected.value ? "selected" : "");
}, [className, state?.$selected.value]);
if (!viewState || !state) {
return null;
}
return (React.createElement("div", { className: containerClassNames, ref: containerRef },
React.createElement("div", { className: wrapperClassNames }, children)));
}
export const GraphBlock = forwardRef(GraphBlockInner);