UNPKG

@joint/react

Version:

React bindings and hooks for JointJS to build interactive diagrams and graphs.

244 lines (224 loc) 7.2 kB
import { dia, shapes } from '@joint/core'; import { listenToCellChange } from '../utils/cell/listen-to-cell-change'; import { ReactElement } from '../models/react-element'; import { setElements } from '../utils/cell/set-cells'; import type { GraphElement } from '../types/element-types'; import type { GraphLink } from '../types/link-types'; import { subscribeHandler } from '../utils/subscriber-handler'; import { createStoreData } from './create-store-data'; import type { CellMap } from '../utils/cell/cell-map'; export const DEFAULT_CELL_NAMESPACE = { ...shapes, ReactElement }; export interface StoreOptions { /** * Graph instance to use. If not provided, a new graph instance will be created. * @see https://docs.jointjs.com/api/dia/Graph * @default new dia.Graph({}, { cellNamespace: shapes }) */ readonly graph?: dia.Graph; /** * Namespace for cell models. * @default shapes * @see https://docs.jointjs.com/api/shapes */ readonly cellNamespace?: unknown; /** * Custom cell model to use. * @see https://docs.jointjs.com/api/dia/Cell */ readonly cellModel?: typeof dia.Cell; /** * Initial elements to be added to graph * It's loaded just once, so it cannot be used as React state. */ readonly initialElements?: Array<dia.Element | GraphElement>; /** * Initial links to be added to graph * It's loaded just once, so it cannot be used as React state. */ readonly initialLinks?: Array<dia.Link | GraphLink>; } export interface Store { /** * The JointJS graph instance. */ readonly graph: dia.Graph; /** * Subscribes to the store changes. */ readonly subscribe: (onStoreChange: (changedIds?: Set<dia.Cell.ID>) => void) => () => void; /** * Get elements */ readonly getElements: () => CellMap<GraphElement>; /** * Get element by id */ readonly getElement: <Element extends GraphElement>(id: dia.Cell.ID) => Element; /** * Get links */ readonly getLinks: () => CellMap<GraphLink>; /** * Get link by id */ readonly getLink: (id: dia.Cell.ID) => GraphLink; /** * Remove all listeners and cleanup the graph. */ readonly destroy: () => void; /** * Set the measured node element. * For safety, each node, can use only one measured node, do not matter how many papers the graph is using, * only one paper and one node can use measured node, otherwise it can lead to unexpected behavior * when many nodes or same node with many measuredNodes try to adjust the size. */ readonly setMeasuredNode: (id: dia.Cell.ID) => () => void; /** * Check if the graph has already measured node for the given element id. */ readonly hasMeasuredNode: (id: dia.Cell.ID) => boolean; } /** * Create a new graph instance. * @param options - Options for creating the graph. * @returns The created graph instance. * @group Graph * @internal * @example * ```ts * const graph = createGraph(); * console.log(graph); * ``` */ function createGraph(options: StoreOptions = {}): dia.Graph { const { cellModel, cellNamespace = DEFAULT_CELL_NAMESPACE, graph } = options; const newGraph = graph ?? new dia.Graph( {}, { cellNamespace: { ...DEFAULT_CELL_NAMESPACE, // @ts-expect-error Shapes is not a valid type for cellNamespace ...cellNamespace, }, cellModel, } ); return newGraph; } /** * Building block of `@joint/react`. * It listen to cell changes and updates UI based on the `dia.graph` changes. * It use `useSyncExternalStore` to avoid memory leaks and state duplicates. * * Under the hood, @joint/react works by listening to changes in the `dia.Graph` via this store. `dia.graph` is the single source of truth. * When you update something—like adding or modifying cells—you do it directly through the `dia.Graph` API, just like in a standard JointJS app. * React components automatically observe and react to changes in the graph, keeping the UI in sync via `useSyncExternalStore` API. * Hooks like `useUpdateElement` are just convenience helpers (**syntactic sugar**) that update the graph directly behind the scenes. * You can also access the graph yourself using `useGraph()` and call methods like `graph.setCells()` or any other JointJS method as needed and react will update it accordingly. * @group Data * @internal * @param options - Options for creating the graph store. * @returns The graph store instance. * @example * ```ts * const { graph, forceUpdate, subscribe } = createStore(); * const unsubscribe = subscribe(() => { * console.log('Graph changed'); * }); * graph.addCell(new joint.shapes.standard.Rectangle()); * forceUpdate(); * unsubscribe(); * ``` */ export function createStore(options?: StoreOptions): Store { const { initialElements } = options || {}; const graph = createGraph(options); // set elements to the graph setElements({ graph, initialElements, }); // create store data - caching the elements and links for the react const data = createStoreData(); const elementsEvents = subscribeHandler(forceUpdate); const unsubscribe = listenToCellChange(graph, onCellChange); data.updateStore(graph); graph.on('batch:stop', onBatchStop); const measuredNodes = new Set<dia.Cell.ID>(); /** * Force update the graph. * This function is called when the graph is updated. * It checks if there are any unsized links and processes them. * @returns changed ids */ function forceUpdate(): Set<dia.Cell.ID> { return data.updateStore(graph); } /** * This function is called when a cell changes. * It checks if the graph has an active batch and returns if it does. * Otherwise, it notifies the subscribers of the elements events. * @param cell - The cell that changed. */ function onCellChange() { if (graph.hasActiveBatch()) { return; } elementsEvents.notifySubscribers(); } /** * This function is called when the batch stops. */ function onBatchStop() { elementsEvents.notifySubscribers(); } /** * Cleanup the store. */ function destroy() { unsubscribe(); graph.off('batch:stop', onBatchStop); graph.clear(); data.destroy(); measuredNodes.clear(); } // Force update the graph to ensure it's in sync with the store. forceUpdate(); const store: Store = { destroy, graph, subscribe: elementsEvents.subscribe, getElements() { return data.elements; }, getLinks() { return data.links; }, getElement<E extends GraphElement>(id: dia.Cell.ID) { const item = data.elements.get(id); if (!item) { throw new Error(`Element with id ${id} not found`); } return item as E; }, getLink(id) { const item = data.links.get(id); if (!item) { throw new Error(`Link with id ${id} not found`); } return item; }, setMeasuredNode(id: dia.Cell.ID) { measuredNodes.add(id); return () => { measuredNodes.delete(id); }; }, hasMeasuredNode(id: dia.Cell.ID) { return measuredNodes.has(id); }, }; return store; }